Pull to refresh

Как мы автоматизировали портирование продуктов с C# на C++

Reading time13 min
Views6.8K
Привет, Хабр. В этом посте я расскажу о том, как нам удалось организовать ежемесячный выпуск библиотек для языка C++, исходный код которых разрабатывается на C#. Речь идёт не об управляемом C++ и даже не о создании моста между неуправляемым C++ и средой CLR — речь об автоматизации генерации кода на C++, повторяющего API и функциональность оригинального кода на C#.

Необходимую инфраструктуру, обеспечивающую трансляцию кода между языками и эмуляцию функций библиотеки .Net, мы написали сами, решив таким образом задачу, которая обычно считается академической. Это позволило начать выпускать ежемесячные релизы дотнетовских продуктов и для языка C++ тоже, получая код каждого релиза из соответствующей версии кода C#. При этом тесты, которыми был покрыт оригинальный код, портируются вместе с ним и позволяют контролировать работоспособность получившегося решения наравне со специально написанными тестами на C++.

В этой статье я кратко опишу историю нашего проекта и используемые в нём технологии. Вопросов экономического обоснования я коснусь лишь вскользь, поскольку техническая сторона для меня гораздо интереснее. В следующих статьях цикла я планирую подробнее остановиться на таких темах, как кодогенерация и управление памятью, а также на некоторых других, если у сообщества будут соответствующие вопросы.

Предыстория


Изначально наша компания занималась выпуском библиотек для платформы .Net. Эти библиотеки, в основном, предоставляют API для работы с некоторыми форматами файлов (документы, таблицы, слайды, графика) и протоколами (электронная почта), занимая определённую нишу на рынке подобных решений. Вся разработка велась на языке C#.

В конце нулевых компания решила выйти на новый для себя рынок, начав выпускать аналогичные продукты для Java. Разработка с нуля, очевидно, потребовала бы вложения ресурсов, сопоставимого с первоначальной разработкой всех затронутых продуктов. Вариант с оборачиванием дотнетовского кода в прослойку, осуществляющую трансляцию вызовов и данных из Java в .Net и обратно, был также отвергнут по некоторым соображениям. Вместо этого был поставлен вопрос о том, можно ли каким-либо образом полностью перенести существующий код на новую платформу. Это было тем более актуально, поскольку речь шла не о разовой акции, а о ежемесячном выпуске новых релизов каждого продукта, синхронизированном между двумя языками.

Было решено разбить решение на две части. Первая — так называемый Портер — осуществляла бы преобразование синтаксиса исходного кода C# в Java, попутно заменяя типы и методы .Net их аналогами из библиотек Java. Вторая — Библиотека — эмулировала бы работу тех частей библиотеки .Net, для которых установить прямое соответствие с Java затруднительно или невозможно, привлекая для этого доступные сторонние компоненты.

В пользу принципиальной реализуемости подобного плана говорило следующее:

  1. Идеологически языки C# и Java достаточно похожи — как минимум, структурой типов и организацией работы с памятью;
  2. Речь шла о портировании библиотек, необходимости в переносе GUI не было;
  3. Данные библиотеки содержали, в основном, бизнес-логику и низкоуровневые файловые операции, а самыми сложными их зависимостями зачастую были System.Net и System.Drawing;
  4. Библиотеки изначально разрабатывались так, чтобы работать под максимально широким спектром версий .Net (как Framework, так и Standard и даже Xamarin), так что различия платформ можно было в большой степени игнорировать.

Не буду вдаваться в подробности, поскольку они заслуживают отдельной статьи (и не одной). Скажу лишь, что от запуска разработки до выхода первого продукта на Java прошло около двух лет, и с тех пор выпуск продуктов на Java стал регулярной практикой компании. За время развития проекта портер прошёл эволюцию от простой утилиты, преобразующей текст по установленным правилам, до сложного кодогенератора, работающего с AST-представлением исходного кода. Библиотека также обросла кодом.

Успех направления Java обусловил желание компании проводить дальнейшую экспансию на новые для себя рынки, и в 2013 году был поставлен вопрос о выпуске продуктов для языка C++ по аналогичному сценарию.

Постановка задачи


Для того, чтобы обеспечить выпуск плюсовых версий продуктов, требовалось создать фреймворк, который позволял бы получить из произвольного кода на C# код на C++, скомпилировать его, проверить и отдать клиенту. Речь шла о библиотеках объёмом от нескольких сотен тысяч до нескольких миллионов строк (не считая зависимостей).

