История о том, как мы в ОК упрощали процесс создания API

Представьте ситуацию: у вас есть большой «зрелый» ИТ-продукт, но специалистов, способных его поддерживать, крайне мало. Что делать, в такой ситуации — продолжать «тянуть чемодан без ручки» или искать способ перехода на понятные и распространенные технологии?
Не так давно команда ОК столкнулась с подобной дилеммой: исторически для отображения страниц мы использовали server-side рендеринг на базе GWT (Google Web Toolkit) и RDK (наша внутренняя разработка), так как разработка первой версии ОК началась более 20 лет назад. Такой набор технологий «под капотом» был оптимален в те времена, но сейчас он, мягко говоря, не самый желательный. Поэтому нам было важно перейти на более распространенную библиотеку, а точнее — на React. Это мы и начали делать итеративно, шаг за шагом для каждого раздела сайта.
Меня зовут Александр Косницкий, я разработчик в компании ОК. В этой статье я расскажу, как мы переводили на React раздел «Обсуждения»: с чего начинали, с чем сталкивались и как в результате смогли получить то, что поможет не только нам, но и остальным командам с переводом на новую библиотеку их собственных разделов.
Подробнее о задаче
Исторически в ОК для отображения страниц использовался server-side рендеринг на базе GWT и RDK. Но в долгосрочной перспективе сохранять такой стек было нерационально — сложно найти фронтенд-разработчиков, которые с таким готовы работать. Соответственно, чтобы одновременно и развивать проект, и упростить его администрирование, нам надо было перейти на более популярные и понятные технологии. В нашем случае приоритетом стал React, который де-факто является стандартом индустрии.
Но история с быстрой, бесшовной миграцией на новую технологию — не наш случай. Дело в том, что для перевода GWT и RDK на React нужен API, на который React будет ходить. И этого API к тому моменту у нас не было. Соответственно, чтобы осуществить переход на React, нам предстояло разработать API для каждого GWT/RDK блока, который бы мы хотели перевести.
Первые подводные камни
Еще на этапе анализа задачи стало понятно, что «простой прогулки» не получится. Дело в том, что у нас есть две группы серверов, которые отображают сайт: один для десктопной версии, второй — для мобильной.
Несмотря на то, что с точки зрения API разницы между мобильной и десктопной версией нет, фактически мы имели два разных проекта, поскольку код на GWT/RDK у двух платформ сильно отличался.
Чтобы понять, как действовать в такой ситуации, мы начали изучать опыт других команд, которые уже сталкивались с подобными задачами. Оказалось, что обычно решение сводится к использованию одинаковых API для двух проектов. То есть, их нужно копипастить друг в друга.

Подход, откровенно говоря, сомнительный, как и его «преимущества»:
Всегда есть, чем занять Junior-специалистов, которые только пришли в компанию, потому что надо постоянно поддерживать код в консистентном состоянии между двумя API.
Команда тестирования держится в тонусе: то, что хорошо работает на mob-версии, может не работать на вебе и наоборот. Даже несмотря на то, что фронтенд-часть на React одинаковая.
Estimate по задачам на создание API можно брать в два раза больше, ведь кода будет в два раза больше.
От копипаста к новой реализации
К созданию единого API нас подталкивали еще несколько моментов:
все методы и возвращаемые значения — одинаковые;
аннотации, которыми помечаются endpoint-ы — одинаковые и взяты из one-desktop;
endpoint-ов фактически два, а frontend React-клиент одинаковый для обеих версий.
Понимая это, у нас появилось желание вынести API в отдельный проект, а после подключать его к мобильной и веб-версии в качестве библиотеки. То есть при сборке и запуске API будет доступно и на вебе, и на мобе, но писать код и отлаживать его придется только один раз.
Кода, который препятствовал такому славному и простому завершению истории, к счастью, было не слишком много: можно заметить, что все методы API принимают AppRequestCtx
, содержащий разную метаинформацию — различные данные о пользователе, сессии и других аспектах.

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

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

