Полное руководство по написанию утилиты для Go
Некоторое время назад я начал делать утилиту, которая упростила бы мне жизнь. Она называется gomodifytags. Утилита автоматически заполняет поля структурных тегов (struct tag) с помощью имён полей. Пример:
Эта утилита облегчает управление многочисленными полями структуры. Она умеет добавлять и удалять теги, управлять их опциями (например, omitempty), определять правила трансформации (snake_case, camelCase и пр.) и многое другое. Как эта утилита работает? Какие Go-пакеты она использует? Наверное, у вас есть много вопросов.
В этой очень длинной статье я подробно расскажу о том, как написать и собрать такую утилиту. Здесь вы найдёте много советов и хитростей, а также кода на Go.
Налейте кофе и начинайте читать!
Сначала разберёмся, что должна сделать утилита:
- Считать исходный файл, понять его, преобразовать в Go-файл.
- Найти соответствующую структуру.
- Получить имена полей.
- Обновить структурные теги с учётом имён полей (в соответствии с правилом трансформации, например snake_case).
- И наконец, внести эти изменения в файл или выдать их программисту в другом, удобном виде.
Начнём с определения структурного тега, а потом последовательно будем строить утилиту, изучая ее части и их взаимодействие друг с другом.
Тег value (к примеру, его содержимое json:"foo" ) не упомянут в официальной спецификации. Но в пакете reflect есть неофициальная спецификация, которая определяет этот тег в формате, который также используют пакеты stdlib (вроде encoding/json). Сделано это через тип reflect.StructTag:
Определение непростое, давайте разберёмся в нём:
- Структурный тег — это строковый литерал (потому что относится к строковому типу).
- Ключ — строковый литерал, не заключённый в кавычки.
- Значение — строковый литерал, заключённый в кавычки.
- Ключ и значение разделены двоеточием (:). Всё вместе называется парой ключ-значение.
- Структурный тег может содержать несколько пар ключ-значение (при желании). Пары разделяются пробелами.
- Настройка опций не описана в определении. Пакеты вроде encoding/json считывают значения в виде списка, где в качестве разделителей используются запятые. Всё, что идёт после первой запятой, — часть опций. Например, в списке “foo,omitempty,string“ перечислены значение “foo” и опции [“omitempty”, “string”].
- Поскольку структурный тег — это строковый литерал, его нужно взять в двойные кавычки или обратные апострофы (левые одинарные кавычки).
Повторим все эти правила:
В определении структурных тегов есть много неочевидных подробностей
Теперь мы знаем, что собой представляет структурный тег, и можем легко изменить его, как нам нужно. Вопрос в том, как теперь его отпарсить, чтобы легко изменить? К счастью, reflect.StructTag также имеет метод, позволяющий парсить тег и возвращать значения для заданного ключа. Пример:
Выводит на экран:
Если ключ не существует, возвращается пустая строка.
Очень полезно, однако есть ограничения, делающие эту возможность бесполезной для нас, поскольку нам нужно более гибкое решение. Список ограничений:
- Не распознаёт ошибки в теге (например, ключ в кавычках, значение без кавычек и т. д.).
- Не распознаёт семантику опций.
- Не умеет проходить по существующим тегам или возвращать их. Нужно знать заранее, какие теги мы хотим изменить. А если имя неизвестно?
- Невозможно изменять существующие теги.
- Нельзя с нуля сконструировать новые структурные теги.
Я написал свой Go-пакет, который исправляет все перечисленные недостатки и предоставляет API, позволяющий легко менять любые части структурного тега.
Пакет называется structtag, его можно скачать отсюда: github.com/fatih/structtag. Пакет умеет аккуратно парсить и изменять теги. Ниже приведён полностью рабочий пример, можете скопировать его и протестировать:
Теперь мы умеем парсить структурный тег, изменять его и создавать новый. Теперь нужно изменить корректный исходный Go-файл. В приведённом примере тег уже есть, а как нам его получить из существующей Go-структуры?
Ответ: через AST. AST (Abstract Syntax Tree, абстрактное синтаксическое дерево) позволяет извлечь из исходного кода любой идентификатор (узел). Ниже показано упрощённое дерево структурного типа:
Базовое представление структурного типа Go ast.Node
В этом дереве можно извлекать любой идентификатор и манипулировать им — строкой, скобкой и т. д. Каждый из них представлен AST-узлом. Например, можно изменить имя поля с “Foo” на “Bar”, заменив представляющий его узел. То же самое и со структурным тегом.
Чтобы получить Go AST, нужно отпарсить исходный файл и преобразовать его в AST. Всё это делается в один этап.
Для парсинга файла воспользуемся пакетом go/parser (чтобы построить дерево всего файла), а затем с помощью пакета go/ast пройдём по дереву (можно и вручную, но это тема для отдельной статьи). Вот полностью работающий пример:
- С помощью единственной структуры определяем пример корректного Go-пакета.
- Используем пакет go/parser для парсинга этой строки. Также пакет может прочитать с диска файл (или весь пакет).
- После разбора файла переходим к нашему узлу (приписанному к файлу переменной) и ищем AST-узел, определённый *ast.StructType (см. схему AST). Посредством функции ast.Inspect() проходим вниз по дереву. Функция перебирает все узлы, пока не находит неверное значение. Получается очень удобно, потому что нам не нужно запоминать узлы.
- Выводим на экран имя поля структуры и структурный тег.
Теперь мы умеем решать две важные задачи. Во-первых, мы знаем, как парсить исходный Go-файл и извлекать структурный тег (с помощью go/parser). Во-вторых, мы умеем парсить структурный тег и изменять его так, как нам нужно (с помощью github.com/fatih/structtag).
Теперь можно начать собирать нашу утилиту (gomodifytags) с помощью двух этих умений. Она должна:
- Получать конфигурацию, в которой указано, какую структуру нужно изменить.
- Находить и изменять эту структуру.
- Выводить результат.
Поскольку gomodifytags будет чаще всего исполняться редакторами, мы станем передавать конфигурацию через CLI-флаги. Второй этап состоит из нескольких шагов, таких как парсинг файла, поиск корректной структуры и её изменение (посредством AST). Наконец, мы выведем результат либо в исходный Go-файл, либо через какой-то протокол (вроде JSON, об этом поговорим ниже).
Упрощённая основная функция gomodifytags:
Давайте подробно рассмотрим каждый шаг. Я постараюсь рассказать попроще. Хотя здесь всё то же самое, и, закончив читать, вы сможете разобраться с исходным кодом безо всякой помощи (ссылки на исходники приведены в конце руководства).
Начнём с получения конфигурации. Ниже показана конфигурация, в которой есть вся необходимая информация.
Конфигурация состоит из трёх разделов.
В первом собраны настройки, описывающие, как и какой файл нужно прочитать. Это можно сделать через имя файла из локальной файловой системы или напрямую из stdin (обычно применяется при работе в редакторах). Также в первом разделе указано, как выводить результаты (в исходный Go-файл или JSON) и нужно ли переписать файл вместо вывода в stdout.
Во втором разделе указано, как выбирать структуру и её поля. Это можно сделать разными способами: с помощью смещения (позиции курсора), имени структуры, одной строки (которая просто выбирает поле) или диапазона строк. В конце нужно обязательно извлечь начальную и конечную строки. Ниже показано, как мы выбираем структуру по её имени, а затем извлекаем начальную и конечную строки, чтобы выбрать правильные поля:
В то же время редакторам удобнее использовать байтовое смещение. На иллюстрации ниже курсор стоит сразу за именем поля "Port" , откуда можно легко получить начальную и конечную строки:
Третий раздел конфигурации посвящён передаче данных нашему пакету structtag . После чтения всех полей конфигурация передаётся в пакет structtag , который позволяет парсить структурный тег и изменять его части. Но при этом поля структуры не перезаписываются и не обновляются.
А как нам получить конфигурацию? Возьмём пакет flag , создадим флаги для каждого поля в конфигурации и привяжем к ним. Например:
Сделаем то же самое для каждого поля в конфигурации. Полный список определений флагов можно найти в текущем мастере gomodifytag.
Получив конфигурацию, выполним базовую проверку:
Если проверка выполняется в одной функции, то её проще тестировать.
Перейдём к парсингу файла:
В начале мы уже говорили о том, как парсить файл. В данном случае парсингом занимается метод в структуре config . На самом деле все методы — часть этой структуры:
Функция parse умеет только парсить исходный код и возвращать ast.Node . Всё очень просто, если мы передаём файл, в нашем случае используется функция parser.ParseFile() . Обратите внимание на token.NewFileSet() , создающую тип *token.FileSet . Мы храним её в c.fset , но также передаём в функцию parser.ParseFile() . Почему?
Потому что fileset используется для хранения информации о расположении каждого узла независимо для каждого файла. Позднее это будет очень полезно для получения точного расположения ast.Node . (Обратите внимание, что ast.Node использует компактную информацию о расположении под названием token.Pos . Если расшифровать token.Pos с помощью функции token.FileSet.Position() , то получим token.Position , содержащую больше информации.)
Идём дальше. Ситуация становится интересней, если передавать исходный файл через stdin. Поле config.modified — это io.Reader для лёгкого тестирования, но на самом деле мы передаём stdin. А как определить, что мы хотим читать из stdin?
Мы спрашиваем пользователя, хочет ли он передавать контент через stdin. В этом случае пользователю нужно передать флаг --modified (это булев флаг). Если он передаёт, то мы просто прикрепляем stdin к c.modified :
Если вы снова посмотрите на функцию config.parse() , то увидите, что она проверяет прикреплённость поля .modified . Stdin — произвольный поток данных, который нужно парсить в соответствии с выбранным протоколом. В нашем случае мы предполагаем, что архив содержит:
- Имя файла, потом новую строку.
- Размер файла в десятичном выражении, потом новую строку.
- Содержимое файла.
Раз мы знаем размер файла, то можем спокойно парсить содержимое. Если что-то окажется больше по размеру, то просто остановим парсинг.
Такой подход используется в нескольких других утилитах (например, guru, gogetdoc и др.), он очень удобен для редакторов, потому что те могут передавать содержимое модифицированных файлов без сохранения в файловой системе. Поэтому и «модифицированных».
Итак, у нас есть узел, давайте искать структуру:
В основной функции мы собираемся вызывать функцию findSelection() с ast.Node, который мы пропарсили на предыдущем шаге:
Функция cfg.findSelection() на основе конфигурации возвращает начальную и конечную позиции структуры, а также порядок выбора структуры. Она проходит по заданному узлу и возвращает начальную и конечную позиции (как объяснялось в разделе про конфигурацию):
Затем функция переберёт все узлы, пока не найдёт *ast.StructType, и вернёт в файл её начальную и конечную позиции.
Но как это делается? Вспомните, что у нас есть три режима. Выбор по строке, по смещению и по имени структуры:
Выбирать по строке легче всего. Мы просто возвращаем само значение флага. Если пользователь передал флаг "--line 3,50" , то функция возвращает (3, 50, nil) . Она просто выделяет значение и преобразует его в целые числа (и заодно проверяет):
Этот режим используют редакторы, когда вы выбираете группу строк и выделяете (подсвечиваете) их.
Выбор по смещению и по имени структуры требует больше работы. Сначала нужно собрать все имеющиеся структуры, чтобы можно было вычислить смещение или поискать по имени. Итак, собираем все структуры:
Функция ast.Inspect() проходит вниз по AST и ищет структуры.
Сначала нам нужна *ast.TypeSpec , чтобы можно было извлечь имя. Поиск *ast.StructType даст нам саму структуру, но не её имя. Поэтому мы используем тип structType , содержащий имя и узел структуры, это удобно. Поскольку позиция каждой структуры уникальна, мы возьмём позицию в качестве ключа для привязки.
Теперь у нас есть все структуры, и мы можем вернуть начальную и конечную позиции для режимов со смещением и именем структуры. В первом случае проверим, попадает ли смещение внутрь заданной структуры:
Мы используем collectStructs() для сбора и последующего итерирования по структурам. Помните, что мы сохранили начальный token.FileSet , который использовали для парсинга файла?
Теперь он поможет нам получить информацию о смещении из каждого отдельного узла структуры (расшифруем в token.Position и получим поле .Offset ). Мы просто проверяем и итерируем, пока не найдём нашу структуру (в данном случае с именем encStruct ):
С помощью этой информации можно извлечь начальную и конечную позиции найденной структуры:
Ту же самую логику применяем при выборе имени структуры. Просто вместо проверки, оказывается ли смещение внутри заданной структуры, мы проверяем имя структуры, пока не найдём нужную:
Получили начальную и конечную позиции, переходим к модифицированию полей структуры:
В нашей основной функции вызовем функцию cfg.rewrite() с узлом, который распарсили на предыдущем шаге:
Это ключевая часть утилиты. Функция rewrite переписывает поля всех структур между начальной и конечной позициями.
Как видите, мы опять используем ast.Inspect() для прохождения вниз по дереву для заданного узла. Внутри функции rewriteFunc мы переписываем теги каждого поля (подробнее об этом поговорим дальше).
Поскольку функция, возвращённая ast.Inspect() , не возвращает ошибку, мы создадим схему ошибок (определим с помощью переменной errs ), а затем будем собирать их, проходя вниз по дереву и обрабатывая поля. Давайте разберёмся с rewriteFunc :
Помните, что эта функция вызывается для каждого узла AST. Поэтому мы ищем только узлы типа *ast.StructType . Потом начинаем итерировать по полям структуры.
Здесь мы снова используем наши любимые переменные start и end . Этот код определяет, хотим ли мы модифицировать поле. Если его позиция находится между start—end , то продолжаем, в противном случае не обращаем внимания:
Далее проверяем, есть ли тег. Если поле тега пустое ( nil ), мы его инициализируем с пустым тегом. Позднее это поможет избежать путаницы в функции cfg.process() :
Прежде чем продолжать, позвольте объяснить кое-что интересное. gomodifytags пытается получить имя поля и обработать его. А если поле анонимное?
В таком случае у поля нет имени, и тогда мы предполагаем имя поля исходя из имени типа:
Получив имя поля и значение тега, можно начать обработку поля. За обработку отвечает функция cfg.process() (если есть имя поля и значение тега). Она возвращает результат (в нашем случае это форматирование структурного тега), который мы используем для перезаписи существующего значения тега:
Если помните structtag, то здесь фактически возвращается String()-представление экземпляра тега. Прежде чем вернуть конечное представление тега, мы воспользуемся разными методами пакета structtag для желаемого модифицирования структуры. Упрощённый обзор:
Пакет structtag используется для модифицирования каждого поля
Допустим, нужно расширить функции removeTags() внутри process() . Эта функция использует приведённую ниже конфигурацию для создания массива тегов (имён ключей), которые будут стёрты:
Внутри removeTags() мы проверяем, использовал ли кто-нибудь --remove-tags . В данном случае удалить теги поможет метод tags.Delete() из structtag:
Тот же подход применяется ко всем отдельным функциям внутри cfg.Process() .
Мы переписали узел, поговорим о финальной части: выводе и форматировании результата.
В основной функции мы вызываем функцию cfg.format() с узлом, который был переписан на предыдущем шаге:
Мы выводим stdout. У этого решения есть ряд преимуществ. Во-первых, можно сделать пробный прогон — ничего не изменится, но зато мы сразу увидим результат. Во-вторых, stdout можно скомпоновать, чтобы переадресовать куда угодно, и даже использовать для перезаписи самой утилиты.
Давайте рассмотрим функцию format() :
У нас два режима вывода данных. Первый режим («исходный код») выводит ast.Node в Go-формате. Так делается по умолчанию, очень удобно, если вы используете утилиту из командной строки или хотите увидеть изменения в своём файле.
Второй режим (“JSON”) более продвинутый и специально придуман для других сред (особенно для редакторов). В этом режиме выходные данные кодируются на основе этой структуры:
Обзор входных данных и соответствующих выходных (без ошибок):
Вернёмся к функции format() . Как уже говорилось, у нас есть два режима. Режим «исходный код» использует пакет go/format для форматирования AST обратно в корректный Go-код. Этот пакет применяют многие официальные утилиты, например gofmt. Вот как реализован режим «исходный код»:
Пакет format принимает io.Writer и форматирует его. Поэтому мы создали промежуточный буфер ( var buf bytes.Buffer ), который может пригодиться для перезаписи файла, когда пользователь передаёт флаг -write . После форматирования возвращаем строковое представление буфера, содержащее отформатированный исходный код Go.
Режим JSON ещё интереснее. Поскольку мы возвращаем часть исходного кода, нам нужно вернуть именно так, как она выглядит. То есть со всеми комментариями. Но дело в том, что при использовании format.Node() для вывода одиночной структуры не получится вывести и комментарии, если они lossy.
Что такое lossy-комментарии? Посмотрите пример:
Каждое поле относится к типу *ast.Field . В этой структуре есть поле *ast.Field.Comment , содержащее комментарий для определённого поля.
Но к какому полю относится комментарий в этом примере? К foo или bar?
Поскольку определить это невозможно, такие комментарии называются свободными. Если вывести эту структуру с помощью функции format.Node() , то вот что вы получите:
Дело в том, что lossy-комментарии являются частью *ast.File и отделены от дерева. Они выводятся лишь тогда, когда мы выводим весь файл. Это можно обойти, если сначала вывести весь файл, а затем отрезать конкретные строки, которые нужно вернуть в виде JSON:
Таким образом можно вывести все комментарии.
Мы закончили создание нашей утилиты, а это просто пошаговая диаграмма, описывающая всё сделанное:
Кратко о gomodifytags
- Мы посредством CLI-флагов извлекли конфигурацию.
- Начали парсить файл с помощью пакета go/parser , чтобы извлечь ast.Node .
- Потом искали (проходя вниз по дереву) начальную и конечную позицию для соответствующей структуры, чтобы узнать, какие поля нужно модифицировать.
- Найдя позиции, снова пошли вниз по ast.Node и переписали каждое поле между начальной и конечной позициями (с помощью пакета structtag).
- Затем отформатировали переписанный узел в виде либо корректного исходного кода Go, либо JSON для редакторов.
Сегодня gomodifytags облегчает жизнь тысячам разработчиков, он успешно используется в нескольких редакторах и плагинах: