Выпуск 2 · подкаст «Тысяча фичей»

#2: Гундосая спека: комменты, Open API

22:21
↓ скачать mp3

Александр Пахомов разбирает, почему коммит-месседж — это история проекта, а не формальность: правила оформления заголовка и тела, conventional commits, атомарные коммиты и главное правило — договориться о формате со всей командой. Во второй части — OpenAPI-спецификация: подходы code-first и design-first, генерация документации и всегда актуального клиента, сервисы, которые продают API без спеки, и история о том, как несовершенный туллинг съел два дня работы из-за стёртых при компиляции имён параметров.

Главное

  • Коммит-месседж должен отвечать на вопросы «что» и «почему»: что поменялось, видно в диффе, а вот почему — только из сообщения коммита.
  • Базовые правила: заголовок до 50 символов, с большой буквы, без точки, в imperative mood (add, fix, improve); строки тела до 72 символов; тело отделяется пустой строкой.
  • Правило №0 — формат коммитов должен быть единым: о нём надо договориться со всей командой, иначе git log превращается в помойку, по которой невозможно искать.
  • Если коммит трудно описать — вы либо сделали лишнее, либо слишком много за раз: это сигнал разбить изменение на более атомарные коммиты.
  • Conventional commits добавляют scope и структуру, чтобы поверх git-лога автоматизировать релиз-ноуты и версионирование.
  • OpenAPI-спецификация — описание HTTP API, читаемое и человеком, и машиной: из неё генерируются документация и всегда up-to-date клиент.
  • Выбор между code-first и design-first — это выбор single source of truth: либо код определяет спецификацию, либо спецификация — код.
  • Туллинг несовершенен: Java стирает имена параметров интерфейсов при компиляции (arg0, arg1), из-за чего Micronaut-плагин терял связь @PathVariable с path — два дня на «бинарный поиск» причины.
Расшифровка

[00:21] Здарова! Меня зовут Саша Пахомов, и я инженер, который любит своё дело. Это второй выпуск подкаста, у которого всё ещё нет ни названия, ни гостей — зато есть куча тем, которые я хочу вместе с вами разобрать. Сегодня мы поговорим о правильном коммит-месседже и о том, почему некоторые люди всё ещё пишут странные комментарии к коммитам. А во второй части продолжим тему REST API и погрузимся в OpenAPI-спецификацию: стоит ли генерировать спецификацию из кода или, наоборот, код из спецификации, какие инструменты даёт Java-стек и почему в 2022 году отсутствие OpenAPI-спеки — не красный, но точно жёлтый флажочек. Поехали!

[01:10] Давно ли вы просматривали git log? Если в командной строке это делают редко, то в IDE — обыденность. А git blame — когда мы хотим узнать автора конкретной строчки кода? Я это делаю постоянно. И вот что меня дико раздражает, так это комментарии к коммитам вида «fixed tests», «refactoring», «algorithm» или, вот любимое, «minor update». Какую информацию дают эти заголовки? Полезной — совсем никакой. Вероятно, автор очень спешил, или обладал слабым знанием английского, или попросту был некомпетентен. Не пишите подобные комментарии. Окей, а какие тогда надо — сейчас обсудим.

[01:54] Вообще говоря, проект определяется не только текущим снапшотом кодовой базы, но и историей её развития. Поясню: проект существует не только здесь и сейчас, но и во времени — это процессы, принятые решения, проведённые исследования. С задачей хранения снапшота Git справляется отлично — для этого он, казалось бы, и создан. А вот хранить в Git историю мало кто считает чем-то естественным: для неё заводят проектную документацию или что-то ещё. Я же всегда пишу комментарии, исходя из того, что пишу историю развития кода: почему я сделал так, а не иначе, зачем здесь стоит этот коммент, почему я оставил ссылку на исследование.

[02:57] Так как же писать комментарии к коммиту? Существуют некоторые правила (в кавычках), которых следует (опять же в кавычках) придерживаться. Первое: заголовок пишем с большой буквы и без точки в конце. Второе: imperative mood — не «edited» в прошедшем времени и не длительное «refactoring», а add, create, improve — будто мы формулируем запрос «I would like to…». Третье: заголовок ограничен 50 символами, и это строгое ограничение, а строка тела — 72 символами. Тело — это всё, что не заголовок; заголовок — первая строчка. И четвёртое: заголовок отделяется от тела пустой строкой.

[03:57] Но, с моей точки зрения, самое главное правило должно идти под номером ноль: о формате коммит-месседжей нужно договориться со всеми — они должны быть единообразными. Когда один коммит написан в одном стиле, другой в другом, а третий вообще как попало — это, во-первых, раздражает, а во-вторых, такой лог невозможно грепать, в нём нельзя искать и группировать. Он превращается просто в помойку. При этом всё перечисленное — не жёсткие догмы: строгого «должно быть только так» нет.

[04:37] Сам коммит-месседж должен стараться ответить на вопросы «что» и «почему». Что меняет это изменение — какую часть продукта, какое поведение — обычными человеческими словами. И почему я сделал так, а не иначе: почему удалил эту строчку, хотя она казалась важной, почему добавил вот этот тест-кейс. Рассказать нужно историю. Причём на вопрос «что» отвечает и сам код: открой коммит и посмотри, что поменялось — вот класс, вот строка. А вот почему это было поменяно — в диффе не видно совершенно. Это и должно быть в body коммит-месседжа.

[05:25] Помимо текстового описания, в коммит-месседж стоит добавлять метаданные: номер тикета в джире, ссылку на тикет в конце или так называемый scope. Про scope стоит сказать отдельно — он определяется в conventional commits. Это люди пошли дальше: им мало человекочитаемых заголовка и тела — хочется накрутить поверх git-лога автоматизацию: генерировать из коммитов релиз-ноуты, формировать minor- и major-версии продукта. Для этого conventional commits и придумали. Не думаю, что это топик для дискуссии в подкасте, но ссылку оставлю. И ещё одна мысль: если вы садитесь описывать коммит и сидите в ступоре — «а что я, собственно, сделал?» — есть два варианта. Либо вы сделали то, чего делать не надо было, либо пытаетесь сделать слишком много изменений за раз. Это звоночек: возможно, стоит пересмотреть изменение и сделать его более атомарным. Лучше менять одну часть функциональности в одном коммите, чем засовывать в него кучу фичей и багфиксов разом, — это антипаттерн.

[07:02] И последняя мысль на этот счёт. Есть множество статей, утверждающих: если человек не читает git log, он будет писать непонятные комментарии. Я считаю, причина не в этом — людям просто лень. Это банальное отношение к своей работе: тебе всё равно, что будут читать те, кто выполнит git blame по твоим изменениям, — нужно просто закоммитить, заревьюить, залить, закрыть таску в джире и пойти пить свой кофе. Лениться не стоит: пишите развёрнутые git-комментарии — вам потом спасибо скажут.

[07:44] А теперь к основной теме подкаста. В начале карьеры мне попадались задачи на интеграцию со сторонними HTTP-сервисами. Документация у них была вида: вот endpoint по такому-то пути, такие хедеры, такое body возвращаем — вот, пожалуйста. Я читал всю документацию, открывал Postman, тыкал в API, потом шёл в проект и неделю, а то и две, потел над REST-клиентом, тестировал его, проходил ревью, чинил баги. Кто бы мне тогда сказал, что вся эта работа уже автоматизирована и мне платят деньги ни за что! Будь у того сервиса OpenAPI-спецификация, работа на две недели заняла бы от силы пару дней. Давайте разберёмся, что это за спецификация и как она может помочь.

[08:35] OpenAPI-спецификация определяет стандарт, который описывает интерфейс HTTP API в виде, доступном и человеку — в человекочитаемом формате, — и машине: машина может, например, сгенерировать клиент к этому API или провалидировать его. На приземлённом уровне это просто файлик в формате JSON или YAML, в котором описаны эндпоинты, дескрипшены к ним, хедеры запросов, возвращаемые коды (если про коды интересно подробнее — послушайте предыдущий выпуск), security и так далее. Всё, что нужно знать про HTTP API, описано в одном файле в специальном формате — компромиссе между человеком и машиной. Собственно, для этого в своё время изобрели и языки программирования.

