雑なAPIはtRPCで作り、ApiGatewayでデプロイでいいかもしれない

2022.10.04

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

この記事でいう雑APIを定義。

  • 見切り発車でスクラップ&ビルドしながらつくる
  • とりあえず動けばいい、スピード命
  • 検証目的で、お金は安くしたい

個人開発や技術素掘りでとにかく手を動かしながら試行錯誤したいときに、適当なAPIを作りたいことがあります。そしてある程度角度が上がったところで、再設計する。思いついた時にすぐに形にしたい。

そんな時に、Fargateやk8sは、捨てコードのためにつくるのは大袈裟すぎるし、AppRunnerは気軽で良いけど時間課金なので消しわすれが怖いし、高くなりやすい。AppSyncはGraphQLで型を自動生成してくれるけど、Resolerが独特、ローカルサーバーたてたり、型が再生成がめんどくさい。単にEC2を一時的にたてるのもVPCだったり繋ぐまで長かったりやっぱりめんどくさい。

また雑APIはリクエスト単位での課金ほうが安くすむケースが多いです。自分しかリクエストしないので、リスエスト量も少ないです。 雑APIをサーバレスで作ることが多いのですが、それはそれでめんどくさいことがあります。

サーバレスでやってもやっぱりめんどくさいことはある

たとえば、GET /hoge , POST /foo を定番のAWS Lambda + API Gateway + Nodeで作る場合、以下の選択肢があると思う。

  • それぞれのエンドポイントを用意する
  • 一つのプロジェクトでそれぞれのエンドポイントを用意する
  • 一つのプロジェクトで一つのエンドポイント

それぞれのエンドポイントを用意する

├── foo-lambda
│   ├── index.js
│   └── package.json
└── hoge-lambda
    ├── index.js
    └── package.json

一番シンプルで、LambdaもIAM最小権限にしやすいし、ソースコードも必要なものだけになるので小さくなりやすい。

反面、foo,hogeで共通したものをつかうことが多いので、Lambdaレイヤーに切り出したり(ex: core-util)、デプロイ手順だったり、ソースコードを更新したときの再ビルドして読ませるなど面倒する。デプロイもそれぞれの関数ごとにビルド&デプロイが必要なので、めんどくさい。

何より、IDE上でいちいちフォルダを切り替えるのが面倒なんです!あれ、今どのソース触ってるっけ、とか試行錯誤中なのにノイズが多くて作業スピードがあがらない(個人の感想)

クライアントと繋ぐためにLocalStackやaws-lambda-nodejsのイメージを使ってローカルでLambdaを動かすけど、foo,hogeを同時に起動したりするものめんどくさい。デプロイして確認したほうが楽まであるかも。

一つのプロジェクトでそれぞれのエンドポイントを用意する

.
├── foo.js
├── hoge.js
└── package.json

ビルドを一回で、それぞれの関数にデプロイするから、まだ楽かな。LambdaものAM最小権限にしやすい。

ただ、一つプロジェクトなので、fooでしか使わないソースもhogeのときに含まれるので、多少オーバーヘッドになる。

その代わり、デプロイが簡単になったり、IDEでファイル切り替えが楽だったりする。

クライアントと繋ぐためにLocalStackやaws-lambda-nodejsのイメージを使ってローカルでLambdaを動かすけど、foo,hogeを同時に起動したりするものめんどくさい。デプロイして確認したほうが楽まであるかも。

一つのプロジェクトで一つのエンドポイント

├── index.js
└── package.json

一つの関数でExpressなどでまるっと全部リクエストを受けちゃう。

いいところは、LocalStackとかなしに、Expressだったらローカルで起動して確認もできるので楽です。ルーティングとサーバー部分が分離される感じがあるのでよいですね。

ただ、Express依存になっちゃうのと、型定義を食わせるのがちょっとむずいかな? npm workspaceとかに切り出して、interfaceとかtypeを共有する感じだと思うけど、IDEで操作で飛んだり、変更時にあっちこっちに飛ぶから雑APIで作るときはノイズな作業かな。

実態のルーティング定義を吐き出させたほうが、実態の乖離ないし、定義してから実装の二度手感がないかな。ただ、堅い作りなのでプロダクションコードでは安定すると思う。

パッケージサイズが大きくなる、権限も大きくなりがちなので欠点。ただ雑APIではありかな。

雑APIに求める開発体験

  • ソースコード変えても、開発中は再ビルドがいらない
  • 実装をベースに型定義をつくる
  • 簡単にローカルで起動できる(クライアウトアプリで動作確認をするため)
  • デプロイが簡単
  • IDEでファイル切り替えが楽

あえてNodeで作ってるのは、クライアントとサーバーでAPI型定義を共通化して使いたいから。これも足しておこう。

簡単にサーバーとクライアントでAPIの型定義が共通化できる

tRPCに期待をよせて

