【Connect/gRPC】connect-goとconnect-queryで型安全で高速なAPIを作る
はじめに
Connect の学習のために簡単なタスクリストを作ったらとても学びがあったので共有します。
アプリ全体のソースコードもあるので誰かの参考になれば幸いです。
Connect とは?
Connect は簡単に言うとブラウザ・gRPC 互換の HTTP API を構築するためのライブラリ群です。
gRPC とは Google が開発した RPC 技術のことで、異なるプログラミング言語間で高速に通信するためのフレームワークです。
Connect は gRPC のメリットをそのまま引き継いでおり、Protocol Buffers で API のスキーマ定義を記述するだけでバックエンドとフロントエンドのコードを自動生成してくれます。
コードの生成が終わったらあとは型の恩恵を受けながらロジックや UI の開発に注力できます。
Connect は Go 言語だけでなく様々な言語に対応しているためプロジェクトやチームに合わせた柔軟な選択が可能です。
例えばクライアントは TypeScript、Swift、Kotlin などの Connect 用ライブラリが開発されているためクロスプラットフォームなアプリが実現できます。
従来の gRPC は構築手順が複雑だったりサイズが大きかったりデバックが困難だったり gRPC WEB のために別途プロキシが必要だったり…どハードルが高めでしたが Connect はそれらの問題を解決してくれています。
Connect は Connect プロトコル、gRPC、gRPC WEB の 3 つのプロトコルに対応しています。
従来の gRPC との互換性があるため HTTP/2 を用いた双方向ストリーミングや高速な通信も可能です。
今回はその便利な Connect ファミリーの中から connect-go と connect-query を採用してシンプルなタスク管理アプリを作りました。
Connect を使うと何ができるか?
Connect を使うと型安全で高速な API が開発できます。
異なるプログラミング言語間の通信をサポートしているので様々なマイクロサービスや WEB アプリなどに活用できます。
Connect を使うメリット:
- スキーマ駆動で gRPC や REST API が開発できるので追加や変更に強い
- スキーマ定義を元に型安全なコードを自動生成できる
- スキーマからモックを作成してバックエンドとフロントエンドを同時進行で開発できる
- Go, TypeScript, Swift, Kotlin など様々なプログラミング言語に対応している
- Connect プロトコルは HTTP/1.1 でも動作するため curl コマンドなどで簡単に動作確認できる
- 従来の gRPC のような双方向ストリーミング通信もできる(1 つのリクエストに対して複数のレスポンスといったことができる)
- 従来の gRPC や gRPC WEB のプロトコルとも互換性があるので移行しやすい
- 従来の gRPC と較べてサイズが小さい(connect-swiftは 200KB 以下)
デモアプリについて
ログイン機能つきのシンプルなタスク管理アプリです。
バックエンド側に配置した proto ファイルを元に Go 言語と TypeScript のコードを生成しています。
- バックエンドはレイヤードアーキテクチャを採用しておりロジックを分離しています
- データベースは PostgreSQL を採用しており SQLC を通して Go 言語とやり取りしています
- 認証後は JWX を使用して JWT を生成しています
- フロントエンドは React + TypeScript で UI を構築しています
- UI ライブラリには Mantine UI を使用しています
- データフェッチには TanStack Query を使用してバックエンドと通信しています
- ログイン時に受け取った JWT をリクエストヘッダーで送信しています
この記事では断片的にしかコードを記載していないので疑問に思った場合はデモアプリのソースコードを見た方がイメージしやすいと思います。
proto ファイルの作成
まずは proto ファイル(.proto)に Protocol Buffers で API のスキーマ定義を記述します。
proto ファイルではサービスやメソッド、リクエストやレスポンスの型など API に必要な情報が定義されます。
この proto ファイルを buf というコマンドでビルドするとサーバーやクライアントのコードを自動生成できます。
buf コマンドには Formatter や Linter が付属しているので proto ファイルの一定の品質を保つことができます。
基本的に Linter に従えば OK ですが気をつける点としては以下の通りです:
- パッケージ名やサービス名は URL にも反映されるので完結でわかりやすい名前にします
- パッケージ名はディレクトリ構造と合わせないと Linter でエラーになってしまいます
- go_package は Go 言語のパッケージ名とインポートパスをセミコロンで区切った形式です
- 日付型は標準にはないので外部から import します。
- メソッドの引数や戻り値が空の場合でも個別の message を定義する必要があります。
以下は今回のタスクリストで実際に使用した user.proto です。
ID に一致するユーザーを取得する GetUser と ID/Pass で認証する Login メソッドを定義しています。
syntax = "proto3";
package rpc.user.v1;
option go_package = "github.com/7oh2020/connect-tasklist/backend/interfaces/rpc/user/v1;userv1";
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse) {}
rpc Login(LoginRequest) returns (LoginResponse) {}
}
message User {
string user_id = 1;
string email = 2;
}
message GetUserRequest {
string user_id = 1;
}
message GetUserResponse {
User user = 1;
}
message LoginRequest {
string email = 1;
string password = 2;
}
message LoginResponse {
User user = 1;
string token = 2;
}
サーバー(Go 言語)のコード生成
コード生成は buf コマンドで行います。
先述した通り buf コマンドには Linter と Formatter が付属しているため一定の品質を保つことができます。
Linter が通れば proto ファイルはコード生成できる状態になっているはずです。
まずは必要なコマンドをインストールします。
go install github.com/bufbuild/buf/cmd/buf@latest \
&& go install google.golang.org/protobuf/cmd/protoc-gen-go@latest \
&& go install connectrpc.com/connect/cmd/protoc-gen-connect-go@latest
2 つの設定ファイル(buf.yaml, buf.gen.yaml)を作成します
buf.yaml には全体的な設定、buf.gen.yaml にはコード生成に関する設定を記述します。
まずはプロジェクトルートに buf.yaml を作成します。
version: v1
breaking:
use:
- FILE
lint:
use:
- DEFAULT
同じくプロジェクトルートに buf.gen.yaml を作成します。
out には出力先ディレクトリを指定します。以下の場合は gen ディレクトリにコードが生成されます。
version: v1
plugins:
- name: go
out: gen
opt: paths=source_relative
- name: connect-go
out: gen
opt: paths=source_relative
Linter と Formatter を実行するコマンドは以下の通りです。
Lint エラーが出る場合は proto ファイルを習性して再度実行します。
buf lint
buf format -w
あとは以下のコマンドを実行するだけでコード生成は完了です。
buf.gen.yaml で指定した出力先ディレクトリにコードが生成されていれば OK です。
buf generate
ハンドラの作成
コード生成すると出力ディレクトリに ServiceHandler の interface が作成されるのでそれを満たす struct を作成します。
各メソッドは必ずコンテキストとリクエストを受け取りレスポンスを返します。
リクエストとレスポンスはジェネリックになっており、proto ファイルの message で定義した型が型引数になっています。
エラーの場合は定義済みの Connect エラーコードを利用できます。
Connect は Connect、gRPC、gRPC WEB の 3 つのプロトコルをサポートしますが、どれも connect.CodeXXX のような定義済みエラーコードで同じように扱えます。
以下は今回のタスクリストで実際に使用した user_handler.go です。
proto ファイルで定義した go_package が反映されていることが分かりますね。
実装に関しては生成された interface を満たしていればどんな実装でも良いと思います。
package handler
import (
"context"
"github.com/7oh2020/connect-tasklist/backend/app/usecase"
userv1 "github.com/7oh2020/connect-tasklist/backend/interfaces/rpc/user/v1"
"connectrpc.com/connect"
)
// UserServiceHandlerの実装
type UserHandler struct {
usecase.IAuthUsecase
usecase.IUserUsecase
}
func NewUserHandler(uca usecase.IAuthUsecase, ucu usecase.IUserUsecase) *UserHandler {
return &UserHandler{uca, ucu}
}
func (h *UserHandler) GetUser(ctx context.Context, arg *connect.Request[userv1.GetUserRequest]) (*connect.Response[userv1.GetUserResponse], error) {
user, err := h.IUserUsecase.GetUser(ctx, arg.Msg.UserId)
if err != nil {
return nil, connect.NewError(connect.CodeAborted, err)
}
return connect.NewResponse(&userv1.GetUserResponse{
User: &userv1.User{
UserId: user.ID,
Email: user.Email,
},
}), nil
}
func (h *UserHandler) Login(ctx context.Context, arg *connect.Request[userv1.LoginRequest]) (*connect.Response[userv1.LoginResponse], error) {
token, user, err := h.IAuthUsecase.Login(ctx, arg.Msg.Email, arg.Msg.Password)
if err != nil {
return nil, connect.NewError(connect.CodeUnauthenticated, err)
}
return connect.NewResponse(&userv1.LoginResponse{
User: &userv1.User{
UserId: user.ID,
Email: user.Email,
},
Token: token,
}), nil
}
インターセプタの作成
インターセプタは REST API のミドルウェアのような役割を持ちます。
ハンドラの前後に任意の処理を追加したりリクエストやレスポンスを操作できます。
リクエストのヘッダーをチェックしたりログを記録したりキャッシュを返したりコンテキスト値をセットしたりと活用法は多岐にわたります。
以下は今回のタスクリストで実際に使用した auth_interceptor.go です。
リクエストヘッダーの JWT をチェックして OK の場合はコンテキストにユーザー ID をセットしています。
package interceptor
import (
"context"
"errors"
"strings"
"github.com/7oh2020/connect-tasklist/backend/app/util/auth"
"github.com/7oh2020/connect-tasklist/backend/app/util/contextkey"
"connectrpc.com/connect"
)
// リクエストのJWTを検証する。成功時にはUserIDをコンテキストにセットする
func NewAuthInterceptor(issuer string, keyPath string) connect.UnaryInterceptorFunc {
interceptor := func(next connect.UnaryFunc) connect.UnaryFunc {
return connect.UnaryFunc(func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
// リクエストヘッダーからJWTを取得する
token := req.Header().Get("Authorization")
if token == "" {
return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("error: invalid token"))
}
token = strings.TrimPrefix(token, "Bearer")
token = strings.TrimSpace(token)
// トークンを検証しUserIDを取得する
tm, err := auth.NewTokenManager(issuer, keyPath)
if err != nil {
return nil, connect.NewError(connect.CodeAborted, err)
}
uid, err := tm.GetUserID(token)
if err != nil {
return nil, connect.NewError(connect.CodeUnauthenticated, err)
}
// コンテキストにUserIDをセットする
cw := contextkey.NewContextWriter()
ctx = cw.SetUserID(ctx, uid)
return next(ctx, req)
})
}
return connect.UnaryInterceptorFunc(interceptor)
}
サーバーの起動
Connect のサーバーは Go 言語の標準パッケージである net/http パッケージで簡単に作成できます。
ハンドラに関してはコード生成の時に NewXXXServiceHandler()
のような関数が生成されているので先程作成したハンドラを渡してインスタンス化します。
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/7oh2020/connect-tasklist/backend/infrastructure/persistence/model/db"
"github.com/7oh2020/connect-tasklist/backend/interfaces/di"
"github.com/7oh2020/connect-tasklist/backend/interfaces/interceptor"
"github.com/7oh2020/connect-tasklist/backend/interfaces/rpc/task/v1/taskv1connect"
"github.com/7oh2020/connect-tasklist/backend/interfaces/rpc/user/v1/userv1connect"
"connectrpc.com/connect"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/rs/cors"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
)
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
// 省略
// サーバーの起動
mux := http.NewServeMux()
mux.Handle(userv1connect.NewUserServiceHandler(userServer))
mux.Handle(taskv1connect.NewTaskServiceHandler(taskServer, authInterceptor))
return http.ListenAndServe(
"localhost:8080",
// CORSハンドラでリクエストを許可する(本番では全ホスト許可ではなく特定のホストのみ許可するべき)
cors.AllowAll().Handler(
// HTTP1.1リクエストはHTTP/2にアップグレードされる
h2c.NewHandler(mux, &http2.Server{}),
),
)
}
準備ができたら main.go を実行してサーバーを起動します。
go run main.go
動作確認
connect は HTTP1.1 にも対応しているため以下のような curl コマンドで動作確認ができます。
connect は基本的に全て POST でアクセスされ、URL は /<package>.<service>/<method>
のような形式になります。
以下の例では UserService の Login メソッドにリクエストしています。
curl --header "Content-Type: application/json" \
--data '{"email": "test@example.com", "password": "pass"}' \
http://localhost:8080/rpc.user.v1.UserService/Login
もちろん curl だけでなく Postman や Playwright などのクライアントでも動作確認できます。
CORS 設定
curl などで動作確認しているうちは問題ありませんがクライアントが後ほど説明する connect-es や connect-query などの場合はブラウザからアクセスされるため CORS 設定が必要になります。
今回は rs/cors というパッケージを使用して CORS ハンドラを必ず通るようにしました。
return http.ListenAndServe(
"localhost:8080",
// CORSハンドラでリクエストを許可する(本番では全ホスト許可ではなく特定のホストのみ許可するべき)
cors.AllowAll().Handler(
h2c.NewHandler(mux, &http2.Server{}),
),
)
ユーザーの認証
従来の REST API と同じくセッションや JWT などが使用できます。
もちろん Firebase Authentication や Auth0 などの外部サービスと連携しても良いと思います。
クライアント(TypeScript)のコード生成
Connect には React や Svelte などの Javascript ライブラリで動作する connect-es というクライアントライブラリがあります。
まるで関数を呼び出すかのような手軽さでサーバーと通信できます。
そして最近 connect-query という TanstackQuery(React Query)の Connect 用クライアントライブラリがリリースされました。
キャッシュキーの管理が不要で入力補完も効くようになるので高速な開発が可能です。
React を使う場合はこちらの方がより使いやすいと思ったので採用しました。
まずは React + Vite + TypeScript をセットアップします。
(今回は pnpm を使用しますが npm や yarn の場合は適宜書き換えてください)
pnpm create vite@latest -- example-app --template react-ts
cd example-app
pnpm install
必要なパッケージをインストールします。
pnpm install -D @bufbuild/buf @bufbuild/protoc-gen-connect-query @bufbuild/protoc-gen-es
pnpm install @bufbuild/connect-query @bufbuild/protobuf @bufbuild/connect-web @tanstack/react-query
コード生成の設定ファイルである buf.gen.yaml を作成します。
out には出力先ディレクトリを指定します。以下の場合は src/gen にコードが生成されます。
version: v1
plugins:
- name: es
out: src/gen
opt: target=ts
- name: connect-query
out: src/gen
opt: target=ts
proto ファイルまたはディレクトリを指定してコード生成します。
buf.gen.yaml で設定した出力先ディレクトリにコードが生成されていれば OK です。
pnpx buf generate ./proto
インターセプタの作成
実は Connect や gRPC はクライアント側にもインターセプタを持つことができます。
こちらもリクエストやレスポンスの中継が可能で、リクエストヘッダーをセットしたりログを記録したりキャッシュを返したりリトライを実装したりと活用法は多岐にわたります。
リクエストヘッダーは Query や Mutation 毎に指定できますが、以下のように認証用のインターセプタを作成しておくと Query や Mutation で毎回ヘッダーを指定しなくて済みます。
import { Interceptor } from "@bufbuild/connect-web";
const authInterceptor: Interceptor = (next) => async (req) => {
if (user != null) {
// リクエストヘッダーにトークンをセットする
req.header.set("Authorization", `Bearer ${user.token}`);
}
return await next(req);
};
クライアントコンポーネントの作成
connect-query を使うためには接続先サーバーの指定や Provider コンポーネントなどいくつかのセットアップが必要です。
そのため以下のようなクライアントコンポーネントを作成しておくと便利です。
先程のインターセプタもこのコンポーネントに含めています。
import { TransportProvider } from "@bufbuild/connect-query";
import { Interceptor, createConnectTransport } from "@bufbuild/connect-web";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { FC, ReactNode } from "react";
type Props = {
baseUrl: string;
token?: string;
children: ReactNode;
};
export const Client: FC<Props> = ({ baseUrl, token, children }) => {
const authInterceptor: Interceptor = (next) => async (req) => {
if (token != null) {
// リクエストヘッダーにトークンをセットする
req.header.set("Authorization", `Bearer ${token}`);
}
return await next(req);
};
const transport = createConnectTransport({
baseUrl,
interceptors: [authInterceptor],
});
const client = new QueryClient();
return (
<TransportProvider transport={transport}>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</TransportProvider>
);
};
使い方は以下のようになります。
import { FC, useState } from "react";
import { Client } from "./Client";
export const App: FC = () => {
const baseUrl = "http://localhost:8080";
const [token, setToken] = useState<string>("");
// 省略
return (
<Client baseUrl={baseUrl} token={token}>
{/* 省略 */}
</Client>
);
};
Query の作成
ここまでできたらあとは TanStack Query(React Query)の知識だけでデータフェッチを実装できます。
proto ファイルからのコード生成により必要な型は全て揃っています。
例えば以下は GetUser をリクエストして結果をプロフィールとして表示するコンポーネントです。
データフェッチは非同期で行われその結果は自動的にキャッシュされます。
import { useQuery } from "@tanstack/react-query";
import { getUser } from "../gen/user/v1/user-UserService_connectquery";
import { FC } from "react";
type Props = {
userId: string;
};
export const Profile: FC<Props> = ({ userId }) => {
const { isLoading, isError, error, data } = useQuery(
getUser.useQuery({ userId })
);
return (
<div>
{isLoading && <p>読み込み中...</p>}
{isError && <p role="alert">{error?.message}</p>}
{!isLoading && !isError && data != null && <h3>{data?.user?.email}</h3>}
</div>
);
};
proto ファイルで定義したサービスのメソッド名やパラメータの型などが反映されていることが分かりますね。
このように connect-query はキャッシュキーの管理が不要で入力補完も効くので高速な開発ができます。
Mutation の作成
データの取得に useQuery()を使うのに対しデータの作成・変更・削除などは useMutation()を使います。
リクエストが成功した場合または失敗した場合の処理を予め記述できます。
例えば以下は Login をリクエストして成功時にはユーザー情報や JWT を取得、エラー時にはエラー内容を表示するコンポーネントです。
mutateAsync()の第 1 引数にはリクエストのパラメータ、第 2 引数には onSuccess()や onError()などのオプションを指定します。
import { useMutation } from "@tanstack/react-query";
import { FC } from "react";
import { login } from "../gen/user/v1/user-UserService_connectquery";
type Props = {
email: string;
password: string;
};
export const SimpleLogin: FC<Props> = ({ email, password }) => {
const { isLoading, isError, error, mutateAsync } = useMutation(
login.useMutation()
);
const handleLogin = () => {
mutateAsync(
// パラメータを渡す
{ email, password },
{
// リクエストが成功した場合の処理
onSuccess: (data) => {
console.log(data.user);
console.log(data.token);
},
// リクエストが失敗した場合の処理
onError: (e) => {
console.error(e);
},
}
);
};
return (
<div>
{isError && <p role="alert">{error.message}</p>}
<button onClick={handleLogin} disabled={isLoading}>
Login
</button>
</div>
);
};
Invalidate の実行
Mutation に伴う副作用としてキャッシュの更新やデータの再取得ができます。
例えばタスクが追加された時はタスク一覧のキャッシュを更新する必要があります。そうしないと古いキャッシュが使用されてしまいタスク一覧に最新の情報がすぐに反映されません。
キャッシュを更新するためには QueryClient の invalidateQueries()メソッドに対象のキャッシュキーを渡して古いキャッシュを無効化します。
サービス毎のキャッシュキーは getQueryKey()メソッドで取得できます。
以下のコンポーネントではタスクの追加(CreateTask)の成功時にタスク一覧(GetTaskList)の古いキャッシュを無効化しています。
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
createTask,
getTaskList,
} from "../gen/task/v1/task-TaskService_connectquery";
import { FC } from "react";
type Props = {
name: string;
};
export const NewTask: FC<Props> = ({ name }) => {
const client = useQueryClient();
const { mutateAsync, isLoading, isError, error } = useMutation(
createTask.useMutation()
);
const handleCreate = () => {
mutateAsync(
{ name },
{
// リクエストが成功した場合の処理
onSuccess: () => {
// タスク一覧(GetTaskList)のキャッシュを更新する
client.invalidateQueries(getTaskList.getQueryKey());
},
}
);
};
return (
<div>
{isError && <p role="alert">{error.message}</p>}
<button onClick={handleCreate} disabled={isLoading}>
Add New Task
</button>
</div>
);
};
invalidateQueries()メソッドにより古いキャッシュが無効化され、新たに Query が実行された時に新しいキャッシュが作成されます。
おわりに
いかがでしたか?
Connect は現在も活発にアップデートされており今後の展開にも期待しています。
本家 gRPC との互換性も意識されているので安心ですね。
ちなみに今回のソースコード全体は僕のリポジトリにあります。
この記事が誰かの参考になれば幸いです。
参考 URL
Discussion