🔌

trpcって知ってますか?

2021/12/19に公開約5,800字

はじめに

みなさんはAPIの定義を何で行なってますか?

Swagger?
GraphQL?
gRPC?

私もGraphQLでアプリとBFFのAPI定義をしたり、gRPCで定義してそれを無理やりRESTAPIの型定義として使っていたりします。
方法はどうであれAPIの型定義はやっぱり欲しいですよね。

いくらTypeScriptとかGoとか型のある言語を使っていても、それをつなぐAPIのインターフェースに型を決めておかないといくらでも事故れますよね…
特にフロントエンドとバックエンドで開発するエンジニアが違うと、こういった定義ファイルがないと思ってたのと違うパラメータが返ってきたり、リクエストボディに入れる型を間違ってしまったりという事故が起きたりすると思います。
なので何かしらの定義ファイルをフロントバック両者で定めておいてこういった事故を起きないようにしているわけです。

定義ファイルがある場合の問題

これは私個人の周りでよく起きていることなのかもしれませんが、定義ファイルを使ってAPIの型定義をしている場合、フロントとバックで参照している定義ファイルのバージョンが違うという問題起きませんか?

フロントは新しい定義ファイルを読み込んで開発しているのに、バックエンドは古いバージョンを読み込んで開発してしまった結果、結合テスト時にエラー地獄になる。(逆もしかり…)みたいなことが起きます。

trpcはそこの問題を少しでも減らすことができそうな気がしています。

trpcってなに?

簡単にまとめると、server側で定義したInterfaceをそのままclient側で取り込んで繋ぎ込みができるという代物です。
公式サイト

対象はBFFのような使用用途でNodejsのサーバがあるようなアーキテクチャをしている場合です。
私の場合は client - (GraphQL) - BFF - (gRPC) - MicroService となっている環境のGraphQLをtrpcに置き換えることができそうと思って調べてみました。

使ってみる

ここから試しに、client(React)とserver(Experss)で書いていきます。

server

インストールするものは以下

$ yarn add @trpc/server zod

routerを書いていく

import * as trpc from '@trpc/server';
import { z } from 'zod';
import * as trpcExpress from '@trpc/server/adapters/express';

/**
 * Context
 * headerから値を取得したりなどcontextに詰めたい情報を返す
 */
export const createContext = ({
  req,
}: trpcExpress.CreateExpressContextOptions) => {
  const getAuthorization = () => {
    return req.headers.authorization || '';
  };

  return {
    authorization: getAuthorization(),
  };
};
type Context = trpc.inferAsyncReturnType<typeof createContext>;

const createRouter = () => {
  return trpc.router<Context>();
};

/**
 * Router
 * trpcで待ち受けるロジックを書く
 * なんとなくGraphQLチックな感じになっている
 */
export const appRouter = createRouter()
  .query('hello', {
    input: z
      .object({
        text: z.string().nullish(),
      })
      .nullish(),
    resolve({ input, ctx }) {
      return {
        greeting: `hello ${input?.text ?? 'world'}`,
        authorization: `token is ${ctx.authorization}`,
      };
    },
  })
  .mutation('write', {
    input: z.object({
      text: z.string(),
    }),
    resolve() {
      return {};
    },
  });

export type AppRouter = typeof appRouter;

ちょっとだけ解説

createRouter

ここで trpc.router を返しています。これがtrpcのrouterになります。

const createRouter = () => {
  return trpc.router<Context>();
};
appRouter

createRouterに生えている querymutation を使って定義していきます。
第一引数に名前、第二引数にrequestとresponseの定義を書いていきます。
input がrequestで resolve が実際の処理を書くところで、そこからreturnしているのがresponseになります。
またinputで定義しているobjectに対して nullish()をつけるとoptionalになります。

export const appRouter = createRouter()
  .query('hello', {
    input: z
      .object({
        text: z.string().nullish(),
      })
      .nullish(),
    resolve({ input, ctx }) {
      return {
        greeting: `hello ${input?.text ?? 'world'}`,
        authorization: `token is ${ctx.authorization}`,
      };
    },
  })
  .mutation('mutation', {
    input: z.object({
      text: z.string(),
    }),
    resolve() {
      return {};
    },
  });

export type AppRouter = typeof appRouter;

client

インストールするものは以下

$ yarn add @trpc/client @trpc/server @trpc/react react-query zod

trpcの基本設定
ここでtrpcのhooksを生成する

import { createReactQueryHooks } from '@trpc/react';
import type { AppRouter } from 'path/to/router';

export const trpc = createReactQueryHooks<AppRouter>();

Providerを定義する

import '../styles/globals.css';
import type { AppProps } from 'next/app';
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { trpc } from '@utils/trpc';

function MyApp({ Component, pageProps }: AppProps) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      trpcが動いているserverのエンドポイント
      url: 'http://localhost:3001/trpc',
      headers() {
        return {
	  // authorizationをheaderに付与する
          authorization: 'Bearer authorization-token',
        };
      },
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <Component {...pageProps} />;
      </QueryClientProvider>
    </trpc.Provider>
  );
}

export default MyApp;

Componentで使う
trpc に生えている useQueryuseMutation を使ってtrpcとつなぐ
第一引数がserver側で待ち受けている名前、第二引数がrequestBodyになります

import type { NextPage } from 'next';
import Head from 'next/head';
import styles from '../styles/Home.module.css';
import { trpc } from '@utils/trpc';

const Home: NextPage = () => {
  const hello = trpc.useQuery(['hello', { text: 'John' }]);

  return (
    <div className={styles.container}>
      <Head>
        <title>Create Next App</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        <h1 className={styles.title}>Welcome to TRPC sample app!!!</h1>

        <div className={styles.description}>
          {(() => {
            if (!hello.data) {
              return <div>Loading...</div>;
            } else {
              return (
                <>
                  <p>{hello.data.greeting}!!!</p>
                  <p>authorization is {hello.data.authorization}</p>
                </>
              );
            }
          })()}
        </div>
      </main>
    </div>
  );
};
export default Home;

感想

冒頭でも書いた通りで、schemaとかprotoを挟むことなく、server側で書いたrouterから型を取得する形でclient側を書くことができるので、定義ファイルのことを忘れて開発することができます。
trpcの場合基本的にserver側が正となるので、利用している型情報が違った場合はserver側を参照するといいってなります。
ただserver側の実装がある程度進んでいないといけないので、定義ファイルを固めてclientとserverと同時進行で開発を進めるということができなくなるので、そこはデメリットになるかなーと感じています。
個人的にはtrpcはTypeScriptの知見があればすんなり入れそうなので、GraphQLやgRPCの教育コストが不要になりそうだなというメリットを感じました。
気になる方は一度お試しあれ。

Discussion

ログインするとコメントできます