1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-03-29 20:51:01 +02:00

SDKs: code generation

This commit is contained in:
Sergey Konstantinov 2023-07-08 20:33:06 +03:00
parent a85c54e535
commit 66858e58b1
13 changed files with 451 additions and 244 deletions

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -122,8 +122,8 @@
<li>
<h4><a href="API.en.html#section-6">[Work in Progress] Section V. SDKs & UI Libraries</a></h4>
<ul>
<li><a href="API.en.html#sdks-toc">Chapter 41. On the Content of This Section</a></li>
<li><a href="API.en.html#chapter-42">Chapter 42. The SDK: Problems and Solutions</a></li>
<li><a href="API.en.html#sdk-toc">Chapter 41. On the Content of This Section</a></li>
<li><a href="API.en.html#sdk-problems-solutions">Chapter 42. SDKs: Problems and Solutions</a></li>
<li><a href="API.en.html#chapter-43">Chapter 43. The Code Generation Pattern</a></li>
<li><a href="API.en.html#chapter-44">Chapter 44. The UI Components</a></li>
<li><a href="API.en.html#chapter-45">Chapter 45. Decomposing UI Components</a></li>

View File

@ -122,8 +122,8 @@
<li>
<h4><a href="API.ru.html#section-6">[В разработке] Раздел V. SDK и UI</a></h4>
<ul>
<li><a href="API.ru.html#sdks-toc">Глава 41. О содержании раздела</a></li>
<li><a href="API.ru.html#chapter-42">Глава 42. SDK: проблемы и решения</a></li>
<li><a href="API.ru.html#sdk-toc">Глава 41. О содержании раздела</a></li>
<li><a href="API.ru.html#sdk-problems-solutions">Глава 42. SDK: проблемы и решения</a></li>
<li><a href="API.ru.html#chapter-43">Глава 43. Кодогенерация</a></li>
<li><a href="API.ru.html#chapter-44">Глава 44. UI-компоненты</a></li>
<li><a href="API.ru.html#chapter-45">Глава 45. Декомпозиция UI-компонентов. MV*-подходы</a></li>

View File