[09:43] Существует два глобальных подхода к разработке с помощью OpenAPI-спецификации. Первый — code-first: мы пишем код бэкенда — контроллер, спринговый или любой другой, — комментарии к нему, его методам и параметрам, возможно, добавляем аннотации (чаще всего из библиотеки Swagger Annotations), прикручиваем плагин генерации — Gradle-плагин, Maven-плагин или CLI-тулзу — и после сборки получаем тот самый файл OpenAPI-спецификации, где всё описано. Второй подход — design-first: сначала пишем спецификацию, согласуем её с аналитиком, с product owner’ом — зависит от специфики работы, — а уже из неё генерируем интерфейсы контроллеров для бэкенда.

[10:47] Какой подход выбрать, зависит от решаемой задачи. Важно понимать одну простую вещь: выбирая подход, вы определяете единственный источник правды — single source of truth. Если у нас design-first и мы сначала пишем спецификацию, то источник правды — она: спецификация определяет, как будет себя вести сервис и какие в нём будут эндпоинты. Если code-first — источником правды является код: как он работает, так и есть, а OpenAPI-спецификация — производная от кода, отражающая то, что он делает. Если у вас в команде есть аналитик, тестировщик, продукт, и вы хотите обсуждать спецификацию с ними — скорее всего, стоит использовать design-first: они откроют её в каком-нибудь Swagger-эдиторе, сразу увидят API, потыкают и дадут комментарии ещё до того, как вы её заимплементировали. А если, как в моём случае с разработкой базы данных, источник правды — код и нужно «слушать код», то спецификация — производная, nice-to-have вещь: есть — супер, нет — в целом мир не рушится.

[12:17] Как я уже говорил, существует кодогенерация спецификации из кода и кода из спецификации — в зависимости от подхода. Это инструмент быть уверенным, что спека соответствует коду, а код — спеке: что параметр name с маленькой буквы и типа string из спецификации действительно есть в коде. Иначе возьмёшь спецификацию, сгенерируешь по ней клиента — а работать он не будет. Есть и набор валидаторов, которые берут на вход спецификацию и код, анализируют их — статически и не только — и говорят, соответствуют они друг другу или нет. Отличный кандидат для CI-пайплайна.

[13:02] Прелесть OpenAPI-спецификации в том, что из неё можно сгенерировать документацию — в целом неплохую статическую документацию, которую можно просто читать на сайте, — и сгенерировать клиента. Тот мой случай из начала — чтение сайта, выяснение краевых случаев, ручное написание клиента — всё это могло быть заменено генерацией клиента из спецификации. Это вообще киллер-фича: я беру спеку, генерирую из неё клиента один раз — или перегенерирую в CI-пайплайне каждый раз — и всегда получаю up-to-date код, который просто беру и использую. И я знаю, что он работает: если параметр name обязательный, клиент скажет — «чувак, пожалуйста, положи параметр name, без этого я компилироваться не буду». Супер вещь.

[13:56] Но несмотря на широкое распространение OpenAPI-спецификации, некоторые сервисы, цель которых — предоставить API и лёгкую интеграцию с ним, всё ещё её не дают. То есть люди зарабатывают деньги на предоставлении API, а спеки нет. У меня от этого дико бомбит. Пример — ЮMoney, конкретно их эквайринг: документация на сайте абсолютно замечательная, божественная, в целом всё понятно — но OpenAPI-спецификации нет. «Мы предоставляем API как инструмент, с нами легко интегрироваться» — а спеки нет. Сорян. Это ещё ладно — там хотя бы документация отличная, за вечер можно разобраться. А вот у Тинькофф в том же эквайринге, помимо того что спецификации, конечно, нет, ещё и документация отвратительная: пойди просто разберись, как с этим работать. И таких примеров полно. Хотя OpenAPI — уже де-факто стандарт, и спецификации сейчас разрабатываются вместе с сервисом практически сразу, как юнит-тесты, многие сервисы их не предоставляют.

[15:08] Как у любой технологии, у подхода разработки через спеку есть минусы — это несовершенный туллинг. И о нём у меня есть история. Эту часть подкаста я начал с рассказа о том, как OpenAPI-спецификация могла бы сэкономить мне неделю работы, — а теперь расскажу, как она потратила два или три дня впустую. У меня есть Micronaut-бэкенд, контроллеры которого аннотированы Swagger Annotations; на стадии билда Maven-плагин Micronaut OpenAPI генерирует из них спецификацию, а в другом модуле из этой спецификации генерируется клиент для CLI-тулзы. Стандартный code-first. Контроллеры лежали в одном модуле, и в нём же был определён плагин кодогенерации. В один прекрасный момент я решил: отчего это контроллеры и имплементации лежат вместе — надо бы сделать decoupling и вынести интерфейсы в отдельный модуль. Переименовал классы контроллеров в интерфейсы, убрал поля и тела методов, вынес в отдельный Maven-модуль. Запускаю ту же кодогенерацию в билде — ошибка: «cannot compile, Maven plugin compilation error». Совершенно неадекватная ошибка. Погуглил — естественно, ничего. Добавил stack trace, -X и все возможные дебаг-флаги Maven-сборки — и не получил вообще ничего: «path variable not found», «Maven compilation error» — всё.

[17:02] Как быть, что делать? Тут я решил применить подход, который называю бинарным поиском проблемы в программировании, — моё авторское название вполне понятного всем метода, когда мы локализуем проблему, уменьшая количество входных данных. У меня есть чёрный ящик — несобирающаяся Maven-сборка. Есть входные данные — мой исходный код и тот факт, что классом в старом модуле оно собиралось, а интерфейсом в другом — нет. Я хочу уменьшить входные данные до минимально возможных и получить ту же проблему — тем самым локализовав её причину. Думаю: ладно, я переименовал класс в интерфейс — давай обратно: интерфейс в класс в том же модуле. Запускаю — работает. Ага, дальше. Возвращаю интерфейс — не работает. Начинаю по одному убирать определения методов: убрал первый — не работает, второй — не работает, третий — заработало. Ага: значит, дело не в ключевом слове class или interface, а в конкретном методе внутри интерфейса. Таким путём я локализовал проблему до стечения трёх обстоятельств: это интерфейс; он лежит в модуле, дочернем к тому, где генерируется спека; и в нём есть аннотация @PathVariable. Убираем любое из трёх — сборка работает.

[18:45] Потратив на этот поиск два дня — процесс реально долгий — плюс почитав сорцы Micronaut OpenAPI-плагина, я пришёл к решению; само оно оказалось тривиальным, но техническая суть была вот в чём. Сейчас будет немного сложно для подкаста, но я попытаюсь объяснить, потому что это реально дичь. За что отвечает аннотация @PathVariable? Мы аннотируем ею входной параметр метода и говорим: подставь сюда значение из path. Допустим, есть /users/1, где 1 — это id. В определении контроллера мы пишем путь /users/{id}, называем параметр метода тоже id, аннотируем его @PathVariable — и фреймворк подставит ту самую единичку в параметр: он видит, что имя параметра id и в пути тоже id, два плюс два равно четыре. И это работает. До поры до времени.

[19:45] До какой же поры? До тех пор, пока имя параметра сохраняется в compile time. Если мы компилируем класс, имена параметров методов в class-файле, как правило, сохраняются (либо можно передать компилятору специальные ключи). Открываем Java-класс с public static void main(String[] args), смотрим в скомпилированный class-файл — переменная называется args, как в сорцах. Но если скомпилировать интерфейс, оригинальные имена параметров стираются: в class-файле они называются var1, var2 либо arg0, arg1, arg2. Java просто берёт и стирает имена при компиляции. Это known issue, и для людей, давно работающих с Java и знающих, как она компилируется, — не новость. Но для разработчиков плагина это, видимо, в какой-то момент было новостью — и они это не учли. Что получается: есть интерфейс с методом, параметр которого должен подставиться в path, и аннотация @PathVariable; когда мы компилируем этот интерфейс в другом модуле, имена параметров стираются — и Micronaut-плагин, потребляя class-файл, уже не может сопоставить @PathVariable и arg0, которого в HTTP path нет. Надеюсь, проблему я немного объяснил. Суть, впрочем, не в том, чтобы понять, почему так произошло, а в том, чтобы иметь в виду: туллинг реально несовершенен, и это несовершенство иногда приводит к тому, что проект просто не компилируется и ты не понимаешь, что происходит, — хотя изменение с точки зрения программирования было тривиальнейшее. Проблему я решил, но потратил на это два дня.

[21:34] Ну и в заключительной части хочу посоветовать подкаст. Называется он «Запуск завтра»: ведущий разбирает технические вопросы с гостем-экспертом на уровне, который поймёт даже человек, далёкий от IT. Отличный вариант, чтобы разбавить технические дебри моего подкаста. Ссылка будет в описании. Ну а на этом всё. Услышимся!