Strona główna / Artykuły

Wykrywanie odchyleń kontraktu API w czasie kompilacji za pomocą stopniowego wdrażania tRPC

tRPC przekształca przemienione pole backendu w błąd kompilacji TypeScript. Jak wdrażać go endpoint po endpoint obok REST oraz kiedy jest to niewłaściwe narzędzie.

Wydawca: REACTAPP.TOP7 min czytania1681 słów

Źródła: **I Switched From REST to tRPC. My Team Almost Revolted. Then They Loved It.**

Załóżmy platformę pożyczkową, na której inżynier backendu zmienia nazwę pola applicantName na applicantFullName w odpowiedzi dotyczącej wniosku o pożyczkę. API, testy backendu oraz prośba o integrację zostały wszystkie zaktualizowane i zatwierdzone, a mimo to dwa dni później na ekranie podsumowania pożyczki wyświetlają się puste nazwy: żadnego wyjątku, żadnego ostrzeżenia w konsoli, tylko pusta struna, ponieważ frontend nadal odczytuje klucz, który już nie istnieje. Obie strony zostały napisane w TypeScript, a żadne z tych informacji typowych nie przekroczyło granic sieci. Poniżej zobaczysz, w jaki sposób tRPC zamienia tę lukę, jak można go stopniowo wprowadzać obok REST oraz w jakich sytuacjach nie nadaje się do użycia.

Czym jest tRPC i czym nie jest

tRPC nie jest protokołem przesyłania danych i nie zastępuje REST ani GraphQL na poziomie transportu. Zapytania pozostają zwykłymi żądaniami HTTP. To, co on dodaje, to wspólna warstwa typów: typy zwracane przez procedury serwera są wywnioskowywane przez TypeScript i trafiają bezpośrednio do klienta, bez żadnego pliku schematu ani kroku generowania kodu pomiędzy nimi.

Przydatnym modelem mentalnym jest wywołanie funkcji, które akurat wykonuje się na serwerze. Umowa pomiędzy wywołującym a wywoływanym jest taka, jak określa to kompilator TypeScript, a nie oddzielny dokument specyfikacji, który może być aktualizowany lub też nie. Gdy serwer zmienia to, co zwraca dana procedura, każde miejsce wywołania klienta, które polega na starym formacie, nie kompiluje się. Błąd pojawia się w edytorze dokładnie na linii, gdzie brakuje danego pola, a nie dopiero po kilku dniach w środowisku produkcyjnym.

Koszt warstwy API bez typowania

W opisanym powyżej scenariuszu warstwa usług frontendowych wyglądała mniej więcej w ten sposób: każda metoda pobierała JSON i zwracała je jako typ any, a komponenty odczytywały pola z tego wyniku, polegając na ich poprawności.

// 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

Zespół zidentyfikował 47 metod o typie zwracanym any. Każda z nich stanowiła miejsce, w którym zmiana nazwy pola, przekształcenie zagnieżdżonego obiektu lub zmiana wartości enum pozwalałoby na czystą kompilację, a problem pojawiłby się jedynie w czasie wykonywania, gdy użytkownik sprawdzał status aplikacji. W produkcie finansowym to nie jest zwykły błąd estetyczny – pokazanie klientowi niewłaściwych lub brakujących danych może stanowić problem zgodności.

Migracja przy użyciu wzorca strangler fig

Zmienianie wszystkich punktów końcowych jednocześnie jest ryzykowne. Bezpieczniejszą metodą jest podejście typu „strangler fig”: pozostawia się istniejące punkty końcowe REST nietknięte, instaluje się tRPC obok nich i przenosi jeden punkt końcowy po drugim. Każdy przemigrowany punkt końcowy natychmiast zyskuje bezpieczeństwo typów, a to, co jeszcze nie zostało przemigrowane, nie musi ulegać zmianom.

Definiowanie routera

