Блог CosySoft
Разработка

End-to-end type safety с кодогенерацией и без

Наш фронтендер Сергей вошел в сумеречную зону между бэкэндом и фронтендом, чтобы выяснить, как можно устранить рассинхронизацию контрактов API, выжать из OpenAPI еще больше пользы и минимизировать человеческий фактор с помощью библиотеки TRPC.

Почему Type safety — это важно

Типизация — один из краеугольных камней developer experience фронтенд разработки. Что может случиться, если типизация отсутствует? Чтобы ответить на этот вопрос, представьте типовой поток данных, где есть две «сумеречных зоны». Первая это переход данных между БД и бэкендом, а вторая — переход между бэкендом и фронтендом.
Если типизации нет, то эти переходы можно сравнить с нерегулируемым перекрестком, где в отсутствии правил начинается хаос. Все что вам остается — вслепую верить, что те данные, которые ты получишь, это те данные, которые ты ожидаешь.
Причины, по которым type safety важна лично для меня, можно просуммировать в одну — меньше общения на неприятные темы, вроде, «кто закосячил и кому теперь разгребать последствия данных косяков». Многолетний тренд движения фронтенд-кода к большей type safety показывает, что не только я задумываюсь об этом.
Глоссарий
  • Тип - абстракция, объединяющая значения по общности неких признаков.
  • Турe safety (тайпсейфти) - соответствие типов, вычисленных во время компиляции, значениям во время выполнения [программы].
  • End-to-end - на всем протяжении [пути данных от и до конечного пользователя].
  • Кодогенерация - генерация кода для кодовой базы посредством исполнения какой-то программы (create-t3-app, например).

Как случается рассинхронизация контрактов API

Возьмем реальный рабочий сценарий. Аналитик описал состав полей сущности на русском языке. Эти описания взял в работу фронтенд и для табличного представления перевел эти названия полей на английский. На основе этого описания бэкендер создал версию 0.1 API-контракта. Параллельно с этим процессом тимлид спроектировал базу данных на основе полей от аналитика и тоже по-своему перевел их на английский.
Когда пришло время делать версию API 0.2, выяснилось, что есть две версии полей, переведенные по-разному. Пришлось заново искать соответствия, синхронизировать сущности и переписывать API. Такая несогласованность привела к лишним тратам ресурса и усложнила рабочий процесс.

Решаем проблему с помощью автоматизации

Решить такие проблемы type safety с рассинхронизацией можно с помощью OpenAPI и tRPC.

OpenAPI

При использовании OpenAPI клиентский код генерируется на основе описанных в спецификации эндпоинтов и схем данных. Это позволяет автоматически создать клиентский код, который соответствует API сервера. Такой подход требует ручного определения спецификации API и последующей генерации кода.

tRPC

tRPC предлагает более интегрированный подход, где типы данных и процедуры определяются непосредственно на сервере с использованием TypeScript. tRPC под капотом генерирует типы соответствующие заданной на сервере логике и позволяет «бесшовно» использовать их на клиенте, гарантируя type-safety. Этот подход упрощает процесс разработки, так как все типы и процедуры находятся в одном месте и не требуют ручной синхронизации между сервером и клиентом.

Что такое Open API и как он появился

OpenAPI (Swagger) — это язык описания спецификаций HTTP API, позволяющий наглядно описать API-контракты независимо от языка на котором имплементирован бэкенд. На данный момент, это стандарт документирования HTTP API.
Open API значительно сокращает монотонный и не всегда приятный ручной труд, который обычно сопровождает разработку.
Почему бы не создавать генерируемые REST клиенты, основанные на спецификации OpenAPI? Такая идея возникла несколько лет назад и быстро нашла своих поклонников. В результате сейчас существует несколько библиотек, которые позволяют автоматически создавать API-клиенты на фронтенде из бэкендерского Swagger-файла. Хотя эти библиотеки еще не достигли массовой популярности, они уже обрели своих пользователей.

Кодогенерация с помощью OpenAPI

Шаг 1. Генерируем API-клиента

1. Указываем на входной YAML или JSON файл спецификации.
2. Выбираем инструмент или библиотеку для кодогенерации, которая позволит автоматически создать код на основе спецификации OpenAPI.
В итоге получаем код, который уже включает классы или функции с прописанными запросами, соответствующие шагам и возможностям API, описанным в спецификации.

