1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-11-29 22:07:39 +02:00

#65 replace 'transitivity' with 'order-dependent'

This commit is contained in:
Sergey Konstantinov
2025-10-11 21:56:22 +03:00
parent b665d4b7a2
commit dfce425f94
4 changed files with 12 additions and 12 deletions

View File

@@ -91,7 +91,7 @@ However, upon closer examination all these conclusions seem less viable:
* 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.
* 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 not order-dependent. In our case, they are: 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.
The solution could be enhanced by introducing explicit control sequences instead of relying on “magical” values and adding meta settings for the operation (such as a field name filter as it's implemented in gRPC over Protobuf[ref Protocol Buffers. Field Masks in Update Operations](https://protobuf.dev/reference/protobuf/google.protobuf/#field-masks-updates)). Here's an example:
@@ -126,11 +126,11 @@ PATCH /v1/orders/{id}↵
While this approach may appear more robust, it doesn't fundamentally address the problems:
* “Magical” values are replaced with “magical” prefixes
* The fragmentation of algorithms and the non-transitivity of operations persist.
* The fragmentation of algorithms and the result’s dependence on the order of operations persist.
Given that the format becomes more complex and less intuitively understandable, we consider this enhancement dubious.
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 independent operations). This approach aligns well with the decomposition principle we discussed in the “[Isolating Responsibility Areas](#api-design-isolating-responsibility)” chapter.
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 order-independence of different types of operations). This approach aligns well with the decomposition principle we discussed in the “[Isolating Responsibility Areas](#api-design-isolating-responsibility)” chapter.
```json
// Creates an order
@@ -189,7 +189,7 @@ PUT /v1/orders/{id}/items/{item_id}
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.
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 order-independent.
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).
@@ -230,4 +230,4 @@ X-Idempotency-Token: <token>
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 a conflict-free replicated data type (*CRDT*).[ref Conflict-Free Replicated Data Type|ref:shapiro-et-al-crdt Conflict-Free Replicated Data Types](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.
**NB**: One approach to this task is developing a set of operations in which the state of the system remains consistent regardless of the order in which the operations are applied. One example of such a nomenclature is a conflict-free replicated data type (*CRDT*).[ref Conflict-Free Replicated Data Type|ref:shapiro-et-al-crdt Conflict-Free Replicated Data Types](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, the order of operation *is* typically important. 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.

View File

@@ -150,7 +150,7 @@ Additionally, if some media data could be attached to an order (such as photos),
The problem of partial updates was discussed in detail in the [corresponding chapter](#api-patterns-partial-updates) of “The API Patterns” section. To quickly recap:
* The concept of fully overwriting resources with `PUT` is viable but soon faces problems when working with calculated or immutable fields and organizing collaborative editing. It is also suboptimal in terms of traffic consumption.
* Partially updating a resource using the `PATCH` method is potentially non-idempotent (and likely non-transitive), and the aforementioned concerns regarding automatic retries are applicable to it as well.
* Partially updating a resource using the `PATCH` method is potentially non-idempotent (and likely order-dependent), and the aforementioned concerns regarding automatic retries are applicable to it as well.
If we need to update a complex entity, especially if collaborative editing is needed, we will soon find ourselves leaning towards one of the following two approaches:
* Decomposing the `PUT` functionality into a set of atomic nested handlers (like `PUT /v1/orders/{id}/address`, `PUT /v1/orders/{id}/volume`, etc.), one for each specific operation.

View File

@@ -88,7 +88,7 @@ PATCH /v1/orders/{id}
* концепция передачи только изменившихся полей по факту перекладывает ответственность определения, какие поля изменились, на клиент;
* это не только не снижает сложность имплементации этого кода, но и чревато его фрагментацией на несколько независимых клиентских реализаций;
* существование клиентского алгоритма построения diff-ов не отменяет обязанность сервера уметь делать то же самое — поскольку клиентские разработчики могли ошибиться или просто полениться правильно вычислить изменившиеся поля;
* наконец, подобная наивная концепция организации совместного доступа работает ровно до того момента, пока изменения транзитивны, т.е. результат не зависит от порядка выполнения операций (в нашим примере это уже не так — операции удаления первого элемента и редактирования первого элемента нетранзитивны);
* наконец, подобная наивная концепция организации совместного доступа работает ровно до того момента, пока результат не зависит от порядка выполнения операций (в нашем примере это уже не так — операции удаления первого элемента и редактирования первого элемента нельзя просто так менять местами);
* кроме того, часто в рамках той же концепции экономят и на исходящем трафике, возвращая пустой ответ сервера для модифицирующих операций; таким образом, два клиента, редактирующих одну и ту же сущность, не видят изменения друг друга, что ещё больше повышает вероятность получить совершенно неожиданные результаты.
Это решение можно улучшить путём ввода явных управляющих конструкций вместо «магических значений» и введением мета-опций операции (скажем, фильтра по именам полей, как это принято в gRPC поверх Protobuf[ref Protocol Buffers. Field Masks in Update Operations](https://protobuf.dev/reference/protobuf/google.protobuf/#field-masks-updates)), например, так:
@@ -126,11 +126,11 @@ PATCH /v1/orders/{id}↵
Такой подход выглядит более надёжным, но в реальности мало что меняет в постановке проблемы:
* «магические значения» заменены «магическими» префиксами;
* фрагментация алгоритмов и нетранзитивность операций сохраняется.
* фрагментация алгоритмов и зависимость результата от порядка операций сохраняется.
При этом формат перестаёт быть простым и интуитивно понятным, что с нашей точки зрения делает такое улучшение спорным.
**Более консистентное решение**: разделить эндпойнт на несколько идемпотентных суб-эндпойнтов, имеющих независимые идентификаторы и/или адреса (чего обычно достаточно для обеспечения транзитивности независимых операций). Этот подход также хорошо согласуется с принципом декомпозиции, который мы рассматривали в предыдущем главе [«Разграничение областей ответственности»](#api-design-isolating-responsibility).
**Более консистентное решение**: разделить эндпойнт на несколько идемпотентных суб-эндпойнтов, имеющих независимые идентификаторы и/или адреса (чего обычно достаточно для обеспечения независимости реузльтата от порядка операций). Этот подход также хорошо согласуется с принципом декомпозиции, который мы рассматривали в предыдущем главе [«Разграничение областей ответственности»](#api-design-isolating-responsibility).
```json
// Создаёт заказ из двух напитков
@@ -187,7 +187,7 @@ PUT /v1/orders/{id}/items/{item_id}
DELETE /v1/orders/{id}/items/{item_id}
```
Теперь для удаления `volume` достаточно *не* передавать его в `PUT items/{item_id}`. Кроме того, обратите внимание, что операции удаления одного напитка и модификации другого теперь стали транзитивными.
Теперь для удаления `volume` достаточно *не* передавать его в `PUT items/{item_id}`. Кроме того, обратите внимание, что результат выполнения операций удаления одного напитка и модификации другого теперь не зависит от порядка.
Этот подход также позволяет отделить неизменяемые и вычисляемые поля (`created_at` и `status`) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить `created_at`?).
@@ -228,4 +228,4 @@ X-Idempotency-Token: <токен>
Этот подход существенно сложнее в имплементации, но является единственным возможным вариантом реализации совместного редактирования, поскольку он явно отражает, что в действительности делал пользовать с представлением объекта. Имея данные в таком формате возможно организовать и оффлайн-редактирование, когда пользовательские изменения накапливаются и сервер впоследствии автоматически разрешает возможные конфликты, основываясь на истории ревизий.
**NB**: один из подходов к этой задаче — разработка такой номенклатуры операций над данными (например, conflict-free replicated data type (*CRDT*)[ref Conflict-Free Replicated Data Type](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type)), в которой любые действия транзитивны (т.е. конечное состояние системы не зависит от того, в каком порядке они были применены). Мы, однако, склонны считать такой подход применимым только к весьма ограниченным предметным областям — поскольку в реальной жизни нетранзитивные действия находятся почти всегда. Если один пользователь ввёл в документ новый текст, а другой пользователь удалил документ — никакого разумного (т.е. удовлетворительного с точки зрения обоих акторов) способа автоматического разрешения конфликта здесь нет, необходимо явно спросить пользователей, что бы они хотели сделать с возникшим конфликтом.
**NB**: один из подходов к этой задаче — разработка такой номенклатуры операций над данными (например, conflict-free replicated data type (*CRDT*)[ref Conflict-Free Replicated Data Type](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type)), в которой операции выполнимы в любом порядке (изменение порядка выполнения операций никогда не приводит к неразрешимому конфликту). Мы, однако, склонны считать такой подход применимым только к весьма ограниченным предметным областям — поскольку в реальной жизни порядок исполнения, как правило, важен. Если один пользователь ввёл в документ новый текст, а другой пользователь удалил документ — никакого разумного (т.е. удовлетворительного с точки зрения обоих акторов) способа автоматического разрешения конфликта здесь нет, необходимо явно спросить пользователей, что бы они хотели сделать с возникшим конфликтом.

View File

@@ -146,7 +146,7 @@
##### Редактирование
Проблемы частичного обновления ресурсов мы подробно разбирали в [соответствующей главе](#api-patterns-partial-updates) раздела «Паттерны дизайна API». Напомним, что полная перезапись ресурса методом `PUT` возможна, но быстро разбивается о необходимость работать с вычисляемыми и неизменяемыми полями, необходимость совместного редактирования и/или большой объём передаваемых данных. Работа через метод `PATCH` возможна, но, так как этот метод по умолчанию считается неидемпотентным (и часто нетранзитивным), для него справедливо всё то же соображение об опасности автоматических перезапросов. Достаточно быстро мы придём к одному из двух вариантов:
Проблемы частичного обновления ресурсов мы подробно разбирали в [соответствующей главе](#api-patterns-partial-updates) раздела «Паттерны дизайна API». Напомним, что полная перезапись ресурса методом `PUT` возможна, но быстро разбивается о необходимость работать с вычисляемыми и неизменяемыми полями, необходимость совместного редактирования и/или большой объём передаваемых данных. Работа через метод `PATCH` возможна, но, так как этот метод по умолчанию считается неидемпотентным (и часто зависящим от порядка выполнения операций), для него справедливо всё то же соображение об опасности автоматических перезапросов. Достаточно быстро мы придём к одному из двух вариантов:
* либо `PUT` декомпозирован на множество составных `PUT /v1/orders/{id}/address`, `PUT /v1/orders/{id}/volume` и т.д. — по ресурсу для каждой частной операции;
* либо существует отдельный ресурс, принимающий список изменений, причём, вероятнее всего, через схему черновик-подтверждение в виде пары методов `POST` + `PUT`.