こんにちは。Inflabのフロントエンドエンジニア、Lucasです。
フロントエンド開発をしていると、code generatorという言葉を耳にしたことがあると思います。Code generatorとは、サーバー側で宣言し、取り決めたデータ型をもとに、クライアントで使えるコードを自動生成するプログラムのことです。特にフロントエンドエンジニアの立場から見ると、APIの変更を追跡し、type-safeな開発をしたいときに、こうした自動化ツールは大きなメリットをもたらします。
Inflabでも、本格的にレガシーシステムの改善を進めるようになってから、APIの型を扱う必要が出てきました。1台のサーバーで動いていたInflearnがAPIサーバーとクライアントに分かれはじめ、APIを通じて機能を実装しなければならない場面が増えたためです。
本記事では、code generatorを自分たちで開発することになった理由についてお話しします。次回の記事では、実装方法と導入後に何が変わったのかを共有する予定です。
1. RESTful APIと向き合う
プロダクトを作っていると多くの悩みに直面しますが、その中でもAPIリクエストの部分をどう管理するかは、いつも深く悩むことになります。
まずどのライブラリを使うかに始まり、コードをどこにどう配置すればAPIの変化に簡単かつ柔軟に対応できるかといった、正解のない選択が求められます。加えて、時間が経つにつれて増えていくAPI endpointの管理も、悩みの種の一つになりがちです。
コードベースでの悩み
特に、今や業界標準と言えるTypeScriptを使う場合は、もう一つ悩みが増えます。それは、APIの戻り値の型をどう管理するかという悩みです。
TypeScriptの特性上、APIの戻り値の型を指定しないままでは使えないので、必ず宣言する必要があります。そのためにフロントエンドエンジニアは通常2つの方法のどちらかを選ぶことになります。1つはすべてのリクエストの戻り値の型をanyで宣言する方法、もう1つはドキュメントに記載された戻り値を見て型を手動で管理する方法です。
経験上、型をanyにせず手動で管理すると、問題が次々に発生していました。
- プロジェクト初期は型をこまめに更新する
- プロジェクトが終盤に近づくにつれ、さまざまな理由(忙しい、急ぎのバグ修正など)で、型をコンパイルが失敗しない程度に適当に管理する
- リリース後に保守が進むにつれ、新しい型が量産されたり、index signature、union、intersectionなどが乱用されたりして、runtimeの安定性が徐々に低下する
- 最終的に型宣言そのものがレガシーとなり、誰も気にしなくなる
もちろんすべてのプロジェクトで起こることではありませんが、おおむね上のような流れをたどった場合、APIとクライアント間のインターフェースの役割を担う型は有名無実化してしまいます。
つまり、APIとクライアント間の役割、責任、協力を信頼できなくなるということです。プロダクトのために最も密接に協力すべき2つのオブジェクトが、互いを信頼できなくなってしまうのです。
フロントエンドエンジニア間の不要な議論
手動で管理しなければならないということは、人や、連携するチーム単位ごとに、管理方法についての考え方ややり方が異なりうるということを意味します。そのため、一貫した型管理のためには、狭くは連携する単位同士で、広くはフロントエンドチーム全体での合意が必要だという結論になります。
チーム全体で合意できたとしても、その合意が盤石とは限りません。もし誰かが新しい管理方法を提案したら、また長くて退屈な議論を一からやり直すべきでしょうか?
実際、こうした技術的な議論そのものが悪いわけではありませんが、個人的にはこの種の議論は正解がなく、消耗するだけだと考えています。この問題を解決する自動化ツールがあれば、議論そのものが不要になるからです。
もしこうした問題をツールで解決できれば、その時間をほかの議論に使えます。たとえばプロダクトの使いやすさ、コードをより良く書く方法、デプロイ時間を短縮する方法といった「ほかの議論」のほうが、プロダクトそのものの生産性により意味のある影響を与えられます。
フロントエンドエンジニアとバックエンドエンジニアの水掛け論
通常、RESTful APIを提供するサーバーを構築すると、ほとんどの場合ドキュメントツールとしてSwaggerを使うことになります。面白いことに、Swaggerを使うと高い確率でフロントエンドエンジニアとバックエンドエンジニアの間にA-chicken-and-egg-problem(鶏が先か卵が先か)が発生します。
たとえば、API開発者も人間なので、当然ながら構築されたAPIとSwaggerで提供されるドキュメント上の仕様が異なるケースが起こりえます。また、フロントエンドエンジニアもそれぞれ仕事のスタイルが違うので、Swaggerで提供されるドキュメントを見ずに開発するケースも出てきます。
こうなると、「どうせ見ないのにドキュメントを更新して何になるのか」という考えと、「どうせ合っていないのに、なぜドキュメントを見るのか」という考えを互いに持つようになります。あとになって「どうしてこうなったのだろう?」と頭を抱えますが、この問題の明確な原因は結局分からないままのことが多いです。
結局、フロントエンドエンジニアはAPIの戻り値の型を自分で確認しながら型を書くことになります。いちばん確実な方法のように思えますが、いくつか問題が生じます。
1つは、プロパティの値がnullやundefinedになりうるという問題です。たとえば次のようなレスポンスがある場合、
{
"user": {
"name": "user name"
}
}
もしuserプロパティがnullになりうるなら、以下のようなコードで問題が発生する可能性があります。
user.name.split(' ');
// Uncaught TypeError: Cannot read properties of null (reading 'name')
もう1つは、プロパティに異なる型の値が入りうるという問題です。同じく、たとえば以下のようなレスポンスがある場合、
{
"user": {
"age": 20
}
}
もしageプロパティがnumberまたはboolean型になりうるなら、以下のようなコードで問題が発生する可能性があります。
user.age > 0;
// ageが20の場合はtrue
// ageがfalseの場合はfalse
こうなると、実際にruntimeでエラーが発生するまでAPIの変更に気づけない可能性が出てきます。もちろんTypeScriptがruntimeの安定性を完全に保証するわけではありませんが、最低限の安全装置が緩んでしまうのです。
RESTful APIは間違いなく良いコンセプトだと思います。ただ、いつものように問題は人にあります。そこで、この問題から人の手をできるだけ取り除くことを試みました。
2. 模索した解決策
以前使ったことのある技術の中から、今のチームに適用できそうな解決策をいくつか模索してみましたが、どれも採用には至りませんでした。
GraphQL
個人的には、GraphQLがこの問題を解決する最もエレガントかつ確実な方法だと考えていました。以前のチームでGraphQLを使ってBFF(Backend For Frontend)サーバーを構築・運用した経験があり、サイドプロジェクトでも積極的に使っていたので、なじみのない技術でもありませんでした。
CTOのHyangLoに恐る恐る導入について相談してみましたが、結局却下されました。正確には、社内サービスで使うのは問題ないものの、外部に公開するサービスには導入できそうにないという回答でした。その理由はいくつかあり、以下のとおりでした。
- GraphQLを使うと、DB modelingがリクエストの結果を通じて大きく露出してしまう。
- GraphQLはORMに過度に依存する。
- どの程度までモニタリングできるのか、現在使っているモニタリングツールがGraphQLをきちんとサポートしているのかが分からない。
- BFFはサービスレイヤーを増やすため、チームの規模を考えると管理の負担になりうる。
- GraphQLの経験者がバックエンドチーム、フロントエンドチームにそれぞれ1人ずつしかいない。学習コストがやや高そうなうえ、経験者が少ないことが導入の障壁になる余地がある。
- ビジネスの状況が急に変わることもありうるのに、開発チームがGraphQLへの移行のせいでスピードを合わせられなくなるおそれがある。
技術的な理由については個人的に腑に落ちない部分もありましたが、それ以外の部分については、十分に納得できる理由だと思いました。しかも最近のInflabチームは素早く動いている状況だったので、新しい技術スタックの導入という決定が足かせになるわけにはいきませんでした。
特に、レガシーAPIを段階的に改善しなければならない状況では、GraphQLの単一エンドポイントがデメリットになる可能性が高いと感じました。エンドポイントがURLで分かれている状況ならURLごとにAPIのtarget groupを切り替えながら改善できますが、エンドポイントが1つだとこうした戦略は取れないからです。
Client side GraphQL
Client side GraphQLも検討対象に含めましたが、結局自ら見送りました。クライアントコードに含まれるGraphQL schemaがAPIに対するインターフェースの役割を果たすことはできても、resolverでAPIを呼び出す必要があるため、根本的な問題の解決策にはならないと判断したからです。
gRPC
GraphQLと似た観点からアプローチしましたが、これを使ってAPIを実装しなければならないバックエンドエンジニアにとっては、GraphQLよりもさらになじみのない方法だということにすぐ気づきました。同じセルのバックエンドエンジニアの同僚と話し合ってみたところ、GraphQLほど前向きな反応を得られなかったからです。プロダクトの安定性も大事ですが、だからといって忙しいバックエンドチームになじみのない技術を勧めるわけにはいきませんでした。
3. コミュニティからヒントを得る
GraphQLコミュニティには、The Guildというオープンソース開発者の集団があります。The GuildはGraphQL関連の強力なオープンソースを数多くメンテナンスしていて、その一つがCode generatorです。
GraphQLを使っていたときに大いに助けられたライブラリだったので、OpenAPI Specificationを対象にした似たようなライブラリがあるかもしれないと考えました。案の定、openapi-generatorというライブラリがすぐに見つかりました。
ライブラリをインストールして試してみるまでに、それほど時間はかかりませんでした。openapi-generatorを導入したベストプラクティスを紹介する記事が非常に多く、簡単に使えたからです。いろいろなオプションを見れば見るほど、本当によくできたライブラリだと思うようになりました。
4. 車輪の再発明を決断する
個人的には、車輪の再発明はかなり避けるほうです。しかし今回は少し事情が違いました。openapi-generatorを試す中で、物足りない部分が見つかったからです。
1. 生成物の物足りなさ
まず最も物足りなかったのは、実行結果が過剰で煩雑すぎるという点です。もちろんオプションでいろいろ調整することはできるでしょうが、満足のいく形になるとは思えませんでした。何より、GraphQL code generatorのようにAPIを呼び出すhooksまで生成されることを期待していたのですが、調べた限りではそういった機能はないようでした。
以下のようなGraphQLスキーマがあるとすると、
schema {
query: Query
}
type Query {
me: User!
}
enum Role {
USER
ADMIN
}
type User {
id: ID!
username: String!
email: String!
role: Role!
}
query findUser {
me {
...UserFields
}
}
fragment UserFields on User {
id
username
role
}
GraphQL code generatorを使えば、通常は以下のようなコードが生成されるので、とても手軽に使えます。
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = {
[_ in K]?: never;
};
export type Incremental<T> =
| T
| { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
function fetcher<TData, TVariables>(
endpoint: string,
requestInit: RequestInit,
query: string,
variables?: TVariables,
) {
return async (): Promise<TData> => {
const res = await fetch(endpoint, {
method: 'POST',
...requestInit,
body: JSON.stringify({ query, variables }),
});
const json = await res.json();
if (json.errors) {
const { message } = json.errors[0];
throw new Error(message);
}
return json.data;
};
}
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: { input: string; output: string };
String: { input: string; output: string };
Boolean: { input: boolean; output: boolean };
Int: { input: number; output: number };
Float: { input: number; output: number };
};
export type Query = {
__typename?: 'Query';
me: User;
};
export enum Role {
User = 'USER',
Admin = 'ADMIN',
}
export type User = {
__typename?: 'User';
id: Scalars['ID']['output'];
username: Scalars['String']['output'];
email: Scalars['String']['output'];
role: Role;
};
export type FindUserQueryVariables = Exact<{ [key: string]: never }>;
export type FindUserQuery = {
__typename?: 'Query';
me: { __typename?: 'User'; id: string; username: string; role: Role };
};
export type UserFieldsFragment = { __typename?: 'User'; id: string; username: string; role: Role };
export const UserFieldsFragmentDoc = `
fragment UserFields on User {
id
username
role
}
`;
export const FindUserDocument = `
query findUser {
me {
...UserFields
}
}
${UserFieldsFragmentDoc}`;
export const useFindUserQuery = <TData = FindUserQuery, TError = unknown>(
dataSource: { endpoint: string; fetchParams?: RequestInit },
variables?: FindUserQueryVariables,
options?: UseQueryOptions<FindUserQuery, TError, TData>,
) =>
useQuery<FindUserQuery, TError, TData>(
variables === undefined ? ['findUser'] : ['findUser', variables],
fetcher<FindUserQuery, FindUserQueryVariables>(
dataSource.endpoint,
dataSource.fetchParams || {},
FindUserDocument,
variables,
),
options,
);
import { useFindUserQuery } from './schema.ts';
function User() {
const { data } = useFindUserQuery();
return <p>{data?.username}</p>;
}
自動生成されたhooksを使う手軽さと安心感を知っていたので、OpenAPI specificationを使う場合でも、同じような結果を得たいと考えました。
2. Inflab独自の要件
私たちのチーム独自の要件が必ず出てくるだろうと予想していました。たとえばセルごとに異なるエラー処理の方法が出てくるだろうと考えていましたし、生成物を1つのファイルにまとめたり、構造を変えたり、生成される型の名前を変えたりといった、厄介な部分が出てくるだろうと見ていました。
これはmustacheとcustom templateで緩和することはできましたが、この問題を完全に解決できるとは思えませんでした。
かなり長い期間悩み続け、さまざまな状況を総合的に考えた結果、一度自分たちで作ってみようと決心しました。こうしてcode generatorへの旅が始まりました。
5. 目標
Code generatorを作る前に立てた目標は以下のとおりです。もちろん最初のバージョンですべてを実装することはできないでしょうが、最終的にv1には以下の機能がすべて含まれていてほしいと考えていました。
- Fileまたはnetworkリクエストを通じてOpenAPI specificationのjsonを直接パースし、望む形の結果をfileとして生成できること。
- フロントエンドエンジニアが、APIのschemaの変化にできるだけ手間なく対応できること。
- フロントエンドエンジニアが、生成物として出力したいAPIを選択できること。
- APIで宣言したresponseをJSON validatorで検証できるtype guardが自動生成されること。
- バックエンドエンジニアが、フロントエンドの手間を気にせずAPIを作れること。
- バックエンドエンジニアが宣言したSwaggerと実際のAPI responseの形が一致しているかを、integration testで検証できること。
特に最後の機能については、GraphQLとは違い、Swaggerで宣言したschemaがresponse typeの整合性を保証しないので、どうしても必要だと考えました。Type guardもAPIのリクエスト結果と直接の関係はありませんが、フロントエンドでAPI schemaごとにいちいちtype guardを作る手間を省きたいと考えました。
詳しい実装方法と導入後の話は、続く記事でご紹介したいと思います。