diff --git a/src/en/clean-copy/02-Section I. The API Design/05.md b/src/en/clean-copy/02-Section I. The API Design/05.md index cafd019..6040f32 100644 --- a/src/en/clean-copy/02-Section I. The API Design/05.md +++ b/src/en/clean-copy/02-Section I. The API Design/05.md @@ -765,6 +765,8 @@ GET /price?recipe=lungo⮠ } ``` +**NB**: sometimes, developers set very long caching times for immutable resources, spanning a year or even more. It makes little practical sense as the server load will not be significantly reduced compared to caching for, let's say, one month. However, the cost of a mistake increases dramatically: if wrong data is cached for some reason (for example, a `404` error), this problem will haunt you for the next year or even more. We would recommend selecting reasonable cache parameters based on how disastrous invalid caching would be for the business. + ##### Keep the Precision of Fractional Numbers Intact If the protocol allows, fractional numbers with fixed precision (such as money sums) must be represented as a specially designed type like `Decimal` or its equivalent. diff --git a/src/en/clean-copy/03-[Work in Progress] Section II. The API Patterns/10.md b/src/en/clean-copy/03-[Work in Progress] Section II. The API Patterns/10.md index 2a4a4bf..dc17b73 100644 --- a/src/en/clean-copy/03-[Work in Progress] Section II. The API Patterns/10.md +++ b/src/en/clean-copy/03-[Work in Progress] Section II. The API Patterns/10.md @@ -1 +1,197 @@ -### Partial Updates \ No newline at end of file +### [Partial Updates][api-patterns-partial-updates] + +The case of partial application of the list of changes described in the previous chapter naturally leads us to the next typical API design problem. What if the operation involves a low-level overwriting of several data fields rather than an atomic idempotent procedure (as in the case of changing the order status)? Let's take a look at the following example: + +``` +// Creates an order +// consisting of two beverages +POST /v1/orders/ +X-Idempotency-Token: +{ + "delivery_address", + "items": [{ + "recipe": "lungo" + }, { + "recipe": "latte", + "milk_type": "oat" + }] +} +→ +{ "order_id" } +``` + +``` +// Partially updates the order +// by changing the volume +// of the second beverage +PATCH /v1/orders/{id} +{ + "items": [ + // `null` indicates + // no changes for the + // first beverage + null, + // list of properties + // to change for + // the second beverage + {"volume": "800ml"} + ] +} +→ +{ /* Changes accepted */ } +``` + +This signature is inherently flawed as its readability is dubious. What does the empty first element in the array mean, deletion of an element or absence of changes? What will happen with fields that are not passed (`delivery_address`, `milk_type`)? Will they reset to default values or remain unchanged? + +The most notorious thing here is that no matter which option you choose, your problems have just begun. Let's say we agree that the `"items":[null, {…}]}` construct means the first array element remains untouched. So how do we delete it if needed? Do we invent another “nullish” value specifically to denote removal? The same issue applies to field values: if skipping a field in a request means it should remain unchanged, then how do we reset it to the default value? + +Partially updating a resource is one of the most frequent tasks that API developers have to solve, and unfortunately, it is also one of the most complicated. Attempts to take shortcuts and simplify the implementation often lead to numerous problems in the future. + +A **trivial solution** is to always overwrite the requested entity completely, which means requiring the passing of the entire object to fully replace the current state and return the new one. However, this simple solution is frequently dismissed due to several reasons: + * Increased request sizes and, consequently, higher traffic consumption + * The necessity to detect which fields were actually changed in order to generate proper signals (events) for change listeners + * The inability to facilitate collaborative editing of the object, meaning allowing two clients to edit different properties of the object in parallel as clients send the full object state as they know it and overwrite each other's changes as they are unaware of them. + +To avoid these issues, developers sometimes implement a **naïve solution**: + * Clients only pass the fields that have changed + * To reset the values of certain fields and to delete or skip array elements some “special” values are used. + +A full example of an API implementing the naïve approach would look like this: + +``` +// Partially rewrites the order: +// * resets delivery address +// to the default values +// * leaves the first beverage +// intact +// * removes the second beverage +PATCH /v1/orders/{id} +{ + // “Special” value #1: + // reset the field + "delivery_address": null + "items": [ + // “Special” value #2: + // do nothing to the entity + {}, + // “Special” value #3: + // delete an entity + false + ] +} +``` + +This solution allegedly solves the aforementioned problems: + * Traffic consumption is reduced as only the changed fields are transmitted, and unchanged entities are fully omitted (in our case, replaced with the special value `{}`). + * Notifications regarding state changes will only be generated for the fields and entities passed in the request. + * If two clients edit different fields, no access conflict is generated and both sets of changes are applied. + +However, upon closer examination all these conclusions seem less viable: + * We have already described the reasons for increased traffic consumption (excessive polling, lack of pagination and/or field size restrictions) in the “[Describing Final Interfaces](#api-design-describing-interfaces)” chapter, and these issues have nothing to do with passing extra fields (and if they do, it implies that a separate endpoint for “heavy” data is needed). + * The concept of passing only the fields that have actually changed shifts the burden of detecting which fields have changed onto the client developers' shoulders: + * Not only does the complexity of implementing the comparison algorithm remain unchanged but we also run the risk of having several independent realizations. + * The capability of the client to calculate these diffs doesn't relieve the server developers of the duty to do the same as client developers might make mistakes or overlook certain aspects. + * Finally, the naïve approach of organizing collaborative editing by allowing conflicting operations to be carried out if they don't touch the same fields works only if the changes are transitive. In our case, they are not: the result of simultaneously removing the first element in the list and editing the second one depends on the execution order. + * Often, developers try to reduce the outgoing traffic volume as well by returning an empty server response for modifying operations. Therefore, two clients editing the same entity do not see the changes made by each other until they explicitly refresh the state, which further increases the chance of yielding highly unexpected results. + +A **more consistent solution** is to split an endpoint into several idempotent sub-endpoints, each having its own independent identifier and/or address (which is usually enough to ensure the transitivity of the operations). This approach aligns well with the decomposition principle we discussed in the “[Isolating Responsibility Areas](#api-design-isolating-responsibility)” chapter. + +``` +// Creates an order +// comprising two beverages +POST /v1/orders/ +{ + "parameters": { + "delivery_address" + }, + "items": [{ + "recipe": "lungo" + }, { + "recipe": "latte", + "milk_type": "oats" + }] +} +→ +{ + "order_id", + "created_at", + "parameters": { + "delivery_address" + }, + "items": [ + { "item_id", "status"}, + { "item_id", "status"} + ] +} +``` + +``` +// Changes the parameters +// of the second order +PUT /v1/orders/{id}/parameters +{ "delivery_address" } +→ +{ "delivery_address" } +``` + +``` +// Partially changes the order +// by rewriting the parameters +// of the second beverage +PUT /v1/orders/{id}/items/{item_id} +{ + // All the fields are passed, + // even if only one has changed + "recipe", "volume", "milk_type" +} +→ +{ "recipe", "volume", "milk_type" } +``` + +``` +// Deletes one of the beverages +DELETE /v1/orders/{id}/items/{item_id} +``` + +Now to reset the `volume` field it is enough *not* to pass it in the `PUT items/{item_id}`. Also note that the operations of removing one beverage and editing another one became transitive. + +This approach also allows for separating read-only and calculated fields (such as `created_at` and `status`) from the editable ones without creating ambivalent situations (such as what should happen if the client tries to modify the `created_at` field). + +Applying this pattern is typically sufficient for most APIs that manipulate composite entities. However, it comes with a price as it sets high standards for designing the decomposed interfaces (otherwise a once neat API will crumble with further API expansion) and the necessity to make many requests to replace a significant subset of the entity's fields (which implies exposing the functionality of applying bulk changes, the undesirability of which we discussed in the previous chapter). + +**NB**: while decomposing endpoints, it's tempting to split editable and read-only data. Then the latter might be cached for a long time and there will be no need for sophisticated list iteration techniques. The plan looks great on paper; however, with API expansion, immutable data often ceases to be immutable which is only solvable by creating new versions of the interfaces. We recommend explicitly pronouncing some data non-modifiable in one of the following two cases: either (1) it really cannot become editable without breaking backward compatibility or (2) the reference to the resource (such as, let's say, a link to an image) is fetched via the API itself and you can make these links persistent (i.e., if the image is updated, a new link is generated instead of overwriting the content the old one points to). + +#### Resolving Conflicts of Collaborative Editing + +The idea of applying changes to a resource state through independent atomic idempotent operations looks attractive as a conflict resolution technique as well. As subcomponents of the resource are fully overwritten, it is guaranteed that the result of applying the changes will be exactly what the user saw on the screen of their device, even if they had observed an outdated version of the resource. However, this approach helps very little if we need a high granularity of data editing as it's implemented in modern services for collaborative document editing and version control systems (as we will need to implement endpoints with the same level of granularity, literally one for each symbol in the document). + +To make true collaborative editing possible, a specifically designed format for describing changes needs to be implemented. It must allow for: + * ensuring the maximum granularity (each operation corresponds to one distinct user's action) + * implementing conflict resolution policies. + +In our case, we might take this direction: + +``` +POST /v1/order/changes +X-Idempotency-Token: +{ + // The revision the client + // observed when making + // the changes + "known_revision", + "changes": [{ + "type": "set", + "field": "delivery_address", + "value": + }, { + "type": "unset_item_field", + "item_id", + "field": "volume" + }], + … +} +``` + +This approach is much more complex to implement, but it is the only viable technique for realizing collaborative editing as it explicitly reflects the exact actions the client applied to an entity. Having the changes in this format also allows for organizing offline editing with accumulating changes on the client side for the server to resolve the conflict later based on the revision history. + +**NB**: one approach to this task is developing a set of operations in which all actions are transitive (i.e., the final state of the entity does not change regardless of the order in which the changes were applied). One example of such a nomenclature is [CRDT](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type). However, we consider this approach viable only in some subject areas, as in real life, non-transitive changes are always possible. If one user entered new text in the document and another user removed the document completely, there is no way to automatically resolve this conflict that would satisfy both users. The only correct way of resolving this conflict is explicitly asking users which option for mitigating the issue they prefer. \ No newline at end of file diff --git a/src/en/clean-copy/03-[Work in Progress] Section II. The API Patterns/11.md b/src/en/clean-copy/03-[Work in Progress] Section II. The API Patterns/11.md index 297896a..4a807da 100644 --- a/src/en/clean-copy/03-[Work in Progress] Section II. The API Patterns/11.md +++ b/src/en/clean-copy/03-[Work in Progress] Section II. The API Patterns/11.md @@ -1 +1,19 @@ -### Degradation and Predictability \ No newline at end of file +### [Degradation and Predictability][api-patterns-degrading] + +In the previous chapters, we repeatedly discussed that the background level of errors is not just unavoidable, but in many cases, APIs are deliberately designed to tolerate errors to make the system more scalable and predictable. + +But let's ask ourselves a question: what does a “more predictable system” mean? For an API vendor, the answer is simple: the distribution and number of errors are both indicators of technical problems (if the numbers are growing unexpectedly) and KPIs for technical refactoring (if the numbers are decreasing after the release). + +However, for partner developers, the concept of “API predictability” means something completely different: how solidly they can cover the API use cases (both happy and unhappy paths) in their code. In other words, how well one can understand based on the documentation and the nomenclature of API methods what errors might arise during the API work cycle and how to handle them. + +Why is optimistic concurrency control better than acquiring locks from the partner's point of view? Because if the revision conflict error is received, it's obvious to a developer what to do about it: update the state and try again (the easiest approach is to show the new state to the end user and ask them what to do next). But if the developer can't acquire a lock in a reasonable time then… what useful action can they take? Retrying most certainly won't change anything. Show something to the user… but what exactly? An endless spinner? Ask the user to make a decision — give up or wait a bit longer? + +While designing the API behavior, it's extremely important to imagine yourself in the partner developer's shoes and consider the code they must write to solve the arising issues (including timeouts and backend unavailability). This book comprises many specific tips on typical problems; however, you need to think about atypical ones on your own. + +Here are some general pieces of advice that might come in handy: + * If you can include recommendations on resolving the error in the error response itself, do it unconditionally (but keep in mind there should be two sets of recommendations, one for the user who will see the message in the application and one for the developer who will find it in the logs) + * If errors emitted by some endpoint are not critical for the main functionality of the integration, explicitly describe this fact in the documentation. Developers may not guess to wrap the corresponding code in a `try-catch` block. Providing code samples and guidance on what default value or behavior to use in case of an error is even better. + * Remember that no matter how exquisite and comprehensive your error nomenclature is, a developer can always encounter a transport-level error or a network timeout, which means they need to restore the application state when the tips from the backend are not available. There should be an obvious default sequence of steps to handle unknown problems. + * Finally, when introducing new types of errors, don't forget about old clients that are unaware of these new errors. The aforementioned “default reaction” to obscure issues should cover these new scenarios. + +In an ideal world, to help partners “degrade properly,” a meta-API should exist, allowing for determining the status of the endpoints of the main API. This way, partners would be able to automatically enable fallbacks if some functionality is unavailable. In the real world, alas, if a widespread outage occurs, APIs for checking the status of APIs are commonly unavailable as well. \ No newline at end of file diff --git a/src/ru/clean-copy/02-Раздел I. Проектирование API/05.md b/src/ru/clean-copy/02-Раздел I. Проектирование API/05.md index b072217..f509be3 100644 --- a/src/ru/clean-copy/02-Раздел I. Проектирование API/05.md +++ b/src/ru/clean-copy/02-Раздел I. Проектирование API/05.md @@ -767,6 +767,8 @@ GET /v1/price?recipe=lungo⮠ } ``` +**NB**: часто можно встретить подход, когда для неизменяемых данных выставляется очень длинный срок жизни кэша — год, а то и больше. С практической точки зрения это не имеет большого смысла (вряд ли можно всерьёз ожидать серьёзного снижения нагрузки на сервер по сравнению, скажем, с кэшированием на месяц), а вот цена ошибки существенно возрастает: если по какой-то причине будут закэшированы неверные данные (например, ошибка `404`), эта проблема будет преследовать вас следующий год, а то и больше. Мы склонны рекомендовать выбирать разумные сроки кэширования в зависимости от того, насколько серьёзным окажется для бизнеса кэширование неверного значения. + ##### Сохраняйте точность дробных чисел Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, `Decimal` или аналогичных. diff --git a/src/ru/clean-copy/03-[В разработке] Раздел II. Паттерны дизайна API/10.md b/src/ru/clean-copy/03-[В разработке] Раздел II. Паттерны дизайна API/10.md index 5d17280..9d07fbe 100644 --- a/src/ru/clean-copy/03-[В разработке] Раздел II. Паттерны дизайна API/10.md +++ b/src/ru/clean-copy/03-[В разработке] Раздел II. Паттерны дизайна API/10.md @@ -1 +1,192 @@ -### Частичные обновления \ No newline at end of file +### [Частичные обновления][api-patterns-partial-updates] + +Описанный в предыдущей главе пример со списком операций, который может быть выполнен частично, естественным образом подводит нас к следующей проблеме дизайна API. Что, если изменение не является атомарной идемпотентной операцией (как изменение статуса заказа), а представляет собой низкоуровневую перезапись нескольких полей объекта? Рассмотрим следующий пример. + +``` +// Создаёт заказ из двух напитков +POST /v1/orders/ +X-Idempotency-Token: <токен> +{ + "delivery_address", + "items": [{ + "recipe": "lungo" + }, { + "recipe": "latte", + "milk_type": "oat" + }] +} +→ +{ "order_id" } +``` + +``` +// Частично перезаписывает заказ, +// обновляет объём второго напитка +PATCH /v1/orders/{id} +{ + "items": [ + // `null` показывает, что + // параметры первого напитка + // менять не надо + null, + // список изменений свойств + // второго напитка + {"volume": "800ml"} + ] +} +→ +{ /* изменения приняты */ } +``` + +Эта сигнатура плоха сама по себе, поскольку её читабельность сомнительна. Что обозначает пустой первый элемент массива — это удаление элемента или указание на отсутствие изменений? Что произойдёт с полями, которые не указаны в операции обновления (`delivery_address`, `milk_type`) — они будут сброшены в значения по умолчанию или останутся неизменными? + +Самое неприятное здесь — какой бы вариант вы ни выбрали, это только начало проблем. Допустим, мы договорились, что конструкция `{"items":[null, {…}]}` означает, что с первым элементом массива ничего не происходит, он не меняется. А как тогда всё-таки его удалить? Придумать ещё одно «зануляемое» значение специально для удаления? Аналогично, если значения неуказанных полей остаются без изменений — как сбросить их в значения по умолчанию? + +Частичные изменения состояния ресурсов — одна из самых частых задач, которые решает разработчик API, и, увы, одна из самых сложных. Попытки обойтись малой кровью и упростить имплементацию зачастую приводят к очень большим проблемам в будущем. + +**Простое решение** состоит в том, чтобы всегда перезаписывать объект целиком, т.е. требовать передачи полного объекта, полностью заменять им текущее состояние и возвращать в ответ на операцию новое состояние целиком. Однако это простое решение часто не принимается по нескольким причинам: + * повышенные размеры запросов и, как следствие, расход трафика; + * необходимость вычислять, какие конкретно поля изменились — в частности для того, чтобы правильно сгенерировать сигналы (события) для подписчиков на изменения; + * невозможность совместного доступа к объекту, когда два клиента независимо редактируют его свойства, поскольку клиенты всегда посылают полное состояние объекта, известное им, и переписывают изменения друг друга, поскольку о них не знают. + +Во избежание перечисленных проблем разработчики, как правило, реализуют некоторое **наивное решение**: + * клиент передаёт только те поля, которые изменились; + * для сброса значения поля в значение по умолчанию или пропуска/удаления элементов массивов используются специально оговоренные значения. + +Если обратиться к примеру выше, наивный подход выглядит примерно так: + +``` +// Частично перезаписывает заказ: +// * сбрасывает адрес доставки +// в значение по умолчанию +// * не изменяет первый напиток +// * удаляет второй напиток +PATCH /v1/orders/{id} +{ + // Специальное значение №1: + // обнулить поле + "delivery_address": null + "items": [ + // Специальное значение №2: + // не выполнять никаких + // операций с объектом + {}, + // Специальное значение №3: + // удалить объект + false + ] +} +``` + +Предполагается, что: + * повышенного расхода трафика можно избежать, передавая только изменившиеся поля и заменяя пропускаемые элементы специальными значениями (`{}` в нашем случае); + * события изменения значения поля также будут генерироваться только по тем полям и объектам, которые переданы в запросе; + * если два клиента делают одновременный запрос, но изменяют различные поля, конфликта доступа не происходит, и оба изменения применяются. + +Все эти соображения, однако, на поверку оказываются мнимыми: + * причины увеличенного расхода трафика (слишком частый поллинг, отсутствие пагинации и/или ограничений на размеры полей) мы разбирали в главе «[Описание конечных интерфейсов](#api-design-describing-interfaces)», и передача лишних полей к ним не относится (а если и относится, то это повод декомпозировать эндпойнт); + * концепция передачи только изменившихся полей по факту перекладывает ответственность определения, какие поля изменились, на клиент; + * это не только не снижает сложность имплементации этого кода, но и чревато его фрагментацией на несколько независимых клиентских реализаций; + * существование клиентского алгоритма построения diff-ов не отменяет обязанность сервера уметь делать то же самое — поскольку клиентские разработчики могли ошибиться или просто полениться правильно вычислить изменившиеся поля; + * наконец, подобная наивная концепция организации совместного доступа работает ровно до того момента, пока изменения транзитивны, т.е. результат не зависит от порядка выполнения операций (в нашим примере это уже не так — операции удаления первого элемента и редактирования первого элемента нетранзитивны); + * кроме того, часто в рамках той же концепции экономят и на исходящем трафике, возвращая пустой ответ сервера для модифицирующих операций; таким образом, два клиента, редактирующих одну и ту же сущность, не видят изменения друг друга, что ещё больше повышает вероятность получить совершенно неожиданные результаты. + +**Более консистентное решение**: разделить эндпойнт на несколько идемпотентных суб-эндпойнтов, имеющих независимые идентификаторы и/или адреса (чего обычно достаточно для обеспечения транзитивности операций). Этот подход также хорошо согласуется с принципом декомпозиции, который мы рассматривали в предыдущем главе [«Разграничение областей ответственности»](#api-design-isolating-responsibility). + +``` +// Создаёт заказ из двух напитков +POST /v1/orders/ +{ + "parameters": { + "delivery_address" + }, + "items": [{ + "recipe": "lungo" + }, { + "recipe": "latte", + "milk_type": "oats" + }] +} +→ +{ + "order_id", + "created_at", + "parameters": { + "delivery_address" + }, + "items": [ + { "item_id", "status"}, + { "item_id", "status"} + ] +} +``` + +``` +// Изменяет параметры, +// относящиеся ко всему заказу +PUT /v1/orders/{id}/parameters +{ "delivery_address" } +→ +{ "delivery_address" } +``` + +``` +// Частично перезаписывает заказ +// обновляет объём одного напитка +PUT /v1/orders/{id}/items/{item_id} +{ + // Все поля передаются, даже если + // изменилось только какое-то одно + "recipe", "volume", "milk_type" +} +→ +{ "recipe", "volume", "milk_type" } +``` + +``` +// Удаляет один из напитков в заказе +DELETE /v1/orders/{id}/items/{item_id} +``` + +Теперь для удаления `volume` достаточно *не* передавать его в `PUT items/{item_id}`. Кроме того, обратите внимание, что операции удаления одного напитка и модификации другого теперь стали транзитивными. + +Этот подход также позволяет отделить неизменяемые и вычисляемые поля (`created_at` и `status`) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить `created_at`?). + +Применения этого паттерна, как правило, достаточно для большинства API, манипулирующих сложносоставленными сущностями, однако и недостатки у него тоже есть: высокие требования к качеству проектирования декомпозиции (иначе велик шанс, что стройное API развалится при дальнейшем расширении функциональности) и необходимость совершать множество запросов для изменения всей сущности целиком (из чего вытекает необходимость создания функциональности для внесения массовых изменений, нежелательность которой мы обсуждали в предыдущей главе). + +**NB**: при декомпозиции эндпойнтов велик соблазн провести границу так, чтобы разделить изменяемые и неизменяемые данные. Тогда последние можно объявить кэшируемыми условно вечно и вообще не думать над проблемами пагинации и формата обновления. На бумаге план выглядит отлично, однако с ростом API неизменяемые данные частенько перестают быть таковыми, и тогда потребуется выпускать новые интерфейсы работы с данными. Мы скорее рекомендуем объявлять данные иммутабельными в одном из двух случаев: либо (1) они действительно не могут стать изменяемыми без слома обратной совместимости, либо (2) ссылка на ресурс (например, на изображение) поступает через API же, и вы обладаете возможностью сделать эти ссылки персистентными (т.е. при необходимости обновить изображение будете генерировать новую ссылку, а не перезаписывать контент по старой ссылке). + +#### Разрешение конфликтов совместного редактирования + +Идеи организации изменения состояния ресурса через независимые атомарные идемпотентные операции выглядит достаточно привлекательно и с точки зрения разрешения конфликтов доступа. Так как составляющие ресурса перезаписываются целиком, результатом записи будет именно то, что пользователь видел своими глазами на экране своего устройства, даже если при этом он смотрел на неактуальную версию. Однако этот подход очень мало помогает нам, если мы действительно обеспечить максимально гранулярное изменение данных, как, например, это сделано в онлайн-сервисах совместной работы с документами или системах контроля версий (поскольку для этого нам придётся сделать столь же гранулярные эндпойнты, т.е. буквально адресовать каждый символ документа по отдельности). + +Для «настоящего» совместного редактирования необходимо будет разработать отдельный формат описания изменений, который позволит: + * иметь максимально возможную гранулярность (т.е. одна операция соответствует одному действию клиента); + * реализовать политику разрешения конфликтов. + +В нашем случае мы можем пойти, например, вот таким путём: + +``` +POST /v1/order/changes +X-Idempotency-Token: <токен> +{ + // Какую ревизию ресурса + // видел пользователь, когда + // выполнял изменения + "known_revision", + "changes": [{ + "type": "set", + "field": "delivery_address", + "value": <новое значение> + }, { + "type": "unset_item_field", + "item_id", + "field": "volume" + }], + … +} +``` + +Этот подход существенно сложнее в имплементации, но является единственным возможным вариантом реализации совместного редактирования, поскольку он явно отражает, что в действительности делал пользовать с представлением объекта. Имея данные в таком формате возможно организовать и оффлайн-редактирование, когда пользовательские изменения накапливаются и сервер впоследствии автоматически разрешает возможные конфликты, основываясь на истории ревизий. + +**NB**: один из подходов к этой задаче — разработка такой номенклатуры операций над данными (например, [CRDT](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type)), в которой любые действия транзитивны (т.е. конечное состояние системы не зависит от того, в каком порядке они были применены). Мы, однако, склонны считать такой подход применимым только к весьма ограниченным предметным областям — поскольку в реальной жизни нетранзитивные действия находятся почти всегда. Если один пользователь ввёл в документ новый текст, а другой пользователь удалил документ — никакого разумного (т.е. удовлетворительного с точки зрения обоих акторов) способа автоматического разрешения конфликта здесь нет, необходимо явно спросить пользователей, что бы они хотели сделать с возникшим конфликтом. diff --git a/src/ru/clean-copy/03-[В разработке] Раздел II. Паттерны дизайна API/11.md b/src/ru/clean-copy/03-[В разработке] Раздел II. Паттерны дизайна API/11.md index 4dfb7cb..e458f20 100644 --- a/src/ru/clean-copy/03-[В разработке] Раздел II. Паттерны дизайна API/11.md +++ b/src/ru/clean-copy/03-[В разработке] Раздел II. Паттерны дизайна API/11.md @@ -1 +1,19 @@ -### Деградация и предсказуемость \ No newline at end of file +### [Деградация и предсказуемость][api-patterns-degrading] + +В предыдущих главах мы много говорили о том, что фон ошибок — не только неизбежное зло в любой достаточно большой системе, но и, зачастую, осознанное решение, которое позволяет сделать систему более масштабируемой и предсказуемой. + +Зададим себе, однако, вопрос: а что значит «более предсказуемая» система? Для нас как для вендора API это достаточно просто: процент ошибок (в разбивке по типам) достаточно стабилен, и им можно пользоваться как индикатором возникающих технических проблем (если он растёт) и как KPI для технических улучшений и рефакторингов (если он падает). + +Но вот для разработчиков-партнёров понятие «предсказуемость поведения API» имеет совершенно другой смысл: насколько хорошо и полно они в своём коде могут покрыть различные сценарии использования API и потенциальные проблемы — или, иными словами, насколько явно из документации и номенклатуры методов и ошибок API становится ясно, какие типовые ошибки могут возникнуть и как с ними работать. + +Чем, например, оптимистичное управление параллелизмом (см. главу «Стратегии синхронизации») лучше блокировок с точки зрения партнёра? Тем, что, получив ошибку несовпадения ревизий, разработчик понимает, какой код он должен написать: обновить состояние и попробовать ещё раз (в простейшем случае — показав новое состояние пользователю и предложив ему решить, что делать дальше). Если же разработчик пытается захватить lock и не может сделать этого в течение разумного времени, то… а что он может полезного сделать? Попробовать ещё раз — но результат ведь, скорее всего, не изменится. Показать пользователю… что? Бесконечный спиннер? Попросить пользователя принять какое решение — сдаться или ещё подождать? + +При проектировании поведения вашего API исключительно важно представить себя на месте разработчика и попытаться понять, какой код он должен написать для разрешения возникающих ситуаций (включая сетевые таймауты и/или частичную недоступность вашего бэкенда). В этой книге приведено множество частных советов, как поступать в той или иной ситуации, но они, конечно, покрывают только типовые сценарии. О нетиповых вам придётся подумать самостоятельно. + +Несколько общих советов, которые могут вам пригодиться: + * если вы можете включить в саму ошибку рекомендации, как с ней бороться — сделайте это не раздумывая (имейте в виду, что таких рекомендаций должно быть две — для пользователя, который увидит ошибку в приложении, и для разработчика, который будет разбирать логи); + * если ошибки в каком-то эндпойнте некритичны для основной функциональности интеграции, очень желательно описать этот факт в документации (потому что разработчик может просто не догадаться обернуть соответствующий вызов в try-catch), а лучше — привести примеры, каким значением/поведением по умолчанию следует воспользоваться в случае получения ошибки; + * не забывайте, что, какую бы стройную и всеобъемлющую систему ошибок вы ни выстроили, почти в любой среде разработчик может получить ошибку транспортного уровня или таймаут выполнения, а, значит, оказаться в ситуации, когда восстанавливать состояние надо, а «подсказки» от бэкенда недоступны; должна существовать какая-то достаточно очевидная последовательность действий «по умолчанию» для восстановления работы интеграции из любой точки; + * наконец, при введении новых ошибок не забывайте о старых клиентах, которые про эти новые типы ошибок не знают; «реакция по умолчанию» на неизвестные ошибки должна в том числе покрывать и эти новые сценарии. + +В идеальном мире для «правильной деградации» клиентов желательно иметь мета-API, позволяющее определить статус доступности для эндпойнтов основного API — тогда партнёры смогут, например, автоматически включать fallback-и, если какая-то функциональность не работает. (В реальном мире, увы, если на уровне сервиса наблюдаются масштабные проблемы, то обычно и API статуса доступности оказывается ими затронуто.) \ No newline at end of file diff --git a/src/ru/drafts/03-Раздел II. Паттерны дизайна API/09.md b/src/ru/drafts/03-Раздел II. Паттерны дизайна API/09.md deleted file mode 100644 index e69de29..0000000 diff --git a/src/ru/drafts/03-Раздел II. Паттерны дизайна API/10.md b/src/ru/drafts/03-Раздел II. Паттерны дизайна API/10.md deleted file mode 100644 index 9204909..0000000 --- a/src/ru/drafts/03-Раздел II. Паттерны дизайна API/10.md +++ /dev/null @@ -1,188 +0,0 @@ -### Частичные обновления - -Описанный выше пример со списком операций, который может быть выполнен частично, естественным образом подводит нас к следующей проблеме дизайна API. Что, если изменение не является самоочевидной операцией (как изменение статуса заказа), а является частичной перезаписью нескольких полей объекта? Рассмотрим следующий пример. - -``` -// Создаёт заказ из двух напитков -POST /v1/orders/ -X-Idempotency-Token: <токен> -{ - "delivery_address", - "items": [{ - "recipe": "lungo", - }, { - "recipe": "latte", - "milk_type": "oat" - }] -} -→ -{ "order_id" } -``` - -``` -// Частично перезаписывает заказ -// обновляет объём второго напитка -PATCH /v1/orders/{id} -{ - "items": [null, { - "volume": "800ml" - }] -} -→ -{ /* изменения приняты */ } -``` - -Эта сигнатура плоха сама по себе, поскольку её читабельность сомнительна. Что обозначает пустой первый элемент массива — это удаление элемента или указание на отсутствие изменений? Что произойдёт с полями, которые не указаны в операции обновления (`delivery_address`, `milk_type`) — они будут сброшены в значения по умолчанию или останутся неизменными? - -Самое неприятное здесь — какой бы вариант вы ни выбрали, это только начало проблем. Допустим, мы договорились, что конструкция `{"items":[null, {…}]}` означает, что с первым элементом массива ничего не происходит, он не меняется. А как тогда всё-таки его удалить? Придумать ещё одно «зануляемое» значение специально для удаления? Аналогично, если значения неуказанных полей остаются без изменений — как сбросить их в значения по умолчанию? - -**NB**. Это замечание не распространяется на те случаи, когда платформа и протокол однозначно и без всяких дополнительных абстракций поддерживают специальные значения обозначения незаданных полей и для сброса значения поля в значение по умолчанию. Однако полная и консистентная поддержка такой функциональности практически нигде не имплементирована. Пожалуй, единственный пример такого API из имеющих широкое распространение сегодня — SQL: в языке есть и концепция `NULL`, и значения полей по умолчанию, и поддержка операций вида `UPDATE … SET field = DEFAULT` (в большинстве диалектов). Хотя работа с таким протоколом всё ещё затруднена (например, во многих диалектах нет простого способа получить обратно значение по умолчанию, которое выставил `UPDATE … DEFAULT`), логика работы с умолчаниями в SQL имплементирована достаточно хорошо, чтобы использовать её как есть. - -Частичные изменения состояния ресурсов — одна из самых частых задач, которые решает разработчик API, и, увы, одна из самых сложных. Попытки обойтись малой кровью и упростить имплементацию зачастую приводят к очень большим проблемам в будущем. - -**Простое решение** состоит в том, чтобы всегда перезаписывать объект целиком, т.е. требовать передачи полного объекта, полностью заменять им текущее состояние и возвращать в ответ на операцию новое состояние целиком. Однако это простое решение часто не принимается по нескольким причинам: - * повышенные размеры запросов и, как следствие, расход трафика; - * необходимость вычислять, какие конкретно поля изменились — в частности для того, чтобы правильно сгенерировать сигналы (события) для подписчиков на изменения; - * невозможность совместного доступа к объекту, когда два клиента независимо редактируют его свойства. - -Во избежание перечисленных проблем разработчики, как правило, реализуют некоторое **наивное решение**: - * клиент передаёт только те поля, которые изменились; - * для сброса значения поля в значение по умолчанию или пропуска/удаления элементов массивов используются специально оговоренные значения. - -Если обратиться к примеру выше, наивный подход выглядит примерно так: - -``` -// Частично перезаписывает заказ: -// * сбрасывает адрес доставки -// в значение по умолчанию -// * не изменяет первый напиток -// * удаляет второй напиток -PATCH /v1/orders/{id} -{ - // Специальное значение №1: - // обнулить поле - "delivery_address": null - "items": [ - // Специальное значение №2: - // не выполнять никаких - // операций с объектом - {}, - // Специальное значение №3: - // удалить объект - false - ] -} -``` - -Предполагается, что: - * повышенного расхода трафика можно избежать, передавая только изменившиеся поля и заменяя пропускаемые элементы специальными значениями (`{}` в нашем случае); - * события изменения значения поля также будут генерироваться только по тем полям и объектам, которые переданы в запросе; - * если два клиента делают одновременный запрос, но изменяют различные поля, конфликта доступа не происходит, и оба изменения применяются. - -Все эти соображения, однако, на поверку оказываются мнимыми: - * причины увеличенного расхода трафика мы разбирали в главе «Описание конечных интерфейсов», и передача лишних полей к ним не относится (а если и относится, то это повод декомпозировать эндпойнт); - * концепция передачи только изменившихся полей по факту перекладывает ответственность определения, какие поля изменились, на клиент; - * это не только не снижает сложность имплементации этого кода, но и чревато его фрагментацией на несколько независимых клиентских реализаций; - * существование клиентского алгоритма построения diff-ов не отменяет обязанность сервера уметь делать то же самое — поскольку клиентские разработчики могли ошибиться или просто полениться правильно вычислить изменившиеся поля; - * наконец, подобная наивная концепция организации совместного доступа работает ровно до того момента, пока изменения транзитивны, т.е. результат не зависит от порядка выполнения операций (в нашим примере это уже не так — операции удаления первого элемента и редактирования первого элемента нетранзитивны); - * кроме того, часто в рамках той же концепции экономят и на входящем трафике, возвращая пустой ответ сервера для модифицирующих операций; таким образом, два клиента, редактирующих одну и ту же сущность, не видят изменения друг друга. - -**Более консистентное решение**: разделить эндпойнт на несколько идемпотентных суб-эндпойнтов. Этот подход также хорошо согласуется с принципом декомпозиции, который мы рассматривали в предыдущем главе [«Разграничение областей ответственности»](#api-design-isolating-responsibility). - -``` -// Создаёт заказ из двух напитков -POST /v1/orders/ -{ - "parameters": { - "delivery_address" - } - "items": [{ - "recipe": "lungo", - }, { - "recipe": "latte", - "milk_type": "oats" - }] -} -→ -{ - "order_id", - "created_at", - "parameters": { - "delivery_address" - } - "items": [ - { "item_id", "status"}, - { "item_id", "status"} - ] -} -``` - -``` -// Изменяет параметры, -// относящиеся ко всему заказу -PUT /v1/orders/{id}/parameters -{ "delivery_address" } -→ -{ "delivery_address" } -``` - -``` -// Частично перезаписывает заказ -// обновляет объём одного напитка -PUT /v1/orders/{id}/items/{item_id} -{ - // Все поля передаются, даже если - // изменилось только какое-то одно - "recipe", "volume", "milk_type" -} -→ -{ "recipe", "volume", "milk_type" } -``` - -``` -// Удаляет один из напитков в заказе -DELETE /v1/orders/{id}/items/{item_id} -``` - -Теперь для удаления `volume` достаточно *не* передавать его в `PUT items/{item_id}`. Кроме того, обратите внимание, что операции удаления одного напитка и модификации другого теперь стали транзитивными. При этом предполагается, что параллельные модификации разных ресурсов не создают конфликтов. - -Этот подход также позволяет отделить неизменяемые и вычисляемые поля (`created_at` и `status`) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить `created_at`?). - -**NB**: при декомпозиции эндпойнтов велик соблазн провести границу так, чтобы разделить изменяемые и неизменяемые данные. Тогда последние можно объявить кэшируемыми условно вечно и вообще не думать над проблемами пагинации и формата обновления. На бумаге план выглядит отлично, однако с ростом API неизменяемые данные частенько перестают быть таковыми, и вся концепция не только перестаёт работать, но и выглядит как плохой дизайн. Мы скорее рекомендуем объявлять данные иммутабельными в одном из двух случаев: либо (1) они действительно не могут стать изменяемыми без слома обратной совместимости, либо (2) ссылка на ресурс (например, на изображение) поступает через API же, и вы обладаете возможностью сделать эти ссылки персистентными (т.е. при необходимости обновить изображение будете генерировать новую ссылку, а не перезаписывать контент по старой ссылке). - -Применения этого паттерна, как правило, достаточно для большинства API, манипулирующих сложносоставленными сущностями, однако и недостатки у него тоже есть: высокие требования к качеству проектирования декомпозиции (иначе велик шанс, что стройное API развалится при дальнейшем расширении функциональности) и необходимость совершать множество запросов для изменения всей сущности целиком (что в целом решается довольно просто через разработку отдельного эндпойнта для массового изменения нескольких сущностей — например, через списки операций, как описано в предыдущей главе). - -#### Разрешение конфликтов доступа к разделяемым ресурсам - -При всей привлекательности идеи организации изменения состояния ресурса через независимые атомарные идемпотентные операции, у неё есть одна большая проблема: когда мы говорим о разрешении конфликтов, мы подразумеваем следующее: даже если клиент при совершении операции ориентировался на неактуальную ревизию данных, он всё равно не сможет перевести систему в непредвиденное состояние. Так как ресурс перезаписывается целиком, результатом записи будет именно то, что пользователь видел своими глазами на экране своего устройства. Однако этот подход очень мало помогает нам, если мы действительно хотим гарантировать максимально функциональное совместное редактирование данных, т.е. обеспечить максимально гранулярное изменение данных, как, например, это сделано в онлайн-сервисах совместной работы с документами или системах контроля версий. - -Для «настоящего» совместного редактирования необходимо будет разработать отдельный формат описания изменений, который позволит: - * иметь максимально возможную гранулярность (т.е. одна операция соответствует одному действию клиента); - * реализовать политику разрешения конфликтов. - -В нашем случае мы можем пойти, например, вот таким путём: - -``` -POST /v1/order/changes -X-Idempotency-Token: <токен> -{ - // Какую ревизию ресурса - // видел пользователь, когда - // выполнял изменения - "known_revision", - "changes": [{ - "type": "set", - "field": "delivery_address", - "value": <новое значение> - }, { - "type": "unset_item_field", - "item_id", - "field": "volume" - }], - … -} -``` - -Этот подход существенно сложнее в имплементации, но является единственным возможным вариантом реализации совместного редактирования, поскольку он явно отражает, что в действительности делал пользовать с представлением объекта. Имея данные в таком формате возможно организовать и оффлайн-редактирование, когда пользовательские изменения накапливаются и сервер впоследствии автоматически разрешает возможные конфликты, основываясь на истории ревизий. - -**NB**: один из подходов к этой задаче — разработка такой номенклатуры операций над данными (например, [CRDT](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type)), в которой любые действия коммутативны (т.е. конечное состояние системы не зависит от того, в каком порядке они были применены). Мы, однако, склонны считать такой подход применимым только к весьма ограниченным предметным областям — поскольку в реальной жизни некоммутативные действия находятся почти всегда. Если один пользователь ввёл в документ новый текст, а другой пользователь удалил документ — никакого разумного (т.е. удовлетворительного с точки зрения обоих акторов) способа автоматического разрешения конфликта здесь нет, необходимо явно спросить пользователей, что бы они хотели сделать с возникшим конфликтом.