はじめに
フロントエンドエンジニアの @aose_developer です。
自分がプログラミングにハマったきっかけでもありますが、「いかに構造化された可読性の高いコードが書けるか」を意識して「ああでもないこうでもない」と模索するのが専ら趣味です。
直近でも、開発業務を通じてAPI通信周りの実装とコードの構造化について模索していたので、今回はその備忘録的な記事を残したいと思います。
- はじめに
- 前置き
- この記事について
- 前提共有
- 既存のAPI通信のやり方について
- 既存のやり方で辛いこと
- 改善したいこと
- 模索案と実装
- おわりに
前置き
あくまでも個人的に良いと感じた実装の模索案です。
「これが絶対正解でこうあるべき」というようなスタンスではないので、あくまでも「参考になる箇所や考え方があれば取り入れよう!」的な読み物感覚をオススメします。
この記事について
まず最初にこの記事の趣旨を簡単にまとめます。
この記事の目的
- 社内・社外問わず「フロントエンドではこういう工夫を試みているよ」の共有
- 読者に、少しでもヤプリのフロントエンド開発ないしプロダクト自身に興味を持ってもらいたい
どんな人が対象か
- 単純にコードの構造化に興味がある人
- フロントエンドでAPI通信部分を実装するときに、カオスコードを生んじゃって悩んでいる人
- ディレクトリ構成を考えていてコンポーネント設計はできるけど、API通信周りで悩んでいて何か案が欲しい人
- REST API(
axios
,useFetch
,useAsyncData
)での例は探せばあるけど、gRPCを使っていてどうしたら良い感じに(主観注意)実装できるか悩んでいる人
読者にどうなってほしいか
- 構造化の例を知ってもらい、開発のヒントを得てほしい
- より良い戦略を模索できるような刺激を得てほしい
- ヤプリのフロントエンド開発に興味を持ってほしい(大事)
前提共有
使用技術
弊社ヤプリの主要プロダクトYappliでは、超ざっくりまとめると以下の技術を用いており、フロントエンド ↔︎ サーバーサイド間ではprotobufを介して型定義を自動生成して共有しています。
カテゴリ | 技術 |
---|---|
クライアント | Vue3 Nuxt3 Vuex |
プロトコル | gRPC-Web |
サーバー | Go |
この部分の詳しい内容に関しては、過去の記事が参考になると思います。
話さないこと
- 細かい実装内容
- protobufについて
- gRPCや関連するライブラリの使用方法・知識など
gRPCやそれに関連する情報は蛇足になるので触れません。というより、自分自身に知見がないため触れられません。
加えて、実装例としてのコードは提示しますが、その詳細であったりロジックに関しては部分的な説明に留めます。
また、フロント実装には様々な「レキシテキケイイ」があり、今でこそ非効率・非推奨なやり方が多くありますが、なぜそれをやっているかなどの背景説明はしません。
以上を踏まえた上で、早速本題に入ります。
既存のAPI通信のやり方について
Yappliでは状態管理ライブラリのVuexに大きく依存しており、今あるほとんどの機能において必要不可欠です。
具体的に説明すると、ページごとにStoreオブジェクトが存在し、モジュールやらなんやらが色々と合わさって最終的に1つの巨大なVuex Storeが完成します。
中でも、ページ遷移時に各StoreのActionsにある init
という関数が呼び出されて必要な情報を取得しますが、大体はこの中でAPIコールを行なってStateに保存する流れです。
逆に言えば、新規ページ作成においても、必ずVuexのStoreオブジェクトと init
関数の実装が必須 になります。
そして、APIコールを行うためにもいくつかの準備が必要です。
詳細を説明する前に、ファイルの大まかな全体像と依存関係について軽く話します。
ファイル | 役割 |
---|---|
store/〇〇/index.ts |
ページのstoreオブジェクトを定義するファイル |
〇〇_api.ts |
APIコール関数を定義するファイル |
〇〇_maper.ts |
APIコール関数に渡された引数をprotobufのオブジェクトに変換したり、必要に応じてなんか色々やったりするファイル |
〇〇_pb.d.ts |
ts-protoc-gen が生成するファイルその1 |
〇〇_servise_pb.d.ts |
ts-protoc-gen が生成するファイルその2 |
ファイルによって実装はまちまちですが、大体はこのような構成になります。
APIの実装に限った話で言うと、開発者は主に 〇〇_api.ts
と 〇〇_mapper.ts
を実装していきます。
実装例
中でも 〇〇_api.ts
を例として取り上げると、概ね以下のような実装が必要になります。
import { grpc } from '@improbable-eng/grpc-web'; import { Ramen, CreateRamenResponse } from '~/protobuf/ramen_pb'; import { RamenService } from '~/protobuf/ramen_pb_service'; import { genCreateRamenRequest } from '~/utilities/grpc/mapper'; import { // ごにょごにょしてgprcのmetadataをよしなに返す便利関数 gprcMetaData, // エラー処理を良い感じにやってくれる便利関数 handleError, } from '~/utilities/grpc/common'; export async function CreateRamen( flavor: Ramen.AsObject['flavor'], topping: Ramen.AsObject['topping'], price: Ramen.AsObject['price'] ) { // mapperで定義した関数を通じてリクエストオブジェクトを生成 request = genCreateRamenRequest(flavor, topping, price) const client = grpc.client(RamenService.CreateRamen, { host: useRuntimeConfig().public.config.url, }); const metadata: grpc.Metadata = grpcMetaData(); client.start(metadata); client.send(request); return new Promise<CreateRamenResponse>((resolve, reject) => { client.onMessage(resolve); client.onEnd((code, message, trailers) => { handleError(reject, code, message, trailers); }); }); }
各ユーティリティ関数となぜラーメンなのかは割愛しますが、ここでは「都度こういうことをやっている」ということが伝わればOKです🍜
必要な準備
〇〇_mapper.ts
を作成- 各リクエストオブジェクトを生成するための関数を定義
〇〇_api.ts
を作成- 各CRUD操作を定義
const client = grpc.client(...)
,client.start(...)
,client.send(...)
,client.onMessage(...)
,client.onEnd(...)
などのお決まりコードを記述
この準備の中で自動生成のファイルも確認しながら実装を行うので、CRUD操作を定義するだけでも最低4つ以上のファイルを行ったり来たりします。
そのため、時折どこまで実装したのか分からなくなります(自分だけかも?)
既存のやり方で辛いこと
- 新規ページ作成においても、必ずVuexのStoreオブジェクトと
init
関数の実装が必須 - そもそもgRPC関連のおまじない的な記述量が多い
init
関数の中でdispatch
やcommit
を実行していたり、Storeによっては複雑なロジックが直書きで実装されているため、APIコールと状態管理が混ざっている
ここで言いたいのはただ単に「既存のやり方がイケていない」ということではなく、「1つの実装に対して開発者ごとに迷いが生じる点が多い」ということです。
これはどんな実装に対しても言えることですが、記述量が多くなってしまう ≒ 抽象化・構造化されていない ことからコピペ実装やおまじないが増えてしまったり、密結合なコードが生まれやすく、結果的に負債になるケースはあると思います。
改善したいこと
いくつか辛い点を挙げましたが、大きく分けて3つのポイントに集約すると考えました。
- Vuexへの依存による問題
- gRPC関連のおまじない量による問題
- 関心ごとの分離・責務の分離における問題
それぞれまとめていきます。
Vuexへの依存による問題
個人的にはここが最大の要因だと考えています。
ページごとにStoreオブジェクトや init
関数の実装が必要という制約はメリットもありますが、巨大な状態管理とAPIコールが密結合している以上、容易にカオスなコードが生まれてしまいます。
また、 init
関数内で別Storeオブジェクト(正確には同じStore)の Actions
や Mutations
を操作したり State
を参照しているため、どこまでがAPIコールに必要な実装で、どこまでが状態管理に必要な実装なのか 曖昧になってしまいます。
そのため、これらの構造化を模索する上で、改善すべき点が状態管理の問題なのか、API通信部分の問題なのかを事前に整理・把握していくことが重要です。
状態管理の課題感とアプローチについては、こちらの記事で言及しています。
gRPC関連のおまじない量による問題
おまじないの量が多いことで実装漏れが生じやすくなるだけでなく、コードリーディングにおいても認知負荷が高くなってしまいます。
ここに関してのアプローチは比較的単純なものになりますが、適切に抽象化を行うことでおまじないを意識することなく実装できるようになるのが理想です。
一方で、〇〇_mapper.ts
の実装に関しては一部Vuexが絡む複雑な処理を行っているケースもありますが、やりたいこととしては概ねリクエストオブジェクトを定義して必要なプロパティをセットして最後に返すだけなので、比較的単純です。
逆に言えば 〇〇_api.ts
は grpc.client
系のお決まりコードが多いため、今回はこちらに対して模索していきます。
関心ごとの分離・責務の分離における問題
重複しますが、「どこからどこまでがどの実装なのか」「抽象化できるおまじないはあるか」などを判別するためには、関心ごとの分離・責務の分離が大切です。
今回の文脈で言えば、
- APIをコールするために必要な処理(おまじない)
- 実際にAPIをコールする処理
- 状態管理の各値を更新・管理する処理(Vuex)
の3つに分離して抽象化・構造化を考えていきます。
模索案と実装
具体的には以下のような方法を模索しました。
- gRPCのおまじないを抽象化したutil関数を作成する
- APIコールと状態管理を1つのコンポーザブルにまとめる
provide
を用いて、Page内のRootにあたるコンポーネントからコンポーザブルを提供する- 各子孫コンポーネントでは
inject
でコンポーザブルから値と関数を取得・操作する
後述しますが、おまじないの大半を削減することにより、先ほどの 〇〇_api.ts
の記述が大幅に簡略化されます。
加えて、Vue3のComposition APIや provide / inject
を用いることで、 Vuexへの依存が最小限になる だけでなく、 API通信の箇所とそれに依存するリアクティブ変数が小さなコンポーザブル単位で1つにまとめる ことが可能になります。
これらを踏まえて、上記の点ごとに具体的なコード例を示して解説します。
gRPCのおまじないを抽象化したutil関数を作成する
この点に関しては、既にフロントエンドマネージャーのこんさんが素敵な解決策を編み出していたのでご紹介します。
前述したように、APIコールの準備に必要なおまじないを抽象化するために、unaryRPC
という便利関数を用いることで、client.〇〇
の記述を省略できるようになっています。
unaryRPC
の実装
import { grpc } from '@improbable-eng/grpc-web'; import { Empty } from 'google-protobuf/google/protobuf/empty_pb'; import { gprcMetaData, handleError } from '~/utilities/grpc/common'; export async function unaryRPC< Service extends grpc.UnaryMethodDefinition<Req, Res>, Res extends grpc.ProtobufMessage, Req extends grpc.ProtobufMessage | Empty = Empty, >( service: Service, req: Req = new Empty() as Req, metadata: grpc.Metadata = grpcMetaData() ): Promise<Res> { const client = grpc.client< Req, Res, grpc.UnaryMethodDefinition<Req, Res> >( service, { host: useRuntimeConfig().public.config.url, } ); client.start(metadata); client.send(req); return new Promise((resolve, reject) => { client.onMessage(resolve); client.onEnd((code, message, trailers) => { handleError(reject, code, message, trailers); }); }); }
便利関数を使って実装した場合
import { Ramen, CreateRamenResponse } from '~/protobuf/ramen_pb'; import { RamenService } from '~/protobuf/ramen_pb_service'; import { genCreateRamenRequest } from '~/utilities/grpc/mapper'; import { unaryRPC } from '~/utilities/grpc/common'; export async function CreateRamen( flavor: Ramen.AsObject['flavor'], topping: Ramen.AsObject['topping'], price: Ramen.AsObject['price'] ) { const request = genCreateRamenRequest(flavor, topping, price); return await unaryRPC< (typeof RamenService)['CreateRamen'], CreateRamenResponse, CreateRamenRequest >(RamenService.CreateRamen, request); }
これにより、最小限のおまじないで済むようになりました。
この便利関数を使用せずに実装した場合と比べても、かなり抽象化できたことが分かると思います。
上記はあくまでもサンプルコードになるため詳細は割愛しますが、これだけの事前準備を毎回行っていたこと・コード量が大幅に削減できたことが伝わればOKです。
APIコールと状態管理を1つのコンポーザブルにまとめる
これまでは状態管理にVuexを使用していましたが、今回はあえて使用せず単純に ref
を用いてコンポーザブルに閉じ込めました。
この記事内のコードは実際の実装を基に作成した例なので、各値や命名は適当なものに変更していますが、大枠は再現しています。
また、const { pending, execAsyncFn } = useAsyncHandler()
に関しては後ほど解説するので、ここでは全体像が把握できれば大丈夫です。
const API_HANDLERS = { CREATE: CreateRamen, GET: GetRamen, UPDATE: UpdateRamen, DELETE: DeleteRamen, }; export function useRamenAPI() { const data = ref< GetRamenResponse.AsObject['ramen'] | null >(null); const { pending, execAsyncFn } = useAsyncHandler(); /** * Ramenを取得する関数 * @returns 取得したRamen */ async function getRamen(): Promise< GetRamenResponse.AsObject['ramen'] > { const res = await API_HANDLERS.GET(); const { ramen } = res.toObject(); return ramen; } /** * Ramenをフェッチして状態を更新する関数 */ async function fetchRamen() { const ramen = await execAsyncFn(getRamen); data.value = ramen; } /** * Ramenを作成する関数 * @param flavor 味 * @param topping トッピング * @param price 値段 */ async function createRamen( flavor: Ramen.AsObject['flavor'], topping: Ramen.AsObject['topping'], price: Ramen.AsObject['price'] ) { await execAsyncFn(async () => { await API_HANDLERS.CREATE(flavor, topping, price); await fetchRamen(); }); } /** * Ramenを更新する関数 * @param flavor 味 * @param topping トッピング * @param price 値段 */ async function updateRamen( flavor: Ramen.AsObject['flavor'], topping: Ramen.AsObject['topping'], price: Ramen.AsObject['price'] ) { await execAsyncFn(async () => { await API_HANDLERS.UPDATE(flavor, topping, price); await fetchRamen(); }); } /** * Ramenを削除する関数 * @param flavor 味 */ async function deleteContent( flavor: Resource.AsObject['flavor'] ) { await execAsyncFn(async () => { await API_HANDLERS.DELETE(flavor); await fetchRamen(); }); } // コンポーネントがマウントされた際にリソースを初期フェッチ onMounted(async () => { await fetchRamen(); }); return { data: readonly(data), pending: readonly(pending), createRamen, getRamen, updateRamen, deleteRamen, }; } type RamenAPI = ReturnType<typeof useRamenAPI>; export const RAMEN_API_INJECTION_KEY: InjectionKey<RamenAPI> = Symbol('RAMEN_API_INJECTION_KEY');
いきなりコード量が多くてお腹いっぱいかもですが、順を追って要点を説明します。
変更を伴う各操作の中で data
を更新する
/** * Ramenを削除する関数 * @param flavor 味 */ async function deleteContent( flavor: Resource.AsObject['flavor'] ) { await execAsyncFn(async () => { await API_HANDLERS.DELETE(flavor); await fetchRamen(); }); }
上記は delete
の例ですが、create
, update
においても最後の部分で fetchRamen
関数を実行しています。
では fetchRamen
関数が何を行っているかと言うと、 get
を実行して値を取得し ref
変数の data
の値を書き換えています。
/** * Ramenを取得する関数 * @returns 取得したRamen */ async function getRamen(): Promise< GetRamenResponse.AsObject['ramen'] > { const res = await API_HANDLERS.GET(); const { ramen } = res.toObject(); return ramen; } /** * Ramenをフェッチして状態を更新する関数 */ async function fetchRamen() { const ramen = await execAsyncFn(getRamen); data.value = ramen; }
これにより、API実行側で毎回 data
の状態管理を意識せずとも、各操作を実行するだけで常に更新された状態を参照可能になります。
pending
の管理を別のコンポーザブルに切り出す
先ほどからチラッと出ていた execAsyncFn
関数が今回の1番の工夫ポイントです。
この関数は useAsyncHandler
という共通コンポーザブルの戻り値の1つで、コンポーネントで使う pending
というboolean変数をよしなに切り替えるための関数です。
export function useAsyncHandler() { const pending = ref(false); // 現在実行中の非同期関数の数を追跡するカウンター let execCount = 0; /** * 非同期操作を管理するラッパー関数 * 実行開始時にカウンターを増加させ、完了時に減少させる * カウンターがゼロになると`pending`を解除する * * @param fn 実行する非同期関数 * @returns 実行された非同期関数の結果 */ async function execAsyncFn<T>(fn: () => Promise<T>): Promise<T> { execCount++; if (!pending.value) { pending.value = true; } try { const result = await fn(); return result; } finally { execCount--; if (execCount === 0) { pending.value = false; } } } return { pending: readonly(pending), execAsyncFn, }; }
先ほどの例の create〇〇
, update〇〇
, delete〇〇
関数では、内部で fetch〇〇
を呼ぶことで get〇〇
の戻り値を元に、リアクティブ変数の data
を間接的に更新していました。
そのため、execAsyncFn
関数がネストされる形で呼び出されます。
その実行中の非同期関数をカウントし、 0
になったタイミングで pending
を false
に切り替えているので、結果的にコンポーネント側でよしなに処理中の状態を取得できます。
やりたいことは useSWR
や useFetch
などの { data, pending } = fn()
に近いですが、プロダクトの都合を踏まえた最小限の実装で実現しているのが工夫ポイントです。
このように、別のコンポーザブルとして切り出すことで、use〇〇API.ts
では pending
の状態管理を意識することなく、APIコールの実装だけ関心を持たせることが可能になります。
provide
を用いて、Page内のRootにあたるコンポーネントからコンポーザブルを提供する
ここでは、作成したコンポーザブルをどのように使うのかについて解説します。
まず、以下のような構造になるようにコンポーネントを組み立てます。
PageコンポーネントではRootコンポーネントの配置のみ行う
pages/ramen/index.vue
<template> <RamenRoot /> </template>
これまではPageコンポーネントの中で画面を組み立てていましたが、それらはRootコンポーネントに委託して、あくまでもページルーティングのみに特化させます。
Rootコンポーネントでは provide
を使用してコンポーザブルを子孫に提供する
RamenRoot.vue
import { useRamenAPI, RAMEN_API_INJECTION_KEY } from '~/composables/api/useRamenAPI'; const ramenAPI = useRamenAPI(); provide(RAMEN_API_INJECTION_KEY, ramenAPI);
Rootコンポーネントでは画面の組み立てに加えて、 provide
を用いて前述したコンポーザブルを子孫コンポーネントに提供します。
これにより、コンポーザブルをバケツリレーすることなく、それぞれの配下でAPI操作が可能になります。
これは一長一短があり賛否両論だと思いますが、画面を構成するコンポーネントの多くは、共通コンポーネントを組み合わせたページ専用のコンポーネントになります。
そのため、この設計ルールを適用した場合は、そのページでしか使用しない &&
Rootコンポーネントから提供されていることが自明的なので、provide / inject
で直接参照しても良いと捉えました。
各子孫コンポーネントでは inject
でコンポーザブルから値と関数を取得・操作する
表題の通りですが、Rootコンポーネント配下の子孫コンポーネントでは、必要に応じて inject
を用いて data
や各API操作関数を取得します。
import { RAMEN_API_INJECTION_KEY } from '~/composables/api/useRamenAPI'; const ramenAPI = inject(RAMEN_API_INJECTION_KEY)!; const data = computed(() => { return ramenAPI.data.value; }); const flavor = computed(() => { return data.value?.flavor ?? ''; }); const topping = computed(() => { return data.value?.topping ?? []; }); const price = computed(() => { return data.value?.price ?? 0; }); async function create() { await ramenAPI.createRamen(...); }
実際には、Rootコンポーネント配下に <RamenCreateModal />
のようなモーダルコンポーネントを作成して、ローカルで定義した create
関数の中で hide()
の操作と一緒に ramenAPI.createRamen
を呼び出すような感じで使用しています。
RamenCreateModal.vue
(template
は省略)
// const pending = computed(() => { // return ramenAPI.pending.value; // }); // APIの実装が完了したら上のcomputedを使う const pending = ref(false); async function create() { if (!flavor.value || !topping.value || price.value < 0) { return; } // await ramenAPI.createRamen( // flavor.value, // topping.value, // price.value // ); // createRamenが完了したらモーダルを自動的に閉じる // APIの実装が終わるまではフロント側で3秒待ってそれっぽくする pending.value = true; await new Promise(resolve => setTimeout(resolve, 3000)); pending.value = false; hide(); // モーダルの非表示 }
<CustomButton @click="create" />
コンポーザブルに隠蔽することでコンポーネント側では参照するだけになるため、上記のように仮実装する場合でも切り替えが簡単になります。
provide / inject
は多用すると、どこで何が渡っていつ操作されたのか分かりづらくなったり認知負荷が高まる場合がありますが、コンポーネントルールと一緒に併用することで秩序を保つことができると考えています。
その点、emit
を利用するとdevtoolsからイベント情報を追うことができるので、こちらも一長一短ですね。
おわりに
API周りの実装はもちろんのこと、全てにおいて銀の弾丸は存在しませんが、チームメンバーにとって良いコードを書けるように日々模索を続けて開発しています。
このように、ヤプリのフロントエンドでは各々が創意工夫を凝らしながらさらに良いプロダクトを目指しています。
もしご興味を持っていただけた方は、是非カジュアル面談でお話しましょう!