При этом учитывался опыт работы с Java-портером: первоначально, когда тот был лишь простым инструментом для преобразования синтаксисов, естественным образом возникла практика ручной доработки портированного кода. В краткосрочной перспективе, ориентированной на скорейший выпуск продуктов, это было актуально, поскольку позволяло ускорить процесс разработки, однако в долгосрочном разрезе это существенно увеличило издержки по подготовке каждой версии к выпуску из-за необходимости править каждую ошибку трансляции каждый раз, когда она возникает.

Разумеется, эта сложность была управляемой — как минимум, путём переноса в итоговый Java-код только патчей, вычисляемых как разность между выводом портера за две последующие ревизии кода C#. Такой подход позволял исправлять каждую портированную строку лишь один раз и в дальнейшем использовать уже доработанный код там, где изменений не вносилось. Тем не менее, при разработке плюсового портера была поставлена цель избавиться от этапа исправления портированного кода, вместо этого исправляя сам фреймворк. Таким образом, каждая, сколь угодно редкая ошибка трансляции исправлялась бы один раз — в коде портера, и этот фикс относился бы ко всем будущим релизам всех портируемых продуктов.

Кроме собственно портера, требовалось также разработать библиотеку на C++, которая бы решала следующие задачи:

  1. Эмуляция окружения .Net в той мере, в какой это нужно для работы портированного кода;
  2. Адаптация портированного кода C# к реалиям C++ (структура типов, управление памятью, прочий сервисный код);
  3. Сглаживание различий между «переписанным C#» и собственно C++, чтобы упростить использование портированного кода программистами, не знакомыми с парадигмами .Net.

В силу очевидных причин попытки прямого отображения типов .Net на типы из стандартной библиотеки не предпринимались. Вместо этого было решено всегда использовать типы из своей библиотеки как замену дотнетовских.

Многие читатели сразу спросят о том, почему было не использовать существующие реализации вроде Mono. К тому были свои причины.

  1. Привлечением такой готовой библиотеки удалось бы удовлетворить только первому требованию, но не второму и не третьему.
  2. Реализация Mono написана на C# и, стало быть, полагается на рантайм, от которого мы как раз и отказались.
  3. Адаптация стороннего кода под наши реалии (API, система типов, система управления памятью, оптимизации кода C++, и так далее) заняла бы время, сопоставимое с разработкой собственного компонента.
  4. Потребности наших продуктов существенно уже, чем полная реализация .Net, однако отделить нужные классы и методы от ненужных при прямом переносе кода во многих случаях было бы сложно. Таким образом, пришлось бы потратить существенное время на поддержку тех классов и вызовов, которые не используются в коде продуктов.

Теоретически, подобная библиотека могла бы быть переведена на C++ целиком с использованием портера, однако это требовало бы иметь полностью функциональный портер в самом начале разработки, т. к. без системной библиотеки отладка какого-либо портированного кода в принципе невозможна. Кроме того, вопрос об оптимизации транслированного кода системной библиотеки встал бы даже острее, чем для кода портированных продуктов, поскольку вызовы системной библиотеки имеют тенденцию становиться бутылочным горлышком.

В итоге было принято решение о разработке библиотеки как набора адаптеров, предоставляющих доступ к функциям, уже реализованным в сторонних библиотеках, но через .Net-подобный API (по аналогии с Java). Это позволило бы сократить работу и использовать готовые, уже оптимизированные, компоненты C++.

Важное требование к фреймворку заключалось в том, что портированный код должен был быть способен работать в составе пользовательских приложений (поскольку речь шла о библиотеках). Это означало, что модель управления памятью должна была быть приведена к понятной программистам C++, так как мы не можем заставить произвольный клиентский код выполняться в среде с уборкой мусора. В качестве компромиссной модели было выбрано использование умных указателей. О том, каким образом нам удалось обеспечить такой переход (в частности, решить проблему циклических ссылок), я расскажу в отдельной статье.

Ещё одним требованием стала возможность портирования не только библиотек, но и тестов к ним. Компания может похвастаться высокой культурой тестового покрытия своих продуктов, и возможность запускать на C++ те же тесты, что были написаны для оригинального кода, существенно упростила бы поиск проблем после трансляции.

Остальные требования (формат запуска, покрытие тестами, технологии и т. п.) касались, в основном, способов работы с проектом и над проектом. Я не буду останавливаться на них.

История


Прежде чем продолжить, придётся сказать несколько слов о структуре компании. Компания работает удалённо, все команды в ней — распределённые. За разработку некоторого продукта обычно отвечает команда, объединённая языком (почти всегда) и географией (в основном).