Чтобы все это запустилось, необходимо было сделать на каждой платформе только пару строчек изменений, которые позволили API понимать, что EndpointContext
— тоже валидный аргумент для API endpoint-ов, куда просто надо подставлять обычный AppRequestCtx
.
На этом подготовка к созданию нового метода под наше API фактически была завершена.
От API к OpenAPI
Когда мы увидели, что такое кроссплатформенное API действительно успешно завелось и работает, появилась идея создавать и поддерживать контракт этих API через наш собственный OpenAPI генератор, используя API-first подход. Подобная реализация потенциально дает много преимуществ.
OpenAPI — хорошо известный всем формат. Написанный разработчиками OpenAPI-файл можно будет напрямую добавлять во множество доступных всем инструментов.
Не нужно будет вручную писать документацию по API — все будет в понятном виде описано в самом файле. Более того, по файлу также можно генерировать страницу на wiki.
Такой файл можно сразу передавать команде фронтенда, чтобы они понимали, как будет выглядеть API и какие ответы будет отдавать еще до того, как само API будет готово.
В дальнейшем файл можно даже захостить и включить генерирование кода на клиенте и беке напрямую в CI. Это удобно — если контракт API поменяется, то новые билды сразу начнут падать, пока разработчики не отреагируют должным образом на изменения. Чтобы поменять этот интерфейс, надо поменять файл, что сразу отразится и на бекенде, и на фронтенде. То есть исключаются ситуации, при которых, например, вносятся изменения в интерфейсе API на беке, а на фронтенде об этом ничего не известно.
Тестировщикам сразу понятно, как тестировать API — не нужно вникать в код или опрашивать разработчиков, чтобы узнать, какие есть аргументы. Достаточно импортировать запросы в postman или открыть в swagger, после чего его можно использовать напрямую.
Оценив преимущества, мы приступили к написанию генератора. Это оказалось на удивление легко — даже без опыта работы с mustache файлами и глубокой экспертизы в особенностях работы кодогенераторов, на разработку первой версии генератора понадобился всего один день.

Причем полученная версия уже могла генерировать все нужные для MVP файлы. Это дало возможность приступать к написанию спецификации OpenAPI, с помощью которой можно нажатием одной кнопки получить интерфейс API.

При этом также генерируются все нужные бины.

Более того, генератор легко добавляется плагином Gradle в builscript
, что существенно упрощает его интеграцию в финальный пайплайн сборки.

От теории к практике
Теперь давайте посмотрим, как именно работает описанный подход.
При изучении кода, приведенного ниже, можно заметить, что описанная спецификация endpoint-а легко превращается в код в discussionsApi
интерфейсе, аналогичный тому, который мы видели в примере с dailyphoto
.

Только теперь код еще и использует в аргументах EndpointContext
, что делает его доступным к использованию на обеих платформах сразу (ради чего изначально все и затевалось).
Также мы получили возможность легко создавать нужные бины, используя внутри них как примитивы, так и более комплексные структуры, такие как листы/мапы или другие объекты.

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

Это может быть полезно, когда есть уже готовые enum или бины из библиотек и мы хотим использовать их напрямую. В таком случае достаточно:
создать объект-«заглушку» в OpenAPI спецификации;
добавить маппинг нашего объекта на реальный Java-класс в
schemaMappings
в сборочном скрипте генератора.
Мы можем использовать этот объект в любом месте нашей спецификации. Но теперь при генерации наш объект будет использован напрямую с нашего маппинга, вместо создания кастомного бина, соответствующего спецификации, как происходит обычно.
Помимо прочего, без проблем работает и наследование.

Используя структуру allOf
, мы можем создавать бины, которые наследуются от других бинов. Например, мы создаем LiveStickerBean
, который сам по себе наследуется от обычного StickerBean
. Это еще сильнее расширяет возможности и эластичность нашей спецификации.
После того как мы получили столь широкий набор возможностей и написали спецификацию, нам самим остается лишь реализовать интерфейс DiscussionsApi
.

При этом, если какая-то часть реализации у нас все еще зависит от платформы (в случае с API Дискуссий это был парсинг ссылок), благодаря наличию у нас в проекте DI от Spring мы можем решить эту проблему довольно простым образом. Так, для этого достаточно создать новый интерфейс с методами в проекте нашего API, и проинжектить его во все необходимые места.
Примечание: При подключении модуля к конкретной платформе важно не забывать о создании компонент-реализации интерфейса с кодом того, как это должно работать на конкретной платформе.
В итоге мы уже готовы к применению API на интересующих платформах. Остается лишь:
Реализовать в модуле нашего API класс-конфигурацию, который будет создавать и настраивать все бины.
Собрать сборку зависимости и подключить ее к интересующей нас платформе.
Добавить конфигурацию в
applicationContext
на этой платформе.Добавить наше API к списку на инициализацию при старте.


Планы на будущее
Разработанная система уже запущена и успешно работает. Первое API сейчас активно проходит ревью. Причем на подходе уже несколько новых API, разработанных на основе той же методики.
Но мы не останавливаемся на достигнутом и уже сейчас думаем над вариантами улучшения системы. Так, в перспективе возможны некоторые доработки, в том числе:
добавление поддержки запуска подобных API-библиотек еще и на forticom-api (нашем модуле с API для мобильных клиентов);
улучшение
EndpointContext
за счет переноса в него еще большего количества методов из реализаций контекстов на конкретных платформах;реализация в генераторе функциональности создания стартовых проектов;
подготовка более подробной документации.
Возможно, о том, как будет развиваться наша реализация, мы расскажем в одной из будущих статей.
Если хотите обсудить наш подход или поделиться своим опытом — добро пожаловать в комментарии!