Как мы пришли к OpenAPI

19.11.2021

Рассказ о том, как мы пришли от спонтанного написания кода апи к процессу с отдельным репозиторием схем апи и кодогенерацией по ним.

OpenAPI

TL;DR

Мы жили с неструктурированным вебсокетным апи собственного дизайна, но поняли, что так жить нельзя и перешли на промышленный стандарт OpenAPI 3.0 (ex Swagger). В процессе перехода мы поняли, очень важная составляющая этого перехода — вынос схемы для API в отдельный репозиторий. Суть изменений не в том, чтобы получить машиночитаемое описание API, а в том, чтобы привнести на этап дизайна продукта кодифицированный и программно-валидируемый формат технического задания.

Эксперимент с единым деревом данных

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

Никаких redux-ов тогда в широком применении не было, но эту штуку я и сегодня постарался бы максимально избегать.

Идея довольно простая: реакт выстраивается вокруг рендеринга какого-то зафиксированного состояния данных. Стандартная практика для реакта — разделять код, получающий данные и рисующий и поднимать вверх по дереву компонент код, получающий данные. Эту идею можно довести это до конца и сделать единственную точку апдейта данных — корень страницы. В ней располагается компонент, который умеет получать с сервера данные и показывать их. Данные приезжают единым деревом и таким же единым деревом в один заход рендерятся.

Первый же эксперимент показал, что для подкачивания всех стримов в админке раз в секунду (чтобы показывать актуальные значения битрейта, количества клиентов и т.п.), нужен трафик под 50 мбит/с, а это просто безумие. Но мы решили не отказываться от этой идеи, а доработать её: присылать дельту.

Так родился дельта-протокол, с которым мы прожили очень долго.

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

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

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

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

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

Идея в чём-то красивая, но от неё мы решили отказаться. Есть техническая проблема: когда на сервере тысяча и больше стримов, то в админку хлещет слишком много данных и она не справляется. Тормоза нашей админки — старая проблема, на которую жалуются клиенты. Её было очень сложно решить с тем подходом.

Но гораздо важнее другая проблема — фактическая невозможность задизайнить апи и задекларировать его для внешних клиентов. Об этом расскажу ниже.

Как дизайнить HTTP API в 2021

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

При выборе дальнейшего направления развития я столкнулся с двумя вопросами:

  1. Есть ли какие-то общепринятые стандарты на то, как пользоваться возможностями протокола HTTP для передачи запросов на чтение и модификацию данных
  2. Есть ли какие-то общепринятые стандарты на то, чтобы описать эти возможности с уровнем жесткости сравнимым с WSDL для SOAP или для gRPC?

Если на второй вопрос сегодня ответ только один — OpenAPI 3, то на первый вопрос ответа не оказалось.

Набор соглашений для HTTP (REST) API

С чего хочется начать, так это с термина REST API. Его часто используют, но чаще всего подошел бы более длинный термин Pragmatic Minimal HTTP API, но это явный оверкилл. Термин REST используют для того, чтобы явно отгородиться от SOAP API (один HTTP урл и через него гоняются туда-сюда XML-ки, в которых зашифрованы команды и ответы).

Так же REST — это не JSON API, который от SOAP отличается только заменой XML на JSON (и лучше бы не меняли).

Те хорошие REST API, которые сегодня используются в индустрии не являются REST с точки зрения фанатичных пуристов, но чем можно охарактеризовать современные примеры хорошего HTTP API:

  • Использование различных HTTP урлов для указания на разные объекты. HTTP урл — хорошая штука и лучше пользоваться им, чем шлюзом в виде SOAP endpoint
  • Использование различных HTTP методов для разделения запросов на чтение и изменение данных
  • Активное использование кодов HTTP ответов, короче максимальное приближение к HTTP протоколу и использование заложенных в него фич для самого API
  • Так же важно, что последние стандартом де-факто стал JSON, хотя надо отметить что раньше вовсю бывал и XML, который вместе с XSLT в браузере позволял сделать прикольную штуку: по API можно было гулять по ссылкам, оно трансформировалось в HTML. Правда в этом было не так много практичности, от такого отказались.

После десятилетия развития HTTP и JSON индустрия пришла к тому, что до сих пор нет ни единой договоренности о том, как же единообразным образом договориться о том, чтобы забирать и редактировать объекты, хранящиеся на другом сервере. REST в его оригинальном виде был попыткой это стандартизовать, но в предельном виде не прижился.

Что выбрали мы? Набор соглашений несколько приближенный к тому, что выбрано в Rails:

  • Доступ к коллекциям по простым именам: GET /sessions
  • Доступ к объектам по под-урлам коллекций: GET(PUT,DELETE) /sessions/1
  • Развитый язык фильтрации и сортировки коллекций, передаваемый в query string, но имеющий конкретные ограничения. Если нужно больше, то мы планируем делать поиск через POST запрос и JSON язык запросов в стиле mongodb query language, но по некоторым причинам скорее всего этого делать не будем.

StreamAPI

Более подробное описание того, какие мы рассматривали варианты упаковки условий в HTTP будет в отдельном рассказе.

API first или нет?

Забегая вперед: да.

Схема по коду

Теперь развернем. Я длительное время не понимал смысла возни со схемами и контрактами, живущими в отдельном репозитории, ведь это жуткая бюрократия: тут поправь, там поправь.

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

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

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

OpenAPI first

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

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

С точки зрения SDLC (software development life cycle), в подходе генерации схемы по коду, этап проектирования фактически скомкан и у проектировщика нет возможности оставлять кодифицированный, валидируемый артефакт от этапа проектирования.

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

API-first

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

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

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

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

Фронтендер больше не будет злиться на то, что опять без предупреждения что-то поменяли, ведь он может участвовать в процессе изменений.

Продакт менеджер может формулировать требования к системе в том виде, который пригоден для программистов и программ типа Postman.

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

Дальше больше: можно сделать SDK и дать готовый SDK клиентам. Можно жить почти так же удобно, как это было 20 лет назад с Corba, только без того бинарного ада, который был там. Или почти так же удобно, как в gRPC, но с сохранением стандартного интроспектируемого HTTP.

OpenAPI first

В чем же важнейшая суть перехода от генерации схемы (или жизни без схемы, это близкие состояния) к генерации кода по схеме? В том, что теперь мы получаем инструментарий для кодифицированного проектирования. У проектировщика теперь четкие внятные критерии готовности его работы: понятно, как отрисовать кнопку на экране с этими данными — работа готова. Непонятно — не готова. Коллеги могут присоединяться уже на этом этапе и обсуждать конкретные Merge Requests в гитлабе с конкретными вопросами, уже начиная что-нибудь пробовать, примерять и прототипировать ещё на этапе формирования задания. Всё это возможно именно благодаря тому, что схема в кодифицированном формате (в отличие от тестовой постановки задачи) является сама по себе программным инструментом.

Проектирование

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

Очень важно отличать простое текстовое формулирование постановки задачи и кодифицированное. Что такое толстенный талмуд текста? Это слова, которые подлежат интерпретированию, причем как правило талмуд довольно подробный и бодрый вначале и скомканный в конце, прям как Игра Престолов. Это текст написанный людьми, которые не особо отвечают за реализацию для людей, которые заранее знают, что прийдется допридумывать в процессе и особенно в конце.

API first подход позволяет выставить предельно жесткие границы и формулировку готовности задачи. Или схема готова, или нет. Проверить валидность и полноту схемы на порядок проще, чем вычитать код и восстановить по коду все возможные поля ответов и их значения.

Мы тоже жили по принципу «месяцы кодирования берегут часы проектирования», но решили рискнуть и попробовать наоборот.

OpenAPI Tools

Распараллеливание работ

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

А когда тестировщик может начать работать? Правильно: с первых строк схемы, ведь у него теперь есть контракт (пусть и частичный). А насколько упрощается взаимодействие с коллегами, ведь вместо «я что-то нажал и всё сломалось», теперь «я шлю валидный запрос и мне отдается не то, что в спецификации».

Всё как у дедов в конце 90-х!

Инструменты

Swagger parser

Первый инструмент, который понадобится при работе со схемой — это @apidevtools/swagger-parser
Все остальные инструменты мягко говоря не дотягивают. Без него вы даже не распарсите маломальски сложную схему.

Валидаторы

В репозитории со схемами обязательно должен быть линтер, без него ничего нельзя мержить. Все ограничения дочерних проектов (вашего Erlang, Rust, Go, Swift кода) должны быть отражены в правилах линтера.

openapi-ibm-validator хорош и помогает. Spece, spectral выглядят слабоватыми без swagger-parser

Postman

Постарайтесь приучиться к Postman, он хорош, хотя с ним есть вопросы на тему его нативной поддержки OpenAPI 3. Мы сейчас для эрланга написали свой кодогенератор и скорее всего будем его доводить до состояния опенсорса.

Mock server

Фронтендеры оценят mock-серверы на базе спек — пока там бекенд подоспеет со своими Rapid development frameworks.

Есть prism, но он нам не зашел — слишком закрытый, не подходит. Пришлось сделать свой на базе openapi-sampler и простого express.

OpenAPI operationId

При внедрении OpenAPI возникли вопросы у коллег, особенно оглядываясь назад на сложившиеся привычки. Дело в том, что в openapi есть такая концепция как operationId и это офигенно крутая штука, но вопрос: зачем это нужно, если рядом есть привычное формирование урлов вручную.

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

Может возникнуть вопрос: зачем эти две конструкции сразу и зачем эти operationId, когда есть понятные удобные урлы.

apiStreamer.init(`/streams/${name}`, 'put', null, dataForUpdate)
apiStreamer.init(`/streams/${mediaName}`, 'get')

и

client.chassis_interface_save({name}, dataForUpdate)

в чём между ними разница? Верхний использует урлы в коде, нижний использует openapi operationId

По сути openapi дает больше, чем список урлов и валидацию параметров. Он дает возможность описать полноценный RPC (remote procedure call), только с опытом предыдущих лет: на базе довольно понятного, читаемого и привычного HTTP.

Почему подход с operationId более долговечный и надежный?

  • Трансформация operationId в функции — более поддерживаемое и читаемое для других людей. В примере со склейкой урлов руками мало того, что допущена ошибка (там name может быть с /), так ещё и надо делать дополнительные усилия, чтобы уследить глазами за соответствием урла методу.
  • operationId грепаются. Можно точно найти все вызовы, сделать это с собранными руками урлами попросту не получится: /streams/${urlescape(name)}/inputs/{selected_input.index} — как написать на такое регексп?
  • operationId дают возможность получить и прочитать полный список функций. Что тоже может быть очень полезно.

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

Что дальше

Сейчас у нас уже около 25 тыс строк кода в схемах и это без примеров и описаний. Из этого мы будем делать готовую среду для документирования, для шаринга объектов и их описаний между проектами(!!!), там же появятся примеры, которые будут использоваться для тестирования и мок серверов.
Перейти к Flussonic HTTP API

Автор:
Максим Лапшин
Ключевые слова:
OpenAPI