З’явленне адаптации кантракта API ў час компілявання за дапамою поступовага впроваджэння tRPC
tRPC перетварае перейменаванае поле сервера на памятку пра збірку коду на TypeScript. Як поступова адаптацыя гэтага падходу па кожны канцэнтры разам з REST, і дзе він не ўсунецца як непадходячы інструмент.
Крыніцы: **I Switched From REST to tRPC. My Team Almost Revolted. Then They Loved It.**
Уявіце сабэксплуатацыйную платформу, дзе інжынер бэкенду перайменавае applicantName на applicantFullName у адпаведным рэспонсе заявкі на кредыт. API, тэсты бэкенду і прыемныя запросы ўсе былі апдэйтаваны і затверджаны, пры тым як чырэз два дні на экране падсумку кредыту тыхо аплываюць порожнія імена: ніякіх асывентаў, ніякіх паведамленняў у консолі, толькі порожняя строка, адтуды што фронтэнд усё ўсё чытае ключ, які больш не існуе. І фронтэнд, і бэкенд былі напісаны на TypeScript, але жадная з інфармацый пра типы не пераводзілася через межу сеті. Чытаючы далей, вы пазнаеце, як tRPC заполняе гэты прыемак, як можна поступова ўваходзіць яго разам з REST і калі ён не падходзіць.
Што такое tRPC і чаго ён не ўсёрахунакоўвае
tRPC не ўраджае протакола канектавання і не заменяе REST чыра GraphQL на рэвэю-рэвэі. Запросы застаюцца звычными HTTP-заклекамі. Яго прыдатнае якосты — гэта спяльны шар типаў: тыпы рэзультатаў процедур сервера адгадваюцца праз TypeScript і пераходзяць безпосередна да кліента, без якіх-леба файла схемы і без крока генеравання коду.
Прыдатны ментальны модэль — гэта заклеканне функцыі, якае выконваецца на сервере. Контракт межа вызывальнікам і вызываемым апраначваецца на адпаведнасці з тым, што пазначае кампайляр TypeScript, а не на аднойчынны дакумент спецыфікацыі, які можа быць або не быць апдэйтаваным. Калі сервер зменяе тое, што вяртае працэдура, кожны месца вызывання кліента, якое залежыць ад старага формата, не можа скомпілявацца. Памылка праказуецца ў рэдагары на той самый рядок, дзе не хачацца поля, а не пазней у прыемным режыме.
Косць шара API без типавання
У вышэўказаным сценарыі слой сервіса фронтэнду выглядаў прыблізна так: кожны метод запрашаў JSON і вяртаў яго з типам any, а компаненты чыталі поля з рэзультата, пакладаючыся на ўпамінанне.
// services/api.service.ts
async getLoanApplication(id: string): Promise<any> {
const res = await this.http.get(`/api/loans/${id}`).toPromise();
return res;
}
// component
const loan = await this.apiService.getLoanApplication(loanId);
console.log(loan.applicantName); // works until it doesn't
Команда палячыла 47 методаў з типам вяртання any. Кожны з іх быў месцам, дзе перэименаванне поля, перакаштовка вялікага об’екта або змена значэння enum моглі бы выконвацца без проблем і паўстаць бяда толькі пад час выканання, калі корыстувальнік пераглядаў статус аплікацыі. У фінансовым продукце такі баг не ўжо проста косметычны: паказанне некоректных аб вялікіх дадзеных кліенту можа стаць проблемай са санкцыямі.
Міграцыя за дапамою шаблона strangler fig
Перапісваўванне всіх канцэнтраў разам ёсць рызыкаванае. Безпечнейшы спосаб — цэ гэта падход «strangler fig»: залишыце існуючыя REST-канцэнтры недараганымі, дадзіце працаваць tRPC праз яны і пераносіце по аднаму канцэнтру. Кожны перанесены канцэнтр адразу ж апынваецца з безпекай типаў, а тое, што яшчо не перанесена, не патрабуе змян.
Апісваўванне рутэра
Сэрверная частка пачатваецца з initTRPC.create(), які вяртае об’ект-будавальнік, звычайна называемы t. Маршруты можаць быць вярстаныя, таму падмаршрут loans агрупавае процедуры, зв’язаныя з кредатамі. Зверніце увагу на тры аспекты ў гэтым фрагменте. Першае – кожная процедура прызначае своі вхідны данні за дапамогою схемы Zod, тады некоректны ID або невядомы статус занікнуць пад час выконання пры раннім етапе, перш чым запрацюе обробнік, а тая ж схема дае обробніку типаваныя input-данныя. Другое – для чытання викорыстоўваецца query, а для збіркі даных – mutation. Трэцяе – ніхто не пішаў тип вяртання рукамі: TypeScript выважвае яго на аднойчыну з тым, што вяртае вызов базы дадзеных. Пасляпэўныя рядкі экспортуе толькі тип маршрута, які і викорыстоўваецца кліентам.
// server/router/index.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
loans: t.router({
getById: t.procedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
const loan = await db.loans.findUnique({ where: { id: input.id } });
if (!loan) throw new TRPCError({ code: 'NOT_FOUND' });
return loan; // TypeScript infers the return type from the DB model
}),
updateStatus: t.procedure
.input(z.object({
id: z.string().uuid(),
status: z.enum(['PENDING', 'APPROVED', 'REJECTED', 'DISBURSED'])
}))
.mutation(async ({ input }) => {
return db.loans.update({
where: { id: input.id },
data: { status: input.status }
});
}),
}),
});
export type AppRouter = typeof appRouter;
Адзін момент, які трэба выправіць, якщо вы копіюеце гэты код: хендлер выклекае TRPCError, які таксама трэба імпортуваць з @trpc/server. Ён не прыўязан у рэчы імпорту.
Стварэнне кліента
У фронтэндзе кліент ствараецца на адной з базовых типавых структураў AppRouter. Пакалі імпорт выкарыстоўвае import type, код сервера не патрапляе ў бандл браузера; дазволена толькі інформацыя пра типы. httpBatchLink адзінам HTTP-запытам об’едначае калькі запытаў, якія выкарыстоўваюцца ў тым жа цыкле, а калбэк headers выкананы для кожнага запыта, таму гэта зручнае месца для дадавання токена-носія.
// lib/trpc.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../../server/router';
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: '/api/trpc',
headers() {
return {
Authorization: `Bearer ${getToken()}`,
};
},
}),
],
});
Назва фабрыкі паводзіцца ў вярсіі tRPC, яка выкорыстоўваецца: createTRPCProxyClient — это API версіі v10, тады як у новейшых выданнях ёй даёцца назва createTRPCClient. Пераканайцеся, што вы вжываеце документацыю для той версіі, яку інсталавалі. Аднароджэнне такога типу таксама предпакоўвае, што кліент можа яго імпортуваць, зазвычай через монорепо або пакет спяльнай рабочай прасторы.
Выкліканне процедуры
Тепер выклік выглядае як вызов метода у локальным об’екте. Рэзультат мае чыста заданую структуру, таму апыл да поля, якое сервер больш не вяртае, вызывае памылку компіляцыі, а не безсимптаматныя значэння undefined.
// In a component or service
const loan = await trpc.loans.getById.query({ id: loanId });
// loan is fully typed - TypeScript knows every field
console.log(loan.applicantFullName); // if this field doesn't exist, compile error
Адпаведзенне на заперчанні каманды
Можна спакавацца на заперчанні. Зазвычай вырашваюцься тры заперчанні, і кожна з яых патрабуе чыстае адпаведзенне.
- Чаму не вжыць OpenAPI з генераваннем коду? эта найсильнейшая працоўка, таму што генераваныя кліенты дзе-жаўна з’являюць типы. Кост заключаецца ў схеме, якую трэба падтрымваць, у кроці генеравання, які трэба выконваць, у генераваных файлах, якія трэба зберагчыць, а таксама ў неабходнасці падтрымвання сінхроназы ўсьго гэтага. З tRPC сам код сервера являецца ўнікальным адказвальным джэрелам. Якщо вы воліяеце падход з OpenAPI, адзін раз паглядзіце, як генераваць кліента Next.js з падтрымкай типаў з NestJS Swagger.
- А што з корыстнікамі, якія не напісаны на TypeScript? У такім случае для абработкі API вжываюцца калькі внутраніх інструментаў на Python. Їх канцэнтры проста застаюцца у формате REST. Пошаговая міграцыя робіць гэта простым, адколі оба падходы можаць існаваць разам.
Калі выгода стае явной
Прыблізна чераз шысць тыдняў машына станоўства заявкі на кредыт патрабавала структурных змян. Плоскае поле статусу ператворылася на вкладаны об’ект, який фіксуе як стадію перагляду, так і рэзультат затверджэння:
// Before
type LoanApplication = {
status: 'PENDING' | 'APPROVED' | 'REJECTED';
}
// After
type LoanApplication = {
applicationStatus: {
stage: 'DOCUMENT_COLLECTION' | 'CREDIT_REVIEW' | 'FINAL_APPROVAL';
approvalStatus: 'PENDING' | 'APPROVED' | 'REJECTED' | null;
};
}
У старайшым падчыненні кожны компанент, які чытаў application.status, продовжваў компілявацца, але выконвалася б з каштою, і ўзбраннем была толькі пошук у всім кодбэсе з наступным спадзяваннем. За дапамогою tRPC змена типу значэння, яке вяртаеся пры выкананні процедуры, адразу ж паказала 8 месца вызоў у фронтэндзе. Усія яны былі выправлены за аднаго дня, а змяны, якія тычыліся кальколькох систем, былі выпусцаны без жадных багоў пад час выканання. Пасля гэтага тэматыка размовы перайшла з адзінакоўства міграцыі на запитанне, який ендпоінт будзе першым перанесены.
Рэзультаты пасля шасці месцаў
- Колькасць типаў значэння
anyу шары API зменілася з 47 на 3. - Ўтрэмшыя 3 — это пакеты дадзеных з вебхукоў трэціх сторон, якія не маюць публікуемых типаў; яны аналізуюцца за дапамогою перавераных схем Zod, а не прыймаюцца без пераканання.
44 выдаленыя типы any апавярнююць латэнтныя багі, якія так і не мелі можласці выйсці на паверхню. Чытайце пра шасць спосабоў безпечнага замены any для усунення апошніх проблемаў у гэтым артыкуле.
Дзе tRPC не падходзіць
tRPC є карысным толькі тады, калі TypeScript выкарыстоўваецца на обох канцах з’яносу. Бэкенд, які падтрымлеў разныя мовы, або публічны API, які викорыстоўваюць трэціяя стороны, патрабуе явнага, нейтральнага па мове контракту, такога як OpenAPI або GraphQL.
Таксама памятаце, што гарантія стосуецца выходных дадзеных у час компілявання. Zod пераканалівае вхідныя даны ў час выконання, але выведзены тип не пераканалівае нічога ў час выконання, і вярненне необработанага запису базы дадзеных, як гэта робіць вышэўшы рутер, апрацоўвае кожны столбець для кліента. Для чутлівых домэнаў выберыце або прысваяйце тыя поля, якія вы насправдзе хочаце адкрыць.
Галоўныя выводы
- Наявнасць TypeScript на обох боках не ўзначае наявнасці типаванага кантракта межы ўсімі; tRPC з’яўляецца як нехватны лінк без неабходнасці стварэння схемы чы адгенеравання коду.
- Мігруйце кожны эндпаўт поштоўна пасля REST, так каб корыстувальнікі, якія не викорыстоўваюць TypeScript, продавалі працаваць, а кожны крок быў достатнька малым, ўбачлівым для перагляду.
- Найбольшая выгода праявляецца пад час рефакторавання, калі компіляр показвае вам усіа афектаваныя месцы вызову.