Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Vol.06 Connect によるスキーマ駆動開発のススメ - connect-web フロントエンド編

はじめに

こんにちは、技術本部 Bill One Engineering Unit の江川です。
普段は、Bill OneでWebアプリケーション開発をやりつつ、フロントエンド周りの技術的な改善に向き合ったりしています。
今回は、バックエンド編の続きとして、フロントエンドにおけるConnectによるスキーマ駆動開発の実例を紹介できたらと思います!

なお、本記事は【Bill One 開発 Unit ブログリレー】という連載記事のひとつになっています。他の記事もぜひぜひご覧ください!

目次

Connectを用いたスキーマ駆動開発

Connectの特徴

Connect はgRPC 互換のHTTP APIを実装するためのフレームワークです。
バックエンド編の方でも触れられているので詳細は割愛しますが、

  • Protoファイルによるスキーマ定義からコード生成ができる。
  • Proxyなしでフロントエンド/サーバーサイド間で通信を行うことができる。
  • データのシリアライズフォーマットとしてJSON, Protobufがサポートされている。

といった特徴を持っています。

これまでフロントエンドでスキーマ駆動開発といえば Open APIや GraphQL の名前が挙がることが多かったかと思いますが、個人的にはこれらに比べてシンプルに記述できるProtocol Buffersを利用できるのは大きな利点になると思っています。

Protocol Buffers schemaからのクライアントコード生成

詳細な手順についてはドキュメントに詳しく書いてあるのでそちらに譲るとします。

クライアントのコード生成は @bufbuild/protoc-gen-connect-es@bufbuild/protoc-gen-es を用いて行われます。生成されるTypeScriptの型については以下にまとまっています。

github.com

github.com

Protocol Buffersで定義したMessageについてはclassとして生成される他、ランタイムである @bufbuild/protobufWell-Known Typesを提供しており、MessageのフィールドにWell-Known Typesを利用した際にも問題なくコード生成できます。

また、私の所属するチームではgoogle typeのDecimal型などを利用する必要がありました。この場合は、buf generateを行う際に、--include-importsオプションを利用することでgoogle typeのTypeScript型も生成されます1

connect-webを利用する上でやったこと

ここからは、実際にConnectによるスキーマ駆動開発をするにあたってやったことや、開発しやすくするためにやった工夫について紹介していきます。

前提

今回関連するBill Oneのフロントエンドにおける利用ライブラリは以下の通りです。

特徴としてはサーバーデータのキャッシュとしてTanstack Queryを利用していることや、テストやStorybook環境においてはMSWを用いたAPIモックを行っていることが挙げられます2

API Client取得用のCustom Hooks生成utilを用意する

公式ドキュメントにも記載があるように、API Clientを利用する度にtransportが生成されることを避ける目的で2つのユーティリティ関数を用意しました。

// createTransport.ts
import { createConnectTransport as createTransport } from "@bufbuild/connect-web"

type CreateConnectTransportArgs = {
  baseUrl: string
}

export const createConnectTransport = ({ baseUrl }: CreateConnectTransportArgs) =>
  createTransport({
    baseUrl,
    useBinaryFormat: false,
    credentials: "same-origin",
    interceptors: [errorHandler], // Interceptorについては後述
  })
// createConnectClient.ts
import { createPromiseClient, PromiseClient, Transport } from "@bufbuild/connect"
import { ServiceType } from "@bufbuild/protobuf"
import { useMemo } from "react"

export type ConnectClient<S extends ServiceType> = PromiseClient<S> & {
  serviceName: S["typeName"]
}

export const createConnectClient = <T extends ServiceType>(service: T, transport: Transport): ConnectClient<T> => {
  return {
    serviceName: service.typeName,  // clientからserviceのtypeNameを参照可能にしている
    ...createPromiseClient(service, transport),
  }
}

export const createUseConnectClient = (transport: Transport) => {
  return <T extends ServiceType>(service: T): ConnectClient<T> =>
    useMemo(() => createConnectClient(service, transport), [service])
}

もともとのPromiseClientを拡張してConnectClientという型定義を用意しています。ここではserviceNameというプロパティを追加しているのですが、用途についてはTanStack Queryに関する章で追ってお話ししようと思います。
これらのユーティリティ関数を用いて、以下のようにAPIのBaseURLごとにtransportの生成とAPI Client取得用のCustom Hooksを生成する形をとっています。

export const baseUrl = "/api/invoice"

const transport = createConnectTransport({ baseUrl })
export const useInvoiceClient = createUseConnectClient(transport)

API呼び出し側では、以下のように呼び出すことが可能です。

const client = useInvoiceClient(InvoiceService) // InvoiceServiceはコード生成によりスキーマから自動生成されたファイル
const invoice = await client.get({ uuid }) // 型チェック・エディタによる推論もしっかり効く

Interceptorを定義する

Connect ではInterceptorによってミドルウェアを差し込むことが可能です。 例えば、リクエスト/レスポンスの加工やエラーハンドリング、ロギングなどを共通処理として行うことが可能です。以下は追加したエラーハンドリングのためのInterceptorの例です。エラーレスポンスが返る場合に共通処理を呼び出しています。

import { Code, ConnectError, Interceptor } from "@bufbuild/connect"

export const errorHandler: Interceptor = (next) => async (req) => {
  try {
    return await next(req)
  } catch (error) {
    if (error instanceof ConnectError) {
      if (error.code === Code.Unauthenticated) {
        redirectToLogin()
      }
    }
    throw error
  }
}

MSWによるモックを作成しやすくする

MSWではREST APIとGraphQL APIのリクエストハンドラーのみがAPIとして提供されています。一方、今回はConnectを採用するということで、MSWによりモックを書きやすくする仕組みを作る必要がありました。

Connectプロトコルは、

  • リクエストはProto Buffers schemaから決定されたURLに対してPOSTメソッドで送られる。
  • レスポンスはJSONまたはProtobuf形式で、ステータスコードはHTTP Status Codeにマッピングされる。

という仕様3であるため、REST APIのリクエストとみなすことが可能です。なので今回モックの作成にはREST API用のハンドラーを活用することにしました。 また、Bill OneではAPIモックをテストやStorybookで活用する都合上、モックを再利用するコストを最小限に抑えるためにモック作成用の関数を用意しています。今回はConnect用に新たにユーティリティ関数を用意しました。
以下は作成した関数の例です。(一部ブログ掲載用に型定義などを省いた箇所があるため、実際には型チェックに落ちるかと思います)

export const createConnectHandler = <
  Client extends ConnectClient<Service>,
  Method extends ConnectClientMethods<Service>, // API ClientにおけるMethodName
  Req extends ConnectClientMethodParameter<Client, Method>, // API呼び出しの引数
  Res extends MockResponse<Client, Method>, // API呼び出しのレスポンス
  Service extends ServiceType,
>(
  baseUrl: string,
  service: Service,
  method: Method,
  resolver: ResponseResolver<RestRequest<Req, {}>, RestContext, Res>,
): MockConnectHandler<Req, Res> => {
  const url = `${baseUrl}/${service.typeName}/${method}` // baseUrlとserviceName, MethodNameからURLを組み立てる
  return (args?: ConnectMockCustomArgs<Res>) =>
    rest.post(url, (req, res, ctx) => {
      if (args === undefined) return resolver(req, res, ctx) // default

      // argsをみてカスタムレスポンスを返す
      ...
    })
}

bytes型を考慮してMockResponse型を作る

Protocol Buffersにおいてbytes型のフィールドは、クライアント側ではUint8Arrayとして型定義されます。しかし、MSWはネットワークレベルでインターセプトするため、bytes型のフィールドにはUint8ArrayではなくBase64文字列をレスポンスとして返す必要がありました。
これを常に意識するのは大変なので、MockResponse型を定義し、モック作成の際にbytes型のフィールドについてはBase64エンコードすることを強制するようにしました。

type Base64String = string & { _type: "base64String" }
export const encodeToBase64String = (bytes: Uint8Array): Base64String => protoBase64.enc(bytes) as Base64String

type MockResponse<
  Client extends ConnectClient<Service>,
  Method extends ConnectClientMethods<Service>,
  BaseResponse extends ConnectClientMethodResponse<Client, Method> = ConnectClientMethodResponse<Client, Method>,
  Service extends ServiceType = ServiceType,
> = {
  [K in keyof BaseResponse]: BaseResponse[K] extends Uint8Array ? Base64String : BaseResponse[K]
}

ErrorDetailを返すパターンに対応する

APIモックをテストで活用していく上で、エラーとそのエラー理由をレスポンスとして返すようなAPIへの対応も必要です。 Connectではエラーとして、code, message, detailsといったプロパティが返ります4。この内、detailsに関しては以下のようにプロパティの値がBase64文字列の形式でレスポンスが返ってきます。

{
  "code": Code,
  "message": string,
  "details": [
    {
      "type": string, // ErrorDetailのtypeNameが入る
      "value": string, // ErrorDetailをBase64文字列にしたものが入る
    },
  ],
};

こちらもBase64エンコードされるということで、ErrorDetailをモックのレスポンスとして定義しやすくするためのユーティリティ関数を用意しました。

type ConnectErrorDetail = {
  type: string
  value: Base64String
} & { _type: "connectErrorDetail" }

export const createMockConnectErrorDetail = <T extends MessageType, M extends InstanceType<T> & Message<M>>(
  messageType: T,
  obj: PlainMessage<M>,
): ConnectErrorDetail => {
  return {
    type: messageType.typeName,
    value: encodeToBase64String(new messageType(obj).toBinary()),
  } as ConnectErrorDetail
}

カスタムレスポンスとしてエラーを返す際にはConnectErrorDetail型を受け取ることを強制することで、ErrorDetailを返すようなパターンも迷わずモック定義ができるようにしています。

TanStack Query用のラッパー関数を用意する

ConnectではTanStack Queryを利用するための@bufbuld/connect-queryというライブラリが用意されています。 Bill OneでもTanStack Queryを利用しているため、こちらのライブラリを利用することは可能でしたが、以下の理由から採用を見送りました。

  • API Client(Connectにより生成されたコード)とサーバーデータキャッシュ(TanStack Query)周りが密結合になってしまう。
  • v1.0になる前にAPIの変更が入る可能性があることが明言されている。5

また、実際に利用している主なHooksがuseQueryuseMutationであったことから、これらのConnectクライアント用のラッパーを用意すれば事足りると判断し、独自のラッパー関数を用意することにしました。 以下はラッパー関数の実装例です。

// useQuery wrapper for Connect
export const useConnectQuery = <
  Client extends ConnectQueryClient<Service>,
  MethodName extends ConnectQueryClientMethods<Client>,
  Req extends Parameters<Client[MethodName]>[0],
  Res extends Awaited<ReturnType<Client[MethodName]>>,
  Service extends ServiceType,
>(
  connectClient: Client,
  methodName: MethodName,
  input: Req,
  options?: UseQueryOptions<Res, ConnectError>,
) => {
  return useQuery<Res, ConnectError, Res>({
    queryKey: generateConnectQueryKey(connectClient, methodName, input),
    queryFn: async () => (await connectClient[methodName](input)) as Res,
    ...options,
  })
}

// useMutation wrapper for Connect
export const useConnectMutation = <
  Client extends ConnectQueryClient<Service>,
  MethodName extends ConnectQueryClientMethods<Client>,
  Req extends Parameters<Client[MethodName]>[0],
  Res extends Awaited<ReturnType<Client[MethodName]>>,
  Service extends ServiceType,
>(
  connectClient: Client,
  methodName: MethodName,
  options?: UseMutationOptions<Res, ConnectError, Req>,
) => {
  return useMutation<Res, ConnectError, Req>({
    mutationFn: async (input) => (await connectClient[methodName](input)) as Res,
    ...options,
  })
}

// queryKey生成ロジック
export const generateConnectQueryKey = <
  Client extends ConnectQueryClient<Service>,
  MethodName extends ConnectQueryClientMethods<Client>,
  Req extends Parameters<Client[MethodName]>[0],
  Service extends ServiceType,
>(
  client: Client,
  methodName: MethodName,
  input: Req,
) => {
  return [client.serviceName, methodName, input] as const
}

ConnectQueryClientという型は上で紹介したConnectClientという型から呼び出し可能なメソッドをUnary methodのみに絞った型になっています。 ConnectClient型におけるserviceNameはこのqueryKey生成ロジックの中で活用していて、これによりキャッシュ用のキーを一意なものに指定することが可能となります。

このラッパー関数の利用例は以下のようになります。キーのinvalidateなども含めて型安全にやりたいことが実現できているかなと思っています。

// useConnectQueryの利用例
const useInvoice = (uuid: string) => {
    const client = useInvoiceClient(InvoiceService)
    return useConnectQuery(client, "get", { uuid })
}

// useConnectMutationの利用例
const useUpdateInvoice = () => {
    const queryClient = useQueryClient()

    const client = useInvoiceClient(InvoiceService)
    const { mutateAsync: updateInvoice } = useConnectMutation(client, "update", { memo: "updated memo" }, { 
        onSuccess: () => queryClient.invalidateQueries(generateConnectQueryKey(client, "search", {}))
    })
    return { updateInvoice }
}

ひとまず現在利用しているユースケースでは問題なく利用できています。 一方、型周りは大いに改善の余地がありそうなのと、Streamingが必要になるユースケースが出てきた際には定義を見直したりする必要がありそうです。

おわりに

今回は、Bill OneでのConnectによるスキーマ駆動開発の取り組みについて紹介しました。 しばらくconnect-webを利用していますが、仕組みさえ整えてしまえば多くの恩恵を受けられるなと感じています。

ここまでお読みいただいてありがとうございました。 スキーマ駆動開発の選択肢としてConnectを検討する方のお役に立てたら幸いです!


Bill One では一緒に働く仲間を募集しています!他にもフロントエンドの改善に向き合ったりしているので、興味がある方のご応募をお待ちしております。

open.talentio.com

© Sansan, Inc.