Привет, сегодня я хочу поговорить о том, с чем мы рано или поздно сталкиваемся, имея много доменной логики — и даже не микросервисную, — а хотя бы просто сервисную архитектуру.
В ожидании комбинаторного взрыва
Я работаю в команде сервиса расписания занятий, который оркестрирует весь процесс подбора преподавателя в Skyeng. Когда новый клиент хочет найти учителя, нам приходит запрос: «ID ученика такой-то хочет заниматься с такого-то числа и в такое-то время». Чтобы подобрать преподавателя, мы идем в сервисы других команд:
- в CRM — чтобы определить, что за услуга сейчас должна быть оказана: занятия английским, математикой или по одному из новых экспериментальных предметов,
- в сервис рейтинга — чтобы отсортировать преподавателей по данной услуге, лучше подходящих для данного ученика,
- и в сам сервис преподавателей, который хранит информацию, кто берёт новых учеников — и много-много других нюансов.
На самом деле, сервисов больше, но даже на этом масштабе можно отследить проблему. Каждый сервис пишет своя команда: и ее разработчики возвращают ошибки, как придумали.
Гипотетическая ситуация, когда все сервисы упали сразу, может обернуться такими ответами.
У каждого сервиса может быть куча вариантов ошибок, и мы должны обрабатывать все их виды на своей стороне. При интеграции любого нового сервиса мы увеличим кучу кода, которая не имеет отношения к бизнес-логике, но которую нужно будет поддерживать. Более того, подключая новый сервис, мы не можем быть уверены, что документация не изменится.
Бывает и такое — бизнес приходит и просит «А добавьте тут...» Допустим, у нас есть форма ввода данных, в ней 10 полей, мы валидируем девять, а десятое — это комментарий, он опционален. В один день нас просят добавить валидацию на комментарий. Фронт говорит: «Если придет такой-то код, я у себя доработаю». Ты пишешь новую обработку: и вот на 10 ошибок у фронта появляется 10 обработчиков, а еще у тебя есть разные вариации if-ов, else-ов и т.д.
При этом сами ошибки возникают редко — и это тоже проблема.
При взаимодействии между сервисами мы используем REST, а он как таковой не регламентирует нам форматы, кроме того, что основан на HTTP.
Все мы знаем какие-то определенные наборы ошибок, а остальные вряд ли можем вспомнить без словаря. В итоге, видя какой-то ответ, мы тратим время на осознание, что он значит на самом деле, — а это сильно влияет на скорость тушения пожара на проде, разбора логов и каких-то еще критичных ситуаций.
Давайте попробуем решить эту проблему
Мы c командой посмотрели обсуждения в комьюнити, посмотрели ролики на ютубе, статьи на хабре (вот интересный хабра-холивар про 200-й ответ). Похоливарили внутри. Пришли к выводу, что серебряной пули не существует… и пошли своим путем. Ввели единый формат, при котором у нас есть:
1. Поле «data» для полезной нагрузки — скажем, вот так может выглядеть успешный ответ:
{
"data": [
{
"field1": 1
},
{
"field2": 2
}
],
"errors": null
}
2. И поле «errors» для ошибок.
...
"data": null,
"errors": [
{
"property": null,
"error": {
"message": "The selected time is alr...",
"code": "selected_time_already_taken"
}
}
]
…
Поля data и errors в ответе не могут быть заполнены одновременно.
У нас большое разнообразие ошибок, поэтому на каждую вариацию мы добавили свои правила.
Ошибки бизнес-логики возвращают код 200 и список ошибок в поле errors. Мы не должны падать, например, если у преподавателя превышено количество учеников, которых он может вести. Поэтому решили отображать на фронт полезную ошибку.
Если это не ошибка бизнес-логики, возвращаем соответствующий по смыслу HTTP-код. Но вы скажете, что в таком случае проблема разнообразия ошибок никуда не уходит. Поэтому их тоже нужно подразделять:
- Непредвиденные ошибки: где-то что-то забыли, линтер не допроверил — всякое бывает.
- Инфраструктурные ошибки — например, ошибки Redis Cluster, ошибка связанности или если приложение не дождалось чего-нибудь другого. Мы понимаем, что у сервиса что-то произошло в эту секунду, и можем отреагировать с помощью retry. Такие ошибки удобно обрабатывать с помощью воркера, который периодически ходит по сервисам и опрашивает их: прошел, собрал метрики, хоп — что-то отвалилось, сделали retry.
- Ошибки валидации входных данных. Они вылазят где-то на тестингах, на dev-режиме, — и должны быть максимально полезными для разработчика. Мы используем Symfony serializer и validator, чтобы маппить все, что пришло на вход: чтобы не просто узнать, что Error validation, а понять, что именно не так, мы выделили отдельный класс ошибок, которые выдаются массивом.
Пример ошибок, которые используем
Первое, что нам нужно четко понимать, — у нас произошла, скажем, ошибка валидации или ошибка инфраструктуры? Нужно как-то разделять это на уровне кода — и не бесконечными if в бизнес-логике, а именно на уровне инструмента языка.
Мы создали иерархию Exception. Например, делаем базовый класс ValidationException и от него наследуем всё, что зависит от валидации. Это может быть TeacherValidationException. А у него свои наследники — NicknameException, AgeException и так далее.
Дальше делаем Exception, который хранит подробную информацию о бизнес-ошибке. Бывают ситуации, когда нам важно понимать, на каком слое что-то пошло не так. Тогда каждого слоя мы получаем свои Exception: есть API-репозиторий с APIRepositoryException, выше идет сервис, у него есть ServiceException, потом идёт Exception контроллера и т.д.
Остается сделать единую точку выхода из приложения, которая позволит отлавливать все исключения и выдавать код формате с data и errors. Это просто — сейчас так или иначе все фреймворки позволяют отлавливать Exception ивентами.
Вот как это выглядит на практике:
class ValidationException extends \Exception
interface HttpExceptionInterface extends \Throwable
ValidationException — бросаем, если Symfony validator собрал что-то, что не позволяет нам продолжить дальше. Здесь же есть HttpExceptionInterface, который кидает Guzzle, дабы мы понимали, какой ответ http, какой response, какие headers и так далее нам пришли.
class SomeServiceException extends \Exception
SomeServiceException — у каждого сервиса есть FeaturesException или какой-нибудь ещё Exception, которые позволяют нам пробрасывать информацию между слоями приложения. Мы можем понять, что у нас произошла бизнес-ошибка, и вывести ее на фронт, либо подавить и отдать ответ о том, что мы ответим позже. Это дает вариативность в нашей логике.
interface ConventionalResponseExceptionInterface extends \Throwable
{
public function getConventionalCode(): string;
public function getProperty(): ?string;
}
ConventionalResponseExceptionInterface — есть интерфейс бизнес-ошибки, фронту нужно получить информацию и вывести текст. Мы на бэкенде рулим тем, что увидит пользователь. В поле Property у нас может быть запись teacher — то есть, что-то произошло на стороне учителя. А ConventionalCode — это какой это уникальный код ошибки. Допустим, это может быть «Selected time have already taken» — выбранное время уже занято. Фронт по факту пишет один обработчик на такие типы ошибок, а мы просто бросаем Exception, который наследует такой интерфейс. Он возвращает все нужные данные, а фронт добавляет в обработку ошибок этот код. Больше ничего дорабатывать не нужно.
Как внедрить на практике
Самое главное — мы не можем сказать бизнесу, что «решили всё переписать, чтобы у нас все работало». Такие вещи делаются с сохранением обратной совместимости: вы вводите API версии 2, которое теперь работает с нужным форматом, и делаете доработки на уровне middleware, либо OnKernelRequest, чтобы каждый раз не проверять, новая это апишка или старая.
Подключаем единый обработчик исключений. Так как мы используем Symfony, то просто сконфигурировали listener, который подписан на события onKernelException. По факту, он перехватывает и обрабатывает любое исключение, которое возникает в системе — при версиях PHP старше семёрки проблем не возникает.
kernel.listener.conventional:
class: ConventionalApiExceptionListener
tags:
- {
name: kernel.event_listener,
event: kernel.exception,
method: onKernelException
}
Сам код listener-а очень большой, покажу интересный кусок: приходит event, он содержит в себе instance Exception-а, мы проверяем, к какому типу ошибки это относится, и в зависимости от этого формируем список наших ошибок.
switch (true) {
case $exception instanceof ConventionalResponseExceptionInterface:
...
break;
case $exception instanceof ValidationException:
...
break;
case $exception instanceof BadRequestHttpException:
...
break;
case $exception instanceof HttpExceptionInterface:
...
break;
case $exception instanceof SomeServiceException:
...
break;
default:
...
}
В случае, если приходит ConventionalResponseException, мы знаем, что есть уникальный код и текст ошибки, какая-то подробная информация, — упаковываем это и выводим так, чтобы фронт тоже это понимал. А если пришёл ValidationException, он уже содержит в себе весь список ошибок, — и нам нужно просто преобразовать его в наш формат.
В логах есть три уровня приоритета — info, warning и critical. В зависимости от приоритета, ошибки попадают в разные хранилища: не критичное в файлы, критичное в Slack. Бизнес-ошибки уходят в info, всё остальное у нас либо warning, либо critical.
Отдельно скажу про 500-е. Мы проверяем environment, и на дев-стенде и тестингах позволяем себе вывести весь стэктрейс пятисотки. Если это прод, мы просто меняем ошибку на текст Internal Error и делаем проброс в Sentry, а Sentry стучится в Slack к разработчику: «Бросай все, горим».
После того, как мы вели регламенты, про которые я сегодня расскажу, у нас увеличилась скорость тушения пожаров (̶х̶о̶т̶я̶,̶ ̶к̶о̶н̶е̶ч̶н̶о̶,̶ ̶б̶и̶з̶н̶е̶с̶ ̶д̶у̶м̶а̶е̶т̶,̶ ̶ч̶т̶о̶ ̶у̶ ̶н̶а̶с̶ ̶п̶о̶ж̶а̶р̶о̶в̶ ̶н̶е̶т̶)̶.
А теперь нам нужно как-то отличить ошибки от других сервисов по нашему регламенту на нашей стороне.
К старым апишкам мы проверки, о которых расскажу, не применяем, — только к тем, о которых договорились. Допустим, у нас есть какой-то service class, который ходит на чужой API, с котором договорились.
Проверяем, корректен ли ответ: если пришел не JSON — сразу бросаем Exception.
try {
$decodedResponse = json_decode($contents, true, 512, \JSON_THROW_ON_ERROR);
} catch (\Throwable $exception) {
return false;
}
Также проверяем HTTP-статусы: есть 4 варианта, о которых мы договорились, что это корректная ситуация.
$isHttpStatusOk = in_array(
$code,
[
Response::HTTP_OK,
Response::HTTP_CREATED,
Response::HTTP_ACCEPTED,
Response::HTTP_NO_CONTENT,
],
true
);
if ($isHttpStatusOk === false) {
return false;
}
Если отсутствуют ключи data или errors, — тоже считаем, что это не по регламенту. В остальном — главное, чтобы ошибок не было: если в поле errors не пришло ошибок, значит все ок.
if (!array_key_exists('errors', $decodedResponse)
||
!array_key_exists('data', $decodedResponse)
) {
return false;
}
return $decodedResponse['errors'] === null;
Положив этот код в trait, мы используем его в фасадах нескольких API-репозиториев: и теперь нам не нужно добавлять новый обработчик на каждый чих, а затем тыкать все апишки — упадут или нет.
В каких случаях подход дает выгоду, в чем минусы, как поддерживать
Ни в коем случае не предлагаю бессмысленно и беспощадно транслировать такую практику на всех и вся. Мы сами используем регламент ограниченно: договорились с несколькими командами, с чьими сервисами взаимодействуем чаще всего. Например, это команда сервисов для учителей: они ходят к нам за расписанием, мы к ним за данными о преподавателях. У нас много разных кейсов взаимодействия: и, договорившись один раз, мы выиграли в скорости разбора логов и тушения пожаров.
Но есть нюансы, которые мы уже увидели на практике:
- Подход требует ответственного отношения от старших разработчиков и тимлидов: мы должны поддерживать при разработке единый формат. В каждом код-ревью нужно вникать и понимать, тот ли тип Exception-а вернулся, если что — создать новый, назвать его корректно, наследовать его от нужного типа ошибки. А когда регламент поменяется (его придется менять), потому что у вас появилась новая договоренность с другими командами, нужно не полениться сходить к соседям и сделать pull request.
- Регламент усложнит онбординг новичков: но зато когда человек вник, увидев ошибку в логах, он будет четко понимать, что произошло и куда бежать.
Но несмотря на эти трудности — мы довольны. Экономим время при обнаружении ошибки и добавление новых однотипных ошибок. А как устроено у вас?