Всем привет! На недавно прошедшем Superjob IT Meetup я рассказывал о том, как мы в Superjob разрабатываем свой API для проекта с миллионной аудиторией и кучей различных платформ.
В этой статье я бы хотел поговорить о том, почему мы не смогли остановиться ни на одном из десятков готовых решений, как больно было писать своё собственное и что ждёт вас, если вы решите повторить наш путь. Всех заинтересовавшихся прошу под кат.
Вместо вступления
История API в Superjob началась с сурового XML API. От него мы перешли к лаконичному JSON, а позже, устав от споров по поводу того, что же правильнее — {success: true} или {result: true}, внедрили JSON API. Со временем мы отказались от некоторых его фич, договорились о форматах данных и написали свою версию спеки, которая сохраняла обратную совместимость с оригиналом. Ровно на этой спеке работает последняя, третья версия нашего API, на которую мы постепенно переводим все наши сервисы.
Для наших задач, когда большинство эндпойнтов в API принимают или отдают некие объекты, JSON API оказался почти идеальным решением. В основе этой спеки — сущности и их связи. Сущности типизированы, имеют фиксированный набор атрибутов и связей и по своей сути очень похожи на модели, с которыми мы привыкли работать в коде. Работа с сущностями осуществляется в соответствии с принципами REST — протокола поверх HTTP, как, например, в SOAP или JSON-RPC, нет. Формат запроса практически полностью повторяет формат ответа, что сильно облегчает жизнь и серверу, и клиенту. Например, типичный ответ JSON API выглядит так:
{
"data": {
"type": "resume",
"id": 100,
"attributes": {
"position": "Курьер"
},
"relationships": {
"owner": {
"data": {
"type": "user",
"id": 200
}
}
}
},
"included": [
{
"type": "user",
"id": 200,
"attributes": {
"name": "Василий Батарейкин"
}
}
]
}
Здесь мы видим сущность типа resume, со связью owner на сущность типа user. Если бы клиент захотел нам отправить такую сущность, точно такой же json он бы положил в тело запроса.
Первые шаги
Изначально реализация нашего API была весьма наивной: ответы эндпойнтов формировались непосредственно в экшенах, данные от клиента получались с помощью небольшой надстройки над Yii1, на котором работает наше серверное приложение, а документация жила в отдельном файлике, заполнявшемся от руки.
С переходом на JSON API мы превратили надстройку в полноценный фреймворк, который управлял преобразованием (маппингом) моделей в сущности, а также заведовал транспортным слоем (разбор запросов и формирование ответов).
Для маппинга модели в сущность нужно было описать два дополнительных класса: DTO для сущности и гидратор, который наполнял бы DTO данными из модели. Такой подход делал процесс маппинга достаточно гибким, однако на деле эта гибкость оказалась злом: наши гидраторы со временем стали обрастать копипастой, а необходимость для каждой модели заводить ещё 2 класса приводила к распуханию нашей кодовой базы.
Транспортный слой также был далёк от идеала. Разработчик был вынужден постоянно думать о внутреннем устройстве JSON API: как и в случае с маппингом моделей, полный контроль над процессом приводил к необходимости таскать из экшена в экшен практически идентичный код.
Мы стали думать о переходе на стороннее решение, работающее с JSON API. На сайте JSON API есть довольно внушительный список имплементаций спеки на самых разных языках как для сервера, так и для клиента. Проектов, реализующих серверную часть на PHP, на момент написания статьи там насчитывалось 18, из которых ни один нам не подошёл:
- Во-первых, сторонние решения имели всё те же проблемы, что и наше собственное, — слишком много лишнего кода, мало автоматизации. В некоторых случаях к моделям предъявлялись определённые требования (например, имплементация интерфейса), а с нашим объемом кода это могло вылиться в серьёзный рефакторинг. Для работы запросов и ответов нам в любом случае пришлось бы писать адаптер, связывающий выбранное решение с Yii.
- Во-вторых, подавляющее количество сторонних решений поддерживало маппинг один в один: у вас есть одна модель, вы можете превратить её в одну сущность. Это нормальный кейс, когда данные в моделях хранятся в том виде, в каком вы хотели бы отдать их клиенту, однако на деле это не всегда так. Например, у модели резюме есть атрибуты с контактами, но эти контакты клиент может получить только при определённых условиях. Было бы здорово вынести контакты в отдельную сущность, связанную с сущностью самого резюме, превратив таким образом одну модель в несколько сущностей, но в сторонних решениях такое можно сделать только через костыли.
- В-третьих, мы хотели максимально упростить разработку типовых эндпойнтов, чтобы программисту, перед которым стоит задача написать эндпойнт, выбирающий модели из базы и отправляющий их клиенту, не приходилось каждый раз писать однотипный код. Однако сторонние решения не предлагали никакой интеграции с DBAL.
- Наконец, в-четвёртых, мы хотели упростить написание документации и тестов, но сторонние решения в большинстве своем не предоставляли никакой информации о том, какие атрибуты и связи есть у той или иной сущности.
Необходимость вновь приступить к написанию своего решения становилась очевидной :)
Разработка фреймворка
Проанализировав недостатки нашей прошлой разработки и сторонних решений, мы сформировали свое видение того, каким должен быть наш новый фреймворк, получивший весьма оригинальное название Mapper:
- Прежде всего вместо написания DTO и гидраторов весь маппинг мы решили описывать в конфиге.
- Этот конфиг незаметно для разработчика должен был компилироваться в PHP-код, который, в свою очередь, использовался бы для гидрации сущностей.
- Вся работа с JSON API должна была вестись за сценой: предполагалось, что для типовых эндпойнтов вся работа будет сводиться к описанию бизнес-логики и получению моделей.
- Наконец, как уже упоминалось выше, мы хотели интегрировать наше решение с DBAL, документацией и тестами.
Ядро
Основа фреймворка — компилируемые гидраторы, то есть объекты, которые занимаются заполнением моделей и построением сущностей. Какими знаниями должен обладать гидратор, чтобы справляться с поставленной задачей? Прежде всего он должен знать, из каких моделей и какую сущность будет билдить. Он должен понимать, какими свойствами и связями обладает сущность и как они соотносятся со свойствами и связями исходных моделей.
Попробуем описать конфиг для такого гидратора. Формат конфига — YAML, который легко пишется, легко читается и легко парсится (у себя мы использовали symfony/yaml).
entities:
TestEntity:
classes:
- TestModel
attributes:
id:
type: integer
accessor: '@getId'
mutator: '@setId'
name:
type: string
accessor: name
mutator: name
relations:
relatedModel:
type: TestEntity2
accessor: relatedModel
relatedModels:
type: TestEntity3[]
accessor: '@getRelatedModels'
Здесь сущность TestEntity собирается из модели TestModel. У сущности два атрибута: id, который получается из геттера getId, и name — из свойства name. Так же у сущности есть две связи: одиночная relatedModel, которая состоит из сущности типа TestEntity2, и множественная relatedModels, которая состоит из сущностей TestEntity3.
Скомпилированный по такому конфигу гидратор выглядит следующим образом:
class TestEntityHydrator extends Hydrator
{
public static function getName(): string
{
return 'TestEntity';
}
protected function getClasses(): array
{
return [Method::DEFAULT_ALIAS => TestModel::class];
}
protected function buildAttributes(): array
{
return [
'id' => (new CompiledAttribute('id', Type::INTEGER))
->setAccessor(
new MethodCallable(
Method::DEFAULT_ALIAS, function (array $modelArray) {
return $modelArray[Method::DEFAULT_ALIAS]->getId();
}
)
)
->setMutator(
new MethodCallable(
Method::DEFAULT_ALIAS,
function (array $modelArray, $value) {
$modelArray[Method::DEFAULT_ALIAS]->setId($value);
}
)
),
'name' => (new CompiledAttribute('name', Type::STRING))
->setAccessor(
new MethodCallable(
Method::DEFAULT_ALIAS, function (array $modelArray) {
return $modelArray[Method::DEFAULT_ALIAS]->name;
}
)
)
->setMutator(
new MethodCallable(
Method::DEFAULT_ALIAS,
function (array $modelArray, $value) {
$modelArray[Method::DEFAULT_ALIAS]->name = $value;
}
)
)
->setRequired(false),
];
}
protected function buildRelations(): array
{
return [
'relatedModel' => (new CompiledRelation('relatedModel', TestEntity2Hydrator::getName()))->setAccessor(
new MethodCallable(
Method::DEFAULT_ALIAS, function (array $modelArray) {
return $modelArray[Method::DEFAULT_ALIAS]->relatedModel;
}
)
),
'relatedModels' => (new CompiledRelation('relatedModels', TestEntity3Hydrator::getName()))->setAccessor(
new MethodCallable(
Method::DEFAULT_ALIAS, function (array $modelArray) {
return $modelArray[Method::DEFAULT_ALIAS]->getRelatedModels();
}
)
)->setMultiple(true),
];
}
}
Весь этот монструозный код, по сути, лишь описывает те данные, которые есть в сущности. Согласитесь, писать такое от руки, да ещё и для каждой сущности, которая есть в проекте, было бы совсем не здорово.
Для того, чтобы всё описанное выше заработало, нам потребовалось реализовать три сервиса: парсер конфига, валидатор и компилятор.
Парсер занимался тем, что следил за изменениями конфига (в этом нам помог symfony/config) и в случае обнаружения таких изменений перечитывал все файлы конфига, объединял их и передавал валидатору.
Валидатор проверял корректность конфига: сперва проверялось соответствие json schema, которую мы описали для нашего конфига (тут мы использовали justinrainbow/json-schema), а затем проверялись на существование все упомянутые классы, их свойства и методы.
Наконец, компилятор брал отвалидированный конфиг и собирал из него PHP-код.
Интеграция с DBAL
По историческим причинам в нашем проекте дружно соседствуют два DBAL: стандартный для Yii1 ActiveRecord и Doctrine, и мы хотели подружить наш фреймворк с обоими. Под интеграцией понималось, что Mapper сможет самостоятельно как получать данные из базы, так и сохранять их.
Чтобы достичь этого, нам прежде всего потребовалось внести небольшие изменения в конфиг. Поскольку в общем случае имя связи в модели может отличаться от имени геттера или свойства, возвращающего эту связь (особенно справедливо это для Doctrine), то нам нужно было уметь рассказать Mapper’у, под каким именем знает ту или иную связь DBAL. Для этого в описание связи мы добавили параметр internalName. Позже этот же internalName появился и у атрибутов, чтобы Mapper мог самостоятельно выполнять выборки по полям.
Помимо internalName, мы добавили в конфиг знание о том, к какому именно DBAL относится сущность: в параметре adapter указывалось название сервиса, который имплементировал интерфейс, позволяющий Mapper’у взаимодействовать с DBAL.
Интерфейс имел следующий вид:
interface IDbAdapter
{
/**
* Statement по контексту.
*
* @param string $className
* @param mixed $context
* @param array $relationNames
*
* @return IDbStatement
*/
public function statementByContext(string $className, $context, array $relationNames): IDbStatement;
/**
* Statement по значениям атрибутов.
*
* @param string $className
* @param array $attributes
* @param array $relationNames
*
* @return IDbStatement
*/
public function statementByAttributes(string $className, array $attributes, array $relationNames): IDbStatement;
/**
* Инстанцировать модель указанного класса.
*
* @param string $className
*
* @return mixed
*/
public function create(string $className);
/**
* Сохранить модель.
*
* @param mixed $model
*/
public function save($model);
/**
* Выполнить привязку одной модели к другой.
*
* @param mixed $parent
* @param mixed $child
* @param string $relationName
*/
public function link($parent, $child, string $relationName);
/**
* Отвязать одну модель от другой.
*
* @param mixed $parent
* @param mixed $child
* @param string $relationName
*/
public function unlink($parent, $child, string $relationName);
}
Для того чтобы упростить взаимодействие с DBAL, мы ввели понятие контекста. Контекст — это некий объект, получив который, DBAL должен был понять, какой запрос он должен выполнить. В случае с ActiveRecord в качестве контекста используется CDbCriteria, для Doctrine — QueryBuilder.
Для каждого DBAL мы написали свой адаптер, имплементирующий IDbAdapter. Не обошлось без сюрпризов: например, оказалось, что за всё время существования Yii1 не было написано ни одного расширения, которое поддерживало бы сохранение всех видов связей, —пришлось писать собственную обёртку.
Документация и тесты
У себя мы используем Behat для интеграционных тестов и Swagger для документирования. Оба инструмента нативно поддерживают JSON Schema, что позволило нам без особых проблем встроить в них поддержку Mapper’а.
Тесты для Behat пишутся на языке Gherkin. Каждый тест представляет собой последовательность шагов, а каждый шаг — предложение на натуральном языке.
Мы добавили шаги, которые интегрировали поддержку JSON API и Mapper в Behat:
# Описываем сущность
When I have entity "resume"
And I have entity attributes:
| name | value |
| profession | Кладовщик |
# Описываем связь
And I have entity relationship "owner" with data:
| name | value |
| id | 100 |
# Отправляем запрос и проверяем, что вернулась сущность resume
Then I send entity via "POST" to "/resume/" and get entity "resume"
В этом тесте мы создаем сущность резюме, заполняем её атрибуты и связи, отправляем запрос и валидируем ответ. При этом вся рутина автоматизирована: нам не нужно составлять тело запроса, поскольку этим занимаются наши хелперы для Behat, нам не нужно описывать JSON Schema ожидаемого ответа, так как его сгенерирует Mapper.
С документацией ситуация несколько интереснее. Файлы JSON Schema для Swagger у нас изначально генерировались на лету из исходников на YAML: как уже упоминалось, YAML значительно проще в написании, чем тот же JSON, но Swagger понимает только JSON. Мы дополнили этот механизм так, чтобы в итоговую JSON Schema попадало не только содержимое YAML-файлов, но и описания сущностей из маппера. Так, например, мы научили Swagger понимать ссылки вида:
$ref: '#mapper:resume'
Или:
$ref: '#mapper:resume.collection.response'
И Swagger рендерил объект сущности resume или целиком объект ответа сервера с коллекцией сущностей resume соответственно. Благодаря таким ссылкам, как только менялся конфиг Mapper’а, автоматически обновлялась документация.
Выводы
Приложив массу усилий, мы сделали инструмент, который существенно упростил жизнь разработчикам. Для создания тривиальных эндпойнтов теперь достаточно описать сущность в конфиге и накидать пару строк кода. Автоматизация рутины в написании тестов и документации позволила нам сэкономить время на разработку новых эндпойнтов, а гибкая архитектура самого Mapper’а дала возможность легко расширять его функциональность, когда нам это было необходимо.
Пришло время ответить на основной вопрос, который я озвучил в начале статьи, — чего же стоило нам сделать свой велосипед? И нужно ли вам делать свой?
Интенсивная фаза разработки Mapper’а заняла у нас около трех месяцев. Мы до сих пор продолжаем добавлять в него новые фичи, но в значительно менее интенсивном режиме. В целом мы довольны результатом: поскольку Mapper проектировался с учётом особенностей нашего проекта, он справляется с возложенными на него задачами значительно лучше, чем любое стороннее решение.
Стоит ли вам идти нашим путём? Если ваш проект ещё молод и кодовая база невелика, вполне возможно, что написание своего велосипеда для вас будет неоправданной тратой времени, и лучшим выбором будет интегрировать стороннее решение. Однако если ваш код писался на протяжении многих лет и вы не готовы проводить серьёзный рефакторинг, то определенно стоит задуматься о своем собственном решении. Несмотря на изначальные сложности при разработке, оно может существенно сэкономить вам время и силы в дальнейшем.
Автор: m4rt1n