Strona serwera zaczyna się od initTRPC.create(), które zwraca obiekt budujący konwencjonalnie nazwany t. Routerzy mogą być nawarstwowane, więc podrouter loans grupuje procedury związane z pożyczkami. Zwróć uwagę na trzy rzeczy w tym fragmencie. Po pierwsze, każda procedura deklaruje swoje dane wejściowe za pomocą schematu Zod, dzięki czemu nieprawidłowy ID lub nieznany stan są odrzucane w czasie wykonywania przed uruchomieniem obsługi, a ten sam schemat dostarcza obsłudze typizowane dane input. Po drugie, do odczytu używa się query, a do zapisu – mutation. Po trzecie, nikt ręcznie nie określa typu zwracanego wartości: TypeScript wywnioskowuje go na podstawie tego, co zwraca wywołanie bazy danych. Ostatni wiersz eksportuje jedynie typ routera, który jest wykorzystywany przez klienta.

// 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;

Jeden szczegół, który trzeba poprawić przy kopiowaniu tego kodu: obsługa zdarzeń rzuca błąd TRPCError, który również musi zostać sprowadzony z modułu @trpc/server. Brakuje go w linii importu tutaj.

Tworzenie klienta

W części frontendowej klient jest tworzony na podstawie typu AppRouter. Ponieważ import wykorzystuje instrukcję import type, żaden kod serwera nie trafia do pliku bundle przeglądarki; udostępniane są jedynie informacje typowe. Funkcja httpBatchLink łączy kilka wywołań dokonanych w tym samym cyklu w jedną prośbę HTTP, a jej funkcja callback headers jest wykonywana przy każdej prośbie, co stanowi wygodne miejsce na dołączenie tokena uprawniającego.

// 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()}`,
};
},
}),
],
});

Nazwa fabryki odzwierciedla używaną wersję tRPC: createTRPCProxyClient to API wersji v10, natomiast nowsze wydania używają nazwy createTRPCClient. Sprawdź aktualną dokumentację pod kątem wersji, którą instalujesz. Udostępnianie tego typu zakłada również, że klient może go zaimportować, zazwyczaj poprzez monorepo lub pakiet wspólnego środowiska pracy.

Wywoływanie procedury

Wywołanie teraz przypomina metodę w lokalnym obiekcie. Wynik jest w pełni typowany, więc odwoływanie się do pola, którego serwer już nie zwraca, powoduje błąd kompilacji, a nie ciche wartość 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

Odpowiadanie na zastrzeżenia zespołu

