Автор Марио Зупан✏️
Возможно, вы уже знакомы с фреймворком удаленного вызова процедур gRPC. Учитывая сходство в названии, вы можете быть склонны полагать, что tRPC как-то связан с ним или делает то же самое или похожее. Однако это не так.
Хотя tRPC действительно является фреймворком удаленного вызова процедур, его цели и основа принципиально отличаются от gRPC. Основная цель tRPC – предоставить простой, безопасный для типов способ создания API для проектов на TypeScript и JavaScript с минимальными затратами.
В этой статье мы построим простое, полностековое TypeScript-приложение с использованием tRPC, которое будет безопасным с точки зрения типов как в коде, так и на границе API. Мы создадим небольшое приложение на кошачью тематику, чтобы показать, как настроить tRPC на бэкенде и как использовать созданный API во фронтенде React. Полный код этого примера вы можете найти на GitHub. Давайте приступим!
- Изучение tRPC
- Настройка tRPC
- Настройка нашего бэкенда Express
- Установка зависимостей
- Бэкенд с Express
- Тестирование нашего бэкенда Express
- Создание нашего фронтенда React
- Тестирование и возможности tRPC
- Изучение tRPC
- Настройка tRPC
- Настройка бэкенда Express
- Установите зависимости
- Бэкэнд с Express
- Тестирование нашего бэкенда Express
- Создание нашего React frontend
- Тестирование и возможности tRPC
- Заключение
- Много пишете на TypeScript? Посмотрите запись нашей недавней встречи по TypeScript, чтобы узнать о написании более читабельного кода.
Изучение tRPC
Если у вас есть приложение, использующее TypeScript как в бэкенде, так и во фронтенде, tRPC поможет вам настроить API таким образом, что вы понесете абсолютный минимум накладных расходов в плане зависимостей и сложности выполнения. Однако tRPC по-прежнему обеспечивает безопасность типов и все сопутствующие ей возможности, такие как автозавершение для всего API и ошибки при использовании API некорректным образом.
С практической точки зрения, вы можете рассматривать tRPC как очень легковесную альтернативу GraphQL. Однако tRPC не лишен своих ограничений. Во-первых, он ограничен TypeScript и JavaScript. Кроме того, API, который вы создаете, будет следовать модели tRPC, что означает, что это не будет REST API. Вы не сможете просто преобразовать REST API в tRPC и получить тот же API, что и раньше, но с включенными типами.
По сути, tRPC – это решение с включенными типами для всех ваших потребностей в API, но это также будет tRPC-API. Именно отсюда и происходит RPC в названии, коренным образом меняя принцип работы удаленных вызовов. tRPC может стать отличным решением для вас, если вам удобно использовать TypeScript на вашем API-шлюзе.
Настройка tRPC
Мы начнем с создания папки в корне нашего проекта под названием server
. В папке server
мы создадим файл package.json
следующим образом:
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo "Error: no test specified" && exit 1"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@tsconfig/node14": "^1.0.1",
"typescript": "^4.5"
},
"dependencies": {
"@trpc/server": "^9.21.0",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"cors": "^2.8.5",
"express": "^4.17.2",
"zod": "^3.14.2"
}
}
Мы также создадим файл tsconfig.json
:
{
"extends": "@tsconfig/node14/tsconfig.json",
"compilerOptions": {
"outDir": "build"
}
}
Наконец, создадим исполняемый файл run.sh
:
#!/bin/bash -e
./node_modules/.bin/tsc && node build/index.js
Далее мы создаем папку src
и включаем в нее файл index.ts
. Наконец, мы выполним команду npm install
в папке server
. На этом мы закончили настройку бэкенда.
Для фронтенда мы будем использовать Create React App для установки приложения React с поддержкой TypeScript с помощью следующей команды в корне проекта:
npx create-react-app client --template typescript
Мы также можем запустить npm install
в папке client
и запустить приложение с помощью npm start
, чтобы убедиться, что все работает и настроено правильно. Далее мы реализуем бэкенд нашего приложения.
Настройка бэкенда Express
Установите зависимости
Как вы можете видеть выше в package.json
части server
нашего приложения, мы используем Express в качестве HTTP-сервера. Кроме того, мы добавляем TypeScript и зависимость trpc-server
.
Кроме того, мы использовали библиотеку cors
для добавления CORS к нашему API, что не совсем необходимо для данного примера, но является хорошей практикой. Мы также добавили Zod, библиотеку проверки схем с поддержкой TypeScript, которая часто используется в сочетании с tRPC. Однако вы можете использовать и другие библиотеки, такие как Yup или Superstruct. Для чего именно они нужны, мы увидим позже.
С зависимостями разобрались, давайте настроим наш базовый бэкенд Express с поддержкой tRPC.
Бэкэнд с Express
Начнем с определения маршрутизатора tRPC, который является очень важной частью всей этой инфраструктуры, позволяя нам соединить бэкенд и фронтенд с точки зрения безопасности типов и автозаполнения. Этот маршрутизатор должен находиться в собственном файле, например, router.ts
, поскольку позже мы импортируем его в наше приложение React.
Внутри router.ts
мы начнем с определения структуры данных для нашего доменного объекта, Cat
:
let cats: Cat[] = [];
const Cat = z.object({
id: z.number(),
name: z.string(),
});
const Cats = z.array(Cat);
...
export type Cat = z.infer<typeof Cat>;
export type Cats = z.infer<typeof Cats>;
Вы можете задаться вопросом, почему мы не создаем простые типы и объекты на JavaScript или TypeScript. Поскольку мы используем Zod для валидации схем в tRPC, нам также необходимо создавать эти доменные объекты с его помощью. Мы можем добавлять правила проверки с помощью Zod, например, максимальное количество символов для строки, проверку электронной почты и многое другое, сочетая проверку типов с фактической проверкой.
Мы также получаем автоматически создаваемые сообщения об ошибках, когда вводимые данные не соответствуют действительности. Однако эти ошибки могут быть полностью настроены. Если вы заинтересованы в валидации и обработке ошибок, ознакомьтесь с документацией для получения дополнительной информации.
После реализации нашего типа с помощью Zod мы можем вывести из него тип TypeScript с помощью z.infer
. После этого мы экспортируем тип, чтобы использовать его в других частях приложения, например во фронтенде, а затем перейдем к созданию сердца приложения – маршрутизатора:
const trpcRouter = trpc.router()
.query('get', {
input: z.number(),
output: Cat,
async resolve(req) {
const foundCat = cats.find((cat => cat.id === req.input));
if (!foundCat) {
throw new trpc.TRPCError({
code: 'BAD_REQUEST',
message: `could not find cat with id ${req.input}`,
});
}
return foundCat;
},
})
.query('list', {
output: Cats,
async resolve() {
return cats;
},
})
Мы можем создать маршрутизатор tRPC, вызвав метод router()
и подключив к нему различные конечные точки. Мы также можем создать несколько маршрутизаторов и объединить их. В tRPC существует два типа процедур:
- Запрос: Используется для получения данных. Например,
GET
. - Мутация: Используется для изменения данных. Используйте
POST
,PUT
,PATCH
,DELETE
.
Во фрагменте кода выше мы создаем конечные точки query
, одну для получения единичного объекта Cat
по ID и одну для получения всех объектов Cat
. tRPC также поддерживает концепцию infiniteQuery
, которая принимает курсор и при необходимости может вернуть постраничный ответ с потенциально бесконечными данными.
Для конечной точки GET
мы определяем input
. Эта конечная точка будет, по сути, конечной точкой GET /get?input=123
, возвращающей JSON нашего Cat
на основе определения выше.
Мы можем определить несколько входов, если они нам нужны. В асинхронной функции resolve
мы реализуем нашу фактическую бизнес-логику. В реальном приложении мы можем вызвать службу или уровень базы данных. Однако, поскольку мы просто сохраняем наши объекты Cat
в памяти или в массиве, мы проверяем, есть ли у нас Cat
с заданным ID, и если нет, мы выдаем ошибку. Если объект Cat
найден, мы возвращаем его.
Конечная точка list
еще проще, поскольку она не принимает никаких входных данных и возвращает только наш текущий список объектов Cat
. Давайте посмотрим, как мы можем реализовать создание и удаление с помощью tRPC:
.mutation('create', {
input: z.object({ name: z.string().max(50) }),
async resolve(req) {
const newCat: Cat = { id: newId(), name: req.input.name };
cats.push(newCat)
return newCat
}
})
.mutation('delete', {
input: z.object({ id: z.number() }),
output: z.string(),
async resolve(req) {
cats = cats.filter(cat => cat.id !== req.input.id);
return "success"
}
});
function newId(): number {
return Math.floor(Math.random() * 10000)
}
export type TRPCRouter = typeof trpcRouter;
export default trpcRouter;
Как вы видите, мы используем метод .mutation
для создания новой мутации. В нем мы можем, опять же, определить input
, который в данном случае будет объектом JSON. Обратите внимание на опцию валидации, которую мы предоставили для name
.
В resolve
мы создаем новую Cat
от заданного имени со случайным ID. Проверяем функцию newId
внизу и добавляем ее в наш список объектов Cat
, возвращая новый Cat
вызывающей стороне. В результате получится что-то вроде POST /create
, ожидающего какого-то тела. Если мы используем application/json content-type
, он вернет нам JSON и будет ожидать JSON.
В мутации delete
мы ожидаем ID объекта Cat
, фильтруем список объектов Cat
по этому ID и обновляем список, возвращая пользователю сообщение об успехе. На самом деле ответы не похожи на то, что мы определили здесь. Скорее, они обернуты внутри ответа tRPC, как показано ниже:
{"id":null,"result":{"type":"data","data":"success"}}
Вот и все для нашего маршрутизатора; у нас есть все конечные точки, которые нам нужны. Теперь нам нужно соединить его с веб-приложением Express:
import express, { Application } from 'express';
import cors from 'cors';
import * as trpcExpress from '@trpc/server/adapters/express';
import trpcRouter from './router';
const app: Application = express();
const createContext = ({}: trpcExpress.CreateExpressContextOptions) => ({})
app.use(express.json());
app.use(cors());
app.use(
'/cat',
trpcExpress.createExpressMiddleware({
router: trpcRouter,
createContext,
}),
);
app.listen(8080, () => {
console.log("Server running on port 8080");
});
tRPC поставляется с адаптером для Express, поэтому мы просто создаем наше приложение Express и используем предоставленное промежуточное ПО tRPC внутри приложения. Мы можем определить подмаршрут, где должна использоваться эта конфигурация, маршрутизатор и контекст.
Функция контекста вызывается для каждого входящего запроса и передает его результат обработчикам. В функцию контекста можно добавить нужные данные контекста для каждого запроса, например, токен аутентификации или userId
пользователя, который вошел в систему.
Если вы хотите узнать больше об авторизации с помощью tRPC, в документации есть раздел об этом.
Тестирование нашего бэкенда Express
Вот и все для приложения! Давайте быстро протестируем его, чтобы знать, что все работает правильно. Мы можем запустить приложение, выполнив файл ./run.sh
, и отправить несколько HTTP-запросов с помощью cURL. Сначала создадим новый Cat
:
curl -X POST "http://localhost:8080/cat/create" -d '{"name": "Minka" }' -H 'content-type: application/json'
{"id":null,"result":{"type":"data","data":{"id":7216,"name":"Minka"}}}
Затем мы можем перечислить существующие объекты Cat
:
curl "http://localhost:8080/cat/list"
{"id":null,"result":{"type":"data","data":[{"id":7216,"name":"Minka"}]}}
Мы также можем получить Cat
по его ID:
curl "http://localhost:8080/cat/get?input=7216"
{"id":null,"result":{"type":"data","data":{"id":7216,"name":"Minka"}}}
И, наконец, удалить Cat
:
curl -X POST "http://localhost:8080/cat/delete" -d '{"id": 7216}' -H 'content-type: application/json'
{"id":null,"result":{"type":"data","data":"success"}}
curl "http://localhost:8080/cat/list"
{"id":null,"result":{"type":"data","data":[]}}
Все работает, как и ожидалось. Теперь, когда бэкенд создан, давайте создадим наш фронтенд React.
Создание нашего React frontend
Сначала в папке src
создадим папку cats
, чтобы добавить некоторую структуру в наше приложение. Затем добавим некоторые дополнительные зависимости:
npm install --save @trpc/client @trpc/server @trpc/react react-query zod
Нам нужен server
для безопасности типов, client
для минимальной логики, необходимой для вызовов API, zod
, как уже упоминалось, для валидации схемы, trpc/react
для более простой интеграции с React Query, и, наконец, React Query. Однако можно также использовать trpc/client
самостоятельно, или полностью ванильно, что также описано в документации.
В этом примере, как и в официальных, мы будем использовать React Query, который добавляет API-взаимодействие в приложения React. Добавление React Query совершенно необязательно, и можно просто использовать ванильный клиент с фронтенд-фреймворком по вашему выбору, включая React, и интегрировать его именно так, как вы хотите. Давайте начнем с создания базовой структуры нашего приложения в App.tsx
:
import { useState } from 'react';
import './App.css';
import type { TRPCRouter } from '../../server/src/router';
import { createReactQueryHooks } from '@trpc/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import Create from './cats/Create';
import Detail from './cats/Detail';
import List from './cats/List';
const BACKEND_URL: string = "http://localhost:8080/cat";
export const trpc = createReactQueryHooks<TRPCRouter>();
function App() {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() => trpc.createClient({ url: BACKEND_URL }));
const [detailId, setDetailId] = useState(-1);
const setDetail = (id: number) => {
setDetailId(id);
}
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<div className="App">
<Create />
<List setDetail={setDetail}/>
{ detailId > 0 ? <Detail id={detailId} /> : null }
</div>
</QueryClientProvider>
</trpc.Provider>
);
}
export default App;
Распаковывать придется довольно много, поэтому начнем с самого начала. Мы создаем trpc
с помощью помощника createReactQueryHooks
из trpc/react
, передавая ему TRPCRouter
, который мы импортируем из нашего внутреннего приложения. Мы также экспортируем его для использования в остальной части нашего приложения.
По сути, это создает все привязки к нашему API. Далее мы создаем клиент React Query и tRPC-клиент для предоставления URL нашего бэкенда. Мы будем использовать этот клиент для выполнения запросов к API, а точнее, клиент, который будет использовать React Query.
В дополнение ко всей этой настройке мы также определяем переменную состояния для detailId
, чтобы знать, какую деталь Cat
показывать, если пользователь выберет любую.
Если вы посмотрите, что мы возвращаем из App
, вы увидите, что наша фактическая разметка, div
с классом App
, вложена в два слоя. Эти слои находятся на внешней стороне, это провайдер tRPC, а внутри него – провайдер React Query.
Эти два компонента делают необходимые движущиеся части доступными для всего нашего приложения. Таким образом, мы можем использовать tRPC во всем нашем приложении, а вызовы запросов легко интегрируются в наше приложение React. Далее мы добавим в нашу разметку компоненты Create
, List
и Detail
, которые будут включать всю нашу бизнес-логику.
Начнем с компонента Create
, создав файл Create.css
и Create.tsx
в папке src/cats
. В этом компоненте мы просто создадим форму и подключим ее к мутации create
, которую мы реализовали на бэкенде. После создания нового Cat
мы хотим повторно получить список объектов Cat
, чтобы он всегда был актуальным. Мы можем реализовать это с помощью следующего кода:
import './Create.css';
import { ChangeEvent, useState } from 'react';
import { trpc } from '../App';
function Create() {
const [text, setText] = useState("");
const [error, setError] = useState("");
const cats = trpc.useQuery(['list']);
const createMutation = trpc.useMutation(['create'], {
onSuccess: () => {
cats.refetch();
},
onError: (data) => {
setError(data.message);
}
});
const updateText = (event: ChangeEvent<HTMLInputElement>) => {
setText(event.target.value);
};
const handleCreate = async() => {
createMutation.mutate({ name: text });
setText("");
};
return (
<div className="Create">
{error && error}
<h2>Create Cat</h2>
<div>Name: <input type="text" onChange={updateText} value={text} /></div>
<div><button onClick={handleCreate}>Create</button></div>
</div>
);
}
export default Create;
Давайте начнем с очень базовой, ванильной логики React. Мы создаем некоторое внутреннее состояние компонента для поля нашей формы и потенциальных ошибок, которые мы, возможно, захотим показать. Мы возвращаем простую форму, содержащую текстовое поле, связанное с нашим состоянием, а также кнопку для отправки.
Теперь давайте рассмотрим функцию handleCreate
. Мы вызываем .mutate
на createMutation
, которую определяем над ней, и после этого сбрасываем поле text
.
createMutation
создается с помощью trpc.useMutation
с нашей конечной точкой create
. В вашей IDE или редакторе обратите внимание, что при наборе create
внутри вызова useMutation
вы получите предложения автозаполнения. Мы также получаем предложения в полезной нагрузке для вызова .mutate
, предлагающие использовать поле name
.
Внутри вызова .useMutation
мы определяем, что должно произойти в случае успеха и ошибки. Если мы столкнулись с ошибкой, мы просто хотим отобразить ее, используя наше внутреннее состояние компонента. Если мы успешно создали Cat
, мы хотим повторно получить данные для нашего списка объектов Cat
. Для этого мы определяем вызов этой конечной точки с помощью trpc.useQuery
с нашей конечной точкой list
и вызываем ее внутри обработчика onSuccess
.
Мы уже видим, как легко интегрировать наше приложение с API tRPC, а также как tRPC помогает нам во время разработки. Далее рассмотрим детальный вид, создав Detail.tsx
и Detail.css
в папке cats
:
import './Detail.css';
import { trpc } from '../App';
function Detail(props: {
id: number,
}) {
const cat = trpc.useQuery(['get', props.id]);
return (
cat.data ?
<div className="Detail">
<h2>Detail</h2>
<div>{cat.data.id}</div>
<div>{cat.data.name}</div>
</div> : <div className="Detail"></div>
);
}
export default Detail;
В компоненте выше, мы в основном просто используем .useQuery
снова для определения конечной точки getCatById
, предоставляя ID, который мы получаем из нашего корневого компонента через props. Если мы действительно получим данные, мы отобразим подробную информацию о Cat
. Мы также можем использовать эффекты для получения данных. По сути, любой способ интеграции API в ваше приложение React будет отлично работать с tRPC и React Query.
Наконец, давайте реализуем наш компонент List
, создав List.css
и List.tsx
в cats
. В нашем списке объектов Cat
мы будем отображать ID и имя Cat
, а также ссылку для детального просмотра и ссылку для удаления:
import './List.css';
import { trpc } from '../App';
import type { Cat } from '../../../server/src/router';
import { useState } from 'react';
function List(props: {
setDetail: (id: number) => void,
}) {
const [error, setError] = useState("");
const cats = trpc.useQuery(['list']);
const deleteMutation = trpc.useMutation(['delete'], {
onSuccess: () => {
cats.refetch();
},
onError: (data) => {
setError(data.message);
}
});
const handleDelete = async(id: number) => {
deleteMutation.mutate({ id })
};
const catRow = (cat: Cat) => {
return (
<div key={cat.id}>
<span>{cat.id}</span>
<span>{cat.name}</span>
<span><a href="#" onClick={props.setDetail.bind(null, cat.id)}>detail</a></span>
<span><a href="#" onClick={handleDelete.bind(null, cat.id)}>delete</a></span>
</div>
);
};
return (
<div className="List">
<h2>Cats</h2>
<span>{error}</span>
{ cats.data && cats.data.map((cat) => {
return catRow(cat);
})}
</div>
);
}
export default List;
Этот компонент в основном объединяет функциональность, которую мы использовали в двух предыдущих. Например, мы получаем список кошек с помощью useQuery
на нашей конечной точке list
, а также реализуем удаление объектов Cat
с последующей повторной выборкой с помощью deleteMutation
, указывая на нашу мутацию delete
на бэкенде.
Кроме этого, все очень похоже. Мы передаем функцию setDetailId
из App
через props, чтобы мы могли установить кошку для показа деталей в Detail
и создать обработчик для удаления кошки, который выполняет нашу мутацию.
Обратите внимание на все автодополнения, предоставляемые tRPC. Если вы введете что-то неправильно, например, имя конечной точки, вы получите ошибку, и фронтенд не запустится, пока ошибка не будет исправлена. Вот и все для нашего фронтенда, давайте протестируем его и посмотрим на tRPC в действии!
Тестирование и возможности tRPC
Во-первых, давайте запустим приложение с помощью npm start
и посмотрим, как оно работает. После запуска приложения мы можем создавать новых котов, удалять их и просматривать страницу их подробностей, наблюдая за изменениями непосредственно в списке. Это не особенно красиво, но это работает!
Давайте посмотрим, как tRPC может помочь нам в процессе разработки. Допустим, мы хотим добавить поле age
для наших кошек:
const Cat = z.object({
id: z.number(),
name: z.string(),
age: z.number(),
});
...
.mutation('create', {
input: z.object({ name: z.string().max(50), age: z.number().min(1).max(30) }),
async resolve(req) {
const newCat: Cat = { id: newId(), name: req.input.name, age: req.input.age };
cats.push(newCat)
return newCat
}
})
...
Мы добавляем поле в наш объект домена, и нам также нужно добавить его в конечную точку create
. После того, как вы нажмете кнопку сохранения в коде бэкенда, вернитесь к коду фронтенда в ./client/src/cats/Create.tsx
. Наш редактор показывает нам ошибку. Свойство age
отсутствует в нашем вызове createMutation
:
Если мы хотим добавить поле age
в нашу мутацию сейчас, наш редактор предоставит нам автозаполнение с полной информацией о типе прямо из нашего измененного router.ts
:
С моей точки зрения, в этом и заключается истинная сила tRPC. Хотя приятно иметь простой способ создания API как на фронтенде, так и на бэкенде, настоящим преимуществом является тот факт, что код фактически не будет собран, если я внесу разрушающее изменение на одной стороне, а не на другой.
Например, представьте себе огромную кодовую базу с несколькими командами, работающими над конечными точками API и элементами пользовательского интерфейса. Такая безопасность в плане совместимости с API при почти полном отсутствии накладных расходов для приложения просто замечательна.
Заключение
Надеюсь, эта статья показала вам, как tRPC может быть полезен в ситуациях, когда используется TypeScript как на фронтенде, так и на бэкенде. Мне нравится подход, не занимающий много места. С минимальным количеством или отсутствием дополнительных зависимостей вы можете сосредоточиться на корректности во время компиляции, а не на проверке во время выполнения.
Очевидно, что в некоторых случаях ограничение TypeScript может оказаться слишком сильным. Принцип, лежащий в основе tRPC, замечателен с точки зрения удобства для разработчиков. tRPC – это интересный проект, за которым я обязательно буду следить в будущем. Надеюсь, вам понравилась эта статья! Счастливого кодинга.
Много пишете на TypeScript? Посмотрите запись нашей недавней встречи по TypeScript, чтобы узнать о написании более читабельного кода.
TypeScript привносит безопасность типов в JavaScript. Между безопасностью типов и читабельностью кода может возникнуть противоречие. Посмотрите запись, чтобы узнать о некоторых новых возможностях TypeScript 4.4.