Я люблю время от времени взять и переделать что-нибудь уже готовое. Цель не в том, чтобы сделать что-то лучше или доказать свою гениальность – я просто ищу опыт. Как получить опыт в разработке сложного инструмента, если ты берешь уже готовые фреймворки и пакеты и просто собираешь из них свое решение? Да, это быстро и часто довольно эффективно, но получаешь ли ты ценный опыт, узнаешь ли о вероятных подводных камнях и начнешь ли ты лучше понимать язык на котором программируешь? Конечно нет. Сразу скажу: велосипедостроение в коммерческой разработке — зло. Создавать что-то, что уже существует и было отлажено многократно – это бессмысленно. И учитывая время, необходимое для выхода на рынок (Time To Market), это еще и опасно. Кроме того, новый код — это новые ошибки. Именно поэтому часто проще взять готовое и дополнить его до нужного уровня при работе в реальных продуктовых проектах. Тем не менее, я противоречу сам себе, опровергая то, что сказал ранее. Я разработал на языке GO свой собственный easyjson в рамках именно продуктовой разработки, за что мне бесконечно стыдно. Если кто-то скажет, что я потратил деньги бизнес-заказчика в угоду своим амбициям, я не буду с ним спорить, но у меня были определенные причины, а главное теперь у меня есть интересный опыт. Об этом опыте я и расскажу. Для кого эта статья будет полезна. В первую очередь для энтузиастов, которые ищут развития и уже почти нащупали такую интересную нишу, как программирование программирования. Т. е. когда ты пишешь код, который пишет код. Во вторую очередь тем, кто интересуется интересными решениями и хочет приобрести аналогичный опыт. Ну и конечно же – хейтерам, которые уже сто раз читали про кодогенерацию и ничего нового тут не нашли. Все, что рассматривается в этой статье, написано на golang. Я пытался использовать easyjson для генерации кода из swagger-файлов (rest-серверный бойлерплейт) для проекта. У нас собственный генератор и все, чего ему не хватает – это эффективный способ генерации/парсинга JSON. Сгенерировать пользовательские типы данных на языке GO на основе их описания (на основе openapi или swagger) – дело техники, и самой сложной задачей в этом проекте оказалось подружить всю эту машинерию с easyjson генератором кода от майл.ру. Я разобрался, как взять их апи и подергать за нужные процедуры, но результат меня совершенно не устроил. Для того, чтобы все это работало, нужно добавлять сам генератор в go.mod, а готовый код никак не хотел потом запускаться через object.MarshalJSON (в случае, если использовалась композиция при описании структур данных) и все это выглядело так, как будто нам нужно затачивать свой кодогенератор под это. В результате короткого обсуждения было решено создать свой собственный кодогенератор JSON, который будет гибким и удобным для использования. A в случае чего жахнет где-нибудь, где не ждешь его можно будет где-то покрутить и подстроить. Если реализовать это отдельным пакетом, можно оставить возможность использовать easyjson в будущем, как это делают все нормальные люди. Казалось, что все довольно просто, и я не собирался парсить что-либо сам. Я не настолько сумасшедший. У Александра Валялкина есть отличный инструмент fastjson, но под этот инструмент нет кодогенерации. Вот его то я и хочу взять за основу. Так что даже название нового проекта уже напрашивается само: valyjson. И вот как выглядит функция, запускающая парсинг (вся машинерия наполнения данными будет скрыта в функции FillFromJSON): А что насчет маршалинга? Кажется, я могу просто отправлять имена полей в какой-нибудь байтовый буфер, добавлять двоеточие и затем значения полей. Что может быть проще, чем писать «имя»: «значение», когда вы уже знаете имя поля и его значение. Если мы будем переиспользовать буфер, это поможет сэкономить память. Давайте же посмотрим, что нас ждет в этом захватывающем и неизведанном мире маршалинга. Я буду не я, если не установлю некоторые правила, которые помогут избежать некоторых ошибок, связанных с выстрелом себе в ногу. Некоторое время назад, выполняя рефакторинг Rename Field, я наткнулся на опасную ситуацию: программист считал, что можно легко поместить объект DTO в базу данных, как JSON, выполнив его JSON-маршалинг. Все было бы хорошо, если бы поля объекта имели JSON-теги. Но по закону Мерфи, если что-то может пойти не так, оно обязательно пойдет не так. Так что после изменения имени поля все поехало: старые значения не могли работать по старому, а новые после исправления ошибки приходилось править SQL-патчем. Все это произошло из-за неявного сопоставления имени поля структуры GO имени поля объекта JSON. Так что я сразу установил следующее правило: если хотя бы одно поле структуры не содержит JSON-тега, генерации кода не будет – все поля должны иметь тег. Если хотите игнорировать маршалинг поля, используйте стандартный тег игнорирования "-". Следующее правило заключается в поддержке обратной совместимости, чтобы можно было в любой момент сделать откат – убрать генерацию и вернуть стандартный маршализатор. Так что правило гласит, что сгенерированный код должен возвращать структуру JSON, полностью аналогичную структуре, которую возвращает стандартный (и для этого случая есть тесты). Теперь что касается неподдерживаемых типов данных или некоторых специфичных структур, которые генератор не может обработать. Генератор не должен создавать ситуацию, в которой он молча отбросил часть структуры, и никто не заметил, что логика обработки данных неполная. Поэтому, при возникновении такой ситуации, генерация кода должна прекращаться, а генератор должен сообщить об ошибке. Подытожим: Первое, что нужно решить – каким путем пойти: использовать шаблоны для печати кода, т.е. вставлять динамические элементы в готовый текст с помощью так называемых Go-шаблонов, или же воспользоваться абстрактным синтаксическим деревом (AST), генерируя динамически само дерево и печатая его в виде готового кода. Какой вариант предпочтительнее для вас? Рассмотрим плюсы и минусы каждого из них. Один из преимуществ использования шаблонизатора — это приближенная к WYSIWYG кодогенерация, т.е. вы сразу видите в общих чертах, что получится в итоге, можете изменять шаблон и ожидать соответствующие изменения после генерации. Если нужно будет что-то изменить, то легко визуально обнаружить, куда нужно внести эти изменения. Никаких неожиданностей – все точно до последнего символа. Шаблонизаторы поддерживают циклы, ветвления и прочие возможности и вряд ли попадется ситуация, когда мы не сможем разобраться, как нужно составить шаблон, чтобы получить желаемый код. А что же с синтаксическим деревом? Мы будем использовать килограммы бойлерплейта и литералов структур данных из пакета ast. На первый взгляд не всегда ясно, как с помощью AST получить нужный результат. Если какой-то атрибут остается неинициализированным, мы получим панику. Кроме того, для каждого типа данных придется писать свою ветку кода с использованием switch-case и надеяться, что мы ничего не забыли. Ниже я подготовил плюсы шаблонизаторов и минусы синтаксического дерева. Да, именно так. По какой то причине я не смог сформулировать объективные минусы использования шаблонизаторов для кодогенерации, которыми бы не обладала кодогенерация через синтаксическое дерево, ну а плюсов последнего я даже не пытался найти – слишком сложный этот путь. Итак, вот они: Плюсы генерации с помощью шаблонов. Минусы генерации с помощью AST. Ежу понятно, что выберем мы генерацию через синтаксическое дерево, ведь это не скучно. К тому же у меня уже есть существенный опыт работы с AST на языке golang. И есть даже пакет хелперов go-ast, которые собраны мной специально для того, чтобы уменьшить бойлерплейты и увеличить читаемость. Конечно, предыдущий абзац является самоиронией. Но вообще, если быть честным, я просто рассчитывал, что в дальнейшем можно будет встраивать в этот механизм какую-нибудь middleware. И в этом случае у нас остается больше контроля над кодогенерацией (а вот и плюсы начались), ведь синтаксическое дерево можно обойти с помощью паттерна Visitor и применить к нему какие то преобразования. Для решения непростой задачи я разработал следующую стратегию: Как я уже говорил, парсить за нас будет Валялкин, так что нам остается только проверить наличие поля по имени и, приведя его к необходимому типу данных, положить в соответствующее поле нашей структуры. Например, вот небольшой сегмент парсинга для поля с типом int64: А вот всего лишь небольшая часть того кода, который задействован при генерации этого сегмента: Можно догадаться, что код из этого примера генерирует одну конкретную строку: valMaxID, err = _maxID.Int64() Так что зря я опасался, что будет нечитабельно и сложно (вероятно, это был сарказм). Первое с чем пришлось столкнуться – это соответствие типов данных. Вот пользователь описывает структуру в которой есть int32, int64, uint16 и bool, а ты будь добр для каждого подтипа подбери правильный парсинг и если нужно приводи один тип данных (широкий, например int64) к другому (узкому, например int32). И я сейчас имею в виду даже не то, что не нужно путать string и int64 – это легко решается с помощью switch-case, но вот когда в дело вступили типы данных определенные пользователем, например “UserType int32”, то тут switch уже бессилен, нужно глубинное понимание исходного типа. К счастью, в пакете ast есть возможность определить исходный тип данных без необходимости непосредственного взаимодействия со всеми структурами в исходном файле – если использовать поле Obj структуры Ident. Но сильно радоваться не приходится, т.к. это работает идеально только, если исходный тип находится в том же файле, вот тут есть обсуждение. Попробуем решить и эту проблему, но сначала напишем такую функцию, которая может определить исходный тип данных: Хорошо, давайте рассмотрим вопрос использования пользовательских типов данных, взятых из другого пакета. Как быть, если у нас есть «UserType types.UserType»? В таких случаях приходится отправляться во внешний пакет, чтобы определить, что это за тип. Возникает вопрос: как определить, что использовать и куда обращаться? Ведь, если это простой тип данных, нужно его обработать, а если структура, то мы можем вызвать у нее метод FillFromJSON. Необходимо дать кодогенерации возможность обозреть проект в целом. Первым делом нужно парсить go.mod файл, чтобы понять глобальное имя всего модуля (модулем я называю проект, который объединен одним go.mod файлом). Затем можно просканировать пакеты внутри модуля, составить карту пакетов и отобрать необходимый по имени или псевдониму из import текущего файла.  Разумеется, в любом случае нам придется выполнить эту операцию. Поэтому добавил в самое начало процесса генерации дискавери всего модуля, на котором проводится кодогенерация, и парсинг всех зависимых пакетов, строго внутри основного модуля. Я задал такое ограничение: если в структуре используются внешние типы данных, то «давай до свидания». Однако есть исключения, такие как uuid.UUID, например или time.Time. Теперь, когда кодогенератор работает с внешними типами данных (из другого пакета), он может легко найти соответствующий пакет внутри проекта, выполнить парсинг и подставить оригинальный тип данных вместо имени внешней структуры. Я считаю, что я успешно справился с этой проблемой. Следующая проблема со звездочкой – это буквально проблема со звездочкой. Ссылочные типы данных добавляют в логику свои нюансы. У нас поле может быть ссылочного типа, а еще поле может быть пользовательского типа, а тот в свою очередь ссылочный. А в особенных случаях поле ссылочного пользовательского типа, а тот в свою очередь тоже ссылочного типа. И так в разных вариантах самые пошлые комбинации. Боже мой, это совершенно нереально развернуть столько вариантов звездения типов данных. Исходный тип найти легко, но заставить ваш код сгенерировать правильный конструктор для того, чтобы правильно заполнить поле – выглядит как овер-сложно. Я обошелся одним уровнем ссылочности типа данных и залепил просто флаговой переменной isRef. В случае чего стрелять код на проде не будет — он просто не скомпилируется. Так что тут я решил не оверинжинирить и просто оставить минимально рабочий вариант. Ну а что. Частичное решение – это тоже решение. Наконец, использование одной структуры внутри другой структуры, как тип поля или композиция. По сути к композиции можно относиться, как к описанию поля, которое имеет имя аналогичное названию типа. Ко вложенному типу мы можем обратиться по его же имени (имени типа), как если бы это было имя поля. Решение довольно простое – из метода, заполняющего все поля структуры родительской, я могу просто вызвать метод заполняющий структуру этого поля. Т.е. я делегирую парсинг подтипа методу этого типа. Вот как это выглядит в итоговом коде для структуры у которой поле meta является другой структурой: Т.е. никакого встраивания, одно сплошное наследование (это я про вызовы). И наконец, довольно интересная штука – обработка ошибок. При возникновении ошибки парсинга в логи должно упасть сообщение содержащее json-path причинного места. А как нам собрать информацию о полном пути до объекта, если в предыдущем блоке я решил переиспользовать методы парсинга на вложенных структурах? Мое решение “в лоб” было в том, чтобы при вызове метода парсинга передавать полный путь к объекту, который сейчас обрабатывается. Т.е. для самого первого вызова path будет что-то типа “(root)”, а дальше “path + meta” для поля “meta”, ну и т.п. Однако в дальнейшем я понял, что каждая конкатенация строки и передача результата в следующий вызов – это целая аллокация памяти. И проблемы вроде почти нет, если у вас структура небольшой вложенности. Но когда у вас большой объем и в нем есть слайсы структур или карты слайсов структур, тогда это превращается в серьезное снижение производительности. Но как без конкатенации? Ведь полный путь нельзя знать заранее – мы же не знаем заранее из какого метода будет вызван текущий метод – путь можно только собрать по частям. Поэтому, я решил отложить (имею в виду runtime) конкатенацию до последнего момента, до того, когда она нужна – т.е. в момент ошибки. Я оборачиваю ошибку в parsingError структуру, которая содержит исходную ошибку и текущий сегмент пути, каждый раз возвращаясь по стеку вызова вверх, эта структура заново оборачивается в parsingError с подмешиванием нового сегмента пути – и так от конца до начала. Да, от конца – глубинного объекта, где произошла ошибка – до самого корня. Вот как эта обертка выглядит: Таким образом, если ошибок не возникает – не возникает и лишних аллокаций памяти. Они появляются только когда существуют ошибки. Но, на то они и ошибки, чтобы создавать проблемы и не только прямые – такие, как обработка исключительной ситуации, но и косвенные – такие, как снижение производительности системы в целом из-за журналирования, эскалирования и т.п. И вот такие результаты я получил на тестах парсинга: Да какие тут могут быть подводные камни? Просто пишешь WriteString(name + ":"), а затем WriteString(value). Однако, все не так просто. С первыми проблемами я столкнулся при использовании запятых. Нельзя ставить запятую перед каждым полем, потому что первое поле начинается без запятой. Также нельзя ставить запятую после каждого поля, так как последнее поле не завершается запятой. Нельзя ставить запятую перед каждым, кроме первого, или после каждого, кроме последнего поля, так как имеются поля с флагом omitempty, которые могут в какой-то момент пропасть и исчезнуть. Чтобы решить эту проблему, мы можем использовать флаговую переменную wantComma, которая инициализируется значением false. В каждом блоке, который обрабатывает одно поле, эта переменная устанавливается в true, если было произведена запись значения. Если перед следующим полем флаг равен true, мы ставим запятую. Однако omitempty снова подкладывает нам свинью. При установленном флаге true запятая ставится перед обработкой поля с флагом omitempty, но если оно оказалось пустым, перед следующим полем мы получим две запятые. Сброс флага после записи не помог, если omitempty было последним, мы получаем лишнюю запятую в конце JSON объекта. Поэтому будьте осторожны и используйте блок с проверкой флага и записью запятой строго перед тем, как выполнить запись следующего значения. Другой серьезной проблемой стала аллокация памяти. При демаршалинге (т.е. при парсинге) с этим проблем не возникало, так как для парсинга используется fastjson пакет, который отличается производительностью. Однако, преобразование разных типов данных в строку и короткие записи – это довольно ресурсоемкий процесс. В качестве примера рассмотрим следующий участок кода (смотреть под спойлером). И чтобы было понятно, что там происходит внутри вызова writeString: Тут видно сколько маленьких (с точки зрения объема полезной нагрузки) вызовов WriteString происходит каждый раз при маршалинге. Если строка не обрамлена специальными символами, то она как есть будет отправлена в объект записи, в противном случае применяется буфер, чтобы собрать посимвольно участки строки без спецсимволов. И вот второй случай является проблемой. Каждая такая запись потенциально может породить аллокацию памяти. Да, у golang довольно умный аллокатор байтовых слайсов, но на огромных структурах данных, делая маленькие записи, я совершенно сбиваю его с толку и против бенчмарков easyjson, на тестах, в которых аллокаций всего 30, я получил больше 20 тысяч аллокаций памяти в своем детище. Вот это было обидно да. Для решения этой проблемы запускаю go tool pprof (если не знаете, погуглите, это нужно знать). Я нашел узкие места в своих алгоритмах, особенно в тех местах, где слайс байтов превращается в строку или наоборот – такие преобразования всегда приводят к аллокации, потому что строки нельзя изменять, а содержимое слайса можно. Решается проблема преобразования строки (в байтовый слайс и обратно) легко: Для тех, кто не в теме поясняю. GO всегда создает новый объект в памяти при преобразовании строки в байтовый слайс и наоборот, чтобы у него не оставалось ссылок разного типа (одна RO, а другая RW) на строку. Но я то могу гарантировать, что после того, как сделаю преобразование в строку, сразу же забуду о существовании байтового слайса и менять там ничего не буду. Клянусь клавиатурой! Поэтому я могу взять и выполнить преобразования через unsafe и жить спокойно без аллокаций. Ну а все остальное за меня сделал bytebufferpool. Нужно было только реализовать свой нестандартный Writer под капотом. После таких манипуляций я получил всего 17 аллокаций на тех же самых тестах, на который easyjson показывает 30. Правда по скорости обработки я отстал в два раза, что посчитал совсем не критичным. Ну ладно, осталось прикрутить это к проекту и проверить на бэнчмарках с учетом специфики самого проекта. У меня там уже были нужные тесты с нужными структурами данных, мне оставалось просто преобразовать тест в бэнчмарк. И, о, ужас! Я проиграл. Ну да, легко взять и заточить свой код на прохождение определенного теста. Обложить со всех сторон буферами подходящих размеров и получить нужный результат. Но когда это работает в других условиях, готовьтесь к неожиданностям. Поначалу мне даже показалось, что результаты, которые я вижу — это нормально, но я обязательно должен был сравнить эти результаты с аналогичными тестами используя easyjson. И я это сделал. И то, что я увидел, меня сильно удивило. Перерасход по памяти был несравнимый, а все из-за того, что в случае недостатка памяти из буфера, приходилось заново аллоцировать пространство памяти, а когда в процессе попеременно встречаются то короткие строки то очень длинные (в несколько килобайт), так и выходит, что в конце-концов короткие буферизированные байтовые слайсы доставляют больше проблем, чем пользы. Да, проблема заключалась в том, что я решил использовать интерфейс io.Writer и написать под него свою реализацию, которая бы переиспользовала байтовые слайсы. Я просто подогнал размер буфера по умолчанию под бэнчмарки и радовался. Недолго думая, я взял и заменил этот интерфейс указателем на jwriter.Writer, который используется в пакете easyjson. У них там под капотом практически то же самое – байтовые буферы, но есть возможность записать строку в формате JSON (со всеми экранированиями и кавычками) гораздо эффективнее, чем то, что накалякал я на коленках. Плюс реализация интерфейса io.Reader позволяет переиспользовать байтовый слайс. Что же я увидел в итоге? Да, количество аллокаций на тестах увеличилось с 17 до 27, но и скорость выполнения возросла в два раза, что дало мне примерно такие же результаты, что и у easyjson. И это не удивительно, поскольку разница между этими двумя реализациями теперь стремится к минимуму. Фактически, я получил неплохой опыт в кодогенерации через AST дерево. Теперь я лучше понимаю структуру кода в том смысле, в котором ее понимают линтеры, например. Я боролся с проблемами использования ресурсов памяти, пробовал написать свой io.Writer, который бы мог переиспользовать память, применял go tool pprof много раз и многое мне удалось исправить. Хотя в конечном итоге я сдался и взял Writer от easyjson, проблемы использования памяти не ограничивались одним лишь этим интерфейсом, так что это не считается полным поражением. Теперь у меня есть свой easyjson, который по сути является гибридом между fastjson и easyjson, он унаследовал от обоих (как мне хочется верить) самое полезное. Его API полностью написан моими руками и легко интегрировался в проект генерации серверного бойлерплейта. А если будет нужно – легко интегрируется в любой другой проект. Ну и наконец, я очень весело провел время. Разработчик программного обеспечения