Cómo automatizamos la portabilidad de productos de C # a C ++

Hola Habr En esta publicación, hablaré sobre cómo logramos organizar un lanzamiento mensual de bibliotecas para el lenguaje C ++, cuyo código fuente se desarrolla en C #. No se trata de C ++ administrado, o incluso de crear un puente entre C ++ no administrado y el entorno CLR: se trata de automatizar la generación de código C ++ que repite la API y la funcionalidad del código C # original.

Escribimos la infraestructura necesaria para traducir código entre idiomas y emular las funciones de la biblioteca .Net nosotros mismos, resolviendo así un problema que generalmente se considera académico. Esto nos permitió comenzar a lanzar versiones mensuales de productos pre-Donets para el lenguaje C ++ también, obteniendo el código para cada versión de la versión correspondiente del código C #. Al mismo tiempo, las pruebas que cubrieron el código original se transfieren junto con él y le permiten controlar el rendimiento de la solución resultante junto con pruebas especialmente escritas en C ++.

En este artículo describiré brevemente la historia de nuestro proyecto y las tecnologías utilizadas en él. Me referiré a los temas de justificación económica solo de pasada, ya que el aspecto técnico es mucho más interesante para mí. En los siguientes artículos de la serie, planeo hacer hincapié en temas como la generación de código y la administración de memoria, así como en algunos otros, si la comunidad tiene preguntas relevantes.

Antecedentes


Inicialmente, nuestra empresa participó en el lanzamiento de bibliotecas para la plataforma .Net. Estas bibliotecas proporcionan principalmente API para trabajar con algunos formatos de archivo (documentos, tablas, diapositivas, gráficos) y protocolos (correo electrónico), ocupando un cierto nicho en el mercado para tales soluciones. Todo el desarrollo se realizó en C #.

A finales de la década de 2000, la compañía decidió ingresar a un nuevo mercado para sí misma, comenzando a lanzar productos similares para Java. El desarrollo desde cero obviamente requeriría una inversión de recursos comparable al desarrollo inicial de todos los productos afectados. La opción de envolver el código de Donnet en una capa que traduce llamadas y datos de Java a .Net y viceversa también fue rechazada por algunas razones. En cambio, se planteó la pregunta de si es posible de alguna manera migrar completamente el código existente a la nueva plataforma. Esto fue aún más relevante ya que no se trataba de una promoción única, sino de un lanzamiento mensual de nuevos lanzamientos de cada producto, sincronizados entre dos idiomas.

Se decidió dividir la decisión en dos partes. El primero, el llamado Porter, convertiría la sintaxis del código fuente de C # a Java, reemplazando simultáneamente los tipos y métodos .Net con sus homólogos de las bibliotecas de Java. El segundo, la Biblioteca, emularía el trabajo de aquellas partes de la biblioteca .Net para las que es difícil o imposible establecer una correspondencia directa con Java, atrayendo componentes de terceros disponibles para esto.

A favor de la viabilidad principal de dicho plan, habló lo siguiente:

  1. Ideológicamente, los lenguajes C # y Java son bastante similares, al menos, con la estructura de tipos y la organización del trabajo con memoria;
  2. Se trataba de portar bibliotecas, no había necesidad de portar la GUI;
  3. , , - , System.Net System.Drawing;
  4. , .Net ( Framework, Standard Xamarin), .

No entraré en detalles, ya que merecen un artículo separado (y no uno). Solo puedo decir que pasaron unos dos años desde el inicio del desarrollo hasta el lanzamiento del primer producto Java, y desde entonces el lanzamiento de los productos Java se ha convertido en una práctica habitual de la compañía. Durante el desarrollo del proyecto, el portero ha evolucionado de una simple utilidad que convierte el texto de acuerdo con las reglas establecidas, a un generador de código complejo que funciona con la representación AST del código fuente. La biblioteca también está cubierta de código.

El éxito de la dirección de Java determinó el deseo de la compañía de expandirse aún más en nuevos mercados para sí misma, y ​​en 2013 se planteó la pregunta sobre el lanzamiento de productos para el lenguaje C ++ en un escenario similar.

Formulación del problema


Para garantizar el lanzamiento de versiones positivas de los productos, era necesario crear un marco que le permitiera obtener código C ++ a partir de código C # arbitrario, compilarlo, verificarlo y entregárselo al cliente. Se trataba de bibliotecas con volúmenes que van desde varios cientos de miles hasta varios millones de líneas (excluyendo dependencias).

Al mismo tiempo, se tuvo en cuenta la experiencia con el portero de Java: inicialmente, cuando era solo una herramienta simple para convertir sintaxis, surgió la práctica de finalizar manualmente el código portado. En el corto plazo, enfocado en el lanzamiento rápido de productos, esto fue relevante, ya que permitió acelerar el proceso de desarrollo, sin embargo, a largo plazo, esto aumentó significativamente los costos de preparación de cada versión para el lanzamiento debido a la necesidad de corregir cada error de traducción cada vez que ocurre.