Шаг 2. Генерируем клиент

Шаг 3. Подключаемся к API

Чтобы использовать полученный код нужно:
  • Инициализировать клиент.
  • Обратиться к необходимому эндпоинту и получить/отправить данные.
  • Не забывать заново генерировать клиент, чтобы оставаться синхронизированными с бэкэндом.

Шаг 4. Встраиваем проверку breaking changes в пайплайн ci/cd

Для дополнительного снижения влияния человеческих ошибок на результаты разработки можно интегрировать проверки в CI/CD пайплайны, чтобы убедиться, что изменения на бэкенде не приводят к поломке существующего фронтенда.
  1. На этапе сборки бекенда, нужно генерировать артефакт OpenAPI спецификации.
  2. Берем актуальный фронтенд и передаем в него обновленную спецификацию.
  3. Генерируем актуальный фронтовый API-клиент на основе новой спецификации.
  4. Прогоняем проверку типов и если что-то сломалось не пропускаем билд дальше.

Преимущества генерации с помощью OpenAPI

Скорость переезда на новую версию АПИ

Генерация клиентского кода на основе OpenAPI спецификации позволяет быстро адаптировать клиентскую часть приложения к изменениям в API. Это особенно полезно при обновлении или добавлении новых функций, так как код клиента автоматически обновляется в соответствии с изменениями на бэкенде.

Меньшее влияние человеческого фактора

Поскольку код клиента генерируется автоматически на основе спецификации API, минимизируется возможность человеческих ошибок при написании клиентского кода. Это позволяет снизить вероятность возникновения ошибок и улучшить общее качество приложения.

Мозг разгружается за счет меньшего количества деталей

Генерация кода на основе OpenAPI спецификации позволяет разработчикам не заботиться о мелких деталях взаимодействия между клиентом и сервером. Вместо этого они могут сконцентрироваться на более важных задачах и улучшении функциональности приложения.

Продолжаем автоматизировать — пробуем tRPC

Погружаясь в тему end-to-end type safety, я столкнулся с библиотекой tRPC. Она позволяет достичь тех же результатов, но без необходимости использования кодогенерации.
Стоит отметить, что tRPC подходит только для проектов, написанных на чистом TypeScript, и желательно, чтобы они находились в монорепозитории. Для проектов вне монорепозитория все равно придется создавать npm-пакет, что уменьшает привлекательность идеи об отсутствии кодогенерации.
Использование tRPC в проекте обеспечивает эффективное решение проблемы Type safety без необходимости создания лишних инструментов и настроек.
Факты про tRPC
  • Для формирования запросов на бэк часть используется React Query, поэтому по итогу на клиенте получается React Query-подобный синтаксис.
  • Для работы tRPC требуется минимальная серверная часть. Серверной части, которая есть в SSR фреймворках типа Next.js вполне хватает, чтобы создать свой tRPC-сервер.
  • На своем сайте заявляют, что tRPC используется в продакшене в тысячах компаний, так что уровень зрелости технологии вполне достаточный, чтобы брать ее на вооружение.

Начало работы с tRPC

Преимуществом tRPC является возможность точечного внедрения и тестирования. Иначе говоря, если вы хотите понять нравится вам или нет получаемый результат, то это можно сделать, переписав только небольшую часть функционала проекта. В случае, если результат работы tRPC вас устроит, то можно без рисков продолжить внедрение. Это удобно и безопасно, например, для больших проектов, состоящих из большого количества элементов.

Шаг 1. Инициализация

Для начала работы с tRPC необходимо произвести его инициализацию. В этом процессе играет ключевую роль контекст, предназначенный для общих данных, таких как авторизация и метаданные.
После определения контекста инициализируется сам tRPC с использованием библиотечных функций. В процессе инициализации также можно указать обработчик данных и ошибок, а также определить формат их представления.
Основная инициализация tRPC включает в себя контекст, который необязателен и может быть опущен, если конкретный сценарий использования этого не требует.
import { initTRPC } from "@trpc/server";
import { type CreateNextContextOptions } from "@trpc/server/adapters/next";
import superjson from "superjson";
import { ZodError } from "zod";

