Мы в Смартомато — супер продуктовые ребята. Со временем у нас скопились недовольство по поводу работы с Golang, захотелось залезть в технические дебри. Произошла гиперкомпенсация и мы придумали препроцессинг в Go. Да, несмотря на то, что этот язык официально не поддерживает препроцессоры —  мы всё равно сделали. А сейчас делимся результатами этой работы с вами.

Привет, Хабр! Меня зовут Марк Чолак, я бэкенд-разработчик в Смартомато. За 5 лет работы с Go и 8 лет в фудтехе я переписал немало шаблонного кода. В какой-то момент переделывать надоело настолько, что родилась идея — построить препроцессинг поверх стандартного тулчейна Go. Мы не изобретали препроцессоры, как в других языках. Мы взяли флаг -toolexec, добавили немного магии с go:linkname, go:line, AST и… получили инструмент, который умеет внедрять OpenTelemetry (и не только) без строчки кода в исходниках. Это рассказ о боли, решениях, неожиданностях и архитектуре нашего псевдопрепроцессора, который всё-таки работает. Да, даже с поддержкой инкрементальной компиляции из коробки. И нет, мы почти не нарушали этику.

Эта статья по мотивам моего доклада на Golang Conf X 2025 о болях шаблонного кода и вариантах их решения с помощью препроцессинга на базе стандартного тулчейна Go. В тексте не будет сравнения с препроцессорами в других языках и морально-этической оценки решения.

Глобальный план:

  • Пишем ядро для препроцессинга (это такая абстрактная единица).

  • Пишем кастомный препроцессор для вставки OpenTelemetry на базе ядра.

  • Разбираем основные проблемы (очевидные и не совсем) и по возможности их решаем .

Термины и история. Но ведь препроцессоров… не существует!

Да, Go официально не поддерживает препроцессоры:

  • В языке нет механизма препроцессинга.

  • У мейнтейнеров не стояло цели добавлять его в язык.

Действительно, ни в спецификации, ни в компиляторе нет ни слова про препроцессинг, и перед core командой Go такой задачи не стояло. Поэтому когда мы будем говорить про предварительные операции над исходным кодом, на самом деле речь будет идти про псевдопрепроцессор, который имитирует его поведение. По сути, это отдельная программа. Да, она написана на Go, на стандартном тулчейне, но это отдельный инструмент.

Наше решение препроцессинга:

  • Отдельный инструмент «в стороне».

  • Используются внутрянки языка: -toolexec, go:linkname/line.

  • Псевдопрепроцессор, но на базе стандартного тулчейна.

Что же мы подразумеваем под препроцессингом? Думаю, это холиварный вопрос. Но предлагаю условиться на том, что препроцессинг — это некий процесс, который на этапе компиляции добавит в итоговый артефакт сборки некий код, который изначально отсутствует в исходных файлах.

Как пример, у нас есть функция, которая что-то делает. А мы хотим при запуске каждой такой функции выводить в терминал её название.

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

И такое вполне возможно сделать в Go.

Проблема работы над компилятором в go

В далёком 2015 году Go-разработчики частенько сталкивались с рутинными проблемами при работе над компилятором:

  • Вручную сверяли результаты компиляции, чтобы понять, всё ли корректно компилируется.

  • Постоянно переключались между версиями компилятора.

  • Тратили много времени на отлов регрессии, поскольку компилятор мог сломаться не в текущей или прошлой версии, а 5−10 версий назад.

В какой-то момент небезызвестный Рас Кокс заявил, что это надо исправлять. И сделал — добавил в компилятор новый флаг -toolexec*, на основе которого фактически описал препроцессор, который либо полностью, либо частично, но максимально закрывал все рутинные проблемы разработчиков Go.

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

Сценарии применения

Я собрал ряд сценариев, в рамках которых может пригодиться препроцессинг.

Обфускация билдов 

На этапе компиляции можно провести ряд манипуляций, которые впоследствии усложнят реверс-инжиниринг бинарного файла.

  • заменит на хеши все идентификаторы, имена файлов и пакетов;

  • «перемешает» номера строк;

  • вырежет отладочную информацию и таблицу символов.

Это, кстати, вполне реальный инструмент. Он есть в open-source на GitHub, если нужно, можно воспользоваться.

Утилиты для работы над самим компилятором

С 2015 года и по текущий день core команда Go по-прежнему пользуется инструментом, созданным Расом Коксом, и всем довольна.

Comptime-вызов функций 

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

Встраивание трейсинга в проект

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

  • alibaba/opentelemetry-go-auto-instrumentation

  • DataDog/orchestrion

  • open-telemetry/opentelemetry-go-instrumentation

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

Какую проблему решаем

Мы решаем проблему шаблонного кода. Опять шаблонный код!

Мы привыкли к шаблонному коду в обработке ошибок. Но вот мы пишем замечательную функцию с бизнес-логикой, всё хорошо.

Потом вспоминаем, что нам ещё нужно добавить OpenTelemetry. И тут наша функция разрастается, причём обслуживающим кодом.

Сам по себе этот код — неплохой: достаточно простой, понятный, что называется boilerplate. Но, к сожалению, он требует к себе столько внимания во время работы над кодовой базой, что по сути это мы начинаем его обслуживать. А нам же нужно облегчить жизнь разработчика, чтобы за минимальное количество усилий и действий, с помощью одного импорта или и вовсе без него, дать ему возможность добавить OpenTelemetry в проект.

Мы ведь разрабатываем на Go, у нас есть такая замечательная вещь, как кодогенерация. Почему бы не использовать её? Рассказываю, почему этот вариант не подошёл.

Почему не кодогенерация: hexdigest/gowrap — генератор декораторов в go

Кодогенерация требует особенного подхода:

  • Определённой архитектуры и использования интерфейсов.

  • Запуска дополнительных команд во время разработки.

  • Коммита сгенерированных файлов в репозиторий.

Усложняется всё тем, что кодогенерация не позволяет оборачивать функции. Такженам необходимо использовать интерфейсы там, где мы их могли бы не использовать вовсе. Задекорировать, например, те же функции зачастую либо невозможно, либо очень сложно, их нужно рефакторить.

Ядро препроцессинга

Приступим к написанию ядра. Для этого нужно разобраться в основах, а именно в нашем главном герое сегодняшнего доклада — флаге -toolexec.Основы -toolexec

Флаг -toolexec позволяет вместо прямого вызова фаз компиляции обернуть эти вызовы в указанную нами команду. То есть, это своего рода middleware, proxy.

Мы вызываем стандартный go build, и запрос сначала идёт в toolexec программу, а уже после этого в компилятор. Благодаря этому есть возможность сделать что-то в toolexec-программе.

Фазы компилятора

Есть всего три фазы компилятора:

  1. compile, где происходит компиляция кода и его оптимизация, в том числе;

  2. asm, где генерируется код ассемблера;

  3. link — фаза линковки, где все промежуточные результаты сборки сшиваются и превращаются в один бинарный файл, грубо говоря.

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

Если мы воспользуемся этой фазой, то также получим дополнительные оптимизации, которые компилятор Go так или иначе проводит.

Фаза compile, как и любая другая, — это подпрограмма стандартного go tool с безумным количеством аргументов. Можно посмотреть полный список отдельной командой: go tool compile --help, но нас интересуют только четыре основных:

  1. Флаг std означает, что мы сейчас будем компилировать стандартную библиотеку. Он нужен, чтобы этого не делать. Причины, почему не будем — опишем дальше в статье.

  2. Флаг buildid — с помощью него добавляем поддержку инкрементальной компиляции в препроцессор.

  3. Флаг importcfg, чтобы мы могли восстановить новые импорты, которые добавили во время препроцессинга.

  4. Флаг pack, в котором перечислены все файлы, которые на текущий момент компилятор собирается компилировать.

Теперь перейдём к построению фундамента.

Строим фундамент

Сначала определимся, как будет вызываться наш препроцессор.

Как вызывается препроцессор

Для вызова препроцессора вызываем обычную go build и указываем флаг toolexec с обозначением препроцессора. Впоследствии компилятор go перевызовет наш препроцессор в виде основной программы со всеми сопутствующими аргументами и указанием фазы.