@ -192,7 +192,22 @@ However, there are also non-trivial problems we face while developing an SDK for
As we can see, the simple operation “try to renew an offer if needed” results in a bulky piece of code that is simultaneously error-prone and totally unnecessary as it doesn't introduce any new functionality visible to end users. For an application developer, it would be more convenient if this error (“offer expired”) *were not exposed in the SDK*, i.e., the SDK automatically renewed offers if needed.
Such situations also occur when working with APIs featuring eventual consistency or optimistic concurrency control — generally speaking, with any API where background errors are expected (which is rather a norm of life for client-server APIs). For frontend developers, writing code to implement policies like “read your writes” (i.e., passing tokens of the last known operation to subsequent queries) is essentially a waste of time.
5. Finally, one more important function that a customized SDK might fulfill is isolating the low-level API and changing the versioning paradigm. It is possible to fully conceal the underlying functionality (i.e., developers won't have direct access to the API) and ensure a certain freedom of working with the API inside the SDK up to seamlessly switching a major version. This approach for sure provides API vendors with more control over how partners' applications work. However, it requires investing more in developing the SDK and, more importantly, properly designing the SDK so that developers won't need to call the API directly due to the lack of access to some functionality or the inconvenience of working with the SDK. Moreover, the SDK itself should be robust enough to be able to handle the transition to a new major version of the API.
To summarize the above, a properly designed SDK, apart from maintaining consistency with the platform guidelines and providing “syntactic sugar,” serves two important purposes:
To summarize the above, a properly designed SDK, apart from maintaining consistency with the platform guidelines and providing “syntactic sugar,” serves three important purposes:
* Lowering the number of mistakes in client code by implementing helpers that cover unobvious and poorly formalizable aspects of working with the API
* Relieving client developers of the duty to write code that is absolutely irrelevant to the tasks they are solving.
* Relieving client developers of the duty to write code that is absolutely irrelevant to the tasks they are solving
* Giving an API vendor more control over integrations.
#### Code Generation
As we have seen, the list of tasks that an SDK developer faces (if they aim to create a quality product, of course) is quite considerable. Given that every target platform requires a separate SDK, it is not surprising that many API vendors seek to replace manual labor with automation.
One of the most potent approaches to such automation is code generation. It involves developing a technology that allows generating the SDK code in the target programming language for the target platform based on the API specification. Many modern data interchange protocols (gRPC, for instance) are shipped with generators of such ready-to-use clients in different programming languages. For other technologies (OpenAPI/Swagger, for instance) generators are being developed by the community.
Code generation allows for solving trivial problems such as adapting code style, (de)serializing complex types, etc. — in other words, resolving issues that are bound to the specifics of the platform, not the high-level business logic. The SDK developers can relatively inexpensively complement this machine “translation” with convenient helpers: realize automated repeating idempotent requests (with some retry time management policy), caching results, storing persistent data (such as authorization tokens) in the system storage, etc.
Such a generated SDK is usually referred to as a “client to an API.” The convenience of usage and the functional capabilities of code generation are so formidable that many API vendors restrict themselves to this technology, only providing their SDKs as generated clients.
However, for the aforementioned reasons, higher-level problems (such as receiving callbacks, dealing with business logic-bound errors, etc.) cannot be solved with code generation without writing some arbitrary code for the specific API. In the case of complex APIs with a non-trivial workcycle it is highly desirable that an SDK also solves high-level problems. Otherwise, an API vendor will end up with a bunch of applications using the API and making all the same “rookie mistakes.” This is, of course, not a reason to fully abolish code generation as it's quite convenient to use a generated client as a basis for developing a high-level SDK.

View File

@ -198,6 +198,21 @@
Аналогичные ситуации возникают и в случае нестрого-консистентных API или оптимистичного управления параллелизмом — и вообще в любом API, в котором фон ошибок является ожидаемым (что в случае распределённых клиент-серверных API является нормой жизни). Для разработчика приложения написание кода, имплементирующего политики типа «read your writes» (т.е. передачу токенов последней известной операции в последующие запросы) — попросту напрасная трата времени.
Суммируя написанное выше, хорошо спроектированный SDK служит, помимо поддержания консистентности платформе и предоставления «синтаксического сахара», двум важным целям:
5. Наконец, ещё одна важная функция, которая может быть доверена SDK — это изоляция нижележащего API и смена парадигмы версионирования. Доступ к функциональности API может быть скрыт (т.е. разработчики не будут иметь доступ к низкоуровневой работой с API), тем самым обеспечивая определённую свободу работы с API изнутри SDK, вплоть до бесшовного перехода на новые мажорные версии API. Этот подход, несомненно, предоставляет вендору API намного больше контроля над приложениями клиентов, но требует и намного больше ресурсов на разработку, и, что важнее, грамотного проектирования SDK — такого, чтобы у разработчиков не было необходимости обращаться к API напрямую в обход SDK по причине отсутствия в нём необходимых функций или их плохой реализации, и при этом SDK могу пережить смену мажорной версии низкоуровневого API.
Суммируя написанное выше, хорошо спроектированный SDK служит, помимо поддержания консистентности платформе и предоставления «синтаксического сахара», трём важным целям:
* снижение количества ошибок в клиентском коде путём имплементации хелперов, покрывающих неочевидные и слабоформализуемые аспекты работы с API;
* избавление клиентских разработчиков от необходимости писать код, который им совершенно не нужен.
* избавление клиентских разработчиков от необходимости писать код, который им совершенно не нужен;
* предоставление разработчику API большего контроля над интеграциями.
#### Кодогенерация
Как мы убедились, список задач, стоящих перед разработчиком SDK (если, конечно, его целью является качественный продукт) — очень и очень значительный. Учитывая, что под каждую целевую платформу необходим отдельный SDK, неудивительно, что многие вендоры API стремятся полностью или частично заменить ручной труд машинным.
Одно из основных направлений такой автоматизации — кодогенерация, то есть разработка технологии, которая позволяет по спецификации API сгенерировать готовый код SDK на целевом языке программирования для целевой платформы. Многие современные стандарты обмена данными (в частности, gRPC) поставляются в комплекте с генераторами готовых клиентов на различных языках; к другим технологиям (в частности, OpenAPI/Swagger) такие генераторы пишутся энтузиастами.
Генерация кода позволяет решить типовые проблемы: стиль кодирования, обработка исключений, (де)сериализация сложных типов — словом все те задачи, которые зависят не от особенностей высокоуровневой бизнес-логики, а от конвенций конкретной платформы. Относительно недорого разработчик API может дополнить такой автоматизированный «перевод» правильными настройками используемых системных средств: обеспечить автоматические перезапросы для идемпотентных эндпойнтов (с реализацией какой-то политики), кэширование результатов, сохранение данных (например, токенов авторизации) в системном хранилище и т.д. Такой сгенерированный SDK часто называют термином «клиент к API».
Удобство использования и функциональные возможности кодогенерации столь привлекательны, что многие вендоры API только ей и ограничиваются, предоставляя свои SDK в виде сгенерированных клиентов.
Как мы, однако, видим из написанного выше, проблемы более высокого порядка — получение серверных событий, обработка ошибок в бизнес-логике и т.п. — никак не может быть покрыта кодогенерацией, во всяком случае — стандартным модулем без его доработки применительно к конкретному API. В случае нетривиальных API со сложным основным циклом работы очень желательно, чтобы SDK решал также и высокоуровневые проблемы, иначе вы просто получите множество разработанных поверх API приложений, раз за разом повторяющие одни и те же «детские ошибки». Тем не менее, это не повод отказываться от кодогенерации полностью — её можно использовать как базис, на котором будет разработан высокоуровневый SDK.

View File

@ -1,212 +0,0 @@
### SDK: проблемы и решения
Первый вопрос, который мы должны себе задать при разработке SDK (напомним, так мы будем называть нативную клиентскую библиотеку, предоставляющую доступ к technology-agnostic клиент-серверному API) — почему вообще такое явление как SDK существует. Иными словами, почему использование обёртки для фронтенд-разработчика является более удобным, нежели работа с нижележащим API напрямую.
Некоторые проблемы лежат на поверхности:
1. Протоколы клиент-серверных API, как правило, разрабатываются так, что не зависят от конкретного языка программирования и, таким образом, без дополнительных действий полученные из API данные будут представлены в не самом удобном формате. Например в JSON нет типа данных «дата и время», и его приходится передавать в виде строки; или, скажем, поддержка (де)сериализации хэш-таблиц в протоколах общего назначения отсутствует.
2. Большинство языков программирования императивные (и чаще всего — объектно-ориентированные), в то время как большинство форматов данных — декларативные. Работать с сырыми данными, полученными из API, таким образом почти всегда неудобно с точки зрения написания кода, программистам зачастую было бы удобнее работать с полученными из API данными как с объектами.
3. Разные языки программирования предполагают разный стиль кодирования (кейсинг, организация неймспейсов и т.п.), в то время как концепция API не предполагает адаптацию форматирования под запрашивающую платформу.
4. Как правило, платформа/язык программирования диктуют свою парадигму работы с возникающими ошибками (в виде исключений и/или механизмов defer/panic), что опять же неприменимо в концепции универсального для всех клиентов сетевого API.
5. API идёт в комплекте с рекомендациями (машино- или человекочитаемыми) по организации перезапросов в случае недоступности эндпойнтов. Эту логику необходимо реализовать разработчику клиента, поскольку библиотеки работы с сетью её, как правило, не предоставляют (и в общем-то не могут этого делать для потенциально неидемпотентных запросов). Этот пункт, при всей видимой малозначительности, является критически важным для любого крупного API, поскольку именно на этом уровне разработчики API могут заложить предохранители от потенциальной перегрузки серверов API лавиной перезапросов, поскольку разработчики клиентов этой частью традиционно пренебрегают:
* читать заголовок Retry-After и не пытаться перезапросить эндпойнт раньше, чем указал сервер;
* ввести увеличивающие интервалы между перезапросами.
Эти проблемы, однако, являются тривиальными — в том смысле, что они не требуют изменять порядок работы с API (каждому вызову и каждому ответу в API однозначно соответствует какая-то конструкция на языке платформы, и достаточно описать правила построения такого соответствия), а только лишь адаптировать платформо-независимый формат API к правилам конкретного языка программирования. Помимо тривиальных проблем, при разработке SDK к клиент-серверному API мы сталкиваемся и с проблемами более высокого порядка:
1. В клиент-серверных API данные передаются только по значению; чтобы сослаться на какую-то сущность, необходимо использовать какие-то внешние идентификаторы. Например, если у нас есть два набора сущностей — рецепты и предложения кофе — то нам необходимо будет построить карту рецептов по id, чтобы понять, на какой рецепт ссылается какое предложение:
```
// Запрашиваем информацию о рецептах
// лунго и латте
const recipes = await api
.getRecipes(['lungo', 'latte']);
// Строим карту, позволяющую обратиться
// к данным о рецепте по его id
const recipeMap = new Map();
recipes.forEach((recipe) => {
recipeMap.set(recipe.id, recipe);
});
// Запрашиваем предложения
// лунго и латте
const offers = await api.search({
recipes: ['lungo', 'latte'],
location
});
// Для того, чтобы показать предложения
// пользователю, нужно из каждого
// предложения извлечь id рецепта,
// и уже по id найти описание
promptUser(
'Найденные предложения',
offers.map((offer) => {
const recipe = recipeMap
.get(offer.recipe_id);
return recipe.name;
}));
```
Указанный код мог бы быть вдвое короче, если бы мы сразу получали из метода `api.search` предложения с заполненной ссылкой на рецепт:
```
// Запрашиваем информацию о рецептах
// лунго и латте
const recipes = await api
.getRecipes(['lungo', 'latte']);
// Запрашиваем предложения
// лунго и латте
const offers = await api.search({
// Передаём не идентификаторы
// рецептов, а ссылки на объекты,
// описывающие рецепты
recipes,
location
});
promptUser(
'Найденные предложения',
offers.map((offer) => {
return offer.recipe.name;
}));
```
2. Клиент-серверные API, как правило, стараются декомпозировать так, чтобы одному запросу соответствовал один тип возвращаемых данных. Даже если эндпойнт композитный (т.е. позволяет при запросе с помощью параметров указать, какие из дополнительных данных необходимо вернуть), это всё ещё ответственность разработчика этими параметрами воспользоваться. Код из примера выше мог бы быть ещё короче, если бы SDK взял на себя инициализацию всех нужных связанных объектов:
```
// Запрашиваем предложения
// лунго и латте
const offers = await api.search({
recipes: ['lungo', 'latte'],
location
});
promptUser(
'Найденные предложения',
offers.map((offer) => {
// SDK сам обратился к эндпойнту
// `getRecipes` и получил данные
// по лунго и латте
return offer.recipe.name;
}));
```
При этом SDK может также заполнять программные кэши сущностей (если мы не полагаемся на кэширование на уровне протокола) и/или позволять «лениво» инициализировать объекты:
```
promptUser(
'Найденные предложения',
offers.map((offer) => {
// SDK обратится за данными
// о рецепте, только если
// они действительно нужны
const recipe = await offer
.getRecipe()
return recipe.name;
}));
```
**NB**: Как видно из примера выше, эта функциональность («ленивая инициализация») требует очень внимательного подхода к имплементации со стороны разработчиков SDK. Если SDK не включает в себя агрегацию запросов и кэширование результатов, то пример кода выше запросит рецепт в цикле на каждое предложение, то есть выполнит огромное количество лишней работы. Мы можем описать эту тонкость в документации, конечно, но вновь закон больших чисел работает против нас: среди множества разработчиков обязательно найдутся те, кто не прочитает документацию или просто не задумается о том, что он запрашивает с сервера одни и те же данные в цикле.
3. Получение обратных вызовов в клиент-серверном API, даже если это дуплексный канал, с точки зрения клиента выглядит крайне неудобным в разработке, поскольку вновь требует наличия карт объектов. Даже если в API реализована push-модель, код выходит чрезвычайно громоздким:
```
// Получаем текущие заказы
const orders = await api
.getOngoingOrders();
// Строим карту заказов
const orderMap = new Map();
orders.forEach((order) => {
orderMap.set(order.id, order);
});
// Подписываемся на события
// изменения состояния заказов
api.subscribe(
'order_state_change',
(event) => {
const order = orderMap
.get(event.order_id);
// Выполняем какие-то
// действия с заказом,
// например, обновляем UI
// приложения
UI.update(order);
}
);
```
Если же API требует поллинга изменений состояний объектов, то разработчику придётся ещё где-то реализовать периодический опрос эндпойнта со списком изменений, и ещё следить за тем, чтобы не перегружать сервер запросами.
Кроме того, обратите внимание, что в вышеприведённом фрагменте кода [разработчиком приложения] допущены множественные ошибки:
* сначала получается список заказов, а затем происходит подписывание на их изменения; если между двумя этими вызовами какой-то из заказов изменился, приложение об этом не узнает;
* если пришло событие изменения какого-то неизвестного приложению заказа (который, например, был создан с другого устройства или в другом потоке исполнения), поиск в карте заказов вернёт пустой результат.
И вновь мы приходим к тому, что недостаточно продуманный SDK приводит к ошибкам в работе использующих его приложений. Разработчику было бы намного удобнее, если бы объект заказа позволял подписаться на свои события, не задумываясь о том, как эта подписка технически работает и как не пропустить события:
```
const order = await api.
createOrder(…);
// Нет нужды подписываться
// на *все* события и потом
// фильтровать их по id
order.subscribe(
'state_change',
(event) => { … }
);
```
**NB**: код выше предполагает, что объект `order` изменяется консистентным образом: даже если между вызовами `createOrder` и `subscribe` состояние заказа успело измениться на сервере, обработчик события `state_change` это изменение получит. Как это организовать технически — как раз забота разработчика SDK.
4. Восстановление после ошибок в бизнес-логике, как правило, достаточно сложная операция, которую сложно описать в машиночитаемом виде. Разработчику клиента необходимо самому продумать эти сценарии.
```
// Получаем предложения
const offers = await api
.search(…);
// Пользователь выбирает
// подходящее предложение
const selectedOffer =
await promptUser(offers);
let order;
let offer = selectedOffer;
let numberOfTries = 0;
do {
// Пытаемся создать заказ
try {
numberOfTries++;
order = await api
.createOrder(offer, …);
} catch (e) {
// Если количество попыток
// пересоздания заказа превысило
// какое-то разумное значение
// следует бросить попытки
// восстановиться
if (numberOfTries > TRY_LIMIT) {
throw new NoRetriesLeftError();
}
// Если произошла ошибка
// «предложение устарело»
if (e.type ==
api.OfferExpiredError) {
// если попытки ещё остались,
// пытаемся получить новое
// предложение
offer = await api
.renewOffer(offer);
} else {
// Обработка других видов
// ошибок
}
}
} while (!order);
```
Как мы видим, простая операция «попробовать продлить предложение» выливается в громоздкий код, в котором легко ошибиться, и, что ещё важнее, который совершенно не нужен разработчику приложения. Было бы гораздо проще, если бы этой ошибки *вовсе не было в SDK*, т.е. попытки обновления и перезапросы выполнялись бы автоматически.
Аналогичные ситуации возникают и в случае нестрого-консистентных API или оптимистичного управления параллелизмом — и вообще в любом API, в котором фон ошибок является ожидаемым (что в случае распределённых клиент-серверных API является нормой жизни). Передача версии ресурса и/или последних известных идентификаторов в эндпойнты с политикой read-your-writes — техническая необходимость, вызванная стремлением вендора API удешевить эксплуатацию и увеличить пропускную способность. Для разработчика приложения написание кода, имплементирующего эти политики — попросту напрасная трата времени.
5. Хранение данных в постоянной памяти (таких, как токены авторизации, ключи идемпотентности при перезапросах, идентификаторы черновиков при двухфазных коммитах и т.д.) также является ответственностью клиента и с трудом поддаётся формализации при кодогенерации.

View File

@ -1,15 +0,0 @@
### Кодогенерация
Как мы убедились в предыдущей главе, список задач, стоящих перед разработчиком SDK (если, конечно, его целью является качественный продукт) — очень и очень значительный. Учитывая, что под каждую целевую платформу необходим отдельный SDK, неудивительно, что многие вендоры API стремятся полностью или частично заменить ручной труд машинным.
Одно из основных направлений такой автоматизации — кодогенерация, то есть разработка технологии, которая позволяет по спецификации API сгенерировать готовый код SDK на целевом языке программирования для целевой платформы. Многие современные стандарты обмена данными (в частности, gRPC) поставляются в комплекте с генераторами готовых клиентов на различных языках; к другим технологиям (в частности, OpenAPI/Swagger) такие генераторы пишутся энтузиастами.
Генерация кода позволяет решить типовые проблемы, описанные в предыдущей главе: стиль кодирования, обработка исключений, (де)сериализацию сложных типов — словом все те задачи, которые зависят не от предметной области, а от особенностей конкретной платформы. Относительно недорого разработчик API может дополнить такой автоматизированный «перевод» правильными настройками используемых системных средств: обеспечить автоматические перезапросы для идемпотентных эндпойнтов (с реализацией какой-то политики), кэширование результатов, сохранение данных (например, токенов авторизации) в системном хранилище и т.д. Такой сгенерированный SDK часто называют термином «клиент к API».
Удобство использования и функциональные возможности кодогенерации столь привлекательны, что многие вендоры API только ей и ограничиваются, предоставляя свои SDK в виде сгенерированных клиентов.
**NB**: напомним, что кодогенерация по спецификации, при всех её достоинствах, имеет один очень существенный недостаток: она искажает понятие обратной совместимости, поскольку вводит ещё одну прослойку между спецификацией и кодом, который пишет разработчик. В общем случае, гарантировать, что обратно-совместимое изменение спецификации не приведёт к обратно-несовместимому изменению клиента к API [т.е. к тому, что написанный когда-то разработчиком код поверх кодогенерированного клиента будет корректно работать с новой версией клиента] — достаточно нетривиальная задача, равно как такая гарантия отсутствует и при переходе от одной версии библиотеки кодогенерации к другой. Как минимум это означает, что сгенерированные клиенты должны интенсивно тестироваться с целью выявления непредвиденных ошибок.
Как мы, однако, видим из предыдущей главы, проблемы более высокого порядка — получение серверных событий, обработка ошибок в бизнес-логике и т.п. — никак не может быть покрыта кодогенерацией, во всяком случае — стандартным модулем без его доработки применительно к конкретному API. В случае нетривиальных API со сложным основным циклом работы очень желательно, чтобы SDK решал также и высокоуровневые проблемы, иначе вы просто получите множество разработанных поверх API приложений, раз за разом повторяющие одни и те же «детские ошибки». Тем не менее, это не повод отказываться от кодогенерации полностью — её можно использовать как базис, на котором будет разработан высокоуровневый SDK.
В соответствии с парадигмой «айсберга» (см. главу «О ватерлинии айсберга») доступ к функциональности сгенерированного клиента может быть скрыт (т.е. разработчики не будут иметь доступ к низкоуровневой работой с API), тем самым обеспечивая определённую свободу работы с API изнутри SDK, вплоть до бесшовного перехода на новые мажорные версии API. Этот подход, несомненно, предоставляет вендору API намного больше контроля над приложениями клиентов, но требует и намного больше ресурсов на разработку, и, что важнее, грамотного проектирования SDK — такого, чтобы у разработчиков не было необходимости обращаться к API напрямую в обход SDK по причине отсутствия в нём необходимых функций или их плохой реализации.

View File

@ -67,7 +67,7 @@ const searchBox = new SearchBox({
});
return res;
}
}
})
```
*Формально* этот подход корректен и никаких рекомендаций не нарушает. Но с точки зрения связности кода, его читабельности — это полная катастрофа, поскольку следующий разработчик, которого попросят заменить иконку *кнопке*, очень вряд ли пойдёт читать код *функции поиска предложений*.