Sansan Tech Blog

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

TypeScriptプロジェクトにスキーマ駆動開発を持ち込み、より型安全な世界へ

Bill One Entry*1グループの秋山です。

本題に入る前にお知らせです。9/12 (火) にTypeScriptを活用した型安全なチーム開発をテーマとしたイベントを開催します。 ぜひ、お気軽にご参加ください! sansan.connpass.com

1. はじめに

1-1. スキーマ駆動開発とは

詳しい解説は他の記事に譲り、一言で表現すれば、事前に定義した単一のインタフェースをもとに、バックエンドやフロントエンド等の各サービスを開発する手法を指します。この記事ではそれらの事前定義されたインタフェースを「スキーマ」と呼びます。

スキーマ駆動開発のメリットは大きく次の3つです。

  • (1) 自動化:スキーマからドキュメント・実装コード・テストを部分的に自動生成できます。
  • (2) 依存制約の解消:例えば従来はバックエンドチームが担当するWebAPIの完成を待たなければ、フロントエンドチームが開発を始められないという制約がありました。スキーマさえ定義されていれば、この制約を取り払えるようになります。
  • (3) 堅牢性の担保:スキーマを各サービスが共有することで、データ構造やプロトコルの一貫性が高まり、サービスの堅牢性を担保できます。

この記事では、特に (3) の堅牢性の担保を目的とし、TypeScriptで書かれた弊チームの既存プロジェクトに対して、スキーマ駆動開発を導入し型安全な開発環境を整えた顛末を書きます。

1-2. Testing Trophyとの関係性

図1. Testing Trophy。https://testingjavascript.com/ より。

フロントエンドにおけるテストのガイドラインとしてTesting Trophyがあります。

図1の上から順番に、

  • End to End:E2Eテスト。
  • Integration:結合テスト。
  • Unit:単体テスト。
  • Static:Lintや型チェック。

のようにレイヤーが並びます。Testing Trophy自体の論旨は、結合テストを厚くし、ユーザーの動作を模倣するようなテストを書くべきというものです。このレイヤーの順番は、有名なTesting Pyramidと同様、上のレイヤーにいくほどテストのコストが大きいことを表しています

📝 TestingPyramidやTestingTrophyについては下記の拙記事を参照ください。

buildersbox.corp-sansan.com

今回導入するスキーマ駆動開発が図1のどのレイヤーに対するアプローチなのかというと、一番下のStaticレイヤーに該当します。

Staticレイヤーが内包するLintや型チェックのような静的解析は最もコストの小さいテストのため、静的解析で防げるものは何でも防ぐべきです。Staticレイヤーにどれだけ厚みを持たせられるかはテスト戦略でも重要なファクターといえます。

2. 構成

私たちのプロジェクトの構成は次の通りで、スキーマ駆動開発もこの構成から変更しない形で導入しました。

  • マルチレポジトリ:この記事では単純化し、バックエンドとフロントエンドの2レポジトリだけを挙げる
  • スキーマ定義:Open API 3.0 で記述
  • 言語:バックエンド・フロントエンドいずれもTypeScript
  • サーバサイドフレームワーク:Express

2-1. 最初の構成と課題

図2. プロジェクトの最初の構成

図2のopenapi.yamlがOpen API 3.0で記述されたスキーマ定義です。最初の構成では、openapi.yamlはAPIリクエストとそれを処理するハンドラを結びつけるルーティングの役割だけを担っており、バリデーションや型による制約は導入していませんでした。

ここでの課題は次の2つです。

課題1:信頼境界の不在

最初の構成では、バックエンドではリクエストを、フロントエンドではレスポンスを、それぞれany型として扱っていました。そのため一連のリクエストとレスポンスのフロー上に信頼境界が存在せず、予期しないデータがフロー上のどこまで到達するか、コントロールできていない状態でした。

信頼境界が存在しない場合、フロントエンドとバックエンドの双方で問題が生じます。例えば、バックエンド側が意図しないデータを受信した場合、エラーを引き起こしたりデータの不整合が発生する可能性があります。フロントエンドにおいても意図しないデータを受信した際、アプリケーションがクラッシュしUXの毀損に繋がるケースもあります。

信頼境界が不在の状態でそのような意図しないデータの混入を防ごうとすると、フローにおける各所でデータをチェックする処理を書く必要があります。このように逐一データをチェックする方法は、チェックを行う処理の重複や抜け漏れが発生するアンチパターンです。逐一のチェック処理に代わり、リクエストやレスポンスという形で入ってきたデータは早い段階でバリデーションを掛け、それ以降のフローにおけるデータは信頼できる状態にする手法(=信頼境界の設定)が望ましいです。

課題2:静的解析の範囲が小さい

定義済みのスキーマからTypeScriptの型を生成しておらず、スキーマ・バックエンド・フロントエンドで個別にインタフェースを定義していました。それらインターフェース間の整合性を静的に保証する仕組みが備わっておらず、スキーマ・バックエンド・フロントエンドの変更の影響がどこまで波及するか予期できない状態でした。

例えば、バックエンドに手を加え、APIのレスポンスのenumに列挙子が一つ加わったとします。このとき、フロントエンド側にそのenumに対応する修正を静的解析を通して強制する仕組みがないため、実装者が影響範囲を目視で確認する必要がありました。

2-2. 最終的な構成

図3. 最終的な構成

最初の構成からの主な変更点は次の通りです。

  • バックエンド
    • スキーマ(openapi.yaml)から型ファイル(openapi.ts)を生成する。
    • APIハンドラが型ファイルを参照し、リクエストとレスポンスに対し型チェックを行う。
    • フローの早い段階でバリデーターを挟み、信頼境界を作る。
  • フロントエンド
    • スキーマからAPIクライアントを生成し、リクエストとレスポンスに対し型チェックを行う。

それぞれについて順番に解説します。

3. バックエンド

3-1. スキーマから型ファイルを作る

図3. swagger-typescript-api の変換例。https://github.com/acacode/swagger-typescript-api より。

まず、スキーマ(openapi.yaml)から型定義(openapi.ts)を生成するようにしました。生成にはswagger-typescript-api というパッケージを用いました。同様のことができるパッケージは複数あるのですが、決め手になったのはオプションの豊富さで、APIクライアントの生成可否、型名のプレフィクス、enumではなくstring unionを使うか、などを選択できます。また出力も単一の型ファイルで、型の記述が非常にシンプルであったことも後押しになりました。

これによりリクエストボディやパラメータ、レスポンスボディなどの型が生成されます。

github.com

3-2. APIハンドラに型を与える

前節で生成した型を、APIハンドラに付与します。API実装のフレームワークにはExpressを採用しており、パラメータとレスポンスの型は次のような形で与えられます。

// 生成された型定義
// types/openapi.ts
export namespace Users {
  export namespace CreateUsers {
    export type RequestParams = {};
    export type RequestQuery = {};
    export type RequestBody = {
      name: string;
      role: "admin" | "member";
    };
    export type RequestHeaders = {};
    export type ResponseBody = {
      id: number;
      name: string;
      role: "admin" | "member";
    };
  }
}
// Express の APIハンドラ
import { Request, Response } from "express";
import { Users } from "types/openapi"; // 型定義

export const createUsers = async (
  req: Request<
    Users.CreateUsers.RequestParams,
    Users.CreateUsers.ResponseBody,
    Users.CreateUsers.RequestBody,
    Users.CreateUsers.RequestQuery
  >,
  res: Response<Users.CreateUsers.ResponseBody>
): Promise<void> => {
  const name = req.body.name; // string
  const role = req.body.role; // "admin" | "member"

  const id = createUserApplicationService(name, role);
  res.status(201).send({ id, name, role });
};

3-3. バリデーターを追加する

リクエストに対してどのAPIハンドラをルーティングするかは、swagger-routes-expressというパッケージを使用しています。スキーマ(openapi.yaml)とAPIハンドラのモジュールを読み込み、適切にルーティングしてくれるパッケージです。

github.com

前節によって、APIハンドラの入出力に型が当たるようになりましたが、まだこの状態では信頼境界が設定されていません。swagger-routes-express はルーティングだけを行うため、リクエストに対するバリデーションまでは行ってくれないのです。例えば、上記サンプルコードの createUsers 関数には型が付与されていますが、引数はany型で入ってきます。

入力値に対する信頼境界は、エッジに近ければ近いほど区間が広がるため望ましいです。そのためルーティングの手前に入力値に対するバリデーションを追加するようにしました。使用したパッケージは express-openapi-validator です。

github.com

次のような設定で、openapi.yamlに沿ってバリデーションが実行されます。

import express from "express";
import * as OpenApiValidator from 'express-openapi-validator';

const app = express();
// バリデーションを追加
app.use(
  OpenApiValidator.middleware({ apiSpec: `path/to/openapi.yaml` }),
);

3-4. huskyでスキーマ変更を検知する

ここまででバックエンドのAPIサーバは、スキーマをもとにした対応により次のような状態になりました。

  • (1) リクエストに対してバリデーションを早い段階で掛けることで、信頼境界を設定できた。
  • (2) APIハンドラを型安全な関数に変えることができた。

これらのメリットはすべてスキーマをベースとしています。ところが、(2)はスキーマから生成する型定義に依存しているため、スキーマだけに更新が行われた場合に型定義が古いままの状態になる危険性があります。そのため、huskyを用いて型定義が最新のスキーマに追従しているかgit push前に確認するよう強制する設定を行いました。

# package.json
{
  "scripts": {
    # openapi.yaml から openapi.ts を生成する共通コマンド
    "_codegen": "swagger-typescript-api -p ./openapi.yaml -o ${OUT_DIR} --no-client --route-types --extract-response-error",

    # openapi.yaml から openapi.ts を生成しコードベースに書き出すコマンド
    "codegen": "OUT_DIR=src npm run _codegen",

    # 新旧の openapi.ts のチェックサムを比較し不一致なら標準エラーに出力するコマンド
    "codegen:checksum": "OUT_DIR=/tmp npm run _codegen -- --silent && [[ $(md5 -q /tmp/openapi.ts) == $(md5 -q src/openapi.ts) ]] && (echo ✅ src/openapi.ts is up to date! ; exit) || (echo ⛔️ src/openapi.ts is old.; exit 1)"
},
{
  "husky": {
    "hooks": {
      "pre-push": "npm run --silent codegen:checksum"
    }
  }
}

4. フロントエンド

フロントエンドの主な対応は、APIクライアントのスキーマからの生成です。3-1節でスキーマからバックエンドの型定義を生成するために用いたパッケージ、swagger-typescript-apiをクライアント生成にも使います。

4-1. スキーマからAPIクライアントを作る

フロントエンドレポジトリのpackage.jsonに、スキーマからAPIクライアントを生成する下図のようなスクリプトを定義しています。

{
  "scripts": {
    "generate:client": "swagger-typescript-api -p <path-to-backend-repo>/openapi.yaml -o ./src/generated-client/ --add-readonly --union-enums --disable-throw-on-error --type-prefix Api"
  }
}

参照するスキーマのパスは、相対パスでバックエンドレポジトリにあるopenapi.yamlを指しています。この指定方法は一見雑な方法で、開発者PCのレポジトリの配置方法に依存することになってしまいますが、シンボリックリンクを使うという方法もあるため、特に問題にはなっていません。

新しくレポジトリを作りそこでスキーマを管理する方法もありますが、持ち込まれる複雑性に対しメリットが小さいため見送りました。

生成されたクライアントは次のような使い方ができます。

// 生成されたAPIクライアント
import { Api } from "path/to/Api.ts";

const client = new Api({
  baseUrl: `${API_HOST_NAME}/v1`,
  format: "json",
});

// 引数に型がついている
const response = client.createUser({ name: "リーダブル秋山" }));
// const response = client.createUser({ nickname: "リーダブル秋山" })); 型エラー

// 正常系
if (response.ok) {
  // レスポンスに型がついている
  // { name: string, role: "admin" | "member" }
  return response.data;
}

// エラーハンドリング
...

5. パターンマッチングを持ち込む

さて、ここまでで当初の目的であったスキーマ駆動の導入は終わりました。バックエンドとフロントエンドが単一のスキーマを参照するようになり、型制約の恩恵が受けられます。スキーマが変更されるとバックエンドとフロントエンドの型定義も変更され、そこで不整合があれば静的解析により検知できるようになりました。

しかし次のようなケースはどうでしょうか。

# openapi.yaml
...
role:
    type: string
    enum:
      - "admin"
      - "member"
      - "guest" # <- 新たな列挙子を追加
...

スキーマのenumに列挙子が一つ増えた結果、バックエンドとフロントエンドの型定義にも列挙子が追加されます。このとき次のような分岐処理があったらどうでしょうか。

// if文で分岐処理しているケース

typeof role; // "admin" | "member" | "guest"

if (role == "admin") {
  ...
} else if (role == "member") {
  ...
}
// "guest" に対する処理がない
// switch文で分岐処理しているケース

typeof role; // "admin" | "member" | "guest"

switch (role) {
  case "admin":
        ...
        break;
  case "member":
        ...
        break;
  // "guest" に対する処理がない
}

いずれのケースでもtscによるビルドは通ってしまいます

そのため、スキーマのenumに追加または削除を行った場合、開発者は目視によって影響範囲を調べる必要があります。これではスキーマ駆動開発の恩恵を十分に得られているとは言えません。

switch文で網羅的チェックを行うテクニックとしてExhaustiveness checkingが知られていますが、冗長な表現をコードベースに持ち込むことになるためお勧めできません。

そこでパターンマッチング(=多様なデータの構造に対して網羅的な分岐処理を定義できる仕組み)をTypeScriptで実現できる ts-pattern というパッケージを導入します。これにより例えば次のような書き方が可能になります。

import { match } from "ts-pattern";

typeof role // "admin" | "member" | "guest"
match(role)
  .with("admin", () => { ... })
  .with("member", () => { ... })
  .exhaustive(); // ⛔️ "guest" に対する処理がないという型エラー

分岐処理をパターンマッチングで書くことで、スキーマのenumに対する変更を静的に検知することができるようになりました。ts-patternはさらに多様な表現力を持っており、下記の拙記事でも魅力を伝えているのでご参照ください。

zenn.dev

関数型プログラミングに明るい方は、ts-patternによる分岐処理が文ではなく式で書ける点も魅力に映るかと思います。

6. まとめ

次の施策を通して、プロジェクト全体に型制約を与え、より安全な開発をできるようになりました。

  • APIリクエストに対するハンドラのリクエストパラメータとレスポンスボディに型制約を与えた。
  • APIクライアントのリクエストパラメータとレスポンスボディに型制約を与えた。
  • スキーマをもとにリクエストに対して早期にバリデーションをかけることで、信頼境界を作った。
  • パターンマッチを導入することで、列挙型に対する網羅的な分岐処理を静的に保証できるようになった。

この記事を書くに当たり、Digitization部 小田崇之 と データ戦略部 木田悠一郎 に助言をもらいました。

📝 よければフォローしてください:@リーダブル秋山

*1:クラウド請求書受領サービス「Bill One」が提供するデータ化機能。

© Sansan, Inc.