Можем ли мы решить проблемы удобства использования скромного API обновлений?

По своей сути API должен решать пять основных проблем, ласково называемых CRUD, плюс запросы. Ваш API позволяет разработчикам создавать, извлекать, обновлять и удалять информацию — запросы представляют собой особый тип действий по извлечению. По сравнению со сложностью запросов основные методы CRUD часто считаются тривиальными.

Хотя они? Для реляционной базы данных одно обновление может привести к большому количеству операций записи и чтения в нескольких индексах. Когда несколько обновлений происходят последовательно, порядок операций может привести к неожиданным последствиям. Плохо построенные обновления могут даже привести к потере данных!

У вас есть множество вариантов при реализации обновлений: от PUT до PATCH, от JsonPatch до GraphQL, а варианты обработки ошибок могут варьироваться от перезаписи до сбоя и обнаружения конфликта версий. В качестве альтернативы некоторые базы данных достигают высокой производительности за счет запрета обновлений и создания неизменяемого индекса!

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

Типы API обновлений

В стандарте REST есть два очень распространенных типа API обновлений:

  • PUT — метод полного обновления, который заменяет существующую запись новой.
  • PATCH — частичныйметод обновления, который изменяет значения одного или нескольких полей в записи.

Когда я создавал AvaTax API, я решил использовать метод PUT. Я предпочитал этот подход, потому что методы PUT могли быть строго типизированы, а автоматизированная документация Swagger/OpenAPI помогла бы пользователям разобраться в этом.

Преимущество синтаксиса PUT также заключалось в том, что он был прямым совпадением с методом POST, который изначально использовался для создания записи. Если вы знали, как создать запись, вы также знаете, как обновить запись, верно?

Я всегда намеревался предложить поддержку PATCH в AvaTax API, но как только это стало успешным, я был занят ростом команды, но так и не сделал этого. Но меня всегда беспокоили риски, подобные этому примеру:

В этом крошечном примере мы заменяем запись виджета 12345 на новую. Но почему пользователь сделал звонок? Пытались ли они изменить имя на Sprocket, или цвет на синий, или и то, и другое? Что произойдет, если в записи виджета есть необязательное поле с именем location? Намерен ли разработчик установить location в null? Они просто забыли передать это значение?

Откуда мы можем знать, что они означают?

Сам JSON различает пропущенное поле и преднамеренно пустое значение, такое как "location": null. Однако в некоторых языках программирования отсутствие поля location в объекте JSON может быть неверно истолковано как попытка установить для него значение null. Некоторые библиотеки сериализации не различают эти два типа нулей.

Поскольку ваш API не может делать предположения о языках программирования, используемых вашими разработчиками, со временем я решил, что предпочитаю PATCH API. Моя команда создала Lockstep API с нуля, используя PATCH только для того, чтобы избежать этого конкретного риска.

Но API-интерфейсы PATCH менее удобны для начинающих разработчиков. В спецификации OpenAPI метод PATCH, позволяющий открыть список пар имя-значение, очень похож на нетипизированный объект. Спецификация JsonPatch (RFC 6901) документирует этот подход… и это полезно, но немного неудобно.

Есть ли другие варианты для рассмотрения?

Учитывая GraphQL и шаблон мутатора

Умные разработчики GraphQL учли эту проблему и попытались решить ее, внеся мутаторы. Поскольку GraphQL построен на основе нетипизированных данных JSON, они решили включить возможность определения метода, который можно было бы вызывать на стороне сервера для изменения данных.

Метод-мутатор определяется как функция, которая принимает определенные параметры и выполняет определенный набор изменений. Официальный сайт GraphQL описывает это на следующем примере:

Этот мутатор является примером системы удаленного вызова процедур (RPC) на стороне сервера. Это не PUT и не PATCH — нет строгих требований к тому, как должен работать такой метод RPC на стороне сервера. Эта уникальность означает, что разработчикам необходимо читать документацию для каждого RPC отдельно, и вы не можете на основании опыта работы с одним сделать вывод о том, как может работать другой.

Мы могли бы сделать то же самое в REST с синтаксисом POST /api/v1/createReviewForEpisode?episode=JEDI&withFriends=false. На самом деле, это не ужасный подход! У него есть несколько интересных преимуществ:

  • Каждый API обновления может быть определен с помощью твердого контракта, применимого к его конкретному варианту использования.
  • Поля, которые можно обновлять, могут быть строго определены для каждого варианта использования.
  • Если мы не хотим, чтобы какое-то конкретное поле было изменяемым, мы можем просто не создавать метод, позволяющий изменять это поле.

С другой стороны, создание десятков отдельных методов обновления может потребовать больших усилий. Разработчику может быть утомительно просматривать десятки методов обновления, чтобы найти тот, который им нужен. Требуемый объем документации огромен.

Я рекомендую зарезервировать вызовы обновлений, подобные RPC, для сложных бизнес-действий. Например, если ваш API позволяет клиенту перейти с одного уровня подписки на другой, создайте специальный API UpgradeSubscriptionTier.

При выборе между RPC и PUT/PATCH вы должны учитывать побочные эффекты конкретного изменения. Для облегченных изменений, которые не вызывают больших последствий, разрешите пользователям изменять поля с помощью стандартного метода PUT или PATCH. Но если изменение определенного поля имеет серьезные побочные эффекты, не позволяйте изменять эти поля с помощью PUT и PATCH. Создайте для него специальный вызов RPC и объясните, почему в документации.

Интересный случай создания или обновления

Как разработчик может надежно запускать обновления через ваш API всякий раз, когда происходит событие во внешней системе?