こんなめんどくさがりの僕でも、やっていけそうなのがtRPC!

tRPC allows you to easily build & consume fully typesafe APIs, without schemas or code generation.

GraphQLと比較されることありますが、コード生成なし、型セーフにできちゃうの!?

そしてLambdaで動かせる用意もしてある。

のちにAPI Gateway + Lambda でデプロイするのでこちらを使用していきます。

バージョンはv10のbetaを使います、v9と少しAPIが異なるのとあとからv10マイグレーションするのが面倒なのでbetaを使っていきます。

tRPCのサーバとクライアントのサンプルをつくる

サンプルはこちらになります

サーバーのプロジェクトをつくる

tRPCのseverとTypeScriptをインストールします。

zod はなくてもよいですが、バリデーションライブラリーでGetパラメータなどの検証で使っています。公式も推してるっぽい雰囲気。

@types/aws-lambda は、API Gatewayのときのリクエストの方定義でなくてもよいですがあった方が便利なはず。

ts-node はローカルでtRPCサーバーを立ち上げる時にいちいちjsにビルドしないでtsのまま起動できるようにするため。

$ touch server
$ cd server
$ npm init --y
$ npm install @trpc/server@next zod 
$ npm install -D typescript @types/aws-lambda ts-node

TypeScriptの設定を追加

./server/tsconfig.json

{
  "compilerOptions": {
    "target": "ES2017",
    "noErrorTruncation": true,
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "strictNullChecks": true,
    "forceConsistentCasingInFileNames": true,
    "esModuleInterop": true,
    "module": "CommonJS",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "outDir": "dist",
    "baseUrl": "./",
  },
}

tRPCのContextを実装

src/trpcフォルダを作り、context.ts,index.tsを作ります。

./src/rtpc/context.ts

export function createApiContext({
  event,
  context,
}: CreateAWSLambdaContextOptions<APIGatewayProxyEventV2>) {
  return {
    event
  };
}

./server/src/rtpc/index.ts

import { inferAsyncReturnType } from "@trpc/server";
import { createApiContext } from "./context";
import { apiRouter } from "./router";

export type ApiContext = inferAsyncReturnType<typeof createApiContext>;
export type ApiRouter = typeof apiRouter;

tRPCのRouterを実装

src/trpcフォルダを作り、router.tsを作ります。

./server/src/rtpc/router.ts

import { initTRPC } from "@trpc/server";
import { z } from "zod";
import { ApiContext } from "./index";

const t = initTRPC.context<ApiContext>().create({});
export const apiRouter = t.router({
  hoge: t.procedure
    .input(z.object({ name: z.string() }))
    .query(({ input, ctx }) => {
      return `Hoge, ${input.name}`;
    }),
  foo: t.procedure
    .input(z.object({ name: z.string() }))
    .mutation(({ input, ctx }) => {
      return `Foo, ${input.name}`;
    }),
});

ローカル動作確認用のサーバーを追加

4100ポートで起動できるようにした。ローカルでではAPI Gateway

./server/src/server.ts

import { createHTTPServer } from "@trpc/server/adapters/standalone";
import { apiRouter } from "./trpc/router";

createHTTPServer({
  router: apiRouter,
  createContext: () => {
    return {
      event: {} as any,
    };
  },
}).listen(4100);

サーバ起動用のスクリプトをpackeage.jsonに追加。

./server/packeage.json

{
   ...
  "scripts": {
    "dev": "ts-node src/server.ts",
  },
  ...
}

クライアントに食わせるAPI型定義

./server/trpc/index.ts のAppRouterをクライアントに食わせる必要がある。

私自身モノレポやライブラリーの知識が疎く、これがベストとは思っていないのですが、一旦読ませるようにします。

今回はnpm workspaceで対応します。プロジェクトルートでpackage.jsonを作成しモノレポにします。./server/packeage.json 、これから作る./client/packeage.jsonprivate: true を追加します。

$ npm init --y

workspacesを追加します。

package.json

{
  "name": "YOUR PROJECT NAME",
  "private": true,
  "version": "1.0.0",
  "description": "",
  "keywords": [],
  "author": "",
  "license": "ISC",
  "workspaces": [
    "client",
    "server"
  ]
}

サーバーの型定義を公開します。今回はAppRouterだけを公開すれば良いので、型定義提供するだけのtsconfig(tsconfig-types.json)とビルドのフォルドを用意します。

./server/tsconfig-types.json

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./types",
    "declaration": true
  },
}

ついでにLambdaなどにアップロードするためのビルドにはローカル確認用の ./server/server.ts は不要なので除外しておきます。

{
  "compilerOptions": {
    ...
    "main": "./dist/index.js",
    "types": "./types/trpc/index.d.ts",
    ...
  },
  "exclude": [
    "./src/server.ts",
    "./dist",
    "./types",
  ]
}

