Как писать кодогенераторы в Go

Kate

Administrator
Команда форума
Однообразный код писать неинтересно, нудно, но приходится. Испокон веков изворотливые программисты ищут Святой Грааль формализма, позволяющего переложить рутинные задачи на машину, писать только раз и переиспользовать код. Так появились структурное программирование, потом объектно-ориентированное, полиморфизм с параметризованными типами, кодогенерация на основе формальных грамматик, препроцессоры макроязыка и прочее… Под катом рассмотрим, как обстоят дела именно в Go.


В Go на сегодня generics нет (хоть третий год и обещают), а выписывать по шаблону GetMax([]MyType) для каждого MyType надоедает.

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

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

Стандартного препроцессора в Go тоже нет. Зато есть директива go:generate и есть доступ к потрохам компилятора, в частности к дереву разбора (Abstract Syntax Tree), в пакетах go/ стандартной библиотеки. Это в совокупности даёт инструментарий богаче, чем препроцессор макросов.

Идиоматическое применение интерфейсов реализовано в stdlib-пакете sort, интроспекция применяется в пакетах encoding и fmt, go:generate — в придворном пакете golang.org/x/tools/cmd/stringer.

Манипулирование AST исходного кода не очень распространено, потому что:

  • кодогенерацию трудно верифицировать;
  • дерево разбора кажется сложным, непонятным и пугает.
Как раз на использовании AST в быту мы и остановимся.

Go- и JS-разработчик Открытой мобильной платформы Дима Смотров рассказал, как писать кодогенераторы в Go и оптимизировать работу над микросервисами с помощью создания инструмента для генерации шаблонного кода. Статья составлена на основе выступления Димы на GopherCon Russia 2020.

О продуктах и компонентах на Go​

Наша команда разрабатывает мобильную ОС Аврора, SDK и экосистему приложений под неё, доверенную среду исполнения Аврора ТЕЕ, систему по управлению корпоративной мобильной инфраструктурой Аврора Центр, включающую несколько «коробочных» продуктов и компонентов.

Группа Дмитрия, в частности, работает над продуктом Аврора Маркет, который обеспечивает управление дистрибуцией приложений. Его бэкенд полностью написан на Go.

В Go принято отдавать предпочтение явному программированию (explicit) в противовес неявному (implicit). Это помогает новым разработчикам легче начинать работать над существующими проектами. Но по пути от неявного программирования к явному можно легко заблудиться и забрести в дебри дубляжа кода, а дубляж кода в дальнейшем превратит поддержку проекта в ад.

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

Кодогенерация — официальный инструмент от авторов Go​

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

И хотя в Go принято отдавать предпочтение явному программированию, разработчики предоставили инструменты для метапрограммирования, такие как кодогенерация ($go help generate) и Reflection API. Reflection API используется на этапе выполнения программы, кодогенерация — перед этапом компиляции. Reflection API увеличивает время работы программы. Пример: инструмент для кодирования и декодирования JSON из стандартной библиотеки Go использует Reflection API. Взамен ему сообществом были рождены такие альтернативы, как easyjson, который с помощью кодогенерации кодирует и декодирует JSON в 5 раз быстрее.

Так как кодогенерация — неявное программирование, она недооценивается сообществом Go, хотя и является официальным инструментом от создателей этого языка программирования. Поэтому в интернете немного информации о написании кодогенераторов на Go. Но всё же на Хабре примеры есть: 1 и 2.

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

Пример дублирующего кода:

type UserRepository struct{ db *gorm.DB }

func NewRepository(db *gorm.DB) UserRepository {
return UserRepository{db: db}
}

func (r UserRepository) Get(userID uint) (*User, error) {
entity := new(User)
err := r.db.Limit(limit: 1).Where(query: "user_id = ?", userID).Find(entity).Error
return entity, err
}

func (r UserRepository) Create(entity *User) error {
return r.db.Create(entity).Error
}
func (r UserRepository) Update(entity *User) error {
return r.db.Model(entity).Update(entity).Error
}

func (r UserRepository) Delete(entity *User) error {
return r.db.Delete(entity).Error
}

Про удачные кодогенераторы​