Активная работа над проектом началась осенью 2013 года. Из-за распределённой структуры компании, а также ввиду некоторых сомнений в успехе разработки был дан старт сразу трём версиям фреймворка: две из них обслуживали по одному продукту, третья покрывала сразу три. Предполагалось, что это позволит затем прекратить разработку менее эффективных решений и перераспределить ресурсы, если это потребуется.

В дальнейшем к работе над «общим» фреймворком подключились ещё четыре команды, две из которых позже пересмотрели своё решение и отказались от выпуска продуктов для C++. В начале 2017 года было принято решение о прекращении разработки одного из «индивидуальных» решений и переводе соответствующей команды на работу с «общим» фреймворком. Остановленная разработка предполагала использование Boehm GC в качестве средства управления памятью и содержала значительно более богатую реализацию некоторых частей системной библиотеки, которая была затем перенесена в «общее» решение.

Таким образом, к финишу — то есть, к релизу портированных продуктов — пришли две разработки: одна «индивидуальная» и одна «коллективная». Первые релизы на основе нашего («общего») фреймворка случились в феврале 2018 года. Впоследствии релизы всех шести команд, использующих данное решение, стали ежемесячными, а сам фреймворк был выпущен в качестве отдельного продукта компании. Поднимался даже вопрос о том, чтобы сделать его опенсорсным, но развития эта дискуссия на сегодняшний день не получила.

Команда, продолжавшая самостоятельную работу над аналогичным фреймворком, также выпустила свой первый релиз на C++ в 2018 году.

Первые релизы содержали урезанные версии оригинальных продуктов, что позволило отсрочить работу по трансляции маловажных частей, насколько это было возможно. В дальнейших релизах происходило (и происходит) порционное добавление функциональности.

Организация работы над проектом


Организация совместной работы над проектом несколькими командами успела претерпеть значительные изменения. Первоначально было решено, что за разработку, поддержку и фиксинг фреймворка будет отвечать одна многочисленная — «центральная» — команда, в то время как малочисленные «продуктовые» команды, занимающиеся выпуском конечных продуктов на C++, будут отвечать, в основном, за попытки портирования своего кода и предоставление обратной связи (информации об ошибках портирования, компиляции и выполнения). Такая схема, однако, оказалась непродуктивной, поскольку центральная команда была перегружена заявками всех «продуктовых» команд, а те не могли двигаться дальше, пока встреченные ими проблемы не будут решены.

По причинам, во многом не зависящим от состояния данной конкретной разработки, было принято решение о расформировании «центральной» команды и переводе людей в «продуктовые» команды, которые теперь сами были ответственны за исправление фреймворка под свои нужды. При этом каждая команда сама принимала бы решение о том, использовать ли ей общие наработки или порождать собственный форк проекта. Такая постановка вопроса была актуальна для Java-фреймворка, код которого был к тому времени стабилен, однако для скорейшего наполнения библиотеки C++ требовалась консолидация усилий, так что команды по-прежнему работали сообща.

Такая форма работы также имела свои недостатки, поэтому в дальнейшем была проведена ещё одна реформа. «Центральная» команда была восстановлена, хоть и в меньшем составе, но с иными функциями: теперь она отвечала не за собственно разработку проекта, а за организацию совместной работы над ним. Это включало поддержку среды CI, организацию практики Merge Requestов, проведение регулярных собраний с участниками разработки, поддержку документации, покрытие тестами, помощь в выработке архитектурных решений и поиске проблем, и так далее. Кроме того, команда взяла на себя работу по устранению технического долга и другим ресурсоёмким направлениям. В таком режиме разработка продолжается и по сей день.

Таким образом, проект был начат усилиями нескольких (порядка пяти) разработчиков и в лучшие времена насчитывал около двадцати человек. Стабильным значением в последние годы можно считать что-то около десяти-пятнадцати человек, отвечающих за разработку и поддержку фреймворка и выпуск шести портированных продуктов.

Автор этих строк присоединился к компании в середине 2016 года, начав работать в одной из команд, транслировавших свой код с использованием «общего» решения. Зимой того же года, когда было принято решение о воссоздании «центральной» комадны, я перешёл на позицию её тимлида. Таким образом, мой опыт в проекте на сегодняшний день составляет более трёх с половиной лет.

Автономность команд, отвечающих за выпуск портированных продуктов, привела к тому, что в некоторых случаях разработчикам оказалось проще дополнять портер режимами работы, чем достигать компромиссов в том, как он должен вести себя по умолчанию. Этим объясняется большее, чем можно было бы ожидать, число доступных опций при конфигурировании портера.

Технологии


Настало время поговорить о технологиях, используемых в проекте. Портер представляет собой консольное приложение, написанное на языке C#, поскольку в таком виде его проще встраивать в скрипты, выполняющие задачи типа «портировать-скомпилировать-прогнать тесты». Кроме того, имеется GUI-компонент, позволяющий достигать тех же целей щелчками на кнопках.

За синтаксический разбор кода и разрешение семантики отвечает древняя библиотека NRefactory. К сожалению, на момент начала проекта Roslyn ещё не был доступен, хотя миграция на него, разумеется, стоит у нас в планах.

Портер использует проходы по AST-дереву для сбора информации и генерации выходного кода на C++. При генерации кода C++ AST-представление не создаётся, и весь код сохраняется в виде простого текста.

Во многих случаях портеру требуется дополнительная информация для тонкой настройки. Такая информация передаётся ему в виде опций и атрибутов. Опции применяются ко всему проекту сразу и позволяют задать, к примеру, имена макросов экспорта членов классов или определения препроцессора C#, используемые при анализе кода. Атрибуты навешиваются на типы и сущности и определяют обработку, специфичную для них (например, необходимость генерации ключевых слов «const» или «mutable» для членов классов или исключения их из портирования).

Классы и структуры C# транслируются в классы C++, их члены и исполняемый код транслируются в ближайшие эквиваленты. Generic-типы и методы отображаются на шаблоны C++. Ссылки C# транслируются в умные указатели (сильные или слабые), определённые в Библиотеке. Более подробно о принципах работы портера будет рассказано в отдельной статье.

Таким образом, исходная сборка C# преобразуется в проект на языке C++, который вместо библиотек .Net зависит от нашей общей библиотеки. Это показано на следующей диаграмме:



Для сборки библиотеки и портированных проектов используется cmake. На данный момент поддерживаются компиляторы VS 2017 и 2019 (Windows), GCC и Clang (Linux).

Как было сказано выше, большинство наших реализаций .Net представляют собой тонкие прослойки над сторонними библиотеками, выполняющими основную работу. Это включает в себя:

  • Skia — для работы с графикой;
  • Botan — для поддержки функций шифрования;
  • ICU — для работы со строками, кодировками и культурами;
  • Libxml2 — для работы с XML;
  • PCRE2 — для работы с регулярными выражениями;
  • zlib — для реализации функций сжатия;
  • Boost — для различных целей;
  • несколько других библиотек.

Как портер, так и библиотека покрыты многочисленными тестами. Тесты библиотеки используют фреймворк gtest. Тесты портера написаны, в основном, на NUnit/xUnit и разбиваются на несколько категорий, удостоверяя, что:

  • вывод портера на данных входных файлах совпадает с целевым;
  • вывод портированных программ после их компиляции и запуска совпадает с целевым;
  • тесты NUnit из входных проектов успешно преобразуются в тесты gtest в портированных проектах и проходят;
  • API портированных проектов успешно работает в C++;
  • влияние отдельных опций и атрибутов на процесс трансляции соответствует ожидаемому.

Для хранения исходного кода мы используем GitLab. В качестве среды CI выбран Jenkins. Портированные продукты доступны в виде пакетов Nuget и в виде архивов для скачивания.

Проблемы


Во время работы над проектом нам пришлось столкнуться с большим количеством проблем. Одни из них были ожидаемы, тогда как другие проявились уже в процессе. Коротко перечислим основные из них.

  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. Сложности с защитой интеллектуальной собственности.
    Если код C# достаточно легко обфусцируется коробочными решениями, то в C++ для этого приходится прилагать дополнительные усилия, поскольку многие члены классов не могут быть удалены из заголовочных файлов без последствий. Трансляция обобщённых классов и методов в шаблоны также создаёт уязвимость, обнажая алгоритмы.

Несмотря на это, проект весьма интересен с технической стороны. Работа над ним позволяет многое узнать и многому научиться. Академичность задачи также способствует этому.

Резюме


В рамках работы над проектом нам удалось реализовать систему, которая решает интересную академическую задачу ради её прямого практического применения. Мы организовали ежемесячный выпуск библиотек компании на языке, для которого они первоначально не предназначались. Оказалось, что большинство проблем вполне решаемо, а получившееся решение — надёжно и практично.

В скором времени планируется публикация ещё двух статей. В одной из них будет подробно, с примерами, рассказано о том, как работает портер и как конструкции C# отображаются на C++. В другой речь пойдёт о том, как нам удалось обеспечить совместимость моделей памяти двух языков.

На вопросы я постараюсь ответить в комментариях. Если читатели проявят интерес к другим аспектам нашей разработки и ответы начнут выходить за рамки переписки в комментариях, мы рассмотрим возможность публикации новых статей.
Tags:
Hubs:
+14
Comments27

Articles