В некоторых случаях в существующей внутренней системе нет места для хранения метаданных, чтобы они соответствовали вашему API. В других случаях система может быть не такой надежной, как вам хотелось бы, и, возможно, не гарантируется совпадение записей с обеих сторон.

В этих случаях для защитного программирования могут потребоваться три API:

  • Запрос, чтобы узнать, существуют ли данные
  • Получить существующие данные
  • Если данные устарели, вызовите обновление, чтобы изменить данные на новое значение.

Этот подход может быть очень медленным и может стать проблемой как для вас, так и для ваших разработчиков. Если вы обнаружите, что ваши пользователи регулярно сталкиваются с этой проблемой, вы можете рассмотреть метод создания или обновления, чтобы позволить этим клиентам работать с помощью одного вызова API.

Этот подход требует умения делать следующее:

  • Используйте для записи ссылочный идентификационный номер клиента, а не свой собственный внутренний идентификационный номер (который не может быть надежно сгенерирован кодом клиента).
  • В транзакции базы данных определите, существует ли запись, и определите, следует ли выполнять метод создания или обновления.

Если для вашего API требуются методы создания или обновления, вы можете стандартизировать и задокументировать поведение или включить его только для определенных объектов. Одним из хороших примеров шаблона создания или обновления является вызов API CreateOrAdjustTransaction, который оказался одной из моих любимых функций удобства использования AvaTax.

Риски нескольких конфликтующих обновлений

Один из наиболее часто обсуждаемых рисков обновления является одним из наименее распространенных: риск перекрытия вызовов обновления. История выглядит так:

  • Алиса создает запись A со значением 123.
  • Билл извлекает A, затем обновляет запись до значения 456.
  • Чарли извлекает A, затем обновляет запись до значения 789.

Каково значение записи после выполнения всех трех команд? Конечно, это зависит от порядка, в котором выполняются изменения. Вызов API Билла может быть задержан и выполнен после вызова Чарли.

Этот тип проблемы порядка операций может часто возникать, когда два человека вместе вводят один и тот же онлайн-документ, но это происходит не очень часто, когда бизнес-пользователи обновляют записи о клиентах. В большинстве случаев бизнес-записи создаются один раз и никогда не обновляются, что делает многократные конфликтующие обновления маловероятными.

Должны ли мы попытаться решить эту проблему? Стоит ли принимать защитные меры? Если мы выберем PATCH для наших методов обновления, это позволит одному пользователю изменять поле X, а другому пользователю изменять поле Y без конфликтов. Это лучше, чем PUT, но не решает проблему полностью, поскольку два пользователя все равно могут попытаться изменить одно и то же поле.

Другой подход — добавить параметр версии в наш метод обновления. Если API извлечения возвращает номер версии записи, мы можем потребовать, чтобы метод обновления принимал в качестве входных данных номер версии, которую пользователь пытается обновить. Затем мы можем выдать ошибку, если номер версии записи на диске не совпадает, что указывает на то, что запись была изменена кем-то за это время.

Как насчет массовых обновлений?

Еще одна интересная задача — концепция массового обновления. В мире реляционных баз данных просто написать команду UPDATE users SET location='Arizona' where location='Nevada' — и наша база данных внесет изменения максимально эффективно.

Но это может быть невозможно сделать с помощью API. Если бы мы позволили клиентам вносить такие масштабные изменения с помощью вызова API, сложность этих обновлений могла бы вызвать отставание и замедление работы других пользователей, одновременно работающих с нашей системой.

Обычно я не рекомендую разработчикам выполнять массовые вызовы обновлений, если только не существует стратегического бизнес-действия, которое делает подобные вещи полезными. По сути, это идеальный вариант использования пользовательского вызова RPC или мутатора.

Категории вариантов использования API обновления

Теперь, когда мы знаем, какие типы API-интерфейсов обновлений мы можем создавать, давайте рассмотрим некоторые реальные варианты использования:

  • Сценарии исправления. Один из наших инженеров службы поддержки обнаруживает, что несколько тысяч клиентов были обновлены с неправильной датой выставления счетов. Разработчику придется искать все записи с неправильными датами и обновлять их на исправленные.
  • Извлечение, преобразование и загрузка. Наш клиент пытается импортировать миллион записей из устаревшей системы в наше программное обеспечение. Сценарий ETL время от времени дает сбой, и ему необходимо перезапустить себя и продолжить с того места, где он остановился. Это означает, что может потребоваться создать некоторые записи и обновить другие, если они остались в поврежденном состоянии.
  • Древний код. Много лет назад клиент написал программу, которая обновляет записи. По какой-то причине клиент не может обновить эту программу, и нам нужно убедиться, что вызовы обновления не имеют каких-либо неожиданных побочных эффектов.
  • Начинающие разработчики. Кто-то пытается собрать небольшую программу и экспериментирует с API. Им нужно руководство и четкие сообщения об ошибках, чтобы объяснить, что они делают неправильно, пока они в конце концов не поймут это.

В зависимости от частоты этих сценариев для ваших разработчиков вы можете выбрать архитектуру, которая обеспечивает вашу собственную уникальную комбинацию PUT, PATCH, RPC и разрешения конфликтов.

Какой бы подход вы ни выбрали, я призываю разработчиков API выбирать стандартный шаблон для вызовов обновлений и определять их поведение для согласованности, ясности и передачи. Если ваш код всегда выбирает разные методы обновления для каждого типа объектов, ваши пользователи будут разочарованы — стандартный и последовательный подход сослужит вам хорошую службу в долгосрочной перспективе.

Тед Спенс возглавляет разработку в ProjectManager.com и преподает в Bellevue College. Если вы интересуетесь разработкой программного обеспечения и бизнес-анализом, я буду рад услышать от вас на Mastodon или LinkedIn.