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

SDKs begin

This commit is contained in:
Sergey Konstantinov 2023-07-08 18:49:26 +03:00
parent 6f3ce42fa6
commit a85c54e535
5 changed files with 403 additions and 19 deletions

View File

@ -1,4 +1,4 @@
### [On the Content of This Section][sdks-toc]
### [On the Content of This Section][sdk-toc]
As we mentioned in the Introduction, the term “SDK” (which stands for “Software Development Kit”) lacks concrete meaning. The common understanding is that an SDK differs from an API as it provides both program interfaces and tools to work with them. This definition is hardly satisfactory as today any technology is likely to be provided with a bundled toolset.

View File

@ -1 +1,198 @@
### The SDK: Problems and Solutions
### [SDKs: Problems and Solutions][sdk-problems-solutions]
The first question we need to clarify about SDKs (let us reiterate that we use this term to denote a native client library that allows for working with a technology-agnostic underlying client-server API) is why SDKs exist in the first place. In other words, why is using “wrappers” more convenient for frontend developers than working with the underlying API directly?
Several reasons are obvious:
1. Client-server protocols are usually designed to allow for the implementation of clients in any programming language. This implies that the data received from such an API will not be in the most convenient format. For example, there is no “datetime” type in JSON, and the dates need to be passed as strings. Similarly, most mainstream protocols don't support (de)serializing hash tables.
2. Most programming languages are imperative (and many of them are object-oriented) while most data formats are declarative. Working with raw data received from an API endpoint is inconvenient in terms of writing code. Client developers would prefer this data to be represented as objects (class instances).
3. Different programming languages imply different code styles (casing, namespace organization, etc.), while the practice of tailoring data formats in APIs to match the client's code style is very rare.
4. Platforms and/or programming languages usually prescribe how error handling should be organized — typically, through throwing exceptions or employing defer/panic techniques — which is hardly applicable to the concept of uniform APIs.
4. APIs are provided with instructions (human- or machine-readable) on how to repeat requests if the API endpoints are unavailable. This logic needs to be implemented by a client developer as client frameworks rarely provide it (and it would be very dangerous to automatically repeat potentially non-idempotent requests). Though this point might appear insignificant, it is in fact very important for every vendor of a popular API, as safeguards need to be installed to prevent API servers from overloading due to an uncontrollable spike of request repeats. This is achieved through:
* Reading the `Retry-After` header and avoiding retrying the endpoint earlier than the time stated by the server
* Introducing exponentially growing intervals between consecutive requests.
This is what client developers should do regarding server errors, but they often skip this part, especially if they work for external partners.
Having an SDK would resolve these issues as they are, so to say, trivial: to fix them, the principles of working with the API aren't changed. For every request and response, we construct the corresponding SDK method, and we only need to set rules for doing this transformation, i.e., adapting platform-independent API formats to specific platforms. Additionally, this transformation usually could be automated.
However, there are also non-trivial problems we face while developing an SDK for a client-server API:
1. In client-server APIs, data is passed by value. To refer to some entities, specially designed identifiers need to be used. For example, if we have two sets of entities — recipes and offers — we need to build a map to understand which recipe corresponds to which offer:
```
// Request 'lungo' and 'latte' recipes
const recipes = await api
.getRecipes(['lungo', 'latte']);
// Build a map that allows to quickly
// find a recipe by its identifier
const recipeMap = new Map();
recipes.forEach((recipe) => {
recipeMap.set(recipe.id, recipe);
});
// Request offers for latte and lungo
// in the vicinity
const offers = await api.search({
recipes: ['lungo', 'latte'],
location
});
// To show offers to the user, we need
// to take the `recipe_id` in the offer,
// find the recipe description in the map
// and enrich the offer data with
// the recipe data
promptUser(
'What we have found',
offers.map((offer) => {
const recipe = recipeMap
.get(offer.recipe_id);
return {offer, recipe};
}));
```
This piece of code would be half as long if we received offers from the `api.search` SDK method with a *reference* to a recipe:
```
// Request 'lungo' and 'latte' recipes
const recipes = await api
.getRecipes(['lungo', 'latte']);
// Request offers for latte and lungo
// in the vicinity
const offers = await api.search({
// Pass the references to the recipes,
// not their identifiers
recipes,
location
});
promptUser(
'What we have found',
// Offer already contains a reference
// to the recipe
offers
);
```
2. Client-server APIs are typically decomposed so that one response contains data regarding one kind of entity. Even if the endpoint is composite (i.e., allows for combining data from different sources depending on parameters), it is still the developer's responsibility to use these parameters. The code sample from the previous example would be even shorter if the SDK allowed for the initialization of all related entities:
```
// Request offers for latte and lungo
// in the vicinity
const offers = await api.search({
recipes: ['lungo', 'latte'],
location
});
// The SDK requested latte and lungo
// data from the `getRecipes` endpoint
// under the hood
promptUser(
'What we have found',
offers
);
```
The SDK can also populate program caches for the entities (if we do not rely on protocol-level caching) and/or allow for the “lazy” initialization of objects.
Generally speaking, storing pieces of data (such as authorization tokens, idempotency keys, draft identifiers in two-phase commits, etc.) between requests is the client's responsibility, and it is rather hard to formalize the rules. If an SDK takes responsibility for managing the data, there will be far fewer mistakes in application code.
3. Receiving callbacks in client-server APIs, even if it is a duplex communication channel, is rather inconvenient to work with and requires object mapping as well. Even if a push model is implemented, the resulting client code will be rather bulky:
```
// Retrieve ongoing orders
const orders = await api
.getOngoingOrders();
// Build order map
const orderMap = new Map();
orders.forEach((order) => {
orderMap.set(order.id, order);
});
// Subscribe to state change
// events
api.subscribe(
'order_state_change',
(event) => {
// Find the corresponding order
const order = orderMap
.get(event.order_id);
// Take some actions, like
// updating the UI
// in the application
UI.update(order);
}
);
```
If the API requires polling a state change endpoint, developers will additionally need to implement periodic requesting of the endpoint (and install safeguards to avoid overloading the server).
Meanwhile, this code sample already contains several mistakes:
* First, the list of orders is requested, and then the state change listener is added. If some order changes its state between those two calls, the application would never learn about this fact.
* If an event comes with an identifier of some unknown order (created from a different device or in a different execution thread), the map lookup operation will return an empty result, and the listener will throw an exception that is not properly handled anywhere.
Once again, we face a situation where an SDK lacking important features leads to mistakes in applications that use it. It would be much more convenient for a developer if an order object allowed for subscribing to its status updates without the need to learn how it works at the transport level and how to avoid missing an event.
```
const order = await api
.createOrder(…)
// No need to subscribe to
// the entire status change
// stream and filter it
.subscribe(
'state_change',
(event) => { … }
);
```
**NB**: This code relies on the idea that the `order` is being updated in a consistent manner. Even if the state changes on the server side between the `createOrder` and `subscribe` calls, the `order` object's state will be consistent with the `state_change` events fired. Organizing this technically is the responsibility of the SDK developers.
4. Restoring after encountering business logic-bound errors is typically a complex procedure. As it can hardly be described in a machine-readable manner, client developers have to elaborate on the scenarios on their own.
```
// Request offers
const offers = await api
.search(…);
// The user selects an offer
const selectedOffer =
await promptUser(offers);
let order;
let offer = selectedOffer;
let numberOfTries = 0;
do {
// Trying to create an order
try {
numberOfTries++;
order = await api
.createOrder(offer, …);
} catch (e) {
// If the number of tries exceeded
// some reasonable limit, it's
// better to give up
if (numberOfTries > TRY_LIMIT) {
throw new NoRetriesLeftError();
}
// If the error is of the “offer
// expired” kind…
if (e.type ==
api.OfferExpiredError) {
// Trying to get a new offer
offer = await api
.renewOffer(offer);
} else {
// Other errors
}
}
} while (!order);
```
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.
To summarize the above, a properly designed SDK, apart from maintaining consistency with the platform guidelines and providing “syntactic sugar,” serves two 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.

View File

@ -1,4 +1,4 @@
### [О содержании раздела][sdks-toc]
### [О содержании раздела][sdk-toc]
Как мы отмечали во Введении, аббревиатура «SDK» («Software Development Kit»), как и многие из обсуждавшихся ранее терминов, не имеет конкретного значения. Считается, что SDK отличается от API тем, что помимо программных интерфейсов содержит и готовые инструменты для работы с ними. Определение это, конечно, лукавое, поскольку почти любая технология сегодня идёт в комплекте со своим набором инструментов.

View File

@ -1 +1,203 @@
### SDK: проблемы и решения
### [SDK: проблемы и решения][sdk-problems-solutions]
Первый вопрос об 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` и не пытаться перезапросить эндпойнт раньше, чем указал сервер;
* ввести увеличивающие интервалы между перезапросами.
Наличие собственного SDK устранило бы указанные проблемы, которые в некотором смысле являются тривиальными: для их решения не требуетя изменять порядок работы с 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 {offer, recipe};
}));
```
Указанный код мог бы быть вдвое короче, если бы мы сразу получали из метода `api.search` предложения с заполненной ссылкой на рецепт:
```
// Запрашиваем информацию о рецептах
// лунго и латте
const recipes = await api
.getRecipes(['lungo', 'latte']);
// Запрашиваем предложения
// лунго и латте
const offers = await api.search({
// Передаём не идентификаторы
// рецептов, а ссылки на объекты,
// описывающие рецепты
recipes,
location
});
promptUser(
'Найденные предложения',
// offer уже содержит
// ссылку на рецепт
offers
);
```
2. Клиент-серверные API, как правило, стараются декомпозировать так, чтобы одному запросу соответствовал один тип возвращаемых данных. Даже если эндпойнт композитный (т.е. позволяет при запросе с помощью параметров указать, какие из дополнительных данных необходимо вернуть), это всё ещё ответственность разработчика этими параметрами воспользоваться. Код из примера выше мог бы быть ещё короче, если бы SDK взял на себя инициализацию всех нужных связанных объектов:
```
// Запрашиваем предложения
// лунго и латте
const offers = await api.search({
recipes: ['lungo', 'latte'],
location
});
// SDK сам обратился к эндпойнту
// `getRecipes` и получил данные
// по лунго и латте
promptUser(
'Найденные предложения',
offers
);
```
При этом SDK может также заполнять программные кэши сущностей (если мы не полагаемся на кэширование на уровне протокола) и/или позволять «лениво» инициализировать объекты.
Вообще, хранение данных (таких, как токены авторизации, ключи идемпотентности при перезапросах, идентификаторы черновиков при двухфазных коммитах и т.д.) между запросам также является ответственностью клиента и с трудом поддаётся формализации. Если SDK возьмёт на себя эти функции, в коде приложений, использующих API, будет допущено намного меньше ошибок.
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
.subscribe(
'state_change',
(event) => { … }
);
```
**NB**: код выше предполагает, что объект `order` изменяется консистентным образом: даже если между вызовами `createOrder` и `subscribe` состояние заказа успело измениться на сервере, состояние объекта `order` будет консистентно списку событий `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» (т.е. передачу токенов последней известной операции в последующие запросы) — попросту напрасная трата времени.
Суммируя написанное выше, хорошо спроектированный SDK служит, помимо поддержания консистентности платформе и предоставления «синтаксического сахара», двум важным целям:
* снижение количества ошибок в клиентском коде путём имплементации хелперов, покрывающих неочевидные и слабоформализуемые аспекты работы с API;
* избавление клиентских разработчиков от необходимости писать код, который им совершенно не нужен.