Por supuesto, esta complejidad era manejable, al menos transfiriendo solo parches al código Java resultante, que se calculan como la diferencia entre la salida del portero para las próximas dos revisiones del código C #. Este enfoque hizo posible corregir cada línea portada solo una vez y en el futuro usar el código ya desarrollado donde no se realizaron cambios. Sin embargo, al desarrollar un portador positivo, el objetivo era deshacerse de la etapa de arreglar el código portado, en lugar de arreglar el marco en sí. Por lo tanto, cada error de traducción arbitrariamente raro se corregirá una vez, en el código del portero, y esta corrección se aplicará a todas las versiones futuras de todos los productos portados.

Además del propio portero, también se requería desarrollar una biblioteca en C ++ que resolviera los siguientes problemas:

  1. Emulación del entorno .Net en la medida en que sea necesario que el código portado funcione;
  2. Adaptar el código C # portado a las realidades de C ++ (estructura de tipo, administración de memoria, otro código de servicio);
  3. Suavizar las diferencias entre "C # reescrito" y el propio C ++, para que sea más fácil para los programadores no familiarizados con los paradigmas .Net usar el código portado.

Por razones obvias, no se intentó asignar directamente los tipos .Net a los tipos de la biblioteca estándar. En cambio, se decidió utilizar siempre los tipos de su biblioteca como reemplazo de los tipos Donnet.

Muchos lectores preguntarán de inmediato por qué no usaron implementaciones existentes como Mono . Había razones para eso.

  1. Al atraer una biblioteca tan terminada, sería posible satisfacer solo el primer requisito, pero no el segundo y no el tercero.
  2. Mono C# , , , .
  3. (API, , , C++, ) , .
  4. , .Net, . , , .

Teóricamente, dicha biblioteca podría traducirse a C ++ completamente usando un puerto, sin embargo, esto requeriría un portero completamente funcional al comienzo del desarrollo, ya que sin una biblioteca del sistema, la depuración de cualquier código portado es imposible en principio. Además, la cuestión de optimizar el código traducido de la biblioteca del sistema sería aún más aguda que para el código de los productos portados, ya que las llamadas a la biblioteca del sistema tienden a convertirse en un cuello de botella.

Como resultado, se decidió desarrollar la biblioteca como un conjunto de adaptadores que proporcionan acceso a funciones ya implementadas en bibliotecas de terceros, pero a través de una API similar a .Net (similar a Java). Esto reduciría el trabajo y usaría componentes C ++ ya listos y optimizados.

Un requisito importante para el marco era que el código portado tenía que poder funcionar como parte de las aplicaciones del usuario (en lo que respecta a las bibliotecas). Esto significaba que el modelo de administración de memoria debería haber quedado claro para los programadores de C ++, ya que no podemos forzar el código arbitrario del cliente para que se ejecute en un entorno de recolección de basura. Se eligió el uso de punteros inteligentes como modelo de compromiso. Sobre cómo logramos asegurar tal transición (en particular, para resolver el problema de las referencias circulares), discutiré en un artículo separado.

Otro requisito era la capacidad de portar no solo bibliotecas, sino también pruebas para ellas. La compañía cuenta con una alta cultura de cobertura de prueba de sus productos, y la capacidad de ejecutar en C ++ las mismas pruebas que se escribieron para el código original simplificaría enormemente la búsqueda de problemas después de la traducción.

Los requisitos restantes (formato de lanzamiento, cobertura de prueba, tecnología, etc.) se referían principalmente a los métodos de trabajo con el proyecto y en el proyecto. No me detendré en ellos.

Historia


Antes de continuar, tengo que decir algunas palabras sobre la estructura de la empresa. La empresa trabaja de forma remota, todos los equipos en ella están distribuidos. El desarrollo de un producto suele ser responsabilidad de un equipo, combinado en lenguaje (casi siempre) y geografía (principalmente).

El trabajo activo en el proyecto comenzó en el otoño de 2013. Debido a la estructura distribuida de la empresa, y también debido a algunas dudas sobre el éxito del desarrollo, se lanzaron inmediatamente tres versiones del marco: dos de ellas sirvieron un producto cada una, la tercera cubrió tres a la vez. Se supuso que esto detendría el desarrollo de soluciones menos efectivas y reasignaría los recursos si fuera necesario.

En el futuro, cuatro equipos más se unieron al trabajo en el marco "común", dos de los cuales reconsideraron su decisión y se negaron a lanzar productos para C ++. A principios de 2017, se tomó la decisión de detener el desarrollo de una de las soluciones "individuales" y transferir el equipo correspondiente para trabajar con un marco "común". El desarrollo detenido asumió el uso del Boehm GC como un medio de administración de memoria y contenía una implementación mucho más rica de algunas partes de la biblioteca del sistema, que luego se transfirió a la solución "general".

Por lo tanto, dos desarrollos llegaron a la línea de meta, es decir, al lanzamiento de productos portados, uno "individual" y otro "colectivo". Los primeros lanzamientos basados ​​en nuestro marco ("común") ocurrieron en febrero de 2018. Posteriormente, los lanzamientos de los seis equipos que usan esta solución se hicieron mensuales, y el marco en sí se lanzó como un producto separado de la compañía. Incluso se planteó la cuestión de hacerlo de código abierto, pero esta discusión aún no se ha desarrollado.

El equipo, que continuó trabajando de forma independiente en un marco similar, también lanzó su primer lanzamiento de C ++ en 2018.

Los primeros lanzamientos contenían versiones truncadas de los productos originales, lo que permitió retrasar el trabajo de transmitir partes sin importancia tanto como sea posible. En versiones posteriores, se ha producido una adición de funcionalidad por partes (y está ocurriendo).

Organización del trabajo en el proyecto.


La organización del trabajo conjunto en el proyecto por parte de varios equipos logró experimentar cambios significativos. Inicialmente, se decidió que un gran equipo "central" sería responsable del desarrollo, el soporte y la reparación del marco, mientras que los pequeños equipos de "producto" involucrados en el lanzamiento de productos finales en C ++ serían los principales responsables de tratar de portar su código y comentarios (información sobre errores de portado, compilación y ejecución). Tal esquema, sin embargo, resultó ser improductivo, ya que el equipo central estaba sobrecargado con las solicitudes de todos los equipos de "productos", y no pudieron continuar hasta que se resolvieron los problemas que encontraron.

Por razones que son en gran medida independientes del estado de este desarrollo particular, se decidió disolver el equipo "central" y transferir a las personas a equipos de "producto", que ahora eran responsables de fijar el marco a sus necesidades. En este caso, cada equipo tomaría una decisión sobre si utilizaría su base común o generaría su propia bifurcación del proyecto. Tal declaración de la pregunta era relevante para el marco de Java, cuyo código era estable en ese momento, pero se requería una consolidación de los esfuerzos para llenar la biblioteca de C ++ lo antes posible, de modo que los equipos aún trabajaran juntos.

Esta forma de trabajo también tenía sus inconvenientes, por lo que en el futuro se llevó a cabo otra reforma. El equipo "central" fue restaurado, aunque en una composición más pequeña, pero con diferentes funciones: ahora no era responsable del desarrollo real del proyecto, sino de la organización del trabajo conjunto sobre el mismo. Esto incluyó soporte para el entorno de CI, organización de prácticas de solicitud de fusión, reuniones periódicas con participantes en el desarrollo, documentación de respaldo, pruebas de cobertura, ayuda con soluciones arquitectónicas y resolución de problemas, etc. Además, el equipo asumió el trabajo para eliminar la deuda técnica y otras áreas intensivas en recursos. En este modo, el desarrollo continúa hasta nuestros días.

Por lo tanto, el proyecto fue iniciado por los esfuerzos de varios (aproximadamente cinco) desarrolladores y en el mejor de los casos contaba con unas veinte personas. Entre diez y quince personas responsables del desarrollo y soporte del marco y el lanzamiento de seis productos portados pueden considerarse un valor estable en los últimos años.

El autor de estas líneas se unió a la compañía a mediados de 2016, comenzando a trabajar en uno de los equipos que transmitían su código utilizando una solución "común". En el invierno del mismo año, cuando se decidió recrear el equipo "central", me mudé al puesto de líder de su equipo. Por lo tanto, mi experiencia en el proyecto hoy es de más de tres años y medio.

La autonomía de los equipos responsables del lanzamiento de los productos portados ha llevado al hecho de que en algunos casos resultó más fácil para los desarrolladores complementar al portero con modos operativos que comprometer la forma en que debería comportarse de manera predeterminada. Esto explica más de lo que cabría esperar, la cantidad de opciones disponibles al configurar el portero.

Tecnologías


Es hora de hablar sobre las tecnologías utilizadas en el proyecto. Porter es una aplicación de consola escrita en C #, porque de esta forma es más fácil incrustar en scripts que realizan tareas como "pruebas de compilación de puerto-ejecución". Además, hay un componente GUI que le permite alcanzar los mismos objetivos haciendo clic en los botones.

La antigua biblioteca NRefactory es responsable de analizar el código y resolver la semántica . Desafortunadamente, en el momento en que comenzó el proyecto, Roslyn aún no estaba disponible, aunque la migración a él, por supuesto, está en nuestros planes.

