はじめに
こんにちは。READYFOR のプロダクトエンジニアのもっさんです。
この記事は「READYFOR Advent Calendar 2022」の 12 日目の記事です。
概要
READYFORでの機能開発
READYFOR では、新規・追加開発を行う際、スキーマ駆動開発を利用して開発を進めています。
スキーマの定義には OpenAPI を利用していて、定義の yaml ファイルをメンバーで相談しながら作成後、その定義を元にフロントエンドとバックエンド分かれて実装を進めています。
フロントエンド領域でのスキーマ利用
フロントエンドでは TypeScript で開発をおこなっているので、この定義をもとに型定義ファイルを作成、実装を行なっています。
READYFOR のフロントエンドでは、SPA またはマイクロフロントエンドがコンテキストごとにいくつか分かれて存在しています(SPA またはマイクロフロントエンドごとにリポジトリがあります)。実際にスキーマ定義を利用する際は、それぞれのリポジトリに、yaml ファイルが commit されているリポジトリを submodule として登録しています。
場所によって採用ライブラリは若干異なりますが私が主に書いている領域では yaml ファイルから型定義ファイルの作成に openapi-typescript を、fetch ライブラリとして SWR を、レスポンス内で取得した JSON のパースに zod を採用しています。
コード例
以下の yaml 定義を例にします。
paths:
/api/user:
get:
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/GetUserResponse'
components:
schemas:
GetUesrResponse:
title: GetUserResponse
properties:
user:
$ref: '#/components/schemas/User'
required:
- user
User:
title: User
type: object
properties:
id:
type: integer
name:
type: string
required:
- id
- name
この定義から openapi-typescript を利用して型定義ファイルを生成すると、以下のようなコードが生成されます。
interface paths {
"/api/user": {
get: {
responses: {
200: {
content: {
"application/json": {
schema: components["schemas"]["GetUserResponse"]
}
}
}
}
}
}
}
interface components {
GetUserResponse: {
user: components["schemas"]["User"]
},
User: {
id: number,
name: string
}
}
この生成された型定義をもとに、それぞれの API を定義します。
import useSWR from "swr";
import { z } from "zod";
import { components } from "./type.ts";
// () => Promise<T> の型安全性を検証するfetch関数
const createFetcher = <T>(schema: z.ZodType<T, any, any>) =>
(input: RequestInfo, requestInit: ReqeustInit = {}): Promise<T> =>
fetch(input, requestInit)
.then((res) => res.json())
.then((data) => {
const result = schema.safeParse(data);
if (!result.success) {
throw result.error
}
return result.data;
});
export type GetResponse = components["schemas"]["GetUserResponse"];
// components["schemas"]["user"]に対応するバリデーション
const userSchema = z.object({
id: z.number(),
name: z.string(),
});
// commponents["schemas"]["GetUserResponse"]に対応するバリデーション
const responseSchema = z.object({
user: userSchema
});
// 実装したバリデーションを使うfetch関数
const getFetcher = createFetcher(responseSchema);
// 特定のアクション時にAPIを呼び出したい場合などに使う実装
export const getUser = (): Promise<GetResponse> => getFetcher("/api/user");
// hookとして使いたい場合の実装
export const useUser = () => useSWR("/api/user", getFetcher);
と、長々書きましたが...
ここまででやっと実際の画面を開発する時に呼び出したい getUser
と useUser
ができるということです。
現在の開発方法での課題
ここまでで読んでいただいたところで薄々気づいていると思いますが、この方法にはいくつかの課題があります。
繰り返しが多い
この方式で開発を進めると以下の事をそれぞれのリポジトリで繰り返す必要があります。
- yaml ファイルを管理しているリポジトリを submodule に追加して管理する。
- yaml ファイルから openapi-typescript を使って型定義ファイルを生成する。
- 同じコンポーネントのバリデーションの定義、API の定義を何度も書く(または他のリポジトリからコピー&ペーストする)必要がある
分かりきっている実装を愚直に書かないといけない
実装例で言うと const userSchema
、const responseSchema
はそれぞれ型定義を見て実装しているので、正直面倒です。
また、yaml ファイルのスキーマ定義が変わった際に、バリデーションの更新が漏れて定義と実装のずれが生じてしまう恐れもあります。
課題の解決
これらの問題を解決するために、以下の 2 つの取り組みを行いました。
- npm ライブラリとしてバリデーションと API の実装をまとめたものを作成し、それぞれのリポジトリで使えるようにする
- バリデーションと API の実装を型定義から自動生成できるようにする
2 つめの型定義からの自動生成を実現するために、今回はTypeScript Compiler APIを利用しました。TypeScript Compiler API は一番基本的な TypeScript から JavaScript への変換の他、TypeScript のコードの情報をプログラム内で参照したり、変更・生成したりできます。今回の自動生成では、このうちの AST の取得・生成の API を利用しています。
スキーマ定義の生成
では、TypeScript Compiler API を利用してコード例に記載した userSchema
を自動生成するコードを書いてみます。
型定義ファイルからASTを取得する
AST を取得するためには ts.Program を作成して、その中から ts.SourceFile を得る必要があります。
ts.Program はパース済みのプログラム及びコンパイル機能全体を持つオブジェクト、 ts.SourceFile はプログラムの中の 1 ファイルの AST の集合をもつオブジェクトです。
import * as ts from "typescript";
const program = ts.createProgram(["./path/to/type.ts"], {});
const sourceFile = program.getSourceFile("./path/to/type.ts");
これで type.ts に含まれる interface paths {}
と interface components {}
の AST を得ることができました。
ts.SourceFileの中から userSchemaに対応する型情報のASTを抜き出す
AST は基本的に Node 型のオブジェクトで、このプロパティとして node.kind
があるのでこれを判別することによりより具体的な Node の型を得ることができます。
例えば今回の対象となる interface。
interface components {
GetUserResponse: {
user: components["schemas"]["User"]
},
User: {
id: number,
name: string
}
}
これを AST として表現されている Node に置き換えるとこのような定義になっています(簡略化して表記しています)。
InterfaceDeclaration {
name: Identifer { "component" }
members: [
PropertySignature: {
name: Identifer { "GetUserResponse" }
type: TypeLiteral { ... }
}
PropertySignature {
name: Identifer { "User" }
type: TypeLiteral {
members: [
PropertySignature {
name: Identifer { "id" }
type: NumberKeyword
}
PropertySignature {
name: Identifer { "name" }
type: StringKeyword
}
]
}
}
]
}
つまり、SourceFile のなかで InterfaceDeclaration で名前が component の Node → members の中で、Identifer の名前が User と定義されている PropertySignature の type を目指せば userSchema に対応する AST の Node を得ることができます。
// ※ 各Nodeがundefinedになっていない事を確認するコードは省略しています
const componentInterfaceNode = sourceFile.statements?.find(
(node) => ts.isInterfaceDeclaration(node) && node.name.escapedText === "components"
);
const userTypeNode = compoenntInterfaceNode.members.find(
(node) => ts.isPropertySignature(node) && node.name.text === "User"
);
バリデーションのNodeを生成する準備をする
抜き出した AST に対応する目標とするコードをもう一度見ます。
const userSchema = z.object({
id: z.number(),
name: z.string(),
});
これを AST として表現されている Node に置き換えるとこのような定義になっています(簡略化して表記しています)。
VariableDeclarationList {
declarations: [
VariableDeclaration {
name: Identifer { "userSchema" }
initializer: CallExpression {
expression: PropertyAccessExpression {
expression: Identifer { "z" }
name: Identifer { "object" }
}
arguments: [
ObjectLiteralExpression {
properties: [
PropertyAssignment {
name: "id"
initializer: CallExpression { (z.objectと同じ形なので省略) }
}
PropertyAssignment {
name: "name"
initializer: CallExpression { (z.objectと同じ形なので省略) }
}
]
}
]
}
}
]
}
少し長いので分解しながら少しずつ見ますが、この Node を生成できるような関数を順番に定義します。
Node を生成する場合は TypeScript Compiler API 内の factory 関数を利用します。
まず z.number()
について。これは PropertyAccessExpression + CallExpression + Identifer で構成されています。
各 Node には対応する createXXX という名前の関数が用意されているので、これを呼び出します。 z.string()
については PropertyAccess が number
→ string
に変わっただけです。
export const zodNumber = () =>
factory.createCallExpression(
factory.createPropertyAccessExpression(
factory.createIdentifier("z"),
factory.createIdentifier("number")
),
undefined,
[]
);
export const zodString = () =>
factory.createCallExpression(
factory.createPropertyAccessExpression(
factory.createIdentifier("z"),
factory.createIdentifier("string")
),
undefined,
[]
);
次に z.object({...})
について、これも基本的には z.number()
と同じですが CallExpression の引数として ObjectLiteralExpression が渡されているのでその部分を追加してあげます。
// { foo: SomeType } の時 ["foo", SomeTypeのExpression]を渡す想定
export const zodObject = (properties: [string, Expression][]) =>
factory.createCallExpression(
factory.createPropertyAccessExpression(
factory.createIdentifier("z"),
factory.createIdentifier("object")
),
undefined,
[
factory.createObjectLiteralExpression(
properties.map(([name, expression]) =>
factory.createPropertyAssignment(
factory.createIdentifier(name),
expression
)
),
true
),
]
);
最後に const userSchema = xxx
の部分についてです。
これは VaribleStatement に identifer と expression を渡してあげると生成できます。
export const variable = (name: string, expression: Expression) =>
factory.createVariableStatement(
undefined,
factory.createVariableDeclarationList(
[
factory.createVariableDeclaration(
factory.createIdentifer(name)
undefined,
undefined,
expression
),
],
NodeFlags.Const
)
);
TypeLiteralのNodeを実装のNodeに変換する
さて、ここまでで Node を変換するための関数の準備ができたので、実際に型情報から実装の Node を生成する関数を書きます。
import * as exp from "./expressions.ts";
const createZodSchema = (typeNode: ts.TypeNode | ts.TypeLiteralNode): Expression => {
switch(typeNode.kind) {
// number型の時は z.number() を返す
case ts.SyntaxKind.NumberKeyword:
return exp.zodNumber();
// string型の時は z.string() を返す
case ts.SyntaxKind.StringKeyword:
return exp.zodString();
// TypeLiteralの時は z.object() を返す
case ts.SyntaxKind.TypeLiteral:
// pickPropertiesは (LiteralTypeNode) => [string, Node][] を返す関数です(実装略)
const properties = pickProperties(typeNode).map(([name, node]) => [name, createZodSchema(node)]);
return exp.zodObject(properties);
default:
throw new Error("not implemented!");
}
}
最後に、実装した関数へ userSchema
の型定義の Node を渡して、実装される Node を得ます。
const userSchemaNode = exp.variable("userSchema", createZodSchema(userTypeNode));
ファイルに出力する
Node をコードの文字列に変換するには TypeScript Compiler API の Printer を利用します。
Printer に Node を渡すと文字列が返ってくるので、これをファイルに吐けばコードを生成するコードが完成します。
import fs from "fs";
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const distFile = ts.createSourceFile("./path/to/dist.ts", "", ts.ScriptTarget.Latest);
const result = printer.printNode(ts.EmitHint.Unspecified, userSchemaNode, distFile);
fs.writeFileSync("./path/to/dist.ts", result);
あとはこれを npx ts-node generate.ts
と叩くだけで面倒だったスキーマの実装をせずとも、型定義から実装を自動生成できます。
まとめ
記事内では components に定義されている型からのバリデーション自動生成のみを説明しましたが、実際の業務では、fetch 関数と hook を含めた api ごとのファイル全体を生成しています。TypeScript Compiler API 自体の扱い自体はとてもシンプルに扱えるものが多く、使い始めてしまえば、業務での活用方法が多く見つかりそうです。アイデア次第では業務での開発効率改善に大きく役に立つ可能性を秘めています。
私自身、今回のコード生成をしたいと決めてから初めて TypeScript Compiler API を触り始めたので、まだまだわからないことも多いですが、理解を深めつつ次の活用方法を見つけていきたいです。