View File

@ -1,15 +0,0 @@
### О содержании раздела
Аббревиатура «SDK» («Software Development Kit»), как и многие из обсуждавшихся ранее терминов, не имеет конкретного значения. Считается, что SDK отличается от API тем, что помимо программных интерфейсов содержит и какое-то количество готовых инструментов для работы с ними. Определение это, конечно, лукавое, поскольку почти любая технология сегодня идёт в комплекте со своим набором инструментов.
Тем не менее, у термина SDK есть и более узкое значение, в котором он часто используется: это клиентская библиотека, которая предоставляет высокоуровневый (обычно, нативный) интерфейс для работы с некоторой нижележащей платформой (и в частности с клиент-серверным API). Чаще всего речь идёт о библиотеках для мобильных платформ или веб-платформы, которые работают поверх нижележащего HTTP API сервиса.
Среди подобных клиентских SDK особо выделяются те из них, которые предоставляют не только программные интерфейсы для работы с API, но также и готовые визуальные компоненты, которые разработчик может использовать. Классический пример такого SDK — это библиотеки карточных сервисов; в силу исключительной сложности самостоятельной реализации движка работы с картами (особенно векторными) вендоры API карт предоставляют и «обёртки» к HTTP API (например, поисковому), и готовые библиотеки для работы с географическими сущностями, которые часто включают в себя и визуальные компоненты общего назначения — кнопки, метки, контекстные меню — которые могут применяться и совершенно самостоятельно вне контекста API (SDK) как такового.
Настоящий раздел будет посвящён именно двум этим технологиям:
* клиентские «обёртки» поверх клиент-серверных API;
* клиентские библиотеки, предоставляющие визуальные компоненты, с которыми конечный пользователь может взаимодействовать напрямую.
Во избежание нагромождения подобных оборотов мы будем называть первый тип библиотеки просто «SDK», а второй — «UI-библиотеки».
**NB**: вообще говоря, UI-библиотека может как включать в себя обёртку над клиент-серверным API, так и предоставлять чистый API к графическому движку. В рамках этой книги мы будем говорить, в основном, о первом варианте, поскольку API второго вида существенно проще с точки зрения дизайна и не требует отдельной главы. Тем не менее, многие паттерны дизайна SDK, которые мы опишем далее, применимы и к «чистым» библиотекам без клиент-серверной составляющей.