ビルドコマンドと型情報をpackeage.jsonに追加します。

./server/packeage.json

{
   ...
  "scripts": {
    "dev": "ts-node src/server.ts",
    "build": "yarn build:src; yarn build:type",
    "build:src": "rm -rf dist; tsc --project tsconfig.json",
    "build:type": "rm -rf types; tsc -P tsconfig-types.json --emitDeclarationOnly",
  },
  ...
}

一旦node_modulesを消して再度インストールします

$ rm -rf ./server/node_modules
$ npm install

他にも@typesプロジェクトを作ってそちらを参照したり、tsconfig.jsonなどで参照させたり(できるのかな?)色々な方法がありそうだと思います。

クライアント(React)のプロジェクト

プロジェクトのルートに戻って、クライアントアプリを作成

$ npm create vite@latest client -- --template react-ts
$ cd client
$ npm install

tRPCのclientをインストール

$ npm install @trpc/client@next @trpc/react@^10.0.0-proxy-beta.13 @tanstack/react-query

tRPCとuseQueryのセットアップ

import { ApiRouter } from "server"; npm workspaceを設定しているので、型定義を読めるようになっていると思います。

既存のApp.tsxを全て消して、以下をつくる。Page は次に作成しますが、表示できるようにあらかじめ作っておきます。

import "./App.css";
import { httpBatchLink } from "@trpc/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createTRPCReact } from "@trpc/react";
import { Page } from "./Page";
import { ApiRouter } from "server";

export const trpc = createTRPCReact<ApiRouter>();
const createTRPCClient = () => {
  return trpc.createClient({
    links: [
      httpBatchLink({
        url: "/api",
      }),
    ],
  });
};

function App() {
  const trpcClient = createTRPCClient();
  const queryClient = new QueryClient();

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

export default App;

Page.tsxで実際にtRPCのAPIを叩きます。

useQuery、useMutationのhoge,fooやパラメータ(name)が保管がききます。すばらしい。

import { FC, useEffect, useState } from "react";
import { trpc } from "./App";

export const Page: FC = () => {
  const hoge = trpc.hoge.useQuery({ name: "Kame" });
  const foo = trpc.foo.useMutation();
  if (!hoge.data) return <div>Loading...</div>;

  return (
    <div>
      <div>Hoge: {hoge.data}</div>
      <br />
      <div>
        <div>
          <button
            onClick={() => {
              foo.mutate({ name: "DonDon" });
            }}
          >
            Request: Foo Mutation
          </button>
        </div>
        Foo: {foo.data ?? "no request"}
      </div>
    </div>
  );
};

./client/Page.tsx

import { FC, useEffect, useState } from "react";
import { trpc } from "./App";

export const Page: FC = () => {
  const hoge = trpc.hoge.useQuery({ name: "Kame" });
  const foo = trpc.foo.useMutation();
  if (!hoge.data) return <div>Loading...</div>;

  return (
    <div>
      <div>Hoge: {hoge.data}</div>
      <br />
      <div>
        <div>
          <button
            onClick={() => {
              foo.mutate({ name: "DonDon" });
            }}
          >
            Request: Foo Mutation
          </button>
        </div>
        Foo: {foo.data ?? "no request"}
      </div>
    </div>
  );
};

CORS対策とAWSにデプロイしてCloudFrontのBehavior /api に当てる予定なので、ローカルで開発する時に/apiをローカルサーバを呼び出すようにproxyを設定します。

./client/vite.config.ts

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: {
    port: 4000,
    proxy: {
      "^/api/*": {
        target: "http://localhost:4100/",
        changeOrigin: true,
        secure: false,
        rewrite: (path) => {
          return path.replace("/api", "/");
        },
      },
    },
  },
});

動作確認

二つのターミナルなどを用いて、サーバーとクライアントを起動し、動作確認する

$ cd server
$ npm run dev
$ cd client
$ npm run dev

`http://localhost:4000` にアクセスにするとCORSエラーなどにもならずに、GETもPOSTも動いてると思い思います

AWSにでデプロイしてみる

... つかれたので次回に。

まとめ

GraphQLなどは、定義してジェネレートして、Resolver書いてっという作業がなくなり、シンプルにルーター書いて実装するだけで、TypeScriptの型が作られてクライアント読ませられる。めちゃめちゃ良いです。

そして、Lambdaをローカルで起動するのもLocalStackなどを使わずに薄いラッパーを包んだけでよく、本質的なルーティングとその処理だけ書けばよいのです。

次はAWSにデプロイするところですが、アプリケーションはラップされた部分だけ変更するだけです。本質的なコードはそのまま。

またルーター部分だけ使うとかできる。Next.jsで使うともっと簡単かもだけど、今回はLambdaで動かしたいので。さまざなフレームワークとの連携もできるので、移植もしやすいかも。一度試してみてはどうだろうか。