Como automatizamos a migração de produtos de C # para C ++

Olá, Habr. Neste post, falarei sobre como conseguimos organizar um lançamento mensal de bibliotecas para a linguagem C ++, cujo código fonte é desenvolvido em C #. Não se trata de C ++ gerenciado, ou mesmo de criar uma ponte entre C ++ não gerenciado e o ambiente CLR - trata-se de automatizar a geração de código C ++ que repete a API e a funcionalidade do código C # original.

Escrevemos a infraestrutura necessária para traduzir o código entre idiomas e emular as funções da biblioteca .Net, resolvendo um problema que geralmente é considerado acadêmico. Isso nos permitiu começar a lançar versões mensais de produtos pré-Donets também para a linguagem C ++, obtendo o código de cada versão da versão correspondente do código C #. Ao mesmo tempo, os testes que cobriram o código original são portados juntamente com ele e permitem controlar o desempenho da solução resultante, juntamente com testes especialmente escritos em C ++.

Neste artigo, descreverei brevemente a história do nosso projeto e as tecnologias usadas nele. Vou abordar apenas as questões da justificação econômica de passagem, uma vez que o lado técnico é muito mais interessante para mim. Nos artigos a seguir da série, pretendo abordar tópicos como geração de código e gerenciamento de memória, além de outros, se a comunidade tiver perguntas relevantes.

fundo


Inicialmente, nossa empresa estava envolvida no lançamento de bibliotecas para a plataforma .Net. Essas bibliotecas fornecem principalmente APIs para trabalhar com alguns formatos de arquivo (documentos, tabelas, slides, gráficos) e protocolos (email), ocupando um determinado nicho no mercado para essas soluções. Todo o desenvolvimento foi realizado em C #.

No final dos anos 2000, a empresa decidiu entrar em um novo mercado, começando a lançar produtos similares para Java. O desenvolvimento a partir do zero obviamente exigiria um investimento de recursos comparáveis ​​ao desenvolvimento inicial de todos os produtos afetados. A opção de agrupar o código Donnet em uma camada que traduz chamadas e dados de Java para .Net e vice-versa também foi rejeitada por alguns motivos. Em vez disso, foi colocada a questão de saber se é possível, de alguma maneira, migrar completamente o código existente para a nova plataforma. Isso foi ainda mais relevante, pois não se tratava de uma promoção única, mas de um lançamento mensal de novos lançamentos de cada produto, sincronizados entre dois idiomas.

Foi decidido dividir a decisão em duas partes. O primeiro - o chamado Porter - converteria a sintaxe do código-fonte C # em Java, substituindo simultaneamente os tipos e métodos .Net pelos equivalentes das bibliotecas Java. A segunda - a Biblioteca - emularia o trabalho daquelas partes da biblioteca .Net para as quais é difícil ou impossível estabelecer correspondência direta com Java, atraindo componentes de terceiros disponíveis para isso.

A favor da viabilidade principal de tal plano, falou o seguinte:

  1. Ideologicamente, as linguagens C # e Java são bastante semelhantes - pelo menos, com a estrutura dos tipos e a organização do trabalho com memória;
  2. Tratava-se de portar bibliotecas, não havia necessidade de portar a GUI;
  3. , , - , System.Net System.Drawing;
  4. , .Net ( Framework, Standard Xamarin), .

Não vou entrar em detalhes, pois eles merecem um artigo separado (e não um). Só posso dizer que demorou cerca de dois anos desde o início do desenvolvimento até o lançamento do primeiro produto Java e, desde então, o lançamento de produtos Java se tornou uma prática regular da empresa. Durante o desenvolvimento do projeto, o porteiro evoluiu de um utilitário simples que converte texto de acordo com regras estabelecidas, para um gerador de código complexo que trabalha com a representação AST do código fonte. A biblioteca também está cheia de código.

