mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-03-17 20:42:26 +02:00
Черновик главы о проблемах сильной связанности
This commit is contained in:
parent
d19dd1d044
commit
c419f4fc6d
@ -1,4 +1,4 @@
|
||||
### API: вариационный анализ
|
||||
### Сильная связность и сопутствующие проблемы
|
||||
|
||||
В предыдущих разделах мы старались приводить теоретические правила и принципы, и иллюстрировать их на практических примерах. Однако понимание принципов проектирования API, устойчивого к изменениям, как ничто другое требует прежде всего практики. Знание о том, куда стоит «постелить соломку» — оно во многом «сын ошибок трудных». Нельзя предусмотреть всего — но можно выработать необходимый уровень технической интуиции.
|
||||
|
||||
@ -12,13 +12,183 @@
|
||||
|
||||
**NB**: в рассматриваемых нами примерах мы будем выстраивать интерфейсы так, чтобы связывание разных сущностей происходило динамически в реальном времени; на практике такие интеграции будут делаться на стороне сервера путём написания ad hoc кода и формирования конкретных договорённостей с конкретным клиентом, однако мы для целей обучения специально будем идти более сложным и абстрактным путём. Динамическое связывание в реальном времени применимо скорее к сложным программным конструктам типа API операционных систем или встраиваемых библиотек; приводить обучающие примеры на основе систем подобной сложности было бы, однако, чересчур затруднительно.
|
||||
|
||||
#### Шаг 1. Собственные рецепты
|
||||
|
||||
Предположим, что мы решили предоставить партнёрам возможность готовить кофе по их собственным рецептам. Какова мотивация предоставления такой функциональности?
|
||||
|
||||
* возможно, партнерская сеть кофеен хочет предложить клиентам особенные «брендовые» напитки;
|
||||
* возможно, партнер хочет построить полностью своё приложение со своим ассортиментом на нашей платформе.
|
||||
|
||||
Разница между этими вариантами в том, что в первом случае брендированные напитки должны «подмешиваться» в общую поисковую выдачу; во втором случае поиск осуществляется только по рецептам партнера.
|
||||
В обоих вариантах нам необходимо начать с рецепта. Какие данные нам необходимы для того, чтобы партнёр мог добавить в систему новый рецепт? Вспомним, какие контексты связывает сущность «рецепт»: эта сущность нам нужна, чтобы связать выбор пользователя с правилами приготовления напитка. На первый взгляд может показаться, что именно так нам и следует описать сущность рецепта:
|
||||
|
||||
Что касается реализации
|
||||
```
|
||||
// Добавляет новый рецепт
|
||||
POST /v1/recipes
|
||||
{
|
||||
"id",
|
||||
"product_properties": {
|
||||
"name",
|
||||
"description",
|
||||
"default_value"
|
||||
// Прочие параметры, описывающие
|
||||
// напиток для пользователя
|
||||
…
|
||||
},
|
||||
"execution_properties": {
|
||||
// Идентификатор программы
|
||||
"program_id",
|
||||
// Параметры исполнения программы
|
||||
"parameters"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
На первый взгляд, вполне разумный и простой интерфейс, который явно декомпозируется согласно уровням абстракции. Попробуем теперь представить, что произойдёт в будущем — как дальнейшее развитие функциональности повлияет на этот интерфейс.
|
||||
|
||||
Первая проблема очевидна тем, кто внимательно читал [главу 11](#chapter-11-paragraph-20): продуктовые данные должны быть локализованы. Это приведёт нас к первому изменению:
|
||||
|
||||
```
|
||||
"product_properties": [
|
||||
{
|
||||
"language_code": "en",
|
||||
"country_code": "US",
|
||||
"name",
|
||||
"description"
|
||||
},
|
||||
// Другие языки и страны
|
||||
…
|
||||
]
|
||||
```
|
||||
|
||||
И здесь возникает первый большой вопрос — а что делать с `default_volume`? С одной стороны, это объективная величина, выраженная в стандартизированных единицах измерения, и она используется для запуска программы на исполнение. С другой стороны, для таких стран, как США, мы будем обязаны указать не объём в виде «300 мл», а в виде «10 унций». Мы можем предложить одно из двух решений:
|
||||
|
||||
* либо партнёр указывает только числовой объём, а числовые представления мы сделаем сами;
|
||||
* либо партнёр указывает и объём, и все его локализованные представления.
|
||||
|
||||
Первый вариант плох тем, что партнёр с помощью нашего API может как раз захотеть разработать сервис для какой-то новой страны или языка — и не сможет, пока локализация для этого региона не будет поддержана в самом API. Второй вариант плох тем, что сработает только для заранее заданных объёмов — заказать кофе произвольного объёма нельзя. И вот практически первым же действием мы сами загоняем себя в тупик.
|
||||
|
||||
Проблемами с локализацией, однако, недостатки дизайна этого API не заканчиваются. Следует задать себе вопрос — а *зачем* вообще здесь нужны `name` и `description`? Ведь это по сути просто строки, не имеющие никакой определённой семантики. На первый взгляд — чтобы возвращать их обратно из метода `/v1/search`, но ведь это тоже не ответ: а зачем эти строки возвращаются из `search`?
|
||||
|
||||
Корректный ответ — потому что существует некоторое представление, UI для выбора типа напитка. По-видимому, `name` и `description` — это просто два описания напитка, короткое (для показа в общем прейскуранте) и длинное (для показа расширенной информации о продукте). Получается, что мы устанавливаем требования на API исходя из вполне конкретного дизайна. Но что, если партнёр сам делает UI для своего приложения? Мало того, что ему могут быть не нужны два описания, так мы по сути ещё и вводим его в заблуждение: `name` — это не «какое-то» название, оно предполагает некоторые ограничения. Во-первых, у него есть некоторая рекомендованная длина, оптимальная для конкретного UI; во-вторых, оно должно консистентно выглядеть в одном списке с другими напитками. В самом деле, будет очень странно смотреться, если среди «Капучино», «Лунго» и «Латте» вдруг появится «Бодрящая свежесть» или «Наш самый качественный кофе».
|
||||
|
||||
Эта проблема разворачивается и в другую сторону — UI (наш или партнера) обязательно будет развиваться, в нём будут появляться новые элементы (картинка для кофе, его пищевая ценность, информация об аллергенах и так далее). `product_properties` со временем превратится в свалку из большого количества необязательных полей, и выяснить, задание каких из них приведёт к каким эффектам в каком приложении можно будет только методом проб и ошибок.
|
||||
|
||||
Проблемы, с которыми мы столкнулись — это проблемы *сильной связности*. Каждый раз, предлагая интерфейс, подобный вышеприведённому, мы фактически описываем имплементацию одной сущности (рецепта) через имплементации других (визуального макета, правил локализации). Этот подход противоречит самой проектирования API «сверху вниз», поскольку **низкоуровневые сущности не должны определять высокоуровневые**. Как бы парадоксально это ни звучало, обратное утверждение тоже верно: высокоуровневые сущности тоже не должны определять низкоуровневые. Это попросту не их ответственность.
|
||||
|
||||
#### Правило контекстов
|
||||
|
||||
Выход из этого логического лабиринта таков: высокоуровневые сущности должны *определять контекст*, который другие объекты будут интерпретировать. Чтобы спроектировать добавление нового рецепта нам нужно не формат данных подобрать — нам нужно понять, какие (возможно, неявные, т.е. не представленные в виде API) контексты существуют в нашей предметной области.
|
||||
|
||||
Как уже понятно, существует контекст локализации. Есть какой-то набор языков и регионов, которые мы поддерживаем в нашем API, и есть требования — что конкретно необходимо предоставить партнёру, чтобы API заработало на новом языке в новом регионе. Конкретно в случае объёма кофе где-то в недрах нашего API есть функция форматирования строк для отображения объёма напитка:
|
||||
|
||||
```
|
||||
l10n.volume.format(value, language_code, country_code)
|
||||
// l10n.formatVolume('300ml', 'en', 'UK') → '300 ml'
|
||||
// l10n.formatVolume('300ml', 'en', 'US') → '10 fl oz'
|
||||
```
|
||||
|
||||
Чтобы наше API корректно заработало с новым языком или регионом, партнер должен или задать эту функцию, или указать, какую из существующих локализаций необходимо использовать. Это можно сделать, например, так:
|
||||
|
||||
```
|
||||
// Добавляем общее правило форматирования
|
||||
// для русского языка
|
||||
PUT /formatters/volume/ru
|
||||
{
|
||||
"template": "{volume} мл"
|
||||
}
|
||||
// Добавляем частное правило форматирования
|
||||
// для русского языка в регионе «США»
|
||||
PUT /formatters/volume/ru/US
|
||||
{
|
||||
// В США требуется сначала пересчитать
|
||||
// объём, потом добавить постфикс
|
||||
"value_preparation": {
|
||||
"action": "divide",
|
||||
"divisor": 30
|
||||
},
|
||||
"template": "{volume} ун."
|
||||
}
|
||||
```
|
||||
|
||||
**NB**: мы, разумеется, в курсе, что таким простым форматом локализации единиц измерения в реальной жизни обойтись невозможно, и необходимо либо положиться на существующие библиотеки, либо разработать сложный формат описания (учитывающий, например, падежи слов и необходимую точность округления), либо принимать правила форматирования в императивном виде (т.е. в виде кода функции). Пример выше приведён исключительно в учебных целях.
|
||||
|
||||
Вернёмся теперь к проблеме `name` и `description`. Для того, чтобы снизить связность в этом аспекте, нужно прежде всего формализовать (возможно, для нас самих, необязательно во внешнем API) понятие «макета». Мы требуем `name` и `description` не просто так в вакууме, а чтобы представить их во вполне конкретном UI. Этому конкретному UI можно дать идентификатор или значимое имя.
|
||||
|
||||
```
|
||||
GET /v1/layouts/{layout_id}
|
||||
{
|
||||
"id",
|
||||
// Макетов вполне возможно будет много разных,
|
||||
// поэтому имеет смысл сразу заложить
|
||||
// расширяемоесть
|
||||
"kind": "recipe_search",
|
||||
// Описываем каждое свойство рецепта,
|
||||
// которое должно быть задано для
|
||||
// корректной работы макета
|
||||
"properties": [{
|
||||
// Раз уж мы договорились, что `name`
|
||||
// на самом деле нужен как заголовок
|
||||
// в списке результатов поиска —
|
||||
// разумнее его так и назвать `seach_title`
|
||||
"field": "search_title",
|
||||
"view": {
|
||||
// Машиночитаемое описание того,
|
||||
// как будет показано поле
|
||||
"min_length": "5em",
|
||||
"max_length": "20em",
|
||||
"overflow": "ellipsis"
|
||||
}
|
||||
}, …],
|
||||
// Какие поля обязательны
|
||||
"required": ["search_title", "search_description"]
|
||||
}
|
||||
```
|
||||
|
||||
Таким образом, партнёр сможет сам решить, какой вариант ему предпочтителен. Можно задать необходимые поля для стандартного макета:
|
||||
```
|
||||
PUT /v1/recipes/{id}/properties
|
||||
{
|
||||
"search_title", "search_description"
|
||||
}
|
||||
```
|
||||
|
||||
Либо создать свой макет и задавать нужные для него поля:
|
||||
|
||||
```
|
||||
POST /v1/layouts
|
||||
{
|
||||
"properties"
|
||||
}
|
||||
→
|
||||
{ "id", "properties" }
|
||||
```
|
||||
|
||||
В конце концов, партнёр может отрисовывать UI самостоятельно и вообще не пользоваться этой техникой, не задавая ни макеты, ни поля.
|
||||
|
||||
Ту же самую технику — выделение отдельной сущности, которая занимается сопоставлением рецепта с его свойствами для нижележащих систем — мы можем использовать и для `execution_properties`, тем самым позволив партнеру контролировать ещё и то, каким образом рецепт связывается с программами исполнения. Тогда наш интерфейс получит вот такой вид:
|
||||
|
||||
```
|
||||
POST /v1/recipes
|
||||
{ "id" }
|
||||
→
|
||||
{ "id" }
|
||||
```
|
||||
|
||||
Этот вывод может показаться совершенно контринтуитивным, однако отсутствие полей у сущности «рецепт» говорит нам только о том, что сама по себе она не несёт никакой семантики и служит просто способом указания контекста привязки других сущностей. В реальном мире следовало бы, пожалуй, собрать эндпойнт-строитель, который может создавать сразу все нужные контексты одним запросом. Разработку такого API мы оставим читателю.
|
||||
|
||||
Заметим, что передача идентификатора вновь создаваемой сущности клиентом — не лучший паттерн. Но раз уж мы с самого начала решили, что идентификаторы рецептов — не просто случайные наборы символов, а значимые строки, то нам теперь придётся с этим как-то жить. Очевидно, в такой ситуации мы рискуем многочисленными коллизиями между названиями рецептов разных партнёров, поэтому операцию, на самом деле, следует модифицировать: либо для партнерских рецептов всегда пользоваться парой идентификаторов (партнера и рецепта), либо ввести составные идентификаторы, как мы ранее рекомендовали в [главе 11](#chapter-11-paragraph-8).
|
||||
|
||||
```
|
||||
POST /v1/recipes/custom
|
||||
{
|
||||
// Первая часть идентификатора:
|
||||
// например, в виде идентификатора клиента
|
||||
"namespace": "my-coffee-company",
|
||||
// Вторая часть идентификатора
|
||||
"id_component": "lungo-customato"
|
||||
}
|
||||
→
|
||||
{
|
||||
"id": "my-coffee-company:lungo-customato"
|
||||
}
|
||||
```
|
||||
|
||||
Заметим, что в таком формате мы сразу закладываем важное допущение: различные партнёры могут иметь как полностью изолированные неймспейсы, так и разделять их. Более того, мы можем ввести специальные неймспейсы типа "common", которые позволят публиковать новые рецепты для всех. (Это, кстати говоря, хорошо ещё и тем, что такое API мы сможем использовать для организации нашей собственной панели управления контентом.)
|
Loading…
x
Reference in New Issue
Block a user