Oczekuj sprzeciwu. Zazwyczaj pojawiają się trzy zastrzeżenia, a każde z nich wymaga jednoznacznej odpowiedzi.

  • Dlaczego nie OpenAPI z generowaniem kodu? To najsilniejszy zarzut, ponieważ generowane klienty rzeczywiście dostarczają typów. Kosztem jest schemat, który trzeba utrzymywać, krok generowania, który musi zostać wykonywany, pliki generowane, które trzeba zapisywać, oraz dyscyplina konieczna do utrzymania ich wszystkich w synchronizacji. W tRPC sam kod serwera jest jedynym źródłem prawdy. Jeśli wolisz podejście OpenAPI, sprawdź, jak stworzyć typowany klient Next.js z NestJS Swagger.
  • A co z odbiorcami, które nie są napisane w TypeScript? W tym przypadku API jest wykorzystywane przez kilka wewnętrznych narzędzi w Pythonie. Ich punkty końcowe pozostają po prostu w formacie REST. Migracja stopniowa sprawia, że jest to proste, ponieważ oba style mogą istnieć obok siebie.
  • Czy krzywa uczenia się jest warta wysiłku? Routerzy, procedury oraz schematy danych Zod to nowy słownictwo. 30 minut instruktażu wraz z krótkim przewodnikiem wewnętrznym wystarczyło, aby zespół mógł efektywnie pracować; pojęć jest niewiele, ale ta pierwsza sesja ma kluczowe znaczenie.
  • Gdy korzyści stają się oczywiste

    Mniej więcej po sześciu tygodniach maszyna stanów do składania wniosków o pożyczkę wymagała zmiany strukturalnej. Płaskie pole stanu przekształciło się w zagnieżdżony obiekt, który rejestruje zarówno etap przeglądu, jak i wynik zatwierdzenia:

    // Before
    type LoanApplication = {
    status: 'PENDING' | 'APPROVED' | 'REJECTED';
    }
    // After
    type LoanApplication = {
    applicationStatus: {
    stage: 'DOCUMENT_COLLECTION' | 'CREDIT_REVIEW' | 'FINAL_APPROVAL';
    approvalStatus: 'PENDING' | 'APPROVED' | 'REJECTED' | null;
    };
    }
    

    W starym układzie każdy komponent, który czytał application.status, nadal byłby kompilowany, ale powodował błędy w czasie wykonywania, a jedyną obroną byłoby przeszukanie całego kodu w poszukiwaniu problemu, po czym pozostawała tylko nadzieja. Dzięki tRPC zmiana typu zwracanego przez procedurę natychmiast wskazała 8 miejsc we frontendzie, gdzie dochodziło do takich wywołań. Wszystkie zostały naprawione w ciągu popołudnia, a zmiana dotycząca kilku systemów została wdrożona bez żadnych błędów w czasie wykonywania. Po tym rozmowa przesunęła się od kwestionowania migracji na pytanie, który endpoint zostanie następnie przeniesiony.

    Wyniki po sześciu miesiącach

    • Liczba typów zwracanych any w warstwie API spadła z 47 do 3.
    • Pozostałe 3 to dane webhooków od dostawców zewnętrznych, które nie mają określonych typów; są one analizowane za pomocą zweryfikowanych schematów Zod zamiast być traktowane bezkrytycznie.
  • Od rozpoczęcia migracji zakończenia punktów końcowych nie miały żadnych błędów związanych z niezgodnością typów w czasie wykonywania.
  • 11 błędów typu zostało wykrytych w czasie kompilacji, które w przeciwnym razie trafiłyby do środowiska produkcyjnego.
  • 44 usunięte typy any reprezentują ukryte błędy, które nigdy nie miały szansy się pojawić. Aby pozbyć się ostatnich takich problemów, zapoznaj się z sześcioma bezpiecznymi zamiennikami any.

    Gdzie tRPC nie nadaje się

    tRPC jest efektywny tylko wtedy, gdy TypeScript jest używany po obu stronach połączenia. Tło wielojęzyczne lub publiczna API używana przez strony trzecie wymagają wyraźnego, neutralnego pod względem języka kontraktu, takiego jak OpenAPI lub GraphQL.

    Pamiętaj również, że gwarancja dotyczy wyników w czasie kompilacji. Zod waliduje dane wejściowe w czasie wykonywania, ale domyślny typ zwracany nie sprawdza niczego w tym czasie, a zwracanie surowego rekordu bazy danych, jak to robi powyższy router, wysyła wszystkie kolumny do klienta. W przypadku wrażliwych domen wybierz lub przypisz tylko te pola, które rzeczywiście chcesz udostępnić.

    Główne wnioski

    • Użycie TypeScript po obu stronach nie jest tym samym co posiadanie typowanego kontraktu między nimi; tRPC zapewnia ten brakujący element bez konieczności tworzenia schematu czy kroków generowania kodu.
    • Migruj punkty końcowe po kolei obok rozwiązania REST, aby klienci niekorzystający z TypeScript nadal mogli pracować, a każdy krok był na tyle mały, by można go było przejrzeć.
    • Największe korzyści pojawiają się podczas refaktoryzacji, gdy kompilator wyświetla wszystkie dotknięte miejsca wywołań.
  • Gdy dane reprezentują pieniądze i informacje o klientach, taki poziom bezpieczeństwa typów jest podstawowym wymogiem, a nie luksusem, a tRPC umożliwia jego osiągnięcie bez zbędnych elementów szablonowych.
  • Literatura pokrewna