Porter usa pasarelas de madera ASTpara recopilar información y generar código de salida de C ++. Cuando se genera el código C ++, la representación AST no se crea y todo el código se guarda como texto sin formato.

En muchos casos, el portero necesita información adicional para un ajuste fino. Dicha información se le transmite en forma de opciones y atributos. Las opciones se aplican a todo el proyecto de inmediato y le permiten establecer, por ejemplo, los nombres de los miembros de macro de exportación de clases o las definiciones de preprocesador de C # utilizadas en el análisis de código. Los atributos se cuelgan en tipos y entidades y determinan el procesamiento específico para ellos (por ejemplo, la necesidad de generar palabras clave "const" o "mutable" para los miembros de la clase o excluirlos de la portabilidad).

Las clases y estructuras de C # se traducen a clases de C ++, sus miembros y el código ejecutable se traducen a los equivalentes más cercanos. Los tipos y métodos genéricos se asignan a plantillas de C ++. Los enlaces de C # se traducen en punteros inteligentes (fuertes o débiles) definidos en la Biblioteca. Se discutirán más detalles sobre los principios del portero en un artículo separado.

Por lo tanto, el ensamblaje original de C # se convierte en un proyecto de C ++, que en lugar de las bibliotecas .Net depende de nuestra biblioteca compartida. Esto se muestra en el siguiente diagrama:



cmake se usa para construir la biblioteca y los proyectos portados. Los compiladores VS 2017 y 2019 (Windows), GCC y Clang (Linux) son actualmente compatibles.

Como se mencionó anteriormente, la mayoría de nuestras implementaciones .Net son capas delgadas de bibliotecas de terceros que hacen la mayor parte del trabajo. Incluye:

  • Skia : para trabajar con gráficos;
  • Botan : para admitir funciones de cifrado;
  • UCI : para trabajar con cadenas, codificaciones y culturas;
  • Libxml2 : para trabajar con XML;
  • PCRE2 : para trabajar con expresiones regulares;
  • zlib : para implementar funciones de compresión;
  • Impulso - para varios propósitos;
  • Varias otras bibliotecas.

Tanto el portero como la biblioteca están cubiertos en numerosas pruebas. Las pruebas de biblioteca usan el marco gtest. Las pruebas de Porter se escriben principalmente en NUnit / xUnit y se dividen en varias categorías, certificando que:

  • la salida del portero en estos archivos de entrada coincide con el objetivo;
  • la salida de los programas portados después de la compilación y el lanzamiento coincide con el objetivo;
  • Las pruebas de NUnit de los proyectos de entrada se convierten con éxito en pruebas de gtest en proyectos portados y pasan;
  • La API de proyectos portados funciona correctamente en C ++;
  • El impacto de las opciones y atributos individuales en el proceso de traducción es el esperado.

Usamos GitLab para almacenar el código fuente . Jenkins fue elegido como el entorno de CI . Los productos portados están disponibles como paquetes Nuget y como archivos de descarga.

Problemas


Mientras trabajábamos en el proyecto, tuvimos que enfrentar muchos problemas. Algunos de ellos se esperaban, mientras que otros ya aparecían en el proceso. Enumeramos brevemente los principales.

  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. Dificultades con la protección de la propiedad intelectual.
    Si el código C # se ofusca con bastante facilidad por las soluciones en caja, entonces en C ++ debe hacer esfuerzos adicionales, ya que muchos miembros de la clase no se pueden eliminar de los archivos de encabezado sin consecuencias. La traducción de clases y métodos genéricos en plantillas también crea vulnerabilidades al exponer algoritmos.

A pesar de esto, el proyecto es muy interesante desde un punto de vista técnico. Trabajar en él te permite aprender mucho y aprender mucho. La naturaleza académica de la tarea también contribuye a esto.

Resumen


Como parte del proyecto, pudimos implementar un sistema que resuelve un problema académico interesante en aras de su aplicación práctica directa. Organizamos un número mensual de bibliotecas de la empresa en un idioma para el que no estaban destinadas originalmente. Resultó que la mayoría de los problemas son completamente solucionables, y la solución resultante es confiable y práctica.

Pronto se planea publicar dos artículos más. Uno de ellos describirá en detalle, con ejemplos, cómo funciona un portero y cómo se muestran las construcciones de C # en C ++. En otro discurso, hablaremos sobre cómo logramos garantizar la compatibilidad de los modelos de memoria de dos idiomas.

Trataré de responder las preguntas en los comentarios. Si los lectores muestran interés en otros aspectos de nuestro desarrollo y las respuestas comienzan a ir más allá de la correspondencia en los comentarios, consideraremos la posibilidad de publicar nuevos artículos.

All Articles