Таким образом, мы понимаем, что у нас обычный исполняемый файл, программа, точка входа в main — парсим аргументы и что-то с этим делаем. Первое, что нам стоит сделать, это проверить, находимся ли мы на нужном этапе компилятора, а именно compile.

Если всё совпадает, то продолжим работу, а если нет, то должны выйти и ничего не сделать. Но мы не можем так поступить, поскольку компилятор полностью отдал управление в нашу toolexec-программу, и если мы просто выйдем, то никакая работа не будет выполнена вообще — компиляция упадёт. Поэтому мы добавляем небольшую обёртку, которая перевызовет оригинальную фазу компилятора со всеми оригинальными аргументами для того, чтобы ничего не сломать.

Пропускаем пакеты std lib

Следующее, что мы сделаем — будем избегать препроцессинга стандартной библиотеки.

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

Извлекаем список файлов

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

Напоминаю, что Go компилирует файлы попакетно. Пишем для этого простую функцию, собираем все файлы в слайс строк.

Важный момент: если вдруг что-то идёт не так — пришли странные аргументы, или вообще не пришли, или мы не смогли открыть файл, то обычно паникуем.

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

Пропускаем «чужие» файлы

«Чужие» — это файлы из third-party библиотек. Их особенности:

  • Идентичные с std lib проблемы.

  • Могут потребоваться для специфичных решений.

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

Пишем для этого простую функцию, проверяем, всё ли подходит. Если файл не относится к нашему проекту, просто пропускаем.

Зафиксируем наш прогресс.

На первом этапе мы ещё не делаем никакой работы, а лишь проводим ряд проверок. Если хотя бы одна из них не проходит,говорим компилятору: «Забирай, делай дальше свою работу сам, мы ничего делать не будем».

Работа с файлами и фикс импортов

На следующем этапе мы работаем с файлами и фиксим импорты, которые добавим во время препроцессинга.

Обработка файлов

Для обработки файлов мы пишем простой цикл, в рамках которого каким-то образом модифицируем наши файлы. 

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

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

Можно ли просто прочитать файл и работать с ним как с текстовым контентом?

На самом деле можно, но лучше этого не делать:

  • Отсутствует структурированная информация о типах и данных.

  • Сложность с обходом конкретных видов деклараций. Например, в рамках препроцессинга нам нужно обойти только функции или методы, возможно, переменные. Фактически, если мы работаем как с текстовым контентом, нам нужно будет реализовать свой парсер или лексер. А зачем это делать, если можно этого не делать? Поэтому мы добавим немного обвязок.

Во-первых, мы будем работать с файлом как с синтаксическим деревом и впоследствии превращать его обратно в текстовый контент. Также нам понадобится packagesResolver.

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

Мы воспользуемся одной сторонней библиотекой от Дэйва Чейни (dave/dst), которая является надстройкой над оригинальным go/ast. Стандартный go/ast предназначен для работы с синтаксическим деревом. В нём всё хорошо, но отсутствует нормальная поддержка комментариев в синтаксическом дереве файла. Они там есть, но плавающие, то есть непонятно, к чему конкретно относятся. Дэйв устал это терпеть и достаточно давно сделал свою обёртку, в которой это пофиксил. Мейнтейнеры Go обещают в следующей версии языка (v1.25) это исправить. Правда, это не точно, они обещали это и в прошлой версии, и позапрошлой, но мы верим и надеемся.

https://github.com/golang/go/issues/20744

Теперь мы можем работать с файлом — Спасибо Дэйву Чейни за dave/dst!

Мы трансформируем файл в абстрактное синтаксическое дерево, а дальше объявляем интерфейс с единственным публичным методом, который что-то делает, проводит какие-то модификации.

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

Восстановление импортов 

Это достаточно простая операция. 

Добавим новые пакеты:

Пройдёмся по всем импортам в нашем синтаксическом дереве и, если видим, что присутствует новый импорт, которого изначально не было, добавим его в файл importCfg. Это обычный текстовый файл с простым форматом, добавляем нужное количество строк со всеми импортами, и у нас всё работает.

Пишем временный файл 

После этого необходимо трансформировать синтаксическое дело в обычный файл, записать его во временную директорию, и всё, казалось бы, готово.

