OpenAPI × Orval × MSW × Next.jsでのスキーマ駆動開発実践 #techtekt Advent Calendar 2022

この記事は techtekt アドベントカレンダー2022 の15日目の記事です🔥

その他にも記事が掲載されていますので、興味がある方は#techtekt Advent Calendar 2022で検索してみてください!

 

はじめに

はじめまして。パーソルキャリア株式会社のサービス開発部でエンジニアをしている西澤と申します。

HR forecasterという採用支援サービスのフロントエンドエンジニアを担当しています。今回はそのプロジェクト内で新たに組み上げた開発体制で導入したスキーマ駆動開発の流れについて紹介したいと思います🙋‍♂️

 

目次

  1. 開発環境
  2. 導入背景
  3. スキーマ駆動開発って何?
  4. OASのディレクトリ構成
  5. OASを利用した型の生成・モックコードの生成
  6. Next.jsでMSWを動かす
  7. おわりに

 

開発環境

  • Next.js 12.3.4
  • React 18.2.0
  • TypeScript 4.7.4

 

導入背景

現在担当しているプロジェクトでは、フロントエンドチーム、バックエンドチームが分かれています。

チームが別組織なこともあり、お互いのコードに触れる機会はほぼありません。

開発の流れは下の図のような流れで、OpenAPI Specification(以降はOASと記述)を利用してWebAPI仕様の擦り合わせは行っていますが、フロントエンド、バックエンドの結合テストは開発環境にデプロイされてからになります。

これの問題点はフロントエンド、バックエンドでWebAPI仕様に齟齬があっても結合テストが実施されるまでそれに気づけない点です。結合テストで仕様齟齬が発覚すると、リリース直前に修正を行わなければならず、リリースサイクルへの影響や品質の低下等の要因に繋がります。(実際にありました)

その他にもプロジェクトの規模が大きくなるに連れて以下の問題が発生していました。

  • OASの更新漏れが起きる。
  • コードジェネレーター等を使っておらず、モックコードを全て書いているためメンテナンスコストが高く、実装漏れ等が起きやすい。(リファクタの優先度が低く認知負荷が高いコードが多い)

バックエンドのコードも触れない、WebAPIの仕様も間違っている、となるとフロントエンドチームからバックエンドチームへ余計なコミュニケーションコストが発生するため、どうにかしたい問題でした。

こういった背景を踏まえ、新しい開発体制ではこれらの問題を解決すべく以下のゴールを設定して進めていきました。

  • 人の手が介在する箇所を減らし、仕様漏れ等の人的ミスが少ない。
  • OASを作成・修正しないと開発が進められない。
  • OASはコードと同等の管理がされている。
  • モックコードの開発・メンテナンスコストが低い。

 

スキーマ駆動開発って何?

はじめに、スキーマ駆動開発について軽く触れようと思います。

ざっくり言うと、みんなでスキーマ定義(どのエンドポイントに、どんなリクエストを送り、どんなレスポンスを返すのか)を決めて、それから開発を始めよう、というのがコンセプトです。

それ以外については、特に明確な定義があるわけではないようですが、作成したOAS等のスキーマ仕様書をもとにコードジェネレーター等を利用して開発を進めるのが一般的なようです。

今回は採用しなかったのですが、スキーマ駆動開発で良く名前が挙がるOpenAPI Generatorのコントリビューターの1人の中野さんが書かれたスライドを参照いただくと詳細が掴めるかと思います。

openapi-generator.tech

 

 

OASのディレクトリ構成

では、実装の詳細の説明に移っていきたいと思います。

一番始めに着手したのは、OASをコード同等の管理ができるようにすることでした。

OASを書いた経験があるエンジニアであれば想像つくと思いますが、モノリス化されたOASはメンテナンス性に欠けるため、認知負荷がかからない程度の単位で分割する必要があると考えました。

あまりローカルルールになりすぎないようにOASのルートオブジェクトレベルでディレクトリを区切り、それより深い階層については必要に応じて決めました。

最終的には以下のような構成で開発を進めています。

openapi
├── components
│   ├── examples
│   │   ├── CommonError403.json
│   │   ├── CommonError500.json
│   │   ├── AdminUser.json
│   │   └── MemberUser.json
│   ├── parameters
│   │   └── id.yaml
│   └── schemas
│       └── User.yaml
├── paths
│   └── users // リソース単位のディレクトリに各エンドポイントの定義ファイル
│       └── index.yaml
└── specification.yaml // OpenAPIのIndexファイル

OASはYAML形式とJSON形式の2種類をサポートしており、examplesに保存するファイルは全てJSON形式、それ以外はYAML形式で管理しています。JSON形式を採用している理由は後述しますが、モックのレスポンスとして利用するためです。

 

またドキュメントの作成支援として、Stoplight社が開発するSpectralを利用してドキュメントの構文チェックを行っています。

stoplight.io

 

他にも強力な作成支援ツールとして、Swagger Editorを利用するのも良さそうですが今回は採用に至りませんでした。

editor.swagger.io

 

OASを利用した型の生成・モックコードの生成

次にOASを利用して、フロントエンドで利用するWebAPIの型生成、モックコードの生成をします。

今回はコードジェネレーターにOrvalを、モックにはMSWを採用しました。

orval.dev

mswjs.io

 

まずOrvalについてです。

OrvalはOASからTypeScriptで利用する型や、各種HTTPクライアント用のリクエストメソッドを生成してくれます。Githubの履歴を確認する限り、2020年頃にスタートしているようなので比較的新しいOSSです。

それでも採用した理由は、機能の1つであるMSWのモックコードの生成です。これにより、exampleだけでは対応しきれない大量の一覧データを用意するのに役立ちそうと考え、Orvalを採用しました。

 

次にMSWについてです。

これはサーバータイプのモックとは異なり、サービスワーカーレベルで動作するモックです。ブラウザ上から送信されるリクエストをサービスワーカーがインターセプトし、対応するエンドポイントのレスポンスを返却してくれます。

もちろんブラウザ上だけでなく、Node上でも実行できるのでテストの実行やSSRにも対応しています。

モック用のインスタンスを個別に建てる必要がなくなるのと、テストやStorybook等全てに使い回しができるので採用することにしました。

より詳細を知りたい方は、弊社の佐藤ゆさんが書かれた記事を参照いただくとよりイメージが掴めると思います。

techtekt.persol-career.co.jp

 

では、実際のコードをもとに生成されるコードを少しだけ紹介します。

以下に記述してあるのは、Userのスキーマです。これをもとにOrvalで型生成・モックコードの生成をしてみたいと思います。

description: ユーザー情報
type: object
required:
  - address
  - name
  - email
  - tel
  - role
properties:
  address:
    description: 住所
    type: string
    example: 東京都千代田区丸の内2-4-1
    nullable: false

  name:
    description: 名前
    type: string
    example: パーソル 太郎
    nullable: false

  email:
    description: メールアドレス
    type: string
    format: email
    example: example@example.com
    nullable: false

  tel:
    description: 電話番号
    type: string
    pattern: '^\d{2}-\d{4}-\d{4}$'
    example: 03-1234-5678
    nullable: false

  role:
    description: 権限
    type: string
    enum:
      - Admin
      - Member
    example: Member
    nullable: false

 

Orvalで生成されたUserモデルです。

import type { UserRole } from './userRole';

/**
* ユーザー情報
*/
export interface User {
  /** 住所 */
  address: string;
  /** 名前 */
  name: string;
  /** メールアドレス */
  email: string;
  /** 電話番号 */
  tel: string;
  /** 権限 */
  role: UserRole;
}
/**
* 権限
*/
export type UserRole = typeof UserRole[keyof typeof UserRole];
 
export const UserRole = {
  Admin: 'Admin',
  Member: 'Member',
} as const;

 

Orvalで生成されたデータ取得メソッドです。

import type { User } from 'schemas';
import { axiosInstance } from 'utils/axios/client';
 
type SecondParameter<T extends (...args: any) => any> = T extends (
  config: any,
  args: infer P,
) => any
  ? P
  : never;

/**
* ユーザーの情報を取得します
*/
export const fetchUser = (options?: SecondParameter<typeof axiosInstance>) => {
  return axiosInstance<User>({
      url: `/api/user`,
      method: 'get'
    },
    options
  );
};
export type FetchUserResult = NonNullable<Awaited<ReturnType<typeof fetchUser>>>;

別ファイルで定義したaxiosインスタンスをOrvalの設定ファイルを経由して読み込ませています。そのインスタンスを利用したメソッドが出力されますが、個別にリクエストヘッダー等をセットすることも可能です。

 

続いて、Orvalで生成されたMSWのモックコードです。