/ **
* 1. CONTEXT
*/
type CreateContextOptions = Record<string, never>;

const createInnerTRPCContext = (_opts: CreateContextOptions) => {
return {};

export const createTRPCContext = (_opts: CreateNextContextOptions) => {
return createInnerTRPCContext({});

/ **
* 2. INITIALIZATION
*/

export const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
... shape,
data: {
... shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,

},

};

}):

/ **
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
*/
export const createTRPCRouter = t. router;
export const publicProcedure = t.procedure;

Шаг 2. Роутер

После инициализации создается роутер с использованием библиотеки tRPC. Роутер состоит из процедур, которые определяются внутри него. В роутере объединяются все необходимые точки доступа, к которым будет обращаться приложение. Также определяется корневой тип данных, который позволяет через типы определять данные на фронтенде.
Роутер состоит из процедур, которые могут быть публичными и иметь различные типы:
  • query для запроса данных,
  • mutation для изменения данных
  • subscription для подписки на изменения.
import { exampleRouter } from "~/server/api/routers/example";
import { createTRPCRouter } from "~/server/api/trpc";
import { calculateRouter } from "./routers/calculate";

export const appRouter = createTRPCRouter({
example: exampleRouter,
calculate: calculateRouter,
});

// export type definition of API
export type AppRouter = typeof appRouter;

Шаг 3. Процедура

Далее в коде определяется процедура query, которая принимает входные параметры и возвращает определенный результат. Валидация данных для возвращаемых значений также возможна, но редко используется. Тем не менее, можно добавить валидатор и за возвращаемыми данными, чтобы tRPC также мог следить за их корректностью. Обычно встроенных механизмов TypeScript достаточно для этой цели.
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { products, processors } from "~/server/types";
import calculate from "~/utils/calculate";

export const calculateRouter = createTRPCRouter({
calculate: publicProcedure
. input (z.object({
products,
processors,
}))
.query(({ input: { products, processors }}) => {
return calculate(products, processors);
}),

});

Шаг 4. Подключение на фронте

После написания и валидации процедуры на бэкенде необходимо подключиться к ней на фронте. Если вы вносите изменения на фронтенде, например, в калькуляторе, и тогда код подчеркивает ваш вызов, это означает, что таких изменений еще нет на бэкенде. Это предоставляет две подсказки на этапе компиляции о том, что ваш фронтенд не соответствует актуальному бэкенду. И наоборот — если вы изменяете параметры на бэкенде, вам необходимо уведомить об этом фронтенд, чтобы избежать ошибок в процессе использования.
});

import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { products, processors } from "~/server/types";
import calculate from "~/utils/calculate";

export const calculateRouter = createTRPCRouter({
calculate: publicProcedure
. input (z. object ({
products,
processors,
}))
.query(({ input: { products, processors }}) => {
return calculate(products, processors);
}),

Итоги

Подводя итог, предлагаю сравнить tRPC с методом генерации кода на основе OpenAPI спецификации для обеспечения end-to-end type safety.
  1. Итак, с использованием tRPC скорость адаптации к новой версии API значительно увеличивается. Даже без кодогенерации ты сразу узнаешь о необходимости внесения изменений и сможешь быстро их внедрить. Это еще большее ускорение итераций разработки, так как исключается шаг генерации кода.
  2. Благодаря ограничению применения tRPC только к монорепозиториям с TypeScript, уменьшается влияние человеческого фактора. Если при использовании сгенерированного клиента можно забыть о его существовании и в результате получить рассинхронизацию API, то в случае с монорепозиторием билд не соберется, если есть проблемы. Таким образом, разработчик получит обратную связь о возможных ошибках гораздо раньше.
  3. Сравнение влияния на «туннельный синдром» и количество дополнительных вещей для прописывания немного затруднительно. Оба подхода минимизируют количество ручного ввода благодаря автозаполнению IDE. Кроме того, разгрузка мозга за счет уменьшения удерживаемого контекста примерно одинакова в обоих случаях. Если все настроено правильно, разработчику не нужно переживать и запоминать дополнительные детали.
Что еще почитать по теме?
  1. Официальный сайт trpc.io
  2. Генератор шаблонных проектов, который использует trpc там и ссылки на пояснительные видосы есть
  3. YouTube-канал Theo - t3․gg