Вы могли обратить внимание на странный магический комментарий в начале файла:

Что же это такое? Предлагаю начать с того, зачем он нужен. Мы обманули компилятор и подставили ему неоригинальные файлы. Компилятор сказал: «Хорошо, я принимаю» и добавил в метаинформацию бинарного файла указания на этот файл.

В случае паники стек-трейс укажет на страшные файлы, которые ещё и отсутствуют в системе. Нас, конечно, это не устраивает, и нам может помочь директива line.

Директива //line означает, что следующая за ней строка исходного кода должна ссылаться на указанный файл и строку.

Компилятор Go поддерживает ряд магических комментариев, они же директивы, которые позволяют что-то делать на этапе компиляции. Директива line говорит: «Компилятор, смотри, весь код ниже на самом деле относится к следующему файлу (который мы укажем) и начинается с определённого номера строки». Так как мы обладаем информацией, какой файл сейчас подвергается препроцессу, то добавляем эту строчку с помощью директивы line, таким образом сохраняем корректные стек-трейсы.

Мы исправили собственную проблему, но при этом не усложнили дальнейшую отладку.

Подменяем команду компиляции

Теперь остаётся перевызвать оригинальную фазу компилятора с оригинальными аргументами, лишь подменив файлы.

Для этого вызываем go tool compile с изменёнными аргументами:

Препроцессинг Routine

Таким образом мы подходим ко второму глобальному этапу — препроцессинг Routine.

Мы делаем конкретные действия, но они всё ещё достаточно абстрактные и не содержат специфики конкретной проблемы. Это маленькие кирпичики, на основе которых можнонаписать что-то конкретное, а именно встроить OpenTelemetry в проект.

Вставляем код OpenTelemetry

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

  1. executable — исполняемая программа (сам препроцессор), которая модифицирует файлы.

  1. package — пакет с кодом для вставок.
    Это библиотека, в которой мы будем описывать ряд кодовых вставок, которые импортируем в проект на этапе препроцессинга.

Используем интерфейс ядра

Ранее в нашем ядре мы объявили интерфейс с одним методом, который в форме коллбэка можем вызывать в нашем препроцессоре.

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

Реализация конкретного препроцессора выглядит так:

Поэтому конкретный препроцессор реализует этот интерфейс и в дальнейшем вызывает один-единственный метод — Process, в который передаёт реализацию нашего препроцессора.

Вставки кода

Далее в библиотеке в самом пакете мы описываем кодовые вставки. Используем хелперы для трейсинга.

Хотя мы можем напрямую добавлять вставки OpenTelemetry в код пользователя, лучше этого не делать, поскольку нам важно, какое количество кода мы вставляем в проект разработчика.

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

Так как мы работаем с синтаксическим деревом, то не можем просто написать код в виде текста и вставить его. Нам необходимо реализовать код с помощью ast-нод. Это достаточно развесистые структуры, они занимают много места.

Но по сути они простые, изучив документацию, базовые вещи можно начать писать достаточно быстро. Мы будем делать любые вставки кода именно с помощью ast-нод.

Инициализация провайдера

Последним шагом необходимо проинициализировать сам TracerProvider.

К сожалению, самый адекватный вариант бесшовной интеграции — инициализация в функции init. Да, она немного с «душком», но для простоты лучше воспользуемся этим вариантом.

Компилируем и запускаем проект

После этого мы компилируем препроцессор и с его помощью вызываем команду go build -toolexec=preprocessor, указав наш препроцессор.

Таким образом мы компилируем код с использованием нашего препроцессора и запускаем проект. 

Ничего не работает. Решаем первые проблемы

Заходим в Jaeger, ждем 5 секунд, 15, минуту…

Ничего нет — ни спанов, ни трейсов, даже сервис в списке не появился.

Было интересно

Узнали много нового

Всё зря…

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

Что сделает любой уважающий себя инженер? Попытается понять, в чём же проблема:

  • Есть ли измененные временные файлы?

  • Корректно ли сформированы аргументы?

  • Точно вызываем?

Вроде бы всё в порядке. Так в чём же тогда проблема? Во время диагностики в моей голове всплыла фраза коллеги-фронтендера, когда ему приносят странные задачи:

Коллеги, а вы кэш почистили перед тем, как мне это нести?

Я подумал, почему бы не перекомпилировать весь проект целиком, включая все сторонние пакеты и пакеты стандартной библиотеки. Благо в Go для этого есть отдельный флаг -a, который фактически инвалидирует весь кэш.

Вызываем компиляцию с этим флагом и запускаем наш проект повторно. Заходим в Jaeger и видим, что наконец появился трейсинг без строчки кода:

Заходим в спаны — всё есть, даже присутствует стек вызова, который мы реализовали за кадром в нашем препроцессоре.

Казалось бы, задача закрыта, всё готово. Но нет. Мы же не можем каждый раз компилировать проект, полностью инвалидируя кэш, особенно в Go, где ценится скорость компиляции. Зато мы можем выдвинуть гипотезу, что вся проблема — в кэше.

Инкрементальная компиляция

Посмотрев на порядок вызова разных частей тулчейна Go, мы увидим, что go build направляется в кэш, как он попадет в компилятор и, соответственно до того, как он попадет в toolexec. Да, он выполняет работу, пишет какие-то файлы. Но, возможно, мы случайно в рамках тестирования каким-то образом меняли код проекта и видели какую-то работу, но это очень малая часть и все остальные файлы под неё не попали.

Разработчики Go об этом заранее подумали, и перед вызовом каждой фазы компилятора вызывается некая preflight-команда, которая состоит из одного единственного аргумента v=full.

Команда запрашивает у любой вызываемой программы, будь то программа самого языка Go или сторонняя, как в нашем случае, уникальный хэш для текущей сборки. Мы можем добавить поддержку этой preflight-команды.

Модифицируем строку хэша:

Мы проверяем, приходит ли нам эта команда — да, действительно, она есть, и можнокаким-то образом изменить строку хэша.

Строка хэша обладает достаточно мистифицированным форматом. Она нигде нормально не описана, искали её по сорсам. Но, по сути, всё, что нас интересует — это префикс, где мы указываем наш препроцессор, его версию и уникальный хэш после последнего слэша.

Что получим в итоге? 

  • Рабочий код. Не придётся вызывать странные дополнительные флаги, все будет работать, как мы задумывали.

  • Поддержку инкрементальной компиляции в нашем препроцессоре. Если мы будем компилировать наш код повторно с помощью препроцессора, то он будет перекомпилировать только те вещи, которые изменились с момента прошлой компиляции. Таким образом работает и обычный go build.

  • Не ломаем «ванильный» кэш. Если разработчик впоследствии захочет скомпилировать проект уже без препроцессора, тулчейн Go пойдёт в этот оригинальный кэш, и они никак не будут пересекаться с кэшом препроцессора. Мы эту проблему победили.

Компилируем и запускаем проект

Компилируем наш код уже без инвалидации кэша, запускаем его.

Заходим в Jaeger и видим, что всё прекрасно работает без костылей.

Но очень скоро мы можем столкнуться с рядом проблем. 

Блиц: проблемы и их решения

Проблемы с дебаг информацией

  • Паника в юзер-коде.

  • Мисматч нейминга файлов.

  • Мисматч нумерации строк.

Их решаем с помощью директивы line.

Первое, что нас интересует — что будет, если в юзер-коде, то есть в коде самого проекта, произойдёт паника.

Представим, что у нас есть некая функция, которая только выкидывает панику, и это действительно случается. 

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

Тут всё логично — появляется смещение. Нас такой вариант не устраивает. Это невозможно дебажить.

Но мы можем воспользоваться уже знакомой нам директивой line и пофиксить это смещение по строкам. 

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

Применим этот подход. Компилятор Go достаточно непривередлив, ему можно скормить даже отрицательное значение строки, он потом автоматически автоинкрементит все последующие строки.

Добавляем такие комментарии во все наших вставки, и всё будет работать. Оригинальные стек-трейсы ведут на нужный нам участок кода.

А что о std?

Это сделать можно, но очень быстро мы столкнемся с циклическими зависимостями: ведь в стандартную библиотеку будем встраивать код, который, в свою очередь, импортирует std. И, конечно, такой код не скомпилируется.