Из примеров написанных и удачно используемых в нашей команде кодогенераторов хотим подробнее рассмотреть генератор репозитория по работе с базой данных. Нам нравится переносить опыт из одного языка программирования в другой. Так, наша команда попыталась перенести идею генерации репозиториев по работе с базой данных из Java Spring (https://spring.io/).

В Java Spring разработчик описывает интерфейс репозитория, исходя из сигнатуры метода автоматически генерируется реализация в зависимости от того, какой бэкенд для базы данных используется: MySQL, PostgreSQL или MongoDB. Например, для метода интерфейса с сигнатурой FindTop10WhereNameStartsWith (prefix string) автоматически генерируется реализация метода репозитория, которая вернёт до 10 записей из базы данных, имя которых начинается с переданного в аргументе префикса.

О нюансах и траблах внедрения кодогенератора​

Существует парадигма Monolith First, когда пишут первую версию как монолит, а потом распиливают на микросервисы. На заре новой версии проекта, когда все команды должны были разбить монолит на микросервисы, мы решили написать свой генератор, который:

  • позволит вводить в систему новые микросервисы с меньшими усилиями, чем при его создании вручную (копируя предыдущий и удаляя лишнее);
  • сократит время на код-ревью за счёт общего шаблона для генерируемых микросервисов;
  • сократит время на будущие обновления одинакового кода микросервисов (main, инфрастуктура, etc…).
Для разработки микросервисов командами было принято решение использовать go-kit. За основу мы взяли один из популярных существующих кодогенераторов для go-kit и стали его дорабатывать под наши требования для микросервисов. Он был написан с использованием не очень удобной библиотеки, которая использовала промежуточные абстракции для генерации кода Go. Код получался громоздким и трудным для восприятия и поддержки. В будущих версиях мы отказались от такого подхода и начали генерировать код Go с помощью шаблонов Go. Это позволило нам писать тот же самый код без каких-либо промежуточных абстракций. За пару недель нашей командой был написан прототип. А ещё через месяц был написан кодогенератор для go-kit, который буквально умел делать всё.

Разработчик описывает интерфейс go-kit-сервиса, а кодогенератор генерирует сразу всё, что для сервиса нужно:

  • CRUD-эндпоинты и REST-, gRPC- и NATS-транспорты;
  • репозиторий для работы с базой данных с возможностью расширять интерфейс репозитория;
  • main для всех go-kit-сервисов.
После того как кодогенератор был закончен, началось его внедрение. Мы сразу же столкнулись с проблемами. Разработчики компании неохотно принимали кодогенератор. Он генерировал слишком много кода, который нужно было ревьюить и перерабатывать. Сначала команды помогали исправлять генерируемый код, но подошло время уже разрабатывать сами микросервисы, а не кодогенератор для генерации сервисов. В итоге одни команды продолжали использовать кодогенератор, обходя его баги, а другие начали писать микросервисы без кодогенератора. Мы получили сегментацию сервисов. Когда инфраструктура менялась, изменения давались нам очень тяжело.

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

Когда наша команда вернулась к разработке своего основного продукта, мы провели ретроспективу кодогенератора и поняли, в чём была основная проблема. Кодогенераторы, которые «генерируют ВСЁ», сложно внедрять и поддерживать.

  • Кодогенератор генерировал слишком много кода.
  • Весь код нужно было ревьювить и перерабатывать.
  • Только часть команд решила пользоваться кодогенератором.
  • Получили сегментацию микросервисов.
Мы исправили эту проблему. Сейчас кодогенератор разбили на несколько мелких, у каждого из которых своя собственная ответственность. Разработчики сами выбирают, какие из них использовать в своих продуктах, а какие нет.

Как же всё-таки генерировать Go-код​

Можно просто использовать шаблоны. Можно написать шаблон и начинить его параметрами, на это вполне способны продвинутые редакторы текста. Можно использовать неинтерактивные редакторы — sed или awk, порог входа круче, зато лучше поддаётся автоматизации и встраивается в производственный конвейер. Можно использовать специфические инструменты рефакторинга Go из пакета golang.org/x/tools/cmd, а именно gorename или eg. А можно воспользоваться пакетом text/template из стандартной библиотеки — решение достаточно гибкое, человекочитаемое (в отличие от sed), удобно интегрируется в pipeline и позволяет оставаться в среде одного языка.

И всё же для конвейерной обработки этого маловато: требует существенного вмешательства оператора.

Можно пойти по проторённому пути: gRPC, Protobuf, Swagger. Недостатки подхода:

  • привязывает к gRPC, Protobuf;
  • не заточен конкретно под Go, а, напротив, требует изучения и внедрения новых, сторонних абстракций и технологий.
Чтобы остаться в родных пенатах воспользуемся средствами из стандартной библиотеки — пакетами go/:

  • go/ast — декларирует типы дерева разбора;
  • go/parser — разбирает исходный код в эти типы;
  • go/printer — выливает AST в файл исходного кода;
  • go/token — обеспечивает привязку дерева разбора к файлу исходного кода.
Можно сделать шаблон, разобрать его AST и манипулировать этим деревом, подгоняя под нужный вид. Основной недостаток метода — плохая читаемость манипуляций с AST и трудоёмкость верификации.

Можно вытащить из AST параметры, вмонтировать в шаблон и всё. Недостаток — разрозненные обработки неудобно собирать в конвейер.

Поэтому выбран такой алгоритм кодогенерации:

  1. Разбираем AST исходного файла.
  2. Создаём пустое AST для генерируемого файла.
  3. Генерируем код из шаблонов Go (template/text).
  4. Разбираем AST сгенерированного кода.
  5. Копируем узлы AST из сгенерированного кода в AST генерируемого файла.
  6. Печатаем и сохраняем AST генерируемого файла в файл.
Чтобы было понятней и не пугала загадочная аббревиатура AST — дерево разбора Hello World:

package main

import "fmt"

func main() {
fmt.Println("Hello, World!")
}
...выглядит вот так:

a69e0d4274e80a4d3c15270b0e64b722.png

...или вот так, напечатанное специализированным принтером ast.Print():

ast.Print

Хватит трепаться, покажите код​

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

//repogen:entity
type User struct {
ID uint `gorm:"primary_key"`
Email string
PasswordHash string
}
...запустить go generate и получить вот такой файл с готовой обвязкой для работы с DB, в котором прописаны методы именно для его типа данных User:

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

Кода потребовалось не очень много, поэтому он представлен одним листингом, чтобы не терялась общая картина. Пояснения даны в комментариях, в стиле literate programming.

Вот модель, для которой нам нужно сгенерировать методы работы с DB. В комментариях видны директивы:

  • go:generate repogen — для команды go generate на запуск процессора repogen;
  • repogen:entity — помечает цель для процессора repogen;
  • и тег поля структуры gorm:"primary_key" для процессора gorm — помечает первичный ключ в таблице DB.
package gophercon2020

//go:generate repogen

//repogen:entity
type User struct {
ID uint `gorm:"primary_key"`
Email string
PasswordHash string
}
Вот код, собственно, процессора repogen:

Процессор repogen

Подводя итоги​

Работа с деревом разбора в Go не требует сверхъестественных способностей. Язык предоставляет для этого вполне годный инструментарий. Кода получилось не слишком много, и он достаточно читаем и, надеемся, понятен. Высокой эффективности здесь добиваться нет нужды, потому что всё происходит ещё до стадии компиляции и на стадии выполнения издержек не добавляет (в отличие от reflect). Важнее валидность генерации и манипуляций с AST. Кодогенерация сэкономила нам достаточно времени и сил в написании и поддержке большого массива кода, состоящего из повторяющихся паттернов (микросервисов). В целом кодогенераторы оправдали затраты на своё изготовление. Выбранный pipeline показал себя работоспособным и прижился в производственном процессе. Из стороннего опыта можем рекомендовать к использованию:

  • dst (у которого лучше разрешение импортируемых пакетов и привязка комментариев к узлам AST, чем у go/ast из stdlib).
  • kit (хороший toolkit для быстрой разработки в архитектуре микросервисов. Предлагает внятные, рациональные абстракции, методики и инструменты).
  • jennifer (полноценный кодогенератор. Но его функциональность достигнута ценой применения промежуточных абстракций, которые хлопотно обслуживать. Генерация из шаблонов text/template на деле оказалась удобней, хоть и менее универсальной, чем манипулирование непосредственно AST с использованием промежуточных абстракций. Писать, читать и править шаблоны проще).

Источник статьи: https://habr.com/ru/company/omprussia/blog/558690/
 
Сверху