[Перевод] Рассказ о том, как не надо проектировать API
Однажды я помогал товарищу, которому нужно было интегрировать с сайтом его клиента данные о свободном и занятом жилье из системы управления имуществом. К моей радости у этой системы было API. Но, к сожалению, устроено оно было из рук вон плохо.
Я решил написать эту статью не для того, чтобы раскритиковать ту систему, о которой пойдёт речь, а для того, чтобы рассказать о том, какие ошибки встречаются при разработке API, и предложить пути исправления этих ошибок.
Обзор ситуации
Организация, о которой идёт речь, использовала для управления жилыми помещениями систему Beds24. Сведения о том, что именно свободно, а что занято, синхронизировались с различными системами бронирования жилья (с такими, как Booking, AirBnB и другими). Организация занималась разработкой сайта и хотела, чтобы при поиске выводились лишь сведения о комнатах, свободных в указанный период времени и подходящих по вместимости. Подобная задача выглядела весьма простой, так как Beds24 предоставляет API для интеграции с другими системами. На самом же деле оказалось, что разработчики этого API допустили при его проектировании множество ошибок. Предлагаю разобрать эти ошибки, выявить конкретные проблемы и поговорить о том, как подходить к разработке API в рассматриваемых ситуациях.
Проблема №1: формат тела запроса
Так как клиенту интересны только сведения о том, свободен ли, скажем, гостиничный номер, или занят, нас интересует лишь обращение к конечной точке API /getAvailabilities
. И, хотя обращение к подобному API должно приводить к получению данных о доступности комнат, это обращение, на самом деле, выглядит как POST-запрос, так как автор API решил оснастить его возможностью принимать, в виде JSON-тела запроса, фильтры. Вот список возможных параметров запроса и примеры принимаемых ими значений:
{
"checkIn": "20151001",
"lastNight": "20151002",
"checkOut": "20151003",
"roomId": "12345",
"propId": "1234",
"ownerId": "123",
"numAdult": "2",
"numChild": "0",
"offerId": "1",
"voucherCode": "",
"referer": "",
"agent": "",
"ignoreAvail": false,
"propIds": [
1235,
1236
],
"roomIds": [
12347,
12348,
12349
]
}
Пройдёмся по этому JSON-объекту и поговорим о том, что здесь не так.
- Даты (
checkIn
,lastNight
иcheckOut
) представлены в форматеYYYYMMDD
. Тут нет абсолютно никакой причины для того, чтобы не использовать стандартный формат ISO 8601 (YYYY-MM-DD
) при преобразовании дат в строки, так как это — широко применяемый стандарт представления дат. Он знаком многим разработчикам, именно его ожидают получить на вход многие JSON-парсеры. Кроме того, возникает ощущение, что полеlastNight
является избыточным, так как тут имеется полеcheckOut
, которое всегда представлено датой, на один день опережающей дату, заданную вlastNight
. В связи с отмеченными выше недостатками предлагаю, при проектировании подобных API, стремиться к тому, чтобы всегда использовать стандартные способы представления дат и стараться не обременять пользователей API необходимостью работы с избыточными данными. - Все поля-идентификаторы, а также поля
numAdult
иnumChild
, являются числовыми, но представлены в виде строк. В данном случае для представления их в виде строк нет никакой видимой причины. - Здесь можно заметить следующие пары полей:
roomId
иroomIds
, а так жеpropId
иpropIds
. Наличие полейroomId
иpropId
является избыточным, так как и то и другое можно использовать для передачи идентификаторов. Кроме того, тут можно заметить проблему с типами. Обратите внимание на то, что полеroomId
является строковым, а в массивеroomIds
нужно использовать числовые значения идентификаторов. Это может привести к путанице, к проблемами с парсингом, и, кроме того, говорит о том, что на сервере некоторые операции выполняются со строками, а некоторые с числами, несмотря на то, что эти строки и числа используются для представления одних и тех же данных.
Мне хотелось бы предложить разработчикам API стараться не усложнять жизнь тем, кто этими API будет пользоваться, допуская при проектировании API ошибки, подобные вышеописанным. А именно, стоит стремиться к стандартному форматированию данных, к тому, чтобы они не были бы избыточными, к тому, чтобы для представления однородных сущностей не использовались бы разные типы данных. И не стоит всё, без разбора, представлять в виде строк.
Проблема №2: формат тела ответа
Как уже было сказано, нам интересна лишь конечная точка API /getAvailabilities
. Давайте посмотрим на то, как выглядит ответ этой конечной точки, и поговорим о том, какие недочёты допущены при его формировании. Помните о том, что нас, при обращении к API, интересует список идентификаторов объектов, свободных в заданный период времени и способных вместить заданное количество людей. Ниже приведён пример тела запроса к API и пример того, что оно выдаёт в ответ на этот запрос.
Вот запрос:
{
"checkIn": "20190501",
"checkOut": "20190503",
"ownerId": "25748",
"numAdult": "2",
"numChild": "0"
}
Вот ответ:
{
"10328": {
"roomId": "10328",
"propId": "4478",
"roomsavail": "0"
},
"13219": {
"roomId": "13219",
"propId": "5729",
"roomsavail": "0"
},
"14900": {
"roomId": "14900",
"propId": "6779",
"roomsavail": 1
},
"checkIn": "20190501",
"lastNight": "20190502",
"checkOut": "20190503",
"ownerId": 25748,
"numAdult": 2
}
Поговорим о проблемах ответа.
- В теле ответа свойства
ownerId
иnumAdult
внезапно стали числами. А в запросе нужно было указывать их в виде строк. - Список объектов недвижимости представлен в виде свойств объекта, ключами которых являются идентификаторы комнат (
roomId
). Логично было бы ожидать того, что подобные данные выводились бы в виде массива. Для нас это означает, что для того чтобы получить список доступных комнат, нужно перебрать весь объект, проверяя при этом наличие у вложенных в него объектов определённых свойств, вродеroomsavail
, и не обращая внимания на что-то вродеcheckIn
иlastNight
. Затем нужно было бы проверить значение свойстваroomsavail
, и, если оно больше 0, можно было бы сделать вывод о том, что соответствующий объект доступен для бронирования. А теперь давайте присмотримся к свойствуroomsavail
. Вот какие варианты его представления встречаются в теле ответа:"roomsavail": "0"
и"roomsavail": 1
. Видите закономерность? Если комнаты заняты — значение свойства представлено строкой. Если свободны — оно превращается в число. Это способно привести к множеству проблем в языках, строго относящихся к типам данных, так как в них одно и то же свойство не должно принимать значения разных типов. В связи с вышесказанным мне хотелось бы предложить разработчикам использовать массивы JSON-объектов для представления неких наборов данных, а не применять для этой цели неудобные конструкции в виде пар ключ-значение, подобные той, что мы тут рассматриваем. Кроме того, нужно следить за тем, чтобы поля однородных объектов не содержали бы данные разных типов. Правильно отформатированный ответ сервера мог бы выглядеть так, как показано ниже. Обратите внимание и на то, что при представлении данных в таком виде сведения о комнатах не содержат дублирующихся данных.
{
"properties": [
{
"id": 4478,
"rooms": [
{
"id": 12328,
"available": false
}
]
},
{
"id": 5729,
"rooms": [
{
"id": 13219,
"available": false
}
]
},
{
"id": 6779,
"rooms": [
{
"id": 14900,
"available": true
}
]
}
],
"checkIn": "2019-05-01",
"lastNight": "2019-05-02",
"checkOut": "2019-05-03",
"ownerId": 25748,
"numAdult": 2
}
Проблема №3: обработка ошибок
Вот как организована обработка ошибок в рассматриваемом здесь API: на все запросы система отправляет ответы с кодом 200
— даже в том случае, если произошла ошибка. Это означает, что единственный способ отличить нормальный ответ от ответа с сообщением об ошибке заключается в разборе тела ответа и в проверке наличия в нём полей error
или errorCode
. В API предусмотрены лишь следующие 6 кодов ошибок.
Коды ошибок API Beds24
Предлагаю всем, кто это читает, постараться не возвращать ответ с кодом 200 (успешная обработка запроса) в том случае, если при обработке запроса что-то пошло не так. Пойти на такой шаг можно лишь в том случае, если это предусмотрено фреймворком, на базе которого вы разрабатываете API. Возврат адекватных кодов ответов позволяет клиентам API заранее знать о том, нужно ли им парсить тело ответа или нет, и о том, как именно это делать (то есть — разбирать ли обычный ответ сервера или объект ошибки).
В нашем случае улучшить API в этом направлении можно двумя способами: можно либо предусмотреть особый HTTP-код в диапазоне 400–499 для каждой из 6 возможных ошибок (лучше всего поступить именно так), либо возвращать, при возникновении ошибки, код 500, что позволит клиенту, по меньшей мере, знать перед разбором тела ответа о том, что оно содержит сведения об ошибке.
Проблема №4: «инструкции»
Ниже приведены «инструкции» по использованию API из документации проекта:
Пожалуйста изучите следующие инструкции при использовании API.
- Обращения к API следует проектировать так, чтобы в ходе их выполнения приходилось бы отправлять и принимать минимальный объём данных.
- Обращения к API выполняются по одному за раз. Необходимо дождаться выполнения очередного обращения к API прежде чем выполнять следующее обращение.
- Если нужно выполнить несколько обращений к API, между ними следует предусмотреть наличие паузы длительностью несколько секунд.
- Вызовы API нужно выполнять не слишком часто, поддерживая уровень обращений на минимальном уровне, необходимом для решения задач клиента.
- Чрезмерное использование API в пределах 5-минутного периода приведёт к блокировке вашей учётной записи без дополнительных уведомлений.
- Мы оставляем за собой право блокировать доступ к системе клиентам, которые, по нашему мнению, чрезмерно используют API. Делается это по нашему усмотрению и без дополнительных уведомлений.
В то время как пункты 1 и 4 выглядят вполне обоснованными, с другими пунктами этой инструкции я согласиться не могу. Рассмотрим их.
- Пункт №2. Если вы разрабатываете REST API, то предполагается, что это будет API, не зависящее от состояния. Независимость обращений к API от предыдущих обращений к нему — это одна из причин того, что технология REST нашла широкое применение в облачных приложениях. Если некий модуль системы не поддерживает состояние, его, в случае ошибки, можно легко развернуть повторно. Системы, основанные на подобных модулях, легко масштабируются при изменении нагрузки на них. При проектировании RESTful API стоит следить за тем, чтобы это было API, не зависящее от состояния, и чтобы тем, кто его использует, не приходилось бы беспокоиться о чём-то вроде выполнения только одного запроса за раз.
- Пункт №3. Этот пункт выглядит довольно странно и неоднозначно. Я не могу понять причину, по которой был написан этот пункт инструкции, но у меня возникает ощущение, что он говорит нам о том, что в процессе обработки запроса система выполняет некие действия, и, если её при этом «отвлечь» ещё одним запросом, отправленным не вовремя, это может нарушить её работу. Кроме того, то, что автор руководства говорит о «нескольких секундах», не позволяет узнать точной длительности паузы, которую нужно выдержать между последовательными запросами.
- Пункты №5 и №6. Тут говорится о «чрезмерном использовании API», но никаких критериев «чрезмерного использования» не приводится. Может, это 10 запросов в секунду? А может — 1? Кроме того, некоторые веб-проекты могут иметь огромные объёмы трафика. Если без каких-то адекватных причин и без уведомлений закрывать им доступ к нужным им API, их администраторы, наверняка, от использования таких API откажутся. Если вам доведётся писать подобные инструкции — используйте в них чёткие формулировки и ставьте себя на место пользователей, которым придётся работать с вашей системой, руководствуясь вашими инструкциями.
Проблема №5: документация
Вот как выглядит документация к API.
Документация к API Beds24
Единственная проблема этой документации — её внешний вид. Она выглядела бы гораздо лучше, если бы её хорошо отформатировали. Специально для того, чтобы показать возможный внешний вид подобной документации, я, воспользовавшись Dillinger, и потратив на это меньше двух минут, сделал следующий её вариант. На мой взгляд, он выглядит гораздо лучше вышеприведённого.
Улучшенный вариант документации
Для создания подобных материалов рекомендуется пользоваться специальными инструментами. Если речь идёт о простых документах, похожих на вышеописанный, то для их оформления вполне достаточно чего-то вроде обычного markdown-файла. Если документация устроена сложнее, то для её оформления лучше всего воспользоваться инструментами наподобие Swagger или Apiary.
Кстати, если сами хотите взглянуть на документацию к API Beds24 — загляните сюда.
Проблема №6: безопасность
В документации ко всем конечным точкам API сказано следующее:
Для использования этих функций должен быть разрешён доступ к API. Это делается в меню SETTINGS → ACCOUNT → ACCOUNT ACCESS.
Однако в реальности любой может обратиться к этому API, и, воспользовавшись некоторыми вызовами, получить из него информацию, не предоставив никаких учётных данных. Например, это относится и к запросам по поводу доступности определённых жилых помещений. Речь об этом идёт в другой части документации.
Большинство JSON-методов требуют ключ API для доступа к учётной записи. Ключ доступа к API можно установить, воспользовавшись меню SETTINGS → ACCOUNT → ACCOUNT ACCESS.
В дополнение к непонятному разъяснению вопросов аутентификации оказывается, что ключ для доступа к API пользователь должен создавать самостоятельно (делается это, кстати, путём ручного заполнения соответствующего поля, какие-то средства для автоматического создания ключей не предусмотрены). Длина ключа должна быть в пределах от 16 до 64 символов. Если позволить пользователям самостоятельно создавать ключи для доступа к API, это может привести к появлению весьма небезопасных ключей, которые можно легко подобрать. В подобной ситуации возможны и проблемы, связанные с содержимым ключей, так как в поле для ключа можно ввести всё что угодно. В худшем случае это может привести к атаке на сервис методом SQL-инъекции или к чему-то подобному. При проектировании API не позволяйте пользователям создавать ключи для доступа к API самостоятельно. Вместо этого генерируйте для них ключи автоматически. Пользователь не должен иметь возможности изменить содержимое такого ключа, но, при необходимости, он должен иметь возможность сгенерировать новый ключ, признав старый недействительным.
В случае с запросами, для выполнения которых нужна аутентификация, мы видим другую проблему. Она заключается в том, что токен аутентификации должен быть отправлен в виде части тела запроса. Вот как это описано в документации.
Пример аутентификации в API Beds24
Если токен аутентификации передаётся в теле запроса — это означает, что серверу, прежде чем он доберётся до ключа, нужно будет разобрать тело запроса. После этого он извлекает ключ, выполняет аутентификацию, а затем уже решает — что ему делать с запросом — выполнять его или нет. Если аутентификация удалась — то сервер не будет подвержен дополнительной нагрузке, так как в таком случае тело запроса всё равно пришлось бы разбирать. А вот если аутентифицировать запрос не получилось, то ценное процессорное время будет потрачено на разбор тела запроса впустую. Лучше будет отправлять токен аутентификации в заголовке запроса, используя что-то наподобие схемы аутентификации Bearer. При таком подходе серверу понадобится разбирать тело запроса лишь в том случае, если аутентификация окажется успешной. Ещё одной причиной, по которой для аутентификации рекомендуется использовать стандартную схему наподобие Bearer, является тот факт, что с подобными схемами знакомо большинство разработчиков.
Проблема №7: производительность
Эта проблема в моём списке последняя, но это не умаляет её важности. Дело в том, что на выполнение запроса к рассматриваемому API уходит немного больше секунды. В современных приложениях такие задержки могут оказаться недопустимыми. Собственно говоря, тут можно посоветовать всем, кто занимается разработкой API, не забывать о производительности.
Итоги
Несмотря на все те проблемы, о которых мы тут говорили, рассматриваемое API позволило решить задачи, стоящие перед проектом. Но разработчикам понадобилось довольно много времени на то, чтобы разобраться в API и реализовать всё, что им нужно. Кроме того, им, для решения простых задач, пришлось писать довольно-таки сложный код. Будь это API спроектировано как следует, работа была бы сделана быстрее, а готовое решение оказалось бы проще.
Поэтому я хотел бы попросить всех, кто проектирует API, думать о том, как с ним будут работать пользователи их сервисов. Следите за тем, чтобы документация к API полно описывала бы их возможности, чтобы она была бы понятной и хорошо оформленной. Контролируйте именование сущностей, обращайте внимание на то, чтобы данные, которые выдаёт или принимает ваше API, были бы чётко структурированными, чтобы с ними было бы легко и удобно работать. Кроме того, не забывайте о безопасности и о правильной обработке ошибок. Если при проектировании API принять во внимание всё то, о чём мы говорили, тогда для работы с ним не понадобится писать нечто вроде тех странных «инструкций», которые мы обсуждали выше.
Как уже было сказано, этот материал направлен не на то, чтобы отбить у читателей охоту пользоваться Beds24 или любой другой системой с плохо спроектированным API. Моя цель заключалась в том, чтобы, продемонстрировав примеры ошибок и подходы к их решению, дать рекомендации, следуя которыми все желающие могли бы повысить качество своих разработок. Надеюсь, этот материал привлечёт внимание прочитавших его программистов к качеству разрабатываемых ими решений. А это значит, что в мире будет больше хороших API.
Уважаемые читатели! Встречались ли вам некачественно спроектированные API?