Можно воспользоваться ещё одной магической директивой Go под названием go:linkname.

Эта директива говорит компилятору, что реализация некой функции или метода находится в другой функции или даже в другом пакете, возможно, это неэкспортируемая функция и неэкспортируемый пакет.

Вы очень удивитесь, как многопопулярных библиотек пользуются таким приёмом. Хотя разработчики Go потихоньку эту лавочку прикрывают. Но точно ли оно того стоит? Это нетривиальная задача в уже нетривиальной задаче, и нужно заранее понимать, осилим ли мы такие риски.

Дебаг несуществующего кода

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

Разработчики из Alibaba Cloud предлагают записывать модифицированные файлы в явном виде в директорию проекта. У этого решения есть несколько плюсов:

  • Аналогичный подход, как в кодогенерации.

  • Файлы никак не влияют на код проекта.

  • Коммит модифицированных файлов в репозиторий.

Правда теряется «магия» препроцессинга, но для кого-то это плюс.

Мы создаём не временные файлы, которые потом удаляем, а записываем их прямо в директорию с проектом, сохраняя иерархию и названия. Здесь важно, что эти файлы потом никак не будут задействованы в проекте. Они просто представлены в виде превью-режима. Но встаёт вопрос, а точно ли это тот самый препроцессинг? Мы по сути уже превращаемся в некую кодогенерацию на стероидах, но для кого-то это, возможно плюс — всё перед глазами, ещё и само работает. Стоит понять, что подходит вам больше всего.

Кастомизация функционала

Если мы хотим каким-то образом регулировать или конфигурировать работу нашего препроцессора, есть несколько вариантов, ни один из которых не является серебряной пулей:

  • Флаги запуска: prone to errors, эвристика позиций флагов. Здесь начинаются проблемы с эвристикой флагов. Во-первых, нам нужно избежать конфликта с флагами самого компилятора Go. Мы должны понимать, где наши флаги, а где флаги компилятора. Там многое зависит от позиций — в целом, достаточно шаткая история.

  • В виде кастомных директив-комментариев. Вариант в виде магических комментариев тоже не особо подходит, поскольку мы хотели убрать с плеч разработчика ответственность за весь OpenTelemetry. А здесь у нас течёт абстракция.

  • Через отдельную обёртку. Вместо того, чтобы вызывать go build toolexec, мы будем вызывать препроцессор в качестве основной программы, которая просто проксирует все вызовы команды Go, будь то go build или go test. Но, опять же, останется ли это тем же самым препроцессором или уже нет?

Выводы

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

Риски

  • Не идиоматическое решение.

  • Дополнительное время на понимание механизма работы инструмента членами команды.

  • Сложность в решении потенциальных проблем.

Но всё же хотелось бы понимать, когда стоит его применять, а когда не стоит. Ведь риски есть всегда.

Не стоит применять, если:

  • Нужна гибкость и точный контроль.

  • Нет ресурсов на объяснение принципа работы команде/командам.

  • Хочется сохранить прозрачность сборки.

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

Стоит применять, если:

  • Необходимо быстро добавить в проект трейсинг.

  • Нужна детализация, а ресурсов на это нет.

  • Хочется поэкспериментировать.

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

Развитие направления

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

  • Составлен список «болевых точек».

  • Выдвинуты потенциальные решения на обсуждение.

  • Альтернативный вариант с -overlay.

Читать здесь: https://github.com/golang/go/issues/69887

Комьюнити не сидит сложа руки. В начале 2025 года Alibaba Cloud, DataDog и Quesma вступили в комитет CNCF для стандартизации решения: https://opentelemetry.io/blog/2025/go-compile-time-instrumentation/

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

GitHub с PoC

Ссылка на мой GitHub, где реализовано ядро препроцессинга:

https://github.com/pijng/goinject

В Readme примеры реализованных препроцессоров:

  • инструментарий OpenTelemetry

  • comptime-вызов функций

  • #ifdef

Если хотите узнать больше про современные возможности Go, приходите на GolangConf в следующем году: обсудим, что и как применять, чего ждать в будущем и какие фичи в языке нам нужны.

А по всем вопросам по статье можно писать в личку автору напрямую в Telegram.

Комментарии (0)