import { rest } from 'msw';
import { faker } from '@faker-js/faker';

export const getFetchUserMock = () => ({
  address: faker.random.word(),
  name: faker.random.word(),
  email: faker.internet.email(),
  tel: faker.random.word(),
  role: faker.helpers.arrayElement(['Admin', 'Member']),
});

export const getUsersMSW = () => [
  rest.get('*/api/user', (_req, res, ctx) => {
    return res(
      ctx.delay(1000),
      ctx.status(200, 'Mocked status'),
      ctx.json(getFetchUserMock())
    );
  }),
];

内部ではFakerを利用してモックデータを生成しています。OASでformat属性が設定されている`email`については、メールアドレスのランダムデータが生成されていますが、それ以外のデータについては全て完全なランダムデータです。

本来Fakerはシチュエーション毎に対応したフェイクデータを生成するメソッドが豊富なのですが、それが利用できないのが若干残念な点です;;

なので、特に制約がない似たような一覧データを取得したい場合はOrvalで生成したコード、厳密なデータが欲しい場合はOASで定義したexampleを利用したコードを使い分ける形で開発を進めています。

fakerjs.dev

 

今回のような特定のユーザーデータを取得するケースであれば、以下のようなモックコードを記述しています。

import { rest } from 'msw';

import { CommonError, User } from 'types/openapi/schemas';

import CommonError500 from 'openapi/components/examples/CommonError500.json';
import AdminUser from 'openapi/components/examples/AdminUser.json';
import MemberUser from 'openapi/components/examples/MemberUser.json';

const mockHandlers = [
  rest.get('api/user', (req, res, ctx) => {
    const prefer = req.headers.get('Prefer');

    if (prefer === '500') {
      return res(
        ctx.delay(1000),
        ctx.status(500),
        ctx.json(CommonError500.value as CommonError)
      );
    }

    if (prefer === 'Admin') {
      return res(
        ctx.delay(1000),
        ctx.status(200),
        ctx.json(AdminUser.value as User)
      );
    }

    return res(
      ctx.delay(1000),
      ctx.status(200),
      ctx.json(MemberUser.value as User)
    );
  }),
];

export default mockHandlers;

OASで定義したexampleは型アサーションで補完します。

また、リクエストヘッダーの`Prefer`を取得できるようにしており、指定されていた場合はそれに応じたサンプルデータを返却するようにしています。これにより特定のユースケースに応じたアプリケーションの実行環境を用意できるので、開発・テストが行いやすくなっています。

 

尚、OASのスキーマとexampleの型比較は標準の仕組みではできません。(公式には`Each example object SHOULD match the media type and specified schema if present.`と書いてあるのに、、、)

なので、OpenAPI.Toolsで紹介されているopenapi-example-validatorを利用してスキーマとexampleの定義に齟齬がないかをチェックして実装ミスを事前に検知できるようにしています。

openapi.tools

github.com

 

このようにほぼOASに依存した運用にしているので仕様漏れ、更新漏れが起きづらく、またコードメンテナンスについても削減できていると考えています。

Next.jsでMSWを動かす

では実際にアプリケーションで動かしていきます。Next.jsであればSSR、CSR両方に対応できるようにしたいところです。

実際にNext.jsで起動させる方法については、公式がexampleを用意してくれているので今回は説明を割愛させていただきます。

github.com

 

その他アプリケーションでのMSWの動作方法については公式にドキュメントが用意されているのでこちらを参照してください。

インスタンスを建てることなくStorybookやテスト等でもそのまま利用できるのは非常に嬉しいポイントですね。

mswjs.io

 

おわりに

今回は私が担当するプロジェクトでのスキーマ駆動開発の流れを紹介しました。

いかがだったでしょうか?

まだこの開発体制で進め始めたばかりなので完全な効果測定までには至っていませんが、現時点では大きな問題なく開発が進められています。

同じような悩みを抱えるエンジニアの方々の解決の一助になれば幸いです!

それではまた👋



西澤 翔利 Shori Nishizawa

エンジニアリング統括部 サービス開発部 第2グループ リードエンジニア

前職、前々職ともに事業会社の社内SEとして従事していた。主にRuby on Rails, Vue.jsをメインとしたシステム開発を行っていた。よりエンジニアが多い環境で開発に注力したいという気持ちが強くなり、2021年4月にパーソルキャリアにジョイン。現在は「HR forecaster」の開発を担当。

※2022年12月現在の情報です。