Как стать автором
Обновить

[SDK и UI-библиотеки] Введение. SDK: Проблемы и решения

Уровень сложностиСложный
Время на прочтение11 мин
Количество просмотров1.6K

Это главы 40 и 41 раздела «SDK и UI-библиотеки» моей книги «API». Второе издание книги будет содержать три новых раздела: «Паттерны API», «HTTP API и REST», «SDK и UI‑библиотеки». Если эта работа была для вас полезна, пожалуйста, оцените книгу на GitHub, Amazon или GoodReads. English version on Substack.

Глава 41. О содержании раздела 

Как мы отмечали, аббревиатура «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, которые мы опишем далее, применимы и к «чистым» библиотекам без клиент‑серверной составляющей.

Глава 42. 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 и не пытаться перезапросить эндпойнт раньше, чем указал сервер; * ввести увеличивающие интервалы между перезапросами.

Наличие собственного 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» (т.е. передачу токенов последней известной операции в последующие запросы) — попросту напрасная трата времени.

  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) такие генераторы пишутся энтузиастами.

Генерация кода позволяет решить типовые проблемы: стиль кодирования, обработка исключений, (де)сериализация сложных типов — словом все те задачи, которые зависят не от особенностей высокоуровневой бизнес‑логики, а от конвенций конкретной платформы. Относительно недорого разработчик SDK может дополнить такой автоматизированный «перевод» удобными хелпера: обеспечить автоматические перезапросы для идемпотентных эндпойнтов (с реализацией какой‑то политики управления интервалами повторных вызовов), кэширование результатов, сохранение данных (например, токенов авторизации) в системном хранилище и т. д.

Такой сгенерированный SDK часто называют термином «клиент к API». Удобство использования и функциональные возможности кодогенерации столь привлекательны, что многие вендоры API только ей и ограничиваются, предоставляя свои SDK в виде сгенерированных клиентов.

Как мы, однако, видим из написанного выше, проблемы более высокого порядка — получение серверных событий, обработка ошибок в бизнес‑логике и т. п. — никак не может быть покрыта кодогенерацией, во всяком случае — стандартным модулем без его доработки применительно к конкретному API. В случае нетривиальных API со сложным основным циклом работы очень желательно, чтобы SDK решал также и высокоуровневые проблемы, иначе вы просто получите множество разработанных поверх API приложений, раз за разом повторяющие одни и те же «детские ошибки». Тем не менее, это не повод отказываться от кодогенерации полностью — её можно использовать как базис, на котором будет разработан высокоуровневый SDK.

Теги:
Хабы:
Рейтинг0
Комментарии5

Публикации

Истории

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань