Как сделать хорошие контракты для микросервисов

Дисклеймер. Это не исчерпывающее руководство, а скорее небольшая заметка по личной боли, которую я постоянно испытываю. Вы предупреждены :3
Многие в IT верят в чудесную силу микросервисов: что если всё переписать на них, проект получит защиту от скверны и не превратиться в ком грязи. Но это, конечно, совсем не так.
Микросервисы сами по себе не делают архитектуру продукта лучше, а помогают решать определённые проблемы в определённых условиях. Можно сказать, что микросервисы убирают одну боль, создавая другую. Этот инструмент требует больше вменяемости труда при проектировании. Если подойти без должного внимания, микросервисы откроют портал во адъ.
Чтобы микросервисы работали во благо, их нужно правильно сопрягать. А для этого требуется чётко определить границы, обеспечить общение с соседями, постараться свести это общение к разумному минимуму и предусмотреть ещё кучу вещей.
Подробнее о куче вещей
Что нужно сделать, чтобы микросервисы не сломали вам жизнь:

  • заложить правильные реакции на ошибки сети и недоступности
  • густо обмазать всё логами
  • также густо обмазать мониторингом
  • обеспечить совместимость, чтобы добавление нового поля не вызвало панику на клиенте
  • постараться не завалить соседа кучей сообщений, поскольку другой микросервис может иметь другие архитектурные характеристики и не быть готовым к такому
  • надо бы понять, где этот самый сосед находится (намёк на Service Discovery)
  • вполне вероятно, увлекательно поперекладывать доменные объекты в транспортные и обратно
Ну и по-хорошему нужно всё вышеперечисленное протестировать.
В общем, с микросервисами работы немало. Часть можно спихнуть на библиотеки, сервис-меши, подготовить типовые решения и вот это всё. Но есть вещь, которую точно нельзя делать механически — составить вразумительные контракты.
Давайте разберём, как контракты невразумительные погружают мир во тьму и что с этим можно сделать.
Общее представление
Например, когда всё напихано в один транспортный объект:

{
"status": 0,
"message": null,
"availableAmount": 100500,
"transactionId": "ABCDEF",
"authorizationCode":"HJKPLG"
}
Здесь получается один формат для ошибки и валидного результата, поэтому в ответе их не получится различить. Например, к чему относится поле availableAmount? Без документации не разобраться, а не факт, что её кто-то составлял.

Решение. Если для ошибки и неошибки сделать отдельные объекты, станет сильно проще понять, что к чему относится:

{ // объект для ошибки
 "errorType": "no_money",
 "message": "Not enough money. Pls earn more",
 "availableAmount": 100500,
}

{ // объект успешного ответа
 "transactionId": "ABCDEF",
 "authorizationCode":"HJKPLG",
 "availableAmount": 100500,
}
Несколько ошибок можно разделить, чтобы они сами могли содержать какие-то дополнительные данные:

{ // объект для ошибки, когда нам не хватило деняк с указанием доступного остатка
 "errorType": "no_money",
 "message": "Not enough money. Pls earn more",
 "availableAmount": 100500,
}

{ // объект для ошибки, когда счёт клиента оказался заблокированным, с указанием причины блокировки
 "errorType": "accout_locked",
 "message": "Account locked",
 "reason": "fraud",
}
Хорошо, когда можно прописать такие вещи прямо в контракте, например, в open api через oneof. Это ещё и снимает проблему поиска непредусмотренных исходов в сценариях. Вот хороший пример из документации keycloak:
Что мы получим, если пользователь с таким логином уже есть? Нам ведь нужно показать клиенту вменяемый ответ. Приходится сидеть и экспериментировать. Очень увлекательное занятие (нет).
Магические числа
Этот вид скверны встречается довольно часто для отображения статусов.

{
"paymentStatus": 0,
}
Всего одна строка кода, а столько вопросов: что значит этот 0? Есть ли 1, 2 или −1?

Разработчики экономят на спичках, а потом начинается дикая путаница. У каждой команды 0 значит что-то своё, кто помнил — тот забыл или уволился (и забыл), а документации нет и никогда не было.

Решение. Используйте выразительные средства, если позволяет протокол:

{
  "paymentStatus": "IN_PROGRESS",
}
Магические форматы
Не все протоколы поддерживают формат дат и другие сложные конструкции, поэтому разработчики выкручиваются и передают их в упрощённом виде. Например, в виде строк:

{
  "creationDate": "2011-01-01",
}
Это плохо, поскольку непонятно, что какой формат даты ожидать на выходе. Дату строкой можно представить как угодно, попробуй потом пойми, какой способ автор контракта считает правильным.

Решение. Явно обозначить формат в имени:

{
  "creationDateIso": "2011-01-01",
}
Уже лучше. Конечно, стандарт ISO включает разные форматы, но уже понятно, в какую сторону копать.

Другой пример, с длительностью:

{
  "duration": 100500",
}
Тут вообще ничего не понять. Чем изменяется этот duration: секундами, часами? В минутах до второго пришествия или от Большого взрыва?

Лучше вшить размерность в название. Например, так:

{
  "durationMs": "100500",
}
Теперь понятно, что нужны миллисекунды. Наименование не очень красивое, но понятен смысл.

Ещё пример. Недавно попался экзотический вариант, когда дни недели передавались не датами, а кодировались индексом элементов массива:

{
  "weeklyResults": [
    {
      "result": "Light and dark separated"
    },
    {
      "result": "Sky and soil"
    },
    {
      "result": "Land, sea and plants"
    },
    {
      "result": "Stars and Sun"
    },
    {
      "result": "Fish, lizards and birds"
    },
    {
      "result": "Mammals, inc. humans"
    },
    {
      "result": "Rest and debugging"
    }
  ]
}
Дальше клиент сам вычислял, где начинается неделя. Предполагалось, что 0 — это понедельник, но оказалось, что 0 — это первый день от текущей даты, а не обязательно понедельник. Такой формат тяжело понять, сложно обработать результат и мучительно отлаживать.

Можно добавить даты в названия переменных и не парить мозг:

{
  "weeklyResults": {
    "2001-01-01": {
      "result": "Light and dark separated"
    },
    "2001-01-02": {
      "result": "Sky and soil"
    },
    "2001-01-03": {
      "result": "Land, sea and plants"
    },
    "2001-01-04": {
      "result": "Stars and Sun"
    },
    "2001-01-05": {
      "result": "Fish, lizards and birds"
    },
    "2001-01-06": {
      "result": "Mammals, inc. humans"
    },
    "2001-01-07": {
      "result": "Rest and debugging"
    }
  }
}
Ошибки
Вот так делать не стоит:

HTTP 400
{
   "errorType":"validation_error"
}
Если так делать, к вам постоянно будут ходить и спрашивать «что у нас не так, помогите пожалусто».

Решение. Подскажите, где именно ошибка.

HTTP 400
{
  "errorType": "validation_error",
  "fields": [
    {
      "name": "serialNumber",
      "error": "must_not_be_null"
    }
  ]
}
Если у вас внутренний обмен, необязательно это делать в красивой машиночитаемой форме, главное, чтобы разработчики поняли, где они ошиблись при интеграции.
Если по соображениям безопасности не стоит показывать суть ошибки, можно присвоить ошибке ID, показать его клиенту и записать в логи:

{
  "errorType":"internal_error",
  "errorId": "0813cb70-8563-4cfa-883d-0e730b46f0f1"
}
Опциональные поля
Классика — неожиданный null вместо столь же ожидаемого не null.

Решение. Если технология позволяет, опишите, опционален элемент или обязателен. Тогда не придётся корячить схему БД, когда вместо ожидаемого поля нам вернётся внезапный null.
Выводы
Чем больше подключений к микросервису с плохим контрактом, тем больше боли, переписок и созвонов. Издали кажется ерундой, а на деле сжирает вагон времени, проверено.
Не надейтесь на документацию. Её мало раз написать, её нужно актуализировать, а при работе — найти и не потерять во вкладках.
Старайтесь сделать контракт самодокументируемым, чтоб его суть была понятна без документации. Это снизит вопросы к разработчикам сервиса и освободит время на полезные и важные дела.
P. S. А еще мы ведем канал в телеге. Приходите читать полезноту и негодовать в комментариях :)
Читайте также