Hello, world!
В этом туториале мы разработаем простое типобезопасное (typesafe) клиент-серверное (fullstack) приложение с помощью tRPC, React и Express.
tRPC — позволяет разрабатывать полностью безопасные с точки зрения типов API для клиент-серверных приложений (предпочтительной является архитектура монорепозитория). Это посредник между сервером и клиентом, позволяющий им использовать один маршрутизатор (роутер) для обработки запросов HTTP. Использование одного роутера, в свою очередь, обуславливает возможность автоматического вывода типов (type inference) входящих и исходящих данных (input/output), что особенно актуально для клиента и позволяет избежать дублирования типов или использования общих (shared) типов.
Руководство по tRPC находится в процессе подготовки — следите за обновлениями ????
Для тех, кого интересует только код, вот ссылка на соответствующий репозиторий.
Интересно? Тогда прошу под кат.
Подготовка и настройка проекта
Функционал нашего приложения будет следующим:
- на сервере хранится массив с данными пользователей;
- на сервере есть конечные точки (endpoints) для:
- получения всех пользователей;
- получения одного пользователя по идентификатору;
- создания нового пользователя;
- клиент запрашивает всех пользователей и рендерит список их имен;
- на клиенте есть форма для запроса одного пользователя по ID;
- на клиенте есть форма для создания нового пользователя.
Как видите, все очень просто. Давайте это реализуем.
Архитектура монорепозитория предполагает, что код клиента и сервера "живет" в одной директории (репозитории).
Создаем корневую директорию:
mkdir trpc-fullstack-app
cd trpc-fullstack-app
Создаем директорию для сервера:
mkdir server
cd server
Обратите внимание: для работы с зависимостями будет использоваться Yarn.
Инициализируем проект Node.js:
yarn init -yp
Устанавливаем основные зависимости:
yarn add express cors
Поскольку клиент и сервер будут иметь разные источники (origins) (будут запускаться на разных портах), "общение" между ними будет блокироваться CORS. Пакет cors позволяет настраивать эту политику.
Устанавливаем зависимости для разработки:
yarn add -D typescript @types/express @types/cors
Создаем файл tsconfig.json
следующего содержания:
{
"compilerOptions": {
"allowJs": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
// директория сборки
"outDir": "./dist",
// директория исходников
"rootDir": "./src",
"skipLibCheck": true,
"strict": true,
"target": "es2019"
}
}
Редактируем файл package.json
:
{
// ...
// основной файл сервера
"main": "dist/index.js",
// модули ES
"type": "module",
"scripts": {
// компиляция TS в JS с наблюдением за изменениями файлов
"ts:watch": "tsc -w",
// запуск сервера с перезагрузкой после изменений
"node:dev": "nodemon",
// одновременное выполнение команд
// мы установим concurrently на верхнем уровне
"start": "concurrently \"yarn ts:watch\" \"yarn node:dev\"",
// производственная сборка
"build": "tsc --build && node dist/index.js"
}
}
Создаем директорию src
и дальше работаем с ней.
Создаем файл index.ts
следующего содержания:
import express from 'express'
import cors from 'cors'
const app = express()
app.use(cors())
// адрес сервера: http://localhost:4000
app.listen(4000, () => {
console.log('Server running on port 4000')
})
Определяем тип пользователя в файле users/types.ts
:
export type User = {
id: string
name: string
}
Создаем массив пользователей в файле users/db.ts
:
import type { User } from './types'
export const users: User[] = [
{
id: '0',
name: 'John Doe',
},
{
id: '1',
name: 'Richard Roe',
},
]
Возвращаемся в корневую директорию и создаем шаблон клиента с помощью Vite:
# client - название проекта/директории
# react-ts - используемый шаблон
yarn create vite client --template react-ts
Vite автоматически настраивает все необходимое, нашего участия в этом процессе не требуется ????
Обратите внимание: клиент будет запускаться по адресу http://localhost:5173
Находясь в корневой директории, инициализируем проект Node.js устанавливаем concurrently:
yarn init -yp
yarn add concurrently
Определяем в package.json
команду для одновременного запуска сервера и клиента:
{
// ...
"scripts": {
"dev": "concurrently \"yarn --cwd ./server start\" \"yarn --cwd ./client dev\""
}
}
Это все, что требуется для подготовки и настройки проекта. Переходим к доработке сервера.
Сервер
Нам потребуется еще 2 пакета:
yarn add @trpc/server zod
- @trpc/server — пакет для разработки конечных точек и роутеров;
- zod — пакет для валидации данных.
Далее работаем с директорией src
.
Создаем файл trpc.ts
с кодом инициализации tRPC:
import { initTRPC } from '@trpc/server'
import type { Context } from './context'
const t = initTRPC.context<Context>().create()
export const router = t.router
export const publicProcedure = t.procedure
Определяем контекст tRPC в файле context.ts
:
import { inferAsyncReturnType } from '@trpc/server'
import * as trpcExpress from '@trpc/server/adapters/express'
const createContext = ({
req,
res,
}: trpcExpress.CreateExpressContextOptions) => {
return {}
}
export type Context = inferAsyncReturnType<typeof createContext>
export default createContext
Определяем корневой роутер/роутер приложения tRPC в файле router.ts
:
import { router } from './trpc.js'
import userRouter from './user/router.js'
const appRouter = router({
user: userRouter,
})
export default appRouter
Для подключения tRPC к серверу используется обработчик запросов или адаптер. Редактируем файл index.ts
:
// ...
import * as trpcExpress from '@trpc/server/adapters/express'
import appRouter from './router.js'
import createContext from './context.js'
// ...
app.use(cors())
app.use(
// суффикс пути
// получаем http://localhost:4000/trpc
'/trpc',
trpcExpress.createExpressMiddleware({
router: appRouter,
createContext,
}),
)
// ...
// обратите внимание: экспортируется не сам роутер, а только его тип
export type AppRouter = typeof appRouter
Наконец, определяем конечные точки пользователей в файле users/router.ts
:
import { z } from 'zod'
import { router, publicProcedure } from '../trpc.js'
import { users } from './db.js'
import type { User } from './types'
import { TRPCError } from '@trpc/server'
const userRouter = router({
// обработка запроса на получение всех пользователей
// выполняем запрос (query)
getUsers: publicProcedure.query(() => {
// просто возвращаем массив
return users
}),
// обработка запроса на получение одного пользователя по ID
getUserById: publicProcedure
// валидация тела запроса
// ID должен быть строкой
.input((val: unknown) => {
if (typeof val === 'string') return val
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Invalid input: ${typeof val}`,
})
})
.query((req) => {
const { input } = req
// ищем пользователя
const user = users.find((u) => u.id === input)
// если не нашли, выбрасываем исключение
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `User with ID ${input} not found`,
})
}
// если нашли, возвращаем его
return user
}),
// обработка создания нового пользователя
createUser: publicProcedure
// тело запроса должно представлять собой объект с полем `name`,
// значением которого должна быть строка
.input(z.object({ name: z.string() }))
// выполняем мутацию
.mutation((req) => {
const { input } = req
// создаем пользователя
const user: User = {
id: `${Date.now().toString(36).slice(2)}`,
name: input.name,
}
// добавляем его в массив
users.push(user)
// и возвращаем
return user
}),
})
export default userRouter
Финальная структура директории server
:
- node_modules
- src
- user
- db.ts
- router.ts
- types.ts
- context.ts
- index.ts
- router.ts
- trpc.ts
- package.json
- tsconfig.json
- yarn.lock
Наш сервер полностью готов к обработке запросов клиента.
Клиент
Здесь нам также потребуется еще 2 пакета.
# client
yarn add @trpc/client @trpc/server
Возможно, мы могли установить @trpc/server
на верхнем уровне ????
Далее работаем с директорией src
.
Создаем файл trpc.ts
с кодом инициализации tRPC:
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'
// здесь могут возникнуть проблемы при использовании синонимов путей (type aliases)
import { AppRouter } from '../../server/src/index'
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:4000/trpc',
}),
],
})
Для начала давайте просто выведем список пользователь в консоль инструментов разработчика в браузере. Редактируем файл App.tsx
следующим образом:
import { useEffect } from 'react'
import { trpc } from './trpc'
function App() {
useEffect(() => {
trpc.user.getUsers.query()
.then(console.log)
.catch(console.error)
}, [])
return (
<div></div>
)
}
export default App
Запускаем приложение. Это можно сделать 2 способами:
- выполнить команду
yarn dev
из корневой директории; - выполнить команду
yarn start
из директорииserver
и командуyarn dev
из директорииclient
.
Результат:
Многим React-разработчикам (и мне, в том числе) нравится библиотека React Query, позволяющая легко получать, кэшировать и модифицировать данные. К счастью, tRPC предоставляет абстракцию над React Query. Устанавливаем еще 2 пакета:
yarn add @tanstack/react-query @trpc/react-query
Редактируем файл trpc.ts
:
import { createTRPCReact, httpBatchLink } from '@trpc/react-query'
import { AppRouter } from '../../server/src/index'
export const trpc = createTRPCReact<AppRouter>()
export const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:4000/trpc',
}),
],
})
Редактируем файл main.tsx
:
// ...
import App from './App'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { trpc, trpcClient } from './trpc'
const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</trpc.Provider>
</React.StrictMode>,
)
Получим пользователей и отрендерим их имена в App.tsx
:
function App {
const {
data: usersData,
isLoading: isUsersLoading,
} = trpc.user.getUsers.useQuery()
if (isUsersLoading) return <div>Loading...</div>
return (
<div>
<ul>
{(usersData ?? []).map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
)
}
Результат:
Добавляем форму для получения одного пользователя по ID:
function App() {
// ...
const [userId, setUserId] = useState('0')
const {
data: userData,
isLoading: isUserLoading,
error,
} = trpc.user.getUserById.useQuery(userId, {
retry: false,
refetchOnWindowFocus: false,
})
if (isUsersLoading || isUserLoading) return <div>Loading...</div>
const getUserById: React.FormEventHandler = (e) => {
e.preventDefault()
const input = (e.target as HTMLFormElement).elements[0] as HTMLInputElement
const userId = input.value.replace(/\s+/g, '')
if (userId) {
// обновление состояния ID пользователя приводит к выполнению нового/повторного запроса
setUserId(userId)
}
}
return (
<div>
{/* ... */}
<div>
<form onSubmit={getUserById}>
<label>
Get user by ID <input type='text' defaultValue={userId} />
</label>
<button>Get</button>
</form>
{/* Если пользователь найден */}
{userData && <div>{userData.name}</div>}
{/* Если пользователь не найден */}
{error && <div>{error.message}</div>}
</div>
</div>
)
}
Результат:
Наконец, добавляем форму для создания нового пользователя:
function App() {
const {
data: usersData,
isLoading: isUsersLoading,
// метод для ручного повторного выполнения запроса
refetch,
} = trpc.user.getUsers.useQuery()
// ...
// Состояние для имени пользователя
const [userName, setUserName] = useState('Some Body')
// Мутация для создания пользователя
const createUserMutation = trpc.user.createUser.useMutation({
// После выполнения мутации необходимо повторно запросить список пользователей
onSuccess: () => refetch(),
})
// ...
// Обработка отправки формы для создания нового пользователя
const createUser: React.FormEventHandler = (e) => {
e.preventDefault()
const name = userName.trim()
if (name) {
createUserMutation.mutate({ name })
setUserName('')
}
}
return (
<div>
{/* ... */}
<form onSubmit={createUser}>
<label>
Create new user{' '}
<input
type='text'
value={userName}
onChange={(e) => setUserName(e.target.value)}
/>
</label>
<button>Create</button>
</form>
</div>
)
}
Результат:
На этом разработку нашего приложения можно считать завершенной.
Надеюсь, вы узнали что-то новое и не зря потратили время.
Happy coding!
Комментарии (7)
stvoid
04.04.2023 11:51+3Ну да, все серверы пишутся же на ноде..
Так и не понял в чем особенность реализации в отличие от gRPC, какой то набросок документации.
mayorovp
04.04.2023 11:51Если бы документации. В документации хотя бы понятно где библиотека начинается и где заканчивается, а тут надо три раза перечитать всё в поисках где вообще эта tRPC подключается.
StanleyShilow
04.04.2023 11:51Интересно, какие это может иметь последствия для взаимодействия с беком). Судя по наличию библиотечки для python версии 0.0.0 (не шутка), например, попытка смешать RPC с REST авторам проекта кажется быстрее и надежнее... Понятно откуда на github лайки появляются))
Heggi
Чем tRPC хуже/лучше того же gRPC или банального JSON API?
microspace
Не нужно дублировать типы на беке и фронте.
Heggi
Раз у нас и фронт и бек написаны на ts (js), да в монорепозитарии, то ничего не мешает объявить общие типы для того же JSON API.