フロントエンドに汎用APIを使わせてはいけない(4年後の続編記事)(翻訳)
私は2021年に、フロントエンド向けにわざわざ汎用のAPIを構築して苦労を増やしてはいけないと呼びかける記事を書きました↓(よろしければ本記事を読む前に目を通しておいてください)。
参考: Don’t Build A General Purpose API To Power Your Own Front End - Max Chernyak
この記事はHacker Newsに2度取り上げられましたが、2度目は評判がよろしくありませんでした(その分議論は白熱しましたが)。推測ですが、2度目に押し寄せたフロントエンダーたちは相当多かったのではないでしょうか 😛。
「フロントエンドAPIを汎用化しない」アプローチを6年間観察してきた結果、この方法が成功へと導いてくれるという確信は深まる一方です。
自信が深まった根拠のひとつは、この方法が実際に成功を収めているのを目の当たりにしてきたことです。私たちのチームのメンテナンスは劇的にシンプルになり、バグもごっそり減ってパフォーマンスも爆上がりしました(ちなみにこのパフォーマンス向上は長期的なリファクタリングの賜物です)。
もうひとつの根拠は、他のチームからも「この方法は間違いなく有効だった」という喜びの声が続々と寄せられてきたことです。
最後に、私の記事には大量の反論や批判が寄せられましたが、それらは筋の通ったものもあれば、記事の内容を誤解していたものもありました。記事の説明が不十分だった部分については私の責任なので、この場で誤解を解きたいと思います。そういうわけで、そうした誤解を以下で1つずつ取り上げて順に説明してまいります。
🔗 誤解1:「それってHTMLの再発明では?」
複数の読者から、JSONペイロードみたいな余計な処理をわざわざ行わなくても、サーバーからHTMLを直接配信すればよいのではという情報をいただきました(1、2、3)。一応私も2003年からWebサイト構築を手がけていますので、言いたいことはわかります。
しかし現実を見ると、今の私たちと組んでいるフロントエンドチームはReactを使っていて、Reactならではの知見がたっぷり15年分蓄えられていますし、Reactで作られた既成のコンポーネントも多数あります。これは尊敬に値することです。
JSONペイロードを望ましい方法で問題なく提供できるのであれば、それでいいじゃないかと思うのです。
個人的にはRuby on Railsの歴史ある技術スタックの方がずっといいと思うのですが、私たちの業務はチームで行われるのですから、お互いの強みを活かすべきでしょう。
と申し上げたものの、問題は「それってHTMLの再発明だよね」と指摘した人たちが私のアドバイスを誤解していることです。
HTMLには「コンテンツ」「構造」「スタイル(特にTailwindを使っている場合)」がひと通り揃っているので、HTMLならアセット以外のすべてを配信できます。
しかしJSONで配信されるのは「コンテンツ」とせいぜい「構造らしきもの」だけで、「スタイル」の配信は一切行いません。JSONの抽象度はHTMLよりずっと高いのです。
ここが肝心なのですが、私はJSON.dump(html)でHTMLを丸ごとフロントエンド側に送信して欲しいなどとバックエンド側にお願いしているわけではなく、そのページで必要な部分を埋めるものだけを送信して欲しいのです。ページの他の静的コンテンツはフロントエンド側でハードコードすれば済むことです。
しかしここでひとつ誤解を正しておく必要があります。私は元記事で「コンテンツと構造はバックエンドから渡してやれ」というアドバイスを書いたのですが、まさか文字通りに「HTMLを丸ごとJSONにぶちこんでシリアライズせよ」と受け取られるとは思いもよりませんでした。これはまったく私の意図と違います。
ページのどの部分にどの値を配置すべきかをフロントエンドエンジニアが理解するのに必要なのは、最小限のJSON構造だけなのです。
たとえば以下のようなHTMLがあるとしましょう。
<div>
<article>
<h1>Title</h1>
<p>Body</p>
</article>
</div>
このHTMLを{ "div": { "article": { "h1": "Title", "p": "Body" } } }のようなJSONに1から10までシリアライズする必要などありません。以下のように必要なキーワード引数を渡して、ArticlePageのそれ以外のコンストラクタはハードコードしておけば済む話です。
{
"title": "Title",
"body": "Body"
}
articleがたくさんあるなら、値を配列にすれば済む話です。簡単でしょう?ページが欲しがっているものだけを渡してやる、ここが肝心です。
🔗 誤解2:「非同期に読み込まないとページが遅くなるよ?」
私の方法論に対して、「ページの各パーツを非同期に読み込まないとパフォーマンスが悪化しますよ」と心配いただきました。
汎用のAPIエンドポイントしか扱ったことがないフロントエンドエンジニアなら、こういう印象を抱くのも無理はありません。「1つのページでレンダリングするエンドポイントが10個必要なのでリクエストの処理をパラレル化したところ、表示が不安定になってランダムに遅くなったりしたので仕方なく一部のエンドポイントを優先した」といったところでしょうか。
気を悪くしないで欲しいのですが、そのつらみは自業自得というべきでしょう。そんなことをしなくても、バックエンドエンジニアに全データを一括で送信するようお願いすれば済む話です。ラウンドトリップ時間200msのリクエスト1件で、サーバーはデータを30ms以下で生成してくれます。ラウンドトリップ時間200msecのリクエスト10件が互いに競合するのを苦心してレンダリングする必要はなくなります。
ページの非同期読み込みというアイデアはフロントエンドでは当たり前のものとされていますが、理論的に正しそうな感じがするというだけの代物です。たとえるなら、これはトラック10台を雇ってUSBディスク10台を同時並行で配送したものの、トラック10台を管理するのに時間がかかるからという理由で「トラックの台数を増やした方がよさそう」という結論を出すようなものです。
非同期読み込みが有意義なのは、本当に低速で重いデータソースやストリーミングのデータソースを扱うときぐらいです。自社バックエンドが自社データベースに接続してキロバイト単位のコンテンツを返すような構成で、リクエストをパラレル化すれば、数百倍、いや数千倍遅くなるでしょう。何よりも、バックエンドエンジニアのチームがデータをバンドル・最適化・キャッシュするのが難しくなってしまいます。データを断片的に送信することを強いられるからです。
また、このスレッド全体やこのコメントなどで見られるような逆の懸念も見かけます。つまり、フォーム全体を送信するのではなく、フォームのフィールドを個別に送信すべきだというのです。そうする必要があるのでしょうか?
私からの答えは「そうしたければご自由にどうぞ」です。私の方法は、別に皆さんが好みの方法でフォームを送信するのを禁止するものではありません。しかし先ほどの場合と同様に、パフォーマンスのためにデータを分割するのは(ほとんどの場合)正当な理由にはなりません。
🔗 誤解3:「それってSPAだと意味がないのでは?」
「シングルページ」アプリケーション(SPA)でページを配信する仕組みをなかなか理解できない人もいます(1、2)。SPAとは何かという答えを得るには、まず発想を切り替えて、頭の中で思い描く送信先を「ページ」から「画面」に書き換えてください。これで準備OKです。
ある画面から別の画面に遷移するとき、次の画面分のデータを1個のバンドルとしてフロントエンドに渡します。画面の特定のセクションを個別にフェッチする必要が生じたら、それ専用のエンドポイントを提供すればよいのです。
ただし現実のほとんどのケースでは、画面の1個のセクションだけを更新したい場合であっても、画面全体分のデータを再読み込みする方がずっと高速で、しかもシンプルです。
ただし、データベースの生データをそのまま配信しないようご注意ください。たとえばフロントエンドでitemsテーブルを表示する場合、itemsテーブルをそのまま渡すのではなく、そのテーブル表示用に事前処理を終えたtable_itemsというエンドポイントを提供すべきです。フロントエンド側でこのテーブルを組み立てるために複数のエンドポイントにアクセスするような余計な苦労をかけないようにしましょう。
🔗 誤解4「GraphQLとかアグリゲーションレイヤを使えばいいのでは?」
汎用APIよりもアグリゲーションレイヤ(aggregation layer)とかGraphQLの方がいいのではと疑問を抱いた読者を何人も見かけました(1、2)。
本音を申し上げると、アグリゲーションレイヤはどこか馬鹿げているというか、どうもフロントエンド中心の発想を感じてしまうのです。最初に汎用APIをこしらえたせいで問題が発生したら、その問題を解決せずに隠蔽するために、また別のレイヤをこしらえるのかと言いたくなります。これではバックエンドのコード量は倍増し、複雑さは2倍にも3倍にもなり、そこに注がれる時間と労力も増えるのに、パフォーマンスの問題はひとつも解決されません。
GraphQLについては、複雑さを著しく増やすうえに開発スタイルのトレードオフを強いられるので、避けた方がよいのではないでしょうか。GraphQLは無限の自由度を持つ複雑な代物なので、バックエンド開発者はGraphQLを掌握してセキュリティを維持する能力が不可欠です。
しかしFacebookの場合は、Facebookクライアントの多種多様ぶりを考えれば、FacebookがGraphQLという終わりのない迷路をサポートする意義は十分あります。Facebookのクライアントはコンピュータからスマホ、TV、洗濯機に至るまで実に多種多様です。Facebookの立場なら、数千種類のクライアントがそれぞれ異なるデータセットを取得可能な、極めて柔軟なレイヤを作成する価値は、おそらくあるでしょう。
さて、皆さんのアプリのクライアントは何種類ありますか?ざっくり言って、Webサイト1種類とモバイルアプリ1種類で済むのがほとんどでしょう(もっと少ないかもしれませんね)。そんなアプリでGraphQLという無限に柔軟なクエリ言語をサポートする必要はあるでしょうか?
解決したかった問題が何だったのかを思い出しましょう。フロントエンドがソフトウェアを表示するのに必要なデータを提供することだったはずです。だったらそういうデータを提供すれば問題は解決です。
🔗 誤解5:「フロントエンド側が自由にやれなくなっちゃうんだけど?」
おそらく私の元記事での説明が舌足らずだったのだと思いますが、APIが汎用的な方がフロントエンドが自由にやれるという意見が今も寄せられています(1、2、3、4)。フロントエンド側が新機能を構築したり再設計したりするときに、いちいちバックエンドにお伺いをたてずに済むというメリットが失われるというのです。
フロントエンドがバックエンドと一切関わりを持たずに新機能を構築したり再設計したりすることが可能な世界は、残念ながら存在しません。古いエンドポイントを想定外の方法で使うことはもちろん可能です。しかしフロントエンドがそんなことをすれば、バックエンドはそれによって生じる想定外の過負荷パターンにも対応しなければならなくなり、エンドポイントごとの利用目的を把握しきれなくなって、非効率さを後付けで頑張って最適化するはめになります。かくして、元記事で私が書いたような中途半端なソリューションの一丁上がりです。
フロントエンド側が自由気ままに構築できることの代償は、バックエンド側がAPIの想定外の利用パターンを無限にサポートしなければならなくなることと、APIがフロントエンド側でどんな使われ方をしているかわかったものではないため、バックエンド側が機能の変更や削除を怖がってしまうことです。さらに、バックエンド側はAPIのバージョニングも強いられ、コードベースは増大の一途をたどります(つまり決して減ることがない)。
さらに悪いことに、フロントエンド側が新機能を構築したり再設計しようとすれば、結局バックエンド側が提供する機能を頼ることになり、つまりバックエンド側がエンドポイントを増設することになるので、フロントエンドが思っているほど自由にやれません。しかも古いエンドポイントは永遠に削除されません、「念のため」。
それに対して、全部入りの汎用APIから脱却して、バックエンド側がページごとに必要なデータだけを提供する形にすれば、再設計は驚くほどシンプルになります。再設計はそのページ内で完結するので、予想外の使われ方をしないかと気を病む必要もなくなります。
結局、データベースのレコードに基づいたナイーブなCRUD APIを使っていても、本当の自由は得られません。「多種多様なユースケースに対応できる真に柔軟なバックエンドを構築する」という主張は理論上はかっこよく響きますが、その代償は、多くのチームが考えているよりもずっと高くつきます。
🔗 誤解6:「じゃあペイロードに何を入れればいいの?」
私に常日頃から寄せられる質問の中でも重要なのはまさにこれで、フロントエンドには具体的にどんなデータを渡せばよいのか、データの構造や命名はどうするのがよいのかというものです(1、2、3、4)。
寄せられる質問の中には、私が「HTMLを丸ごとJSONに自動変換して送信しろ」などと提案していると勘違いしているかと思えば、私が「JSONベースでUI構築プロトコルを作れ」などと提案していると思い込んでいたり、はたまたフロントエンド側全体に存在するステートや静的コンテンツをどう扱えばいいのか測りかねていたりするものもありました。そんなものまでバックエンド側が提供する必要があるのでしょうか?
3つの質問への私からの答えは、「皆さん考えすぎ」です。
フロントエンド開発者は、ハードコードできるものは徹底的にハードコードして、バックエンドから取得する意味がある部分だけ「空き地」を確保しておけばよいのです。そしてその空き地を埋めるデータは、小規模なJSONペイロードに簡潔にまとめましょう。
- 静的コンテンツはフロントエンド側でハードコードする
- フロントエンド側のステートはフロントエンド側で保持する
- バックエンドが制御するものはバックエンドから送信する
たったこれだけです。
ついでに言えば、JSONをわざわざ形式化して、異なるページに渡ってフォーマットを揃えようなどと頑張らないこと。
JSONの値を受け取って適切な空き地に表示するためのコードは、ページごとに異なるのが当然です。ほとんど同じコンポーネントが複数のページに散らばっているのであれば引数を正規化する必要性があるかもしれませんが、ページ構造全体に渡って引数を揃えようとしたところで面倒を増やすだけです。
余計なことをせずに、そのページで必要な作業を粛々と進めましょう。そのページで使うJSONは、そのページのコンストラクタに渡す引数に過ぎないのですから、ページが変わればJSON構造が変わるものだと割り切りましょう。ページ全体の構造がページ同士で似ていることがあったとしても、ただの偶然です。
🔗 誤解7:「CRUDはバックエンド側のメンテを楽にしてくれるものなのでは?」
CRUDの構築やドキュメンテーションやテストは、カスタムページよりもやりやすいはずだという主張を見かけます(1、2)。しかしこれはCRUDという用語を誤解しています。
CRUDでは、フロントエンドがデータベースのレコードをCREATE/READ/UPDATE/DELETEすることが重要なのではなく、それらをリソース(resource)を用いて行うことが重要なのです。「ここではこれがリソースである」と見抜き、何がリソースなのかを見極めることこそが、Webアプリを正しく設計するための鍵なのです。リソースはデータベースのレコードと直接対応付けられることもありますが、直接対応付けられないことの方がむしろ多いくらいです。
ページは、READエンドポイントを持つリソースとみなせます。
CREATEやUPDATEはページ単位ではほとんど使い道がありませんが、「項目」や「リレーションシップ」のような、ページよりも細かいリソースでは有用です。
リソースはデータベースのレコードと素朴に対応付けられることもありますが、実際に欲しくなるのは、それよりも抽象度の高いリソースであることがほとんどです。
Form Objectは、そうした抽象度の高いリソースの役割を果たします。Form Objectは、「まとめて変更される」エンドユーザーエンティティを表現し、フロントエンド側で1個のフォームとして扱われます。コントローラはこのオブジェクトからデータを受け取ると、データベース内の対応するレコード数に応じてトランザクショナルにコミットします。
要するに、「CRUDは構築やドキュメンテーションやテストを楽にしてくれる」と思われているのは、実は「データベースのレコードを直接フロントエンド側に公開すると、フロントエンド側の構築やドキュメンテーションやテストは楽になる」と考える方が実体に即しているということです。フロントエンドがアプリ全体をすっ飛ばして低レベルのストレージに直接アクセスしていれば、たしかにフロントエンド側はバックエンドのことを考えないで済む分楽になるでしょう。
しかしこのようなことをすると、アプリ全体がフロントエンドに寄せられてしまい、フロント側でのデータ組み立てにはネットワーク障害モードという莫大なコストが立ちはだかります。フロントエンド側はテストやドキュメント作成を省略しても何となく許されがちなので、開発が楽になるのはある意味当然です。
🔗 誤解8:「汎用APIって要するに何なの?」
汎用APIの定義が知りたいというコメントをいただきました。
私が「汎用APIを構築するのはやめろ」と言うときのAPIは、一般の顧客が幅広いユースケースで利用しようと思えばできてしまう全部入りのAPIを指しています。
私はそういう汎用APIよりも、BFF(backend for frontend: フロントエンドのためのバックエンド)APIを推奨しています。BFF APIはフロントエンドチームの要件のみを考慮し、一般公開しません。
もしどうしても汎用APIが必要なら、BFF APIと別に構築して、要件の衝突や不要なリリース管理やドキュメント作成の手間を避けましょう。
🔗 誤解9:「それってAIの時代でも通用するの?」
これは私がプログラミングに関する記事を書くときに常に自問自答しています。もしかすると私の記事をAIに読ませればAIが私のアドバイスに従ってくれるでしょうか?冗談ではなく、私の書いた記事はとっくにLLMの肥やしとなって、他のブログ記事コンテンツの海を漂っています。先のことはわかりませんが、せいぜい前向きに楽しく書いていれば、何もかもうまくいきそうな気がします。
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。