You've already forked The-API-Book
mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-07-06 22:45:35 +02:00
SDK finished
This commit is contained in:
@ -1,4 +1,4 @@
|
||||
### Разделяемые сущности и асинхронные блокировки
|
||||
### Разделяемые ресурсы и асинхронные блокировки
|
||||
|
||||
Другой важный паттерн, который мы должны рассмотреть — это доступ к общим ресурсам. Предположим, что в нашем учебном приложении разработчик решил открывать экран предложения с анимацией. Для этого он воспользуется объектом offerPanel, который мы реализуем в составе searchBox:
|
||||
|
||||
@ -18,7 +18,7 @@ searchBox.on('selectOffer', (offer) {
|
||||
});
|
||||
```
|
||||
|
||||
Возникает вопрос: а что должно произойти, если, например, пользователь пытается прокрутить список предложений, если сейчас происходит анимация панели? Логически, мы должны эту операцию запретить. Мы можем изменить *состояние* компонента, выставив флаг «происходит анимация»:
|
||||
Возникает вопрос: а что должно произойти, если, например, пользователь пытается прокрутить список предложений, если сейчас происходит анимация панели? Логически, мы должны эту операцию запретить, поскольку в ней нет никакого смысла — выезжающая панель всё равно не даст просмотреть новые элементы списка. Мы можем изменить *состояние* компонента, выставив флаг «происходит анимация»:
|
||||
|
||||
```
|
||||
searchBox.on('selectOffer', (offer) {
|
||||
@ -45,8 +45,8 @@ searchBox.on('scroll', (event) => {
|
||||
|
||||
Но этот код очень плох по множеству причин:
|
||||
* непонятно, как его модифицировать, если у нас появятся разные виды анимации, причём некоторые из них будут требовать блокировки прокрутки, а некоторые — нет;
|
||||
* явно нарушена изоляция абстракций — логическое событие прокрутки списка оказывается привязанным к низкоуровневому состоянию «идёт анимация»;
|
||||
* сложно предсказать, что произойдёт, если каким-то образом (например, программно) будет открыто другое предложение — оба процесса будут «драться» за один флаг и один объект
|
||||
* этот код просто плохо читается: совершенно непонятно, почему флаг `isAnimating` влияет на обработку события `scroll`;
|
||||
* сложно предсказать, что произойдёт, если каким-то образом (например, программно) будет открыто другое предложение — оба процесса будут «драться» за один флаг и один объект;
|
||||
* если при выполнении анимации произойдёт какая-то ошибка, флаг `isAnimating` не будет сброшен, и прокрутка будет заблокирована навсегда.
|
||||
|
||||
Корректное решение первых двух проблемы — это абстрагирование от самого факта анимации и переформулирование проблемы в высокоуровневых терминах. Почему мы запрещаем прокрутку во время анимации? Потому что появление панели предложения как бы «захватывает» эту область экрана. Пользователь не может работать с другими объектами в этой области во время анимации (или, скорее, нет разумного сценария использования, при котором пользователю может понадобиться это делать). Следовательно, именно такой флаг нам и надо объявить — признак «разделяемая область на экране заблокирована»:
|
||||
@ -63,7 +63,6 @@ searchBox.on('selectOffer', (offer) {
|
||||
await searchBox.offerPanel
|
||||
.view.animate('left', 0, '1s');
|
||||
|
||||
|
||||
searchBox.state.
|
||||
isInnerAreaLocked = false;
|
||||
});
|
||||
@ -92,6 +91,7 @@ try {
|
||||
} catch (e) {
|
||||
// Какая-то логика обработки
|
||||
// невозможности захвата ресурса
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
@ -112,7 +112,7 @@ const lock = await searchBox
|
||||
}
|
||||
);
|
||||
|
||||
lock.on('released', () => {
|
||||
lock.on('lost', () => {
|
||||
// Если у нас забрали блокировку,
|
||||
// отменяем анимацию
|
||||
searchBox.offerPanel.view
|
||||
|
@ -1,180 +1,149 @@
|
||||
### Декомпозиция UI-компонентов. MV*-подходы
|
||||
|
||||
Сложность решения проблем, описанных в предыдущей главе, заключается прежде всего в том, что почти любой объект вложен в две (а то и три) различные иерархии сущностей. Если мы возьмём объект «кнопка создания заказа», то она:
|
||||
* вложена в иерархию визуальных сущностей: экран → `SearchBox` → компонент «просмотр предложения» → компонент «панель действий»;
|
||||
* привязана к некоторой сущности внутри иерархии данных (результату поиска);
|
||||
* как программный объект является наследником каких-то базовых классов, отвечающих за UX (системного объекта типа «кнопка», который является реализацией системного интерфейса «интерактивный элемент» и так далее).
|
||||
Продолжим рассматривать пример с анимацией панели, и отметим неприятную проблему в нашем коде (для простоты опустим часть с получением доступа к разделяемому ресурсу):
|
||||
|
||||
Как мы помним из главы «Разделение уровней абстракции», объединение в одном объекте двух-трёх-четырёх разноплановых контекстов — чрезвычайно деструктивное решение, которое приводит к «перепрыгиваниям» по областям ответственности и, как следствие, переусложнённому коду, в рамках которого разработчику придётся манипулировать множеством самых разных сущностей из самых отдалённых фрагментов документации. Увы, с визуальными компонентами у нас просто нет другого выбора: наша кнопка действительно является таким многомерным контекстом, если мы хотим дать возможность гибко настраивать её внешний вид, UX и бизнес-логику. Таков путь.
|
||||
```
|
||||
searchBox.on('selectOffer', (offer) {
|
||||
searchBox.offerPanel.render(offer, {
|
||||
left: screenWidth
|
||||
});
|
||||
|
||||
Идея декомпозиции визуальных объектов, разумеется, придумана не нами и формализована во множестве методологий — в первую очередь, MV*-фреймворках (Model—View—Controller, Model—View—Presenter и т.д.) Все они, в конечно счёте, предлагают реализовать следующий подход:
|
||||
searchBox.state.
|
||||
isInnerAreaLocked = true;
|
||||
|
||||
1. Выделить сущность *модель*, отвечающую только за данные, поверх которых построен компонент.
|
||||
await searchBox.offerPanel
|
||||
.view.animate('left', 0, '1s');
|
||||
|
||||
searchBox.state.
|
||||
isInnerAreaLocked = false;
|
||||
});
|
||||
```
|
||||
|
||||
В данном фрагменте кода налицо неполное разделение уровней абстракций и сильная связность: обработчик *логического* события `selectOffer`, сформулированного в терминах предметной области высокого уровня («выбрано предложение»), запускает такой *низкоуровневый* процесс как анимация. Практическое следствие этой проблемы следующее: если разработчик захочет изменить способ показа предложения (использовать какую-то другую сущность, не `offerPanel` или, скажем, заменить имплементацию `offerPanel` через, например, отрисовку анимации на уровне GL-контекста), этот код перестанет работать. Соответственно, если мы хотим включить вышеприведённое поведение (анимацию показа предложения по его выбору) как часть UX нашего компонента (т.е. сделать поведением по умолчанию), писать код в таком виде мы не можем. (Строго говоря, и часть про блокирование `innerArea` тоже может оказаться ненужной, если мы дадим возможность показывать предложение где-то в другой части экрана, но этот случай мы опустим).
|
||||
|
||||
Как мы описывали в главе «Слабая связность», чтобы избежать этих проблем, необходимо ввести промежуточные сущности между разноуровневыми контекстами и необязывающие взаимодействия между ними. В нашем случае, введём некоторый объект-арбитр, который перехватывает и переформулирует события:
|
||||
|
||||
```
|
||||
class Arbiter implements IArbiter {
|
||||
constructor(
|
||||
searchBox: ISearchBox,
|
||||
offerPanel: IOfferPanel
|
||||
) {
|
||||
// Панель показа предложений
|
||||
// должна быть каким-то образом
|
||||
// привязана к арбитру
|
||||
offerPanel.setArbiter(this);
|
||||
|
||||
searchBox.on('selectOffer', (event) {
|
||||
// Арбитр переформулирует события
|
||||
// `searchBox` в требования к
|
||||
// панели предложений.
|
||||
|
||||
// 1. Необходимо задать контент панели
|
||||
this.emit(
|
||||
'setContent',
|
||||
// Какая-то, возможно переопределяемая
|
||||
// функция получения визуального
|
||||
// контента панели по offer-у
|
||||
this.generateContent(offer.event)
|
||||
);
|
||||
// 2. Необходимо инициализировать
|
||||
// положение панели
|
||||
this.emit(
|
||||
'setInitialPosition',
|
||||
// Мы только указываем панели,
|
||||
// что она должна быть в состоянии
|
||||
// «не видна», не предписывая
|
||||
// конкретной механики скрытия
|
||||
{ "state": "hidden" }
|
||||
);
|
||||
// 3. Необходимо оповестить сам SearchBox
|
||||
// о том, что `innerArea` заблокирована
|
||||
this.emit('innerAreaLocked');
|
||||
// 4. Сообщаем панели, что необходимо
|
||||
// анимировать показ контента
|
||||
this.emit(
|
||||
'animateContentShow',
|
||||
// Желаемая длительность анимации
|
||||
{'duration': '1s'}
|
||||
);
|
||||
// 5. Как только контент панели
|
||||
// полностью готов и показан,
|
||||
// разблокировать `innerArea`
|
||||
offerPanel.on('contentReady', () => {
|
||||
this.emit('innerAreaUnlocked')
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Важно, что интерфейсы `ISearchBox` и `IContentPanel` (как и интерфейс IArbiter) состоят *только* из событий (плюс метод связывания всех трёх сущностей, в нашем случае — `IContentPanel.setArbiter`) и, таким образом, не являются обязывающими для всех трёх сторон. Панель предложений может быть заменена на реализацию, которая вообще не поддерживает никаких анимаций и просто сразу генерирует сообщение `contentReady` после получения `setContent` и отрисовки переданного контента.
|
||||
|
||||
Аналогичным образом, через подобные арбитры, мы можем выстроить взаимодействие между любыми составляющими визуального компонента, где нам требуется «перебросить мостик» через уровни абстракций — но, разумеется, это *чрезвычайно* дорого в имплементации. Чем больше у нас таких суб-компонентов, чем дальше логическая дистанция между их предметными областями — тем больше арбитров, событий и возможностей сделать ошибку в дизайне.
|
||||
|
||||
Вновь обратимся к главе «Слабая связность»: мы могли бы упростить взаимодействие и снизить сложность коде, если бы разрешили нижележащим субкомпонентам напрямую вызывать методы вышестоящих сущностей. Например, так:
|
||||
|
||||
```
|
||||
searchBox.on('selectOffer', (event) {
|
||||
this.emit(
|
||||
'setContent',
|
||||
this.generateContent(offer.event)
|
||||
);
|
||||
// Вместо бросания события,
|
||||
// напрямую модифицируем состояние
|
||||
// родительского компонента
|
||||
searchBox.lockInnerArea();
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
Сделав подобное упрощение, мы фактически получили компонент, следующий методологии [MVC](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller): `OfferPanel` — это view, который непосредственно взаимодействует с пользователем и оповещает об этом событиями, `Arbiter` — это controller, который получает события от view и модифицирует модель (сам `SearchBox`). Если мы выберем другие направления редукции полного взаимодействия, мы получим другие варианты MV*-фреймворков (Model—View—View model, Model—View—Presenter и т.д.) Все они, в конечно счёте, предлагают реализовать следующий подход:
|
||||
|
||||
1. Выделить сущность *модель*, отвечающую за данные, поверх которых построен компонент.
|
||||
2. Потребовать, чтобы внешний вид и поведение компонента полностью определялись его моделью.
|
||||
3. Построить механизм изменений внешнего вида компонента через изменение модели.
|
||||
|
||||
Разница между MV*-подходами заключается в разрешённых направлениях взаимодействия между составляющими.
|
||||
4. Запретить взаимодействия, которые не соответствуют выбранному способу разделения компонента (например, в MVC view запрещено изменять состояние модели напрямую).
|
||||
|
||||
(схемы MVC, MVP, MVVP)
|
||||
|
||||
MV*-подход выглядит вполне простым и понятным в случае статических объектов, но, увы, становится гораздо менее удобным в случае интерактивных, поскольку оставляет открытым вопрос о том, где и как должно храниться *состояние* компонента (например, «происходит ли сейчас анимация нажатия кнопки»). Фактически, это тоже часть данных, на которых строится компонент, но редко часть модели (в том числе потому, что это явное нарушение принципа изоляции уровней абстракции). Для компонентов, обладающих сложным (и асинхронно изменяемым) внутренним состоянием мы должны пойти ещё дальше, и декомпозировать также данные, поверх которых он строится. UI-компоненты, как правило, оперируют:
|
||||
Достоинством MV*-подходов и иных способов ограничения сложности субкомпонентного взаимодействия (например, Redux также является одним из подходов, хотя он оставляет реализацию view за скобками) является строгое насаждение уровней абстракции, которое упрощает дизайн API и вообще оставляет меньше возможностей сделать в нём ошибку. Один очевидный недостаток всех этих фреймворков — это фиксация явных интерфейсов (в нашем случае — контроллер напрямую вызывает методы `SearchBox`-а), что означает ограничение возможности замены составляющих на альтернативные имплементации. Вторую очевидный недостаток мы рассмотрим ниже.
|
||||
|
||||
* данными, полученными из внешних источников, и связанные с бизнес-логикой (в случае нашей кнопки — это предложение, которое по нажатию конвертируется в заказ);
|
||||
* данными, унаследованными по различным иерархиям (например, из визуальной иерархии кнопка получает положение своего контекста на экране);
|
||||
* данными, которые описывают её собственное внутреннее состояние (например, состояние анимации нажатия кнопки).
|
||||
#### Паттерн «модель»
|
||||
|
||||
Если мы позволяем кастомизировать всё вышеперечисленное, кнопка превращается в очень сложный объект:
|
||||
Общая черта, объединяющая все MV*-фреймворки — это выделение сущности «модель» (некоторого набора данных), которая детерминировано *определяет* внешний вид и состояние UI-компонента. Изменения в модели порождают и изменения в отображении компонента (или дерева компонентов; модель может быть одной на всё приложение, и полностью определять весь интерфейс).
|
||||
|
||||
У этого подхода есть несколько очень важных свойств:
|
||||
* он позволяет восстанавливать состояние в случае ошибок (например, сбоя в рендеринге или перезагрузки приложения);
|
||||
* любые переходы между состояниями можно рассматривать как применение изменения (патча) к модели, что, помимо прочего, позволят «возвращаться в прошлое», т.е. отменять последние действия.
|
||||
|
||||
Один из частных случаев использование модели — это сериализация её в виде URL (или App Links в случае мобильных приложений). Тогда URL полностью определяет состояние приложения, и любые изменения состояния отражаются в виде изменений URL. Этот подход чрезвычайно удобен тем, что можно сгенерировать специальные ссылки, открывающие нужный экран в приложении.
|
||||
|
||||
Недостатки этого подхода, увы, также очевидны:
|
||||
* если задаться целью *полностью* описать состояние компонента, то мы обязаны внести в него и такие данные, как выполняющиеся сейчас анимации и даже процент их выполнения — очевидно, на таком уровне детализации ни о каком хранении модели в виде набора патчей речи идти не может, так как нам придётся сохранить каждый шаг анимации;
|
||||
* таким образом, модель обязана будет содержать в себе все данные всех уровней абстракции (причём, зачастую, дублирующие друг друга — например, контент панели предложения является производной от показанного предложения), и, более того, каким-то образом включать в себя две или более иерархии подчинения (по семантической и визуальной иерархиям, а так же, возможно, все селекторы и правила, как мы описали их в главе «Вычисляемые свойства»).
|
||||
|
||||
В нашем примере с анимацией панели предложения из предыдущей главы мы должны составить полную модель в таком виде:
|
||||
|
||||
```
|
||||
class Button extends SystemButton {
|
||||
// Модель данных
|
||||
public model: IButtonModel;
|
||||
// Состояние кнопки
|
||||
public state: IButtonState;
|
||||
// Отображение кнопки
|
||||
public view: IButtonView;
|
||||
}
|
||||
// Каждый из суб-компонентов
|
||||
// должен в свою очередь
|
||||
// предоставлять функциональность:
|
||||
interface IButtonModel {
|
||||
// доступа к данным
|
||||
data: IDataAccessor,
|
||||
// генерации событий изменения
|
||||
// данных
|
||||
events: IEventEmitter,
|
||||
// вышестоящий контекст
|
||||
parentContext: IContext
|
||||
}
|
||||
```
|
||||
|
||||
Напомним, префикс `I` мы используем, чтобы индицировать *интерфейсы* (абстрактные классы). В данном случае подразумевается, что кнопка должна уметь работать с любой моделью, состоянием и/или отображением, лишь бы они выполняли требуемый программный контракт. Именно благодаря этому требованию мы можем говорить о полной замене одного из компонентов кнопки альтернативной реализацией.
|
||||
|
||||
**NB**: чтобы замена компонента работала, должен существовать способ её осуществить — например, через функции-билдеры.
|
||||
|
||||
```
|
||||
class Button {
|
||||
constructor() {
|
||||
this.view = this.buildView();
|
||||
{
|
||||
"searchBox": {
|
||||
"isInnerAreaLocked": true,
|
||||
"offerPanel": {
|
||||
"content",
|
||||
"animations": [{
|
||||
"left": {
|
||||
"begin": "100%",
|
||||
"end": "0",
|
||||
"progress": 0.23
|
||||
}]
|
||||
}
|
||||
},
|
||||
…
|
||||
}
|
||||
}
|
||||
// Разработчик может переопрелить
|
||||
// метод построения отображения
|
||||
class MyButton {
|
||||
buildView() {
|
||||
return new MyCustomView();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Чтобы вся эта система заработала, мы должны добавить к ней события: каждый из компонентов генерирует определённые события и обрабатывает события других компонентов. Например, реакцию на нажатие мы можем описать так:
|
||||
Подобная полная модель представляет собой проблему не только теоретически и семантически (перемешивание в одной сущности разнородных данных), но и в практическом смысле — сериализация таких моделей окажется ограничена рамками конкретной версии API или приложения. Если мы в следующей версии изменим анимацию (панель станет выезжать справа), то старые ссылки перестанут работать (либо нам потребуется держать слой совместимости, описывающий, как интерпретировать модели предыдущих версий).
|
||||
|
||||
```
|
||||
button.events.on(
|
||||
// Получаем событие
|
||||
// нажатия кнопки
|
||||
'press',
|
||||
(event) => {
|
||||
// Пытаемся заблокировать
|
||||
// кнопку, чтобы не допустить
|
||||
// отправки двух запросов
|
||||
// на создание заказа
|
||||
button.state.lock().then(
|
||||
(lock) => {
|
||||
// В случае успеха
|
||||
// создаём заказ
|
||||
const order = await api
|
||||
.createOrder(
|
||||
button.data.offer
|
||||
);
|
||||
lock.release();
|
||||
this.events.emit(
|
||||
'orderCreated',
|
||||
order
|
||||
);
|
||||
},
|
||||
(error) => {
|
||||
// Если блокировку
|
||||
// не удалось получить,
|
||||
// как-то обработать
|
||||
// ошибку
|
||||
…
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
В свою очередь, сущность view должен опираться на состояние кнопки и события её изменения:
|
||||
|
||||
```
|
||||
class ButtonView {
|
||||
public parent: Button;
|
||||
|
||||
protected initialize () {
|
||||
this.parent.state.events.on(
|
||||
'lock',
|
||||
() => {
|
||||
// Вносит изменения во
|
||||
// внешний вид кнопки
|
||||
…
|
||||
}
|
||||
);
|
||||
// аналогично для 'unlock'
|
||||
…
|
||||
// Объект view должен
|
||||
// генерировать событие 'press',
|
||||
// только если он не заблокирован
|
||||
this.element.onTouch(
|
||||
(event) => {
|
||||
if (!this.parent.state.locked) {
|
||||
this.events.emit('press');
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Важно, что при этом каждая составляющая должна оперировать сущностями своей области ответственности, как показано в примере выше: объект View получает от системы событие о том, что над ним произошло касание пользователя, но превращает его в событие 'press' только если UI не заблокирован, таким образом выступая «фильтром», который переформулирует низкоуровневые системные события так, чтобы они стали *ближе* к бизнес-логике.
|
||||
|
||||
Заменив получение и генерацию событий в некоторых парах компонентов на вызовы методов родительского контекста, мы можем редуцировать систему до MV*- или, скажем, Redux-подобного фреймворка. В рамках MVC мы написали бы следующий код:
|
||||
|
||||
```
|
||||
class ButtonView {
|
||||
protected model;
|
||||
protected controller;
|
||||
|
||||
protected initialize () {
|
||||
this.element.onTouch(
|
||||
function (event) {
|
||||
this.controller.onPress();
|
||||
}
|
||||
)
|
||||
|
||||
this.model.onChange(
|
||||
function () {
|
||||
// Перерисовывает кнопку
|
||||
// в соответствие с моделью
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class OrderButtonController () {
|
||||
protected writeableModel;
|
||||
|
||||
public onPress () {
|
||||
this.writeableModel.set('locked', true);
|
||||
const order = await api
|
||||
.createOrder(
|
||||
this.model.offer
|
||||
);
|
||||
this.writeableModel.set('order', order);
|
||||
this.writeableModel.set('locked', false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Как можно заметить, получившийся код полностью эквивалентен предыдущему варианту с точностью до замены событий на вызовы функций. Это неслучайно — смысл MV*-фреймворков как раз заключается в том, чтобы интерфейсно *запретить* определённые направления распространения событий (скажем, только контроллер может изменять состояние модели), тем самым снизив общую сложность взаимодействия.
|
||||
В практическом смысле, разумеется, разработчики визуальных SDK и не пытаются строить такие огромные модели, описывающие до последней запятой состояние каждого субкомпонента в дереве; модели состоят из *важных* полей, а всё «неважное» остаётся на откуп другим частям системы. (Что, по факту, означает следующее: UI-библиотеки никогда не реализуют MV*-подходы *строго*, поскольку это попросту невозможно. View-компоненты всегда имеют свои скрытые состояния-субмодели, которые лишь частично согласованы с основной моделью — в том числе потому, что далеко не всегда возможно это согласование произвести синхронно.)
|
38
src/ru/drafts/06-Раздел V. SDK и UI-библиотеки/08.md
Normal file
38
src/ru/drafts/06-Раздел V. SDK и UI-библиотеки/08.md
Normal file
@ -0,0 +1,38 @@
|
||||
### Backend-Driven UI
|
||||
|
||||
Другой способ обойти сложность «переброса мостов» между несколькими предметными областями, которые нам приходится сводить в рамках одного UI-компонента — это убрать одну из них. Как правило, речь идёт о бизнес-логике: мы можем разработать компоненты полностью абстрактными, и скрыть все трансляции UI-событий в полезные действия вне контроля разработчика.
|
||||
|
||||
В такой парадигме код открытия панели предложений (которая должна перестать быть панелью предложений и стать просто «панелью чего-то») выглядел бы так:
|
||||
|
||||
```
|
||||
searchBox.on('selectItem', (item) => {
|
||||
this.isInnerAreaLocked = true;
|
||||
this.innerAreaContent = await api
|
||||
.getInnerAreaContent(
|
||||
this.model, item
|
||||
);
|
||||
this.isInnerAreaLocked = false;
|
||||
});
|
||||
```
|
||||
|
||||
Или даже так:
|
||||
|
||||
```
|
||||
searchBox.on('selectItem', (item) => {
|
||||
// Получаем от сервера набор
|
||||
// команд, которые необходимо выполнить
|
||||
// по событию selectItem
|
||||
const reaction = await api.onSelectItem(
|
||||
this.model, this.state, item
|
||||
);
|
||||
this.exec(reaction);
|
||||
});
|
||||
```
|
||||
|
||||
(Реализацией этой идеи можно также считать «Web 1.0» — сервер присылает готовый контент страницы, а вся интерактивность сводится к переходам по ссылкам.)
|
||||
|
||||
Этот подход, безусловно, является крайне привлекательным с двух сторон:
|
||||
* возможность с сервера регулировать поведение клиента, в том числе устранять возможные ошибки в реальном времени;
|
||||
* возможность сэкономить на разработке консистентной и читабельной номенклатуре сущностей публичного SDK, ограничившись минимальным набором доступной функциональности.
|
||||
|
||||
Тем не менее, мы не можем не отметить: при том, что любая крупная IT-компания проходит через эту фазу — разработки Backend-Driven UI (они же — «тонкие клиенты») для своих приложений или своих публичных SDK — мы не знаем ни одного заметного на рынке API, разработанного в этой парадигме (кроме протоколов удалённых терминалов), хотя во многих случаях возникающими сетевыми задержками вполне можно было бы пренебречь. Нам сложно выделить конкретные причины, почему так происходит, но мы рискнём предположить, что разработать серверный код управления UI ничуть не проще, нежели клиентский — даже если вы не должны документировать каждый метод и абстрагировать каждую сущность — и игра, в конечном счёте, не стоит свеч.
|
9
src/ru/drafts/06-Раздел V. SDK и UI-библиотеки/09.md
Normal file
9
src/ru/drafts/06-Раздел V. SDK и UI-библиотеки/09.md
Normal file
@ -0,0 +1,9 @@
|
||||
### В заключение
|
||||
|
||||
Предыдущие восемь глав были написаны нами, чтобы раскрыть две очень важные мысли:
|
||||
* разработка качественной UI-библиотеки — это отдельная и весьма непростая инженерная задача;
|
||||
* и эта задача не сводится к автоматической генерации SDK по спецификации.
|
||||
|
||||
Автор этой книги в течение 10 лет разрабатывал подобную сложную интерактивную визуальную систему, позволяющую кастомизировать компоненты вплоть до замены технологии рендеринга, и может уверенно констатировать: не знакомые с проблематикой разработки такого SDK программисты и менеджеры склонны считать проблемы UI несущественными и не заслуживающими большого внимания, и это само по себе быстро становится проблемой. (Впрочем, то же самое мы можем сказать и про разработку API в целом.)
|
||||
|
||||
Оглядываясь на всё написанное, мы с трудом можем сказать, что нашли лучшие примеры и самые понятные слова для описания такой сложной предметной области. Мы, тем не менее, надеемся, что сделали вашу жизнь — и жизнь ваших пользователей — чуточку проще. Спасибо за ваше внимание!
|
@ -1,78 +0,0 @@
|
||||
// NB: перенести в будущий раздел «SDK»
|
||||
### Связывание объектов
|
||||
|
||||
Существует множество техник связывания и управления объектами; ряд паттернов проектирования - порождающие, поведенческие, а также MV*-техники посвящены именно этому. Однако, прежде чем говорить о конкретных паттернах, нужно, как и всюду, ответить на вопрос "зачем" - зачем с точки зрения разработки API нам нужно регламентировать связывание объектов? Чего мы хотим добиться?
|
||||
|
||||
В разработке программного обеспечения в целом снижение связанности объектов необходимо прежде всего для уменьшения сайд-эффектов, когда изменения в одной чести кода могут затронуть работоспособность другого части кода, а также для унификации разработки.
|
||||
|
||||
Разумеется, к API эти суждения также применимы с поправкой на то, что изменения происходят не только при рефакторинге кода; API так же должно быть устойчиво:
|
||||
|
||||
* к изменениям в реализации других компонентов api, в том числе модификации объектов API сторонними разработчика, если такая модификация разрешена;
|
||||
* К изменениям во внешней среде - появлению новой функциональности, обновлению стороннего программного и аппаратного обеспечения, адаптации к новым платформам.
|
||||
|
||||
Необходимость слабой связанности объектов API также вытекает из требования дискретных интерфейсов, поскольку поддержание разумно минимальной номенклатуры свойств и методов требует снижения количества связей между объектами. Чем слабее и малочисленнее связи между различными частями API, тем проще заменить одну технологию другой, если возникнет такая необходимость.
|
||||
|
||||
Проблема связывания объектов разбивается на две части:
|
||||
|
||||
* установление связей между объектами;
|
||||
* передача сообщений/команд.
|
||||
|
||||
#### Установление связей между объектами
|
||||
|
||||
В нашем примере нам нужно установить связи между источниками данных - map и source - и объектами, эти данные представляющими - vehicle и overlay - при посредничестве промежуточных сущностей - renderingEngine. При этом вариантов, как же нам связать эти объекты друг с другом просматривается множество: фактически, связь должна быть - напрямую или опосредованно - между любой парой объектов.
|
||||
|
||||
Можем, например, сделать вот так:
|
||||
|
||||
* карта принимает при создании source как параметр конструктора;
|
||||
* карта инстанцирует vehicle, хранит список всех созданных объектов и обновляет им координаты;
|
||||
* разработчик сам создаёт renderingEngine и прикрепляет его к карте методом setRenderingEngine;
|
||||
* после прикрепления engine карта создаёт оверлеи на каждый объект vehicle и передаёт их в renderingEngine для отрисовки;
|
||||
* При смене области просмотра карта вызывает метод setViewport у source;
|
||||
* При изменении географических (вследствие обновления) или пиксельных (вследствие смены области просмотра) координат карта перебирает все созданные ей оверлеи и устанавливает им новые координаты.
|
||||
|
||||
В этой схеме нарочно допущены все мыслимые ошибки проектирования. Разберём их в порядке, описанном в предыдущих главах, от области применения к конечным интерфейсам.
|
||||
|
||||
Во-первых, мы грубо проигнорировали кейсы использования. В нашей схеме у карты только один несменяемый источник данных, хотя разработчику может понадобиться как несколько карт с одним источником, так и множество источников на одной карте. Обратите внимание, кейс "много карт на один источник" мы сами себе заблокировали, заставив карту вызывать setViewport источнику - теперь источник не може отличить, какая из нескольких карт изменила область просмотра. При этом, при наличии нескольких карт, один vehicle, вполне возможно, будет отображаться сразу на нескольких картах.
|
||||
|
||||
Во-вторых, мы переступили через уровень абстракции, заставив карту задавать пиксельные координаты оверлеям. Это приводит к тому, что мы, возможно, будем не в состоянии реализовать дополнительные движки рендеринга и даже оптимизировать старые - например, движок мог бы оптимизировать движение карты на небольшие смещения, отрисовывая графическое окно с запасом и перемещая только область показа, а не все объекты.
|
||||
|
||||
В-третьих, мы создали объект-"швейцарский нож" - карту, которая следит за всем и реализует все сценарии, что приводит к сложностям в рефакторинге, тестировании и поддержке этого объекта.
|
||||
|
||||
Наконец, в-четвёртых, вместо того, чтобы сделать выбор движка рендеринга автоматизированным, мы заставляем разработчика всегда самому выбирать технологию, что осложняет работу с API, вынуждая разбираться в дополнительных, не связанных с решаемой задачей, концепциях и приводит к невозможности сменить движок по умолчанию в будущем.
|
||||
|
||||
Следует заметить, что каждую из этих задач можно закостылять и решить без нарушения обратной совместимости. Например, можно сделать в объекте source метод порождения дочерних объектов, выполняющих тот же интерфейс, чтобы передавать их в конструкторы map - таким образом можно будет привязывать несколько карт к одному источнику. Проблему с выставлением пиксельных координат оверлеям можно решить, написав новую реализацию оверлея, которая не будет применять полученные координаты, а обратится в renderingEngine для пересчёта полученных от карты координат в актуальные. И так далее, и тому подобное - через несколько итераций таких "улучшений" мы получим типичное современное API, в котором каждая функция есть магический чёрный ящик, выполняющий что угодно, кроме того, что написано в её названии, а для полноценной работы с таким API нужно прочитать не только всю документацию, но и комментарии на форумах разработчиков относительно неочевидной работы тех или иных функций.
|
||||
|
||||
Попробуем теперь спроектировать API правильно. Начнём с отношений map и source.
|
||||
|
||||
Мы знаем, исходя из сценариев использования, что связь map и source имеет вид "многие ко многим", хотя кейс "одна карта - один источник" является самым частотным. Мы знаем, что разработчики будут реализовывать свои source, но вряд ли свои map. Мы также знаем, что эти реализации будут сильно различаться по потребностям - в каких-то системах потребуется оптимизация - так, чтобы source следил только за видимыми объектами - а в каких-то, напротив, объектов мало и заниматься оптимизацией преждевременно.
|
||||
|
||||
Отсюда мы можем сформулировать наши требования к связыванию:
|
||||
|
||||
* начальное привязывание одиночного source к карте должно быть максимально упрощено;
|
||||
* должны быть методы добавления и удаления связей в runtime;
|
||||
* стандартные реализации source и map должны максимально упростить реализацию сложного кейса "к одному source подключено много map, и он оптимизирует слежение, запрашивая только видимые объекты"
|
||||
* разработчик должен иметь возможность реализовать свою имплементацию source, работающего по иным принципам, не прибегая к необходимости костылить методы.
|
||||
|
||||
Поскольку и карта, и источник должны знать друг о друге (источник отслеживает изменение области просмотра карты, а карта отслеживает обновление состояния источника), нам придётся создать парные методы добавления и удаления связей и в карте, и в источнике, что приводит нас к интерфейсу вида:
|
||||
|
||||
`Map.addSource(source) Map.removeSource(source) Source.addMap(map) Source.removeMap(map)`
|
||||
|
||||
Однако такой интерфейс чрезвычайно не очевиден: из него не понятно, что для успешного связывания нужно выполнить и addSource, и addMap.
|
||||
|
||||
Мы можем упростить этот момент, реализовав эти методы так, чтобы они сами выполняли вызов связанного метода. Однако, проблемы разработчика это не решит: всё ещё неясно, каким методом пользоваться правильно.
|
||||
|
||||
Популярное решение состоит в том, чтобы объявить один из методов точкой входа для разработчика, а второй объявить техническим и запретить его вызывать иначе как из другого. Расширенным вариантом этого решения является объявление обеих пар методов техническими и создание специального объекта, через который осуществляется связывание.
|
||||
|
||||
На самом деле, такое решение не внесёт больше понимания. Из номенклатуры методов неясно, что же конкретно они делают. Попросту скрыть их также нельзя: разработчику, пишущему свою имплементацию source или map, всё равно придётся эти методы реализовать и разобраться в механизме их работы.
|
||||
|
||||
Чтобы выйти из этого порочного круга, вспомним о правиле декомпозиции интерфейсов. Если source имеет единственную задачу быть источником данных, то карта - композиция нескольких интерфейсов, и во взаимоотношениях с source должна выступать не как объект map, а как некий абстрактный провайдер сведений о наблюдаемой области.
|
||||
|
||||
Следовательно, метода addMap у source быть не может - для него добавление карты означает появление дополнительной отслеживаем ой области. Метод должен выглядеть примерно следующим образом:
|
||||
|
||||
`Source.addObserver(IGeographicalContext, IObserver)`
|
||||
|
||||
При выполнении этого метода source начинает передавать observer-у сведения о том, что происходит в указанной области. Тогда мы можем реализовать метод addSource так, чтобы он создавал observer и привязывал карту-контекст к источнику.
|
||||
|
||||
В этом решении ситуация выглядит понятной и логичной и в решении стандартных кейсов, так и при написании собственных имплементаций source.
|
||||
|
||||
Перейдём теперь к вопросу создания и связывания vehicle с картой и источником.
|
@ -1,68 +0,0 @@
|
||||
### Статус-коды HTTP
|
||||
|
||||
Ситуацию с использованием кодов ответов HTTP можно заносить в палату мер и весов: вот что происходит, когда благие намерения разработчиков спецификации сталкиваются с жестокой реальностью. Даже с двумя жестокими реальностями.
|
||||
|
||||
Как мы обсудили в [Главе 10](https://twirl.github.io/The-API-Book/docs/API.ru.html#chapter-10), одна из целей существования семантических ошибок — помочь клиенту понять, что стало причиной ошибки. При разработке спецификации HTTP (в частности, [RFC 7231](https://tools.ietf.org/html/rfc7231#section-6)) эта цель очевидно была одной из главных. Более того, архитектурные ограничения REST, как их описал Фьелдинг [в своей диссертации](https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm), предполагают, что не только клиенты должны понимать семантику ошибки, но и все сетевые агенты (прокси) между клиентом и сервером в «многослойной» архитектуре. И, в соответствии с этим, номенклатура статус-кодов HTTP действительно весьма подробно описывает почти любые проблемы, которые могут случиться с HTTP-запросом: недопустимые значения `Accept-*`-заголовков, отсутствующий `Content-Length`, неподдерживаемый HTTP-метод, слишком длинный URI и так далее.
|
||||
|
||||
Но вот с чем RFC совершенно не помогает — это с вопросом, а что собственно клиенту или прокси делать с ошибкой. Как мы обсуждали, ошибки могут быть устранимыми или неустранимыми. Если ошибки неустранимая, то клиентам по большому счёту наплевать на всю эту петрушку со статус-кодами и заголовками, а уж промежуточным прокси тем более. Для этого на самом деле трёх кодов было бы достаточно:
|
||||
* `400` для персистентных ошибок (если просто повторить запрос — ошибка никуда не денется);
|
||||
* `404` для статуса неопределённости (повтор запроса может дать другой результат);
|
||||
* `500` для проблем на стороне сервера плюс заголовок `Retry-After`, чтобы дать понять клиенту, когда прийти снова.
|
||||
|
||||
**Замечание**: кстати, обратите внимание на проблему дизайна спецификации. По умолчанию все `4xx` коды не кэшируются, за исключением: `404`, `405`, `410`, `414`. Мы не сомневаемся, что это было сделано из благих намерений, но подозреваем, что количество людей, знающих об этой тонкости, примерно равно количеству редакторов спецификации. В результате мы имеем множество ситуаций (автор лично разгребал последствия одной из них), когда `404`-ки были возвращены ошибочно, но клиент их закэшировал, тем самым продлив факап на неопределённое время.
|
||||
|
||||
Что касается *устранимых* проблем — то да, статус-коды в чем-то помогают. Некоторые из них вполне конкретны, например `411 Length Required`. А некоторые — нет. Можно привести множество ситуаций, где под одним кодом прячутся разнородные ошибки:
|
||||
* `400 Bad Request` для ситуаций, когда часть параметров отсутствует или имеет недопустимое значение. От этой ошибки клиентам нет абсолютно никакого толку, если только в ответе не указано, какое конкретно поле имеет недопустимое значение — и вот как раз именно это стандарт и не стандартизирует! Да, конечно, можно самому стандарт придумать — но это как минимум противоречит идее прозрачности в REST.
|
||||
|
||||
**NB**: некоторые пуристы считают, что `400` означает проблемы с самим запросом, т.е. кривой URI, заголовок, невалидный JSON и т.д., а для логических ошибок с параметрами предлагают использовать `422 Unprocessable Entity` или `412 Precondition Failed`. Как вы понимаете, это влияет примерно ни на что.
|
||||
|
||||
* `403 Forbidden` для ошибок аутентификации и/или авторизации. И вот тут есть множество совершенно разных `Forbidden`-ов, которые требует совершенно разных действий от клиента:
|
||||
* токен авторизации отсутствует — надо предложить клиенту залогиниться;
|
||||
* токен протух — надо выполнить процедуру подновления токена;
|
||||
* токен принадлежит другому пользователю — обычно свидетельствует о протухании кэша;
|
||||
* токен отозван — пользователь выполнил выход со всех устройств;
|
||||
* злоумышленник брутфорсит авторизационный эндпойнт — надо выполнить какие-то антифродные действия.
|
||||
|
||||
Каждая `403` связана со своим сценарием разрешения, некоторые из них (например, брутфорсинг) вообще ничего общего не имеют с другими.
|
||||
|
||||
* `409 Conflict`;
|
||||
* тысячи их.
|
||||
|
||||
Таким образом, мы вполне естественным образом приходим к идее отдавать детальное описание ошибки в заголовках и/или теле ответа, не пытаясь изобрести новый код для каждой ситуации — абсолютно очевидно, что нельзя задизайнить по ошибке на каждый потенциально неправильный параметр вместо единой `400`-ки, например.
|
||||
|
||||
**Замечание**: авторы спецификации тоже это понимали, и добавили следующую фразу: ‘The response message will usually contain a representation that explains the status.’ Мы с ними, конечно, полностью согласны, но не можем не отметить, что эта фраза не только делает кусок спецификации бесполезным (а зачем нужны коды-то тогда?), но и противоречит парадигме REST: другие агенты в многоуровневой системе не могут понять, что же там «объясняет» представление ошибки, и сама ошибка становится для них непрозрачной.
|
||||
|
||||
Казалось бы, мы пришли к логичному выводу: используйте статус-коды для индикации «класса» ошибки в терминах протокола HTTP, а детали положите в ответ. Но вот тут теория повторно на всех парах напарывается на практику. С самого появления Web все фреймворки и серверное ПО полагаются на статус-коды для логирования и построения мониторингов. Я не думаю, что сильно совру, если скажу, что буквально не существует платформы, которая из коробки умеет строить графики по семантическим данным в ответе ошибки, а не по статус-кодам. И отсюда автоматически следует дальнейшее усугубление проблемы: чтобы отсечь в своих мониторингах незначимые ошибки и эскалировать значимые, разработчики начали попросту придумывать новые статус-коды — или использовать существующие не по назначению.
|
||||
|
||||
Это в свою очередь привело не только к распуханию номенклатуры кодов, но и размытию их значений. Многие разработчики просто не читают спецификации ¯\\\_(ツ)\_/¯. Самый очевидный пример — это ошибка `401 Unauthorized`: по спецификации она **обязана** сопровождаться заголовком `WWW-Authenticate` — чего в реальности, конечно, никто не делает, и по очевидным причинам, т.к. единственное разумное значение этого заголовка — `Basic` (да-да, это та самая логин-парольная авторизация времён Web 1.0, когда браузер диалоговое окно показывает). Более того, спецификация в этом месте расширяема, никто не мешает стандартизовать новые виды `realm`-ов авторизации — но всем традиционно всё равно. Прямо сейчас использование `401` при отсутствии авторизационных заголовков фактически является стандартом индустрии — и никакого `WWW-Authenticate` при этом, конечно, не шлётся.
|
||||
|
||||
В современном мире мы буквально живём в этом бардаке: статус-коды HTTP используются вовсе не в целях поддержания чистоты протокола, а для графиков; их истинное значение забыто; клиенты обычно и не пытаются хоть какие-то выводы из кода ответа сделать, редуцируя его до первой цифры. (Честно говоря, ещё неизвестно, что хуже — игнорировать код или, напротив, писать логику поверх кодов, использованных не по назначению.) Ну и, конечно, нельзя не упомянуть о широко распространённой практике отдавать ошибки внутри `200`-ок.
|
||||
|
||||
В качестве примера — реальная ситуация, с которой столкнулся автор этой книги. Давайте представим, что у нас есть приложение, и в его коде была допущена ошибка: если срок жизни токена авторизации истекал, приложение не могло его обновить. Срок жизни токенов был установлен, допустим, в 30 дней, так что QA-инженеры не обнаружили этой проблемы при тестировании, и релиз был успешно выпущен, скажем, первого июня. Процедура релиза была инкрементальной: сначала релиз раскатили на 1% пользователей, а потом постепенно довели процент до ста.
|
||||
|
||||
По истечении 30 дней, первого июля, мы начали получать странные обращения в службу поддержки, буквально одно или два. Несколько процентов от 1% аудитории испытывали проблемы с аутентификацией, но мы этих проблем не видели: наши графики `403`-х ошибок выглядели нормально, а если мы смотрели в логи отдельных пользователей, то по ним у пользователя был просто просрочен токен почему-то. Поэтому никакой пожарной тревоги не прозвучало, и проблему посчитали малозначимой.
|
||||
|
||||
Прошло ещё несколько дней, всё больше и больше пользователей сталкивалось с проблемой, пока в один прекрасный момент (через 30 дней после выкатывания релиза на 100% аудитории) нас не завалило обращениями. Все графики ушли в красную зону одномоментно. Следующие несколько часов выдались крайне непростыми, мы срочно разбирались в проблеме, готовили хотфикс и выкатывали новый релиз.
|
||||
|
||||
Всего этого не случилось бы, если бы наши графики показывали логические причины ошибок. Если бы у нас был отдельный график количества токенов с истекшим сроком жизни — мы увидели бы на этом графике скачок. Но его не было. А на общем графике `403`-х всегда наблюдалось значительное количество ошибок (от забаненных пользователей, роботов, отозванных токенов и т.д.), на фоне которого проблемы нескольких процентов от нескольких процентов пользователей разглядеть было невозможно.
|
||||
|
||||
#### А какие ваши предложения?
|
||||
|
||||
На самом деле есть три подхода к решению этой ситуации:
|
||||
* отказаться от REST и перейти на чистый RPC. Использовать статус-коды HTTP только для индикации проблем с соответствующим уровнем сетевого стэка. Достаточно двух:
|
||||
* `200 OK` если сервер получил запрос, независимо от результата — ошибки исполнения запроса все равно возвращаются как `200`.
|
||||
* `500 Internal Server Error` если запрос до сервера не дошёл.
|
||||
|
||||
Можно ещё использовать `400 Bad Request` для клиентских ошибок. Это чуть усложняет конструкцию, но позволяет пользоваться ПО и сервисами для организации API Gateway;
|
||||
|
||||
* «и так сойдёт» — ну раз сложилась такая ситуация, ну в ней и жить, только осторожненько, совсем уж явно не нарушая стандарт. Графики строить по кодам; нужно поделить ошибки по типу — используй какой-нибудь экзотический код. Клиенты код ответа игнорируют и смотрят на данные в теле ответа.
|
||||
|
||||
**NB**: некоторые признанные лидеры индустрии умудряются при этом делать и то, и другое: использовать RPC-подход и, одновременно, кучу статус-кодов для каких-то частных проблем (например, `403` и `429`, которые вообще-то явно связаны с бизнес-логикой работы клиентов, а не с самим HTTP). В чисто практическом смысле такой подход работает, хотя и трудно предсказать наперёд, какие проблемы могут притаиться в современной инфраструктуре, где любая «умная» прокси норовит прочитать запрос. Ну и эстетические чувства соответствующие;
|
||||
|
||||
* прибрать бардак. Включая, но не ограничиваясь:
|
||||
* использовать HTTP-коды для проблем, которые можно описать в терминах HTTP (т.е. использовать `406 Unacceptable` при недопустимом значении заголовка `Accept-Language`, например, а не для чего-то ещё);
|
||||
* стандартизировать дополнительные машиночитаемые данные в ответе, предпочтительно в форме заголовков HTTP (потому что чтение заголовков не требует вычитывания и разбора всего тела ответа, так что промежуточные прокси и гейтвеи смогут понять семантику ошибки без дополнительных расходов; а так же их можно логировать) — например, использовать что-то наподобие `X-My-API-Error-Reason` и жестко регламентировать возможные значения;
|
||||
* настроить графики и мониторинги так, чтобы они работали по доп. данным из предыдущего пункта в дополнение к статус-кодам (или вместо них);
|
||||
* убедиться, что клиенты верно трактуют и статус-коды, и дополнительные данные, особенно в случае неизвестных ошибок.
|
||||
|
||||
Выбор за вами, но на всякий случай заметим, что подход \#3 весьма дорог в реализации.
|
@ -1,167 +0,0 @@
|
||||
### Мифология REST
|
||||
|
||||
#### Матчасть
|
||||
|
||||
Мало какая технология в истории IT вызывала столько ожесточённых споров, как REST. Самое удивительное при этом состоит в том, что спорящие стороны, как правило, совершенно не представляют себе предмет спора.
|
||||
|
||||
Начнём с самого начала. В 2000 году один из авторов спецификаций HTTP и URI Рой Филдинг защитил докторскую диссертацию на тему «Архитектурные стили и дизайн архитектуры сетевого программного обеспечения», пятая глава которой была озаглавлена как «Representational State Transfer (REST)». Диссертация доступна [по ссылке](https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm).
|
||||
|
||||
Как нетрудно убедиться, прочитав эту главу, она представляет собой довольно абстрактный обзор распределённой сетевой архитектуры, вообще не привязанной ни к HTTP, ни к URL. Более того, она вовсе не посвящена правилам дизайна API; в этой главе Филдинг методично перечисляет ограничения, с которыми приходится сталкиваться разработчику распределённого сетевого программного обеспечения. Вот они:
|
||||
|
||||
* клиент и сервер не знают внутреннего устройства друг друга (клиент-серверная архитектура);
|
||||
* сессия хранится на клиенте (stateless-дизайн);
|
||||
* данные должны размечаться как кэшируемые или некэшируемые;
|
||||
* интерфейсы взаимодействия должны быть стандартизированы;
|
||||
* сетевые системы являются многослойными, т.е. сервер может быть только прокси к другим серверам;
|
||||
* функциональность клиента может быть расширена через поставку кода с сервера.
|
||||
|
||||
Всё, на этом определение REST заканчивается. Дальше Филдинг конкретизирует некоторые аспекты имплементации систем в указанных ограничениях, но все они точно так же являются совершенно абстрактными. Буквально: «ключевая информационная абстракция в REST — ресурс; любая информация, которой можно дать наименование, может быть ресурсом».
|
||||
|
||||
Ключевой вывод, который следует из определения REST по Филдингу, вообще-то, таков: *любое сетевое ПО в мире соответствует принципам REST*, за очень-очень редкими исключениями.
|
||||
|
||||
В самом деле:
|
||||
* очень сложно представить себе систему, в которой не было бы хоть какого-нибудь стандартизованного интерфейса взаимодействия, иначе её просто невозможно будет разрабатывать;
|
||||
* раз есть интерфейс взаимодействия, значит, под него всегда можно мимикрировать, а значит, требование независимости имплементации клиента и сервера всегда выполнимо;
|
||||
* раз можно сделать альтернативную имплементацию сервера — значит, можно сделать и многослойную архитектуру, поставив дополнительный прокси между клиентом и сервером;
|
||||
* поскольку клиент представляет собой вычислительную машину, он всегда хранит хоть какое-то состояние и кэширует хоть какие-то данные;
|
||||
* наконец, code-on-demand вообще лукавое требование, поскольку всегда можно объявить данные, полученные по сети, «инструкциями» на некотором формальном языке, а код клиента — их интерпретатором.
|
||||
|
||||
Да, конечно, вышеприведённое рассуждение является софизмом, доведением до абсурда. Самое забавное в этом упражнении состоит в том, что мы можем довести его до абсурда и в другую сторону, объявив ограничения REST неисполнимыми. Например, очевидно, что требование code-on-demand противоречит требованию независимости клиента и сервера — клиент должен уметь интерпретировать код с сервера, написанный на вполне конкретном языке. Что касается правила на букву S («stateless»), то систем, в которых сервер *вообще не хранит никакого контекста клиента* в мире вообще практически нет, поскольку ничего полезного для клиента в такой системе сделать нельзя. (Что, кстати, постулируется в соответствующем разделе прямым текстом: «коммуникация … не может получать никаких преимуществ от того, что на сервере хранится какой-то контекст».)
|
||||
|
||||
Наконец, сам Филдинг внёс дополнительную энтропию в вопрос, выпустив в 2008 году [разъяснение](https://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven), что же он имел в виду. В частности, в этой статье утверждается, что:
|
||||
* REST API не должно зависеть от протокола;
|
||||
* разработка REST API должна фокусироваться на описании медиатипов, представляющих ресурсы; при этом клиент вообще ничего про эти медиатипы знать не должен;
|
||||
* в REST API не должно быть фиксированных имён ресурсов и операций над ними, клиент должен извлекать эту информацию из ответов сервера.
|
||||
|
||||
Короче говоря, REST по Филдингу подразумевает, что клиент, получив каким-то образом ссылку на точку входа REST API, далее должен быть в состоянии полностью выстроить взаимодействие с API, не обладая вообще никаким априорным знанием о нём, и уж тем более не должен содержать никакого специально написанного кода для работы с этим API.
|
||||
|
||||
Оставляя за скобками тот факт, что Филдинг весьма вольно истолковал свою же диссертацию, просто отметим, что ни одна существующая система в мире не удовлетворяет описанию REST по Филдингу-2008.
|
||||
|
||||
#### Здравое зерно REST
|
||||
|
||||
Нам неизвестно, почему из всех обзоров абстрактной сетевой архитектуры именно диссертация Филдинга обрела столь широкую популярность; очевидно другое: теория Филдинга, преломившись в умах миллионов программистов (включая самого Филдинга), превратилась в целую инженерную субкультуру. Путём редукции абстракций REST применительно конкретно к протоколу HTTP и стандарту URL родилась химера «RESTful API», [конкретного смысла которой никто не знает](https://restfulapi.net/).
|
||||
|
||||
Хотим ли мы тем самым сказать, что REST является бессмысленной концепцией? Отнюдь нет. Мы только хотели показать, что она допускает чересчур широкую интерпретацию, в чём одновременно кроется и её сила, и её слабость.
|
||||
|
||||
С одной стороны, благодаря многообразию интерпретаций, разработчики API выстроили какое-то размытое, но всё-таки полезное представление о «правильной» архитектуре API. С другой стороны, если бы Филдинг чётко расписал в 2000 году, что же он конкретно имел в виду, вряд ли бы об этой диссертации знало больше пары десятков человек.
|
||||
|
||||
Что же «правильного» в REST-подходе к дизайну API (таком, как он сформировался в коллективном сознании широких масс программистов)? То, что такой дизайн позволяет добиться более эффективного использования времени — времени программистов и компьютеров.
|
||||
|
||||
Если коротко обобщить все сломанные в попытках определить REST копья, то мы получим следующий принцип: *лучше бы ты разрабатывал распределённую систему так, чтобы промежуточные агенты умели читать метаданные запросов и ответов, проходящих через эту систему* (готовая заповедь для RESTful-пастафарианства практически!).
|
||||
|
||||
У протокола HTTP есть очень важное достоинство: он предоставляет стороннему наблюдателю довольно подробную информацию о том, что произошло с запросом и ответом, даже если этот наблюдатель ничего не знает о семантике операции:
|
||||
|
||||
* URL идентифицирует некоего конечного получателя запроса, какую-то единицу адресации;
|
||||
* по статусу ответа можно понять, выполнена ли операция успешно или произошла ошибка; если имеет место быть ошибка — то можно понять, кто виноват (клиент или сервер), и даже в каких-то случаях понять, что же конкретно произошло;
|
||||
* по методу запроса можно понять, является ли операция модифицирующей; если операция модифицирующая, то можно выяснить, является ли она идемпотентной;
|
||||
* по методу запроса и статусу и заголовкам ответа можно понять, кэшируем ли результат операции, и, если да, то какова политика кэширования.
|
||||
|
||||
Немаловажно здесь то, что все эти сведения можно получить, не вычитывая тело ответа целиком — достаточно прочитать служебную информацию из заголовков.
|
||||
|
||||
Почему это *полезно*? Потому что современный стек взаимодействия между клиентом и сервером является (как предсказывал Филдинг) многослойным. Разработчик пишет код поверх какого-то фреймворка, который отправляет запросы; фреймворк базируется на API языка программирования, которое, в свою очередь, обращается к API операционной системы. Далее запрос (возможно, через промежуточные HTTP-прокси) доходит до сервера, который, в свою очередь, тоже представляет собой несколько слоёв абстракции в виде фреймворка, языка программирования и ОС; к тому же, перед конечным сервером, как правило, находится веб-сервер, проксирующий запрос, а зачастую и не один. В современных облачных архитектурах HTTP-запрос, прежде чем дойти до конечного обработчика, пройдёт через несколько абстракций в виде прокси и гейтвеев. Если бы все эти агенты трактовали мета-информацию о запросе одинаково, это позволило бы обрабатывать многие ситуации оптимальнее — тратить меньше ресурсов и писать меньше кода.
|
||||
|
||||
(На самом деле, в отношении многих технических аспектов промежуточные агенты и так позволяют себе разные вольности, не спрашивая разработчиков. Например, свободно менять Accept-Encoding и Content-Length при проксировании запросов.)
|
||||
|
||||
Каждый из аспектов, перечисленных Филдингом в REST-принципах, позволяет лучше организовать работу промежуточного ПО. Ключевым здесь является stateless-принцип: промежуточные прокси могут быть уверены, что метаинформация запроса его однозначно описывает.
|
||||
|
||||
Приведём простой пример. Пусть в нашей системе есть операции получения профиля пользователя и его удаления. Мы можем организовать их разными способами. Например, вот так:
|
||||
|
||||
```
|
||||
// Получение профиля
|
||||
GET /me
|
||||
Cookie: session_id=<идентификатор сессии>
|
||||
// Удаление профиля
|
||||
GET /delete-me
|
||||
Cookie: session_id=<идентификатор сессии>
|
||||
```
|
||||
|
||||
Почему такая система неудачна с точки зрения промежуточного агента?
|
||||
|
||||
1. Сервер не может кэшировать ответы; все /me для него одинаковые, поскольку он не умеет получать уникальный идентификатор пользователя из куки; в том числе промежуточные прокси не могут и заранее наполнить кэш, так как не знают идентификаторов сессий.
|
||||
2. На сервере сложно организовать шардирование, т.е. хранение информации о разных пользователях в разных сегментах сети; для этого опять же потребуется уметь обменивать сессию на идентификатор пользователя.
|
||||
|
||||
Первую проблему можно решить, сделав операции более машиночитаемыми, например, перенеся идентификатор сессии в URL:
|
||||
|
||||
```
|
||||
// Получение профиля
|
||||
GET /me?session_id=<идентификатор сессии>
|
||||
// Удаление профиля
|
||||
GET /delete-me?session_id=<идентификатор сессии>
|
||||
```
|
||||
|
||||
Шардирование всё ещё нельзя организовать, но теперь сервер может иметь кэш (в нём будут появляться дубликаты для разных сессий одного и того же пользователя, но хотя бы ответить из кэша неправильно будет невозможно), но возникнут другие проблемы:
|
||||
|
||||
1. URL обращения теперь нельзя сохранять в логах, так как он содержит секретную информацию; более того, появится риск утечки данных со страниц пользователей, т.к. одного URL теперь достаточно для получения данных.
|
||||
2. Ссылку на удаление пользователя клиент обязан держать в секрете. Если её, например, отправить в мессенджер, то робот-префетчер мессенджера удалит профиль пользователя.
|
||||
|
||||
Как же сделать эти операции правильно с точки зрения REST? Вот так:
|
||||
|
||||
```
|
||||
// Получение профиля
|
||||
GET /user/{user_id}
|
||||
Authorization: Bearer <token>
|
||||
// Удаление профиля
|
||||
DELETE /user/{user_id}
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Теперь URL запроса в точности идентифицирует ресурс, к которому обращаются, поэтому можно организовать кэш и даже заранее наполнить его; можно организовать маршрутизацию запроса в зависимости от идентификатора пользователя, т.е. появляется возможность шардирования. Префетчер мессенджера не пройдёт по DELETE-ссылке; а если он это и сделает, то без заголовка Authorization операция выполнена не будет.
|
||||
|
||||
Наконец, неочевидная польза такого решения заключается в следующем: промежуточный сервер-гейтвей, обрабатывающий запрос, может проверить заголовок Authorization и переслать запрос далее без него (желательно, конечно, по безопасному соединению или хотя бы подписав запрос). И, в отличие от схемы с идентификатором сессии, мы всё ёщё можем свободно организовывать кэширование данных в любых промежуточных узлах. Более того, *агент может легко модифицировать операцию*: например, для авторизованных пользователей пересылать запрос дальше как есть, а неавторизованным показывать публичный профиль, пересылая запрос на специальный URL, ну, скажем, `GET /user/{user_id}/public-profile` — для этого достаточно всего лишь дописать `/public-profile` к URL, не изменяя все остальные части запроса. Для современных микросервисных архитектур *возможность корректно и дёшево модифицировать запрос при маршрутизации является самым ценным преимуществом в концепции REST*.
|
||||
|
||||
Шагнём ещё чуть вперёд. Предположим, что гейтвей спроксировал запрос `DELETE /user/{user_id}` в нужный микросервис и не дождался ответа. Какие дальше возможны варианты?
|
||||
|
||||
**Вариант 1**. Можно сгенерировать HTML-страницу с ошибкой, вернуть её веб-серверу, чтобы тот вернул её клиенту, чтобы клиент показал её пользователю, и дождаться реакции пользователя. Мы прогнали через систему сколько-то байтов и переложили решение проблемы на конечного потребителя. Попутно заметим, что при этом на уровне логов веб-сервера ошибка неотличима от успеха — и там, и там какой-то немашиночитаемый ответ со статусом 200 — и, если действительно пропала сетевая связность между гейтвеем и микросервисом, об этом никто не узнает.
|
||||
|
||||
**Вариант 2**. Можно вернуть веб-серверу подходящую HTTP-ошибку, например, 504, чтобы тот вернул её клиенту, чтобы клиент обработал ошибку и, сообразно своей логике, что-то предпринял по этому поводу, например, отправил запрос повторно или показал ошибку пользователю. Мы прокачали чуть меньше байтов, попутно залогировав исключительную ситуацию, но теперь переложили решение на разработчика клиента — это ему надлежит не забыть написать код, который умеет работать с ошибкой 504.
|
||||
|
||||
**Вариант 3**. Гейтвей, зная, что метод `DELETE` идемпотентен, может сам повторить запрос; если исполнить запрос не получилось — проследовать по варианту 1 или 2. В этой ситуации мы переложили ответственность за решение на архитектора системы, который должен спроектировать политику перезапросов внутри неё (и гарантировать, что все операции за `DELETE` действительно идемпотенты), но мы получили важное свойство: система стала самовосстанавливающейся. Теперь она может «сама» побороть какие-то ситуации, которые раньше вызывали исключения.
|
||||
|
||||
Внимательный читатель может заметить, что вариант (3) при этом является наиболее технически сложным из всех, поскольку включает в себя варианты (1) и (2): для правильной работы всей схемы разработчику клиента всё равно нужно написать код работы с ошибкой. Это, однако, не так; есть очень большая разница в написании кода для системы (3) по сравнению с (1) и (2): разработчику клиента *не надо знать* как устроена политика перезапросов сервера. Он может быть уверен, что сервер уже сам выполнил необходимые действия, и нет никакого смысла немедленно повторять запрос. Все серверные методы с этой точки зрения для клиента начинают выглядеть одинаково, а значит, эту функциональность (ожидание перезапроса, таймауты) можно передать на уровень фреймворка.
|
||||
|
||||
Важно, что для разработчика клиента при правильно работающих фреймворках (клиентском и серверном) пропадают ситуации неопределённости: ему не надо предусматривать какую-то логику «восстановления», когда запрос вроде бы не прошёл, но его ещё можно попытаться исправить. Клиент-серверное взаимодействие становится бинарным — или успех, или ошибка, а все пограничные градации обрабатываются другим кодом.
|
||||
|
||||
В итоге, более сложная архитектура оказалась разделена по уровням ответственности, и каждый разработчик занимается своим делом. Разработчик гейтвея гарантирует наиболее оптимальный роутинг внутри дата-центра, разработчик фреймворка предоставляет функциональность по реализации политики таймаутов и перезапросов, а разработчик клиента пишет *бизнес-логику* обработки ошибок, а не код восстановления из низкоуровневых состояний неопределённости.
|
||||
|
||||
**Разумеется** все подобные оптимизации можно выполнить и без опоры на стандартную номенклатуру методов / статусов / заголовков HTTP, или даже вовсе поверх другого протокола. Достаточно разработать одинаковый формат данных, содержащий нужную мета-информацию, и научить промежуточные агенты и фреймворки его читать. В общем-то, именно это Филдинг и утверждает в своей диссертации. Но, конечно, очень желательно, чтобы этот код уже был кем-то написан за нас.
|
||||
|
||||
Заметим, что многочисленные советы «как правильно разрабатывать REST API», которые можно найти в интернете, никак не связаны с изложенными выше принципами, а зачастую и противоречат им:
|
||||
|
||||
1. «Не используйте в URL глаголы, только существительные» — этот совет является всего лишь костылём для того, чтобы добиться правильной организации мета-информации об операции. В контексте работы с URL важно добиться двух моментов:
|
||||
* чтобы URL был ключом кэширования для кэшируемых операций и ключом идемпотентности для идемпотентных;
|
||||
* чтобы при маршрутизации запроса в многослойной системе можно было легко понять, куда будет маршрутизироваться эта операция по её URL — как машине, так и человеку.
|
||||
|
||||
2. «Используйте HTTP-глаголы для описания действий того, что происходит с ресурсом» — это правило попросту ставит телегу впереди лошади. Глагол указывает всем промежуточным агентам, является ли операция (не)модифицирующей, (не)кэшируемой, (не)идемпотентной и есть ли у запроса тело; вместо того, чтобы выбирать строго по этим четырём критериям, предлагается воспользоваться какой-то мнемоникой — если глагол подходит к смыслу операции, то и ок. Это в некоторых случаях просто опасно: вам может показаться, что `DELETE /list?element_index=3` прекрасно описывает ваше намерение удалить третий элемент списка, но т.к. эта операция неидемпотентна, использовать метод `DELETE` здесь нельзя;
|
||||
|
||||
3. «Используйте `POST` для создания сущностей, `GET` для доступа к ним, `PUT` для полной перезаписи, `PATCH` для частичной и `DELETE` для удаления» — вновь мнемоника, позволяющая «на пальцах» прикинуть, какие побочные эффекты возможны у какого из методов. Если попытаться разобраться в вопросе глубже, то получится, что вообще-то этот совет находится где-то между «бесполезен» и «вреден»:
|
||||
* использовать метод `GET` в API имеет смысл тогда и только тогда, когда вы можете указать заголовки кэширования; если выставить `Cache-Control` в `no-cache` — то получится просто неявный `POST`; если их не указать совсем, то какой-то промежуточный агент может взять и додумать их за вас;
|
||||
* создание сущностей желательно делать идемпотентным, в идеале — за `PUT` (например, через [схему с драфтами](#chapter-11-paragraph-13));
|
||||
* частичная перезапись через `PATCH` опасная и двусмысленная операция, лучше её [декомпозировать через более простые `PUT`](#chapter-11-paragraph-12);
|
||||
* наконец, в современных системах сущности очень редко удаляются — скорее архивируются или помечаются скрытыми, так что и здесь `PUT /archive?entity_id` будет уместнее.
|
||||
|
||||
4. «Не используйте вложенные ресурсы» — это правило просто отражает тот факт, что отношения между сущностями имеют тенденцию меняться, и строгие иерархии перестают быть строгими.
|
||||
|
||||
5. «Используйте множественное число для сущностей», «приписывайте слэш в конце URL» и тому подобные советы по стилистике кода, не имеющие никакого отношения к REST.
|
||||
|
||||
Осмелимся в конце этого раздела сформулировать четыре правила, которые действительно позволят вам написать хорошее REST API:
|
||||
|
||||
1. Соблюдайте стандарт HTTP, *особенно* в части семантики методов, статусов и заголовков.
|
||||
2. Используйте URL как ключ кэша и ключ идемпотентности.
|
||||
3. Проектируйте архитектуру так, чтобы для организации маршрутизации запросов внутри многослойной системы было достаточно манипулировать частями URL (хост, путь, query-параметры), статусами и заголовками.
|
||||
4. Рассматривайте сигнатуры вызовов HTTP-методов вашего API как код, и применяйте к нему те же стилистические правила, что и к коду: сигнатуры должны быть семантичными, консистентными и читабельными.
|
||||
|
||||
#### Преимущества и недостатки REST
|
||||
|
||||
Главное преимущество, которое вам предоставляет REST — возможность положиться на то, что промежуточные агенты, от клиентских фреймворков до API-гейтвеев, умеют читать метаданные запроса и выполнять какие-то действия с их использованием — настраивать политику перезапросов и таймауты, логировать, кэшировать, шардировать, проксировать и так далее — без необходимости писать какой-то дополнительный код. Немаловажно уточнить, что, если вы этими преимуществами не пользуетесь, никакой REST вам не нужен.
|
||||
|
||||
Главным недостатком REST является то, что промежуточные агенты, от клиентских фреймворков до API-гейтвеев, умеют читать метаданные запроса и выполнять какие-то действия с их использованием — настраивать политику перезапросов и таймауты, логировать, кэшировать, шардировать, проксировать и так далее — даже если вы их об этом не просили. Более того, так как стандарты HTTP являются сложными, концепция REST — непонятной, а разработчики программного обеспечения — неидеальными, то промежуточные агенты могут трактовать метаданные запроса *неправильно*. Особенно это касается каких-то экзотических и сложных в имплементации стандартов.
|
||||
|
||||
Разработка распределённых систем в парадигме REST — это всегда некоторый торг: какую функциональность вы готовы отдать на откуп чужому коду, а какую — нет. Увы, нащупывать баланс приходится методом проб и ошибок.
|
||||
|
||||
#### О метапрограммировании и REST по Филдингу
|
||||
|
||||
Отдельно всё-таки выскажемся о трактовке REST по Филдингу-2008, которая, на самом деле, уходит корнями в распространённую концепцию [HATEOAS](https://en.wikipedia.org/wiki/HATEOAS). С одной стороны, она является довольно логичным продолжением принципов, изложенных выше: если машиночитаемыми будут не только метаданные текущей исполняемой операции, но и всех возможных операций над ресурсом, это, конечно, позволит построить гораздо более функциональные сетевые агенты. Вообще сама идея метапрограммирования, когда клиент является настолько сложной вычислительной машиной, что способен расширять сам себя без необходимости привлечь разработчика, который прочитает документацию API и напишет код работы с ним, конечно, выглядит весьма привлекательной для любого технократа.
|
||||
|
||||
Недостатком этой идеи является тот факт, что клиент будет расширять сам себя без привлечения разработчика, который прочитает документацию API и напишет код работы с ним. Возможно, в идеальном мире так работает; в реальном — нет. Любое большое API неидеально, в нём всегда есть концепции, для понимания которых (пока что) требуется живой человек. А поскольку, повторимся, API работает мультипликатором и ваших возможностей, и ваших ошибок, автоматизированное метапрограммирование поверх API чревато очень-очень дорогими ошибками.
|
||||
|
||||
Пока сильный ИИ не разработан, мы всё-таки настаиваем на том, что код работы с API должен писать живой человек, который опирается на подробную документацию, а не догадки о смысле гиперссылок в ответе сервера.
|
@ -1,7 +0,0 @@
|
||||
Паттерны SDK
|
||||
* MV* (пример?)
|
||||
* разделяемые ресурсы, локи
|
||||
* наследование опций и вычисляемые значения
|
||||
* переопределение интерфейсов
|
||||
* динамическое связывание (пример с viewCustomBuilder)
|
||||
* селекторы
|
Reference in New Issue
Block a user