O sucesso da direção Java determinou o desejo da empresa de expandir ainda mais para novos mercados e, em 2013, foi levantada a questão sobre o lançamento de produtos para a linguagem C ++ em um cenário semelhante.

Formulação do problema


Para garantir o lançamento de versões positivas de produtos, era necessário criar uma estrutura que permitisse obter o código C ++ a partir de um código C # arbitrário, compilá-lo, verificá-lo e entregá-lo ao cliente. Tratava-se de bibliotecas com volumes que variavam de várias centenas de milhares a vários milhões de linhas (excluindo dependências).

Ao mesmo tempo, a experiência com um porteiro Java foi levada em consideração: inicialmente, quando era apenas uma ferramenta simples para converter sintaxes, surgiu naturalmente a prática de finalizar manualmente o código portado. No curto prazo, focado na rápida liberação de produtos, isso foi relevante, pois permitiu acelerar o processo de desenvolvimento, no entanto, a longo prazo, isso aumentou significativamente os custos de preparação de cada versão, devido à necessidade de corrigir cada erro de tradução toda vez que ocorre.

Obviamente, essa complexidade era gerenciável - pelo menos transferindo apenas os patches para o código Java resultante, que são calculados como a diferença entre a saída do porteiro nas próximas duas revisões do código C #. Essa abordagem tornou possível corrigir cada linha portada apenas uma vez e no futuro usar o código já desenvolvido, onde nenhuma alteração foi feita. No entanto, ao desenvolver um carregador positivo, o objetivo era livrar-se do estágio de correção do código portado, em vez de corrigir a própria estrutura. Assim, cada erro de conversão arbitrariamente raro seria corrigido uma vez no código do porter, e essa correção se aplicaria a todas as versões futuras de todos os produtos portados.

Além do carregador, também era necessário o desenvolvimento de uma biblioteca em C ++ que resolvesse os seguintes problemas:

  1. Emulação do ambiente .Net na medida em que é necessário que o código portado funcione;
  2. Adaptação do código C # portado às realidades do C ++ (estrutura de tipo, gerenciamento de memória, outro código de serviço);
  3. Suavizando as diferenças entre o “C # reescrito” e o próprio C ++, para tornar mais fácil para os programadores não familiarizados com os paradigmas .Net usarem o código portado.

Por razões óbvias, nenhuma tentativa foi feita para mapear diretamente tipos .Net para tipos da biblioteca padrão. Em vez disso, decidiu-se sempre usar tipos de sua biblioteca como um substituto para os tipos Donnet.

Muitos leitores perguntam imediatamente por que eles não usaram implementações existentes como o Mono . Havia razões para isso.

  1. Ao atrair uma biblioteca finalizada, seria possível satisfazer apenas o primeiro requisito, mas não o segundo e nem o terceiro.
  2. Mono C# , , , .
  3. (API, , , C++, ) , .
  4. , .Net, . , , .

Teoricamente, essa biblioteca poderia ser convertida em C ++ inteiramente usando uma porta, no entanto, isso exigiria um carregador totalmente funcional no início do desenvolvimento, uma vez que, sem uma biblioteca do sistema, a depuração de qualquer código portado é impossível em princípio. Além disso, a questão de otimizar o código traduzido da biblioteca do sistema seria ainda mais aguda do que o código dos produtos portados, já que as chamadas para a biblioteca do sistema tendem a se tornar um gargalo.

Como resultado, decidiu-se desenvolver a biblioteca como um conjunto de adaptadores que fornecem acesso a funções já implementadas em bibliotecas de terceiros, mas por meio de uma API do tipo .Net (semelhante ao Java). Isso reduziria o trabalho e usaria componentes C ++ prontos, já otimizados.

Um requisito importante para a estrutura era que o código portado tivesse que poder trabalhar como parte dos aplicativos do usuário (no que diz respeito às bibliotecas). Isso significava que o modelo de gerenciamento de memória deveria ter sido esclarecido para os programadores de C ++, pois não podemos forçar o código arbitrário do cliente para executar em um ambiente de coleta de lixo. O uso de ponteiros inteligentes foi escolhido como modelo de compromisso. Sobre como conseguimos garantir essa transição (em particular, resolver o problema das referências circulares), discutirei em um artigo separado.

Outro requisito era a capacidade de portar não apenas bibliotecas, mas também testes para elas. A empresa possui uma alta cultura de cobertura de teste de seus produtos, e a capacidade de executar em C ++ os mesmos testes que foram escritos para o código original simplificaria bastante a busca por problemas após a tradução.

Os requisitos restantes (formato de lançamento, cobertura de teste, tecnologia etc.) diziam respeito principalmente aos métodos de trabalho com o projeto e no projeto. Eu não vou insistir neles.

História


Antes de continuar, tenho que dizer algumas palavras sobre a estrutura da empresa. A empresa trabalha remotamente, todas as equipes nela são distribuídas. O desenvolvimento de um determinado produto geralmente é de responsabilidade de uma equipe, unida por idioma (quase sempre) e geografia (principalmente).

O trabalho ativo no projeto começou no outono de 2013. Devido à estrutura distribuída da empresa, e também devido a algumas dúvidas sobre o sucesso do desenvolvimento, três versões do framework foram lançadas imediatamente: duas delas serviam um produto cada, a terceira abrangia três de uma só vez. Supunha-se que isso interromperia o desenvolvimento de soluções menos eficazes e realocaria os recursos, se necessário.

No futuro, mais quatro equipes ingressaram no trabalho na estrutura “comum”, duas das quais reconsideraram sua decisão posteriormente e se recusaram a lançar produtos para C ++. No início de 2017, foi tomada uma decisão para interromper o desenvolvimento de uma das soluções "individuais" e transferir a equipe correspondente para trabalhar com uma estrutura "comum". O desenvolvimento interrompido assumiu o uso do Boehm GC como um meio de gerenciamento de memória e continha uma implementação muito mais rica de algumas partes da biblioteca do sistema, que depois foi transferida para a solução "geral".

Assim, dois desenvolvimentos chegaram à linha de chegada - ou seja, ao lançamento de produtos portados - um “individual” e um “coletivo”. Os primeiros lançamentos baseados em nossa estrutura ("comum") ocorreram em fevereiro de 2018. Posteriormente, os lançamentos de todas as seis equipes que usam essa solução se tornaram mensais, e o próprio framework foi lançado como um produto separado da empresa. Até a questão foi levantada para torná-lo de código aberto, mas essa discussão ainda não se desenvolveu.

A equipe, que continuou trabalhando de forma independente em uma estrutura semelhante, também lançou seu primeiro lançamento em C ++ em 2018.

Os primeiros lançamentos continham versões truncadas dos produtos originais, o que permitiu adiar o trabalho de transmissão de peças sem importância, tanto quanto possível. Nas liberações subsequentes, ocorreu uma adição parcial de funcionalidade (e está ocorrendo).

Organização do trabalho no projeto


A organização do trabalho conjunto no projeto por várias equipes conseguiu sofrer mudanças significativas. Inicialmente, foi decidido que uma grande equipe “central” seria responsável pelo desenvolvimento, suporte e correção da estrutura, enquanto as pequenas equipes de “produto” envolvidas no lançamento de produtos finais em C ++ seriam as principais responsáveis ​​por tentar portar seus código e fornecer feedback (informações sobre erros de portabilidade, compilação e execução). Esse esquema, no entanto, acabou sendo improdutivo, uma vez que a equipe central estava sobrecarregada com solicitações de todas as equipes de "produtos" e não pôde seguir em frente até que os problemas encontrados fossem resolvidos.

Por razões que são amplamente independentes do estado desse desenvolvimento específico, foi decidido dissolver a equipe “central” e transferir pessoas para as equipes de “produtos”, que agora eram responsáveis ​​por fixar a estrutura de acordo com suas necessidades. Nesse caso, cada equipe em si tomaria uma decisão sobre usar suas bases comuns ou gerar sua própria bifurcação do projeto. Essa declaração da pergunta era relevante para a estrutura Java, cujo código era estável na época, mas era necessária a consolidação de esforços para preencher a biblioteca C ++ o mais rápido possível, para que as equipes continuassem trabalhando juntas.

Essa forma de trabalho também teve suas desvantagens, portanto, no futuro, outra reforma foi realizada. A equipe “central” foi restaurada, embora em uma composição menor, mas com funções diferentes: agora não era responsável pelo desenvolvimento real do projeto, mas pela organização do trabalho conjunto. Isso incluiu suporte para o ambiente de IC, organização de práticas de solicitação de mesclagem, realização de reuniões regulares com participantes do desenvolvimento, documentação de suporte, cobertura de testes, ajuda com soluções de arquitetura e solução de problemas, etc. Além disso, a equipe assumiu o trabalho para eliminar a dívida técnica e outras áreas de uso intensivo de recursos. Nesse modo, o desenvolvimento continua até hoje.

Assim, o projeto foi iniciado pelos esforços de vários (cerca de cinco) desenvolvedores e, na melhor das hipóteses, contou com cerca de vinte pessoas. Cerca de dez a quinze pessoas responsáveis ​​pelo desenvolvimento e suporte da estrutura e pelo lançamento de seis produtos portados podem ser considerados um valor estável nos últimos anos.

O autor dessas linhas ingressou na empresa em meados de 2016, começando a trabalhar em uma das equipes que transmitiam seu código usando uma solução "comum". No inverno do mesmo ano, quando foi decidido recriar a equipe “central”, mudei para a posição de líder da equipe. Assim, minha experiência no projeto hoje é de mais de três anos e meio.

A autonomia das equipes responsáveis ​​pelo lançamento de produtos portados levou ao fato de que, em alguns casos, tornou-se mais fácil para os desenvolvedores suplementar o carregador com modos operacionais do que comprometer o modo como ele deveria se comportar por padrão. Isso explica mais do que você poderia esperar, o número de opções disponíveis ao configurar o carregador.

Tecnologias


É hora de falar sobre as tecnologias usadas no projeto. O Porter é um aplicativo de console escrito em C #, pois, dessa forma, é mais fácil incorporar scripts que executam tarefas como "testes de execução de compilação de portas". Além disso, existe um componente da GUI que permite alcançar os mesmos objetivos clicando nos botões.

A antiga biblioteca NRefactory é responsável por analisar o código e resolver a semântica . Infelizmente, no momento em que o projeto começou, Roslyn ainda não estava disponível, embora a migração para ele, é claro, esteja em nossos planos.

Porter usa passarelas de madeira ASTpara coletar informações e gerar código de saída C ++. Quando o código C ++ é gerado, a representação AST não é criada e todo o código é salvo como texto sem formatação.

Em muitos casos, o carregador precisa de informações adicionais para o ajuste fino. Essa informação é transmitida a ele na forma de opções e atributos. As opções se aplicam a todo o projeto imediatamente e permitem definir, por exemplo, os nomes dos membros da macro de exportação das classes ou as definições do pré-processador C # usadas na análise de código. Os atributos dependem de tipos e entidades e determinam o processamento específico a eles (por exemplo, a necessidade de gerar palavras-chave “const” ou “mutável” para os membros da classe ou excluí-los da portabilidade).

As classes e estruturas C # são convertidas em classes C ++, seus membros e código executável são convertidos nos equivalentes mais próximos. Tipos e métodos genéricos são mapeados para modelos C ++. Os links C # são traduzidos em ponteiros inteligentes (fortes ou fracos) definidos na Biblioteca. Mais detalhes sobre os princípios do carregador serão discutidos em um artigo separado.

Assim, o assembly C # original é convertido em um projeto C ++, que em vez de bibliotecas .Net depende da nossa biblioteca compartilhada. Isso é mostrado no diagrama a seguir:



cmake é usado para construir a biblioteca e os projetos portados. Atualmente, os compiladores VS 2017 e 2019 (Windows), GCC e Clang (Linux) são suportados.

Como mencionado acima, a maioria de nossas implementações .Net são camadas finas de bibliotecas de terceiros que fazem a maior parte do trabalho. Inclui:

  • Skia - para trabalhar com gráficos;
  • Botan - para suportar funções de criptografia;
  • UTI - para trabalhar com strings, codificações e culturas;
  • Libxml2 - para trabalhar com XML;
  • PCRE2 - para trabalhar com expressões regulares;
  • zlib - para implementar funções de compactação;
  • Impulso - para vários propósitos;
  • várias outras bibliotecas.

O carregador e a biblioteca são abordados em vários testes. Os testes da biblioteca usam a estrutura gtest. Os testes do Porter são escritos principalmente em NUnit / xUnit e são divididos em várias categorias, certificando que:

  • a saída do porteiro nesses arquivos de entrada corresponde ao destino;
  • a saída dos programas portados após a compilação e o lançamento coincide com o destino;
  • Os testes NUnit de projetos de entrada são convertidos com êxito em testes gtest em projetos portados e passam;
  • A API Ported Projects funciona com êxito em C ++;
  • o impacto de opções e atributos individuais no processo de tradução é o esperado.

Usamos o GitLab para armazenar o código fonte . Jenkins foi escolhido como o ambiente de IC . Os produtos portados estão disponíveis como pacotes Nuget e como arquivos de download.

Problemas


Enquanto trabalhamos no projeto, tivemos que enfrentar muitos problemas. Alguns deles eram esperados, enquanto outros já apareciam no processo. Listamos brevemente os principais.

  1. .Net C++.
    , C++ Object, RTTI. .Net STL.
  2. .
    , , . , C# , C++ — .
  3. .
    — . , . , .
  4. .
    C++ , , .
  5. C#.
    C# , C++. , :

    • , ;
    • , (, yeild);
    • , (, , , C#);
    • , C++ (, C# foreground-).
  6. .
    , .Net , .
  7. .
    - , , «» , . , , , , using, -. . , .
  8. .
    , , , , , / - .
  9. .
    . , . , , , .
  10. Dificuldades com a proteção da propriedade intelectual.
    Se o código C # for facilmente ofuscado por soluções in a box, em C ++ você precisará fazer esforços adicionais, pois muitos membros da classe não podem ser excluídos dos arquivos de cabeçalho sem consequências. A conversão de classes e métodos genéricos em modelos também cria vulnerabilidades, expondo algoritmos.

Apesar disso, o projeto é muito interessante do ponto de vista técnico. Trabalhar nele permite que você aprenda muito e aprenda muito. A natureza acadêmica da tarefa também contribui para isso.

Sumário


Como parte do projeto, fomos capazes de implementar um sistema que resolve um problema acadêmico interessante em prol de sua aplicação prática direta. Organizamos uma edição mensal das bibliotecas da empresa em um idioma para o qual elas não foram originalmente projetadas. Verificou-se que a maioria dos problemas é completamente solucionável e a solução resultante é confiável e prática.

Em breve, está prevista a publicação de mais dois artigos. Um deles descreverá em detalhes, com exemplos, como um carregador funciona e como as construções C # são exibidas em C ++. Em outro discurso, falaremos sobre como conseguimos garantir a compatibilidade de modelos de memória de dois idiomas.

Vou tentar responder às perguntas nos comentários. Se os leitores mostrarem interesse em outros aspectos de nosso desenvolvimento e as respostas começarem a ir além da correspondência nos comentários, consideraremos a possibilidade de publicar novos artigos.

All Articles