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)


  1. Heggi
    04.04.2023 11:51
    +3

    Чем tRPC хуже/лучше того же gRPC или банального JSON API?


    1. microspace
      04.04.2023 11:51
      -1

      Не нужно дублировать типы на беке и фронте.


      1. Heggi
        04.04.2023 11:51
        +1

        Раз у нас и фронт и бек написаны на ts (js), да в монорепозитарии, то ничего не мешает объявить общие типы для того же JSON API.


  1. stvoid
    04.04.2023 11:51
    +3

    Ну да, все серверы пишутся же на ноде..

    Так и не понял в чем особенность реализации в отличие от gRPC, какой то набросок документации.


    1. mayorovp
      04.04.2023 11:51

      Если бы документации. В документации хотя бы понятно где библиотека начинается и где заканчивается, а тут надо три раза перечитать всё в поисках где вообще эта tRPC подключается.


  1. micronull
    04.04.2023 11:51


  1. StanleyShilow
    04.04.2023 11:51

    Интересно, какие это может иметь последствия для взаимодействия с беком). Судя по наличию библиотечки для python версии 0.0.0 (не шутка), например, попытка смешать RPC с REST авторам проекта кажется быстрее и надежнее... Понятно откуда на github лайки появляются))