目次
この記事は、丁寧に学ぶフロントエンドアーキテクチャの第4章です。
いいね・ストックをよろしくお願いします!
はじめに
第3章までで、フロントエンドの設計対象には大きく分けてBFFとViewという二種類のレイヤーが存在し、それぞれ論理画面データの作成と、物理画面データの作成を担っていることを説明しました。
ソフトウェアアーキテクチャにおいて、レイヤー分けと深い関係があるのがDIです。
第4章では、フロントエンドにおけるDI設計について説明します。
DIの利点
あるソフトウェアコンポーネントが、下位レイヤーに依存するとき、依存関係を事前に記述しておいて、動作時に設定取りにインターフェースの実装への参照を与える仕組みです。
DIの利点は、設定次第でレイヤー単体で動かせることです。
動作検証が容易になり、問題の局所化ができます。
さらに、通信が必要な部分をレイヤーで切り分けることで、高速化ができます。View・BFF・バックエンドの間には、ブラウザ・レンダリングサーバー・バックエンドサーバーが存在し、通信を行いながら処理を実現します。通信の切れ目に合わせてDIを行うことで、レイヤー単体での動作を高速化できます。
DIの目標
結合して実行する場合と、単体で実行する場合(多くの場合、動作確認およびテスト)を切り替えて起動できるようにすること
インターフェースというプログラミング言語の文法やDIコンテナなどのツールを使わなくても、最終的に目標さえ達成できれば問題ありません。
より詳しい内容については、この書籍の第1部、第2部が参考になります。
View
Viewは、BFFから受け取った論理画面に依存し、物理画面を生成します。基本的にはHTMLを生成すると言い換えてもよいでしょう。
Viewには、次の特殊な性質があります。
ViewとBFFの依存関係
Viewは1つのインタラクションごとに1回だけBFFを呼び出し、論理画面を取得する
この性質が成り立つのは、論理画面が直接画面に一対一で対応したデータだからです。画面に必要な情報自体はすでにすべてそろっているため、Viewは不足しているHTMLへの変換作業など、Webフロントエンド特有の、ブラウザ特有の作業のみを行います。このような作業は、Viewのみの知識で行うことができます。逆に、Webフロントエンド特有の知識が必要な処理はView以外に記述するべきではありません。
インタラクションの分類
Viewのインタラクションには、二種類が存在します。比較的扱いやすい初期描画と、複雑なユーザー主導の操作です。これは、第3章で詳しく解説したものです。
初期描画
ブラウザからのページ要求、ユーザーからの読み込み要求というインタラクションです。大部分のViewはこの時点で作成され、HTMLとCSSになり、ブラウザに返却されます。ブラウザからすると、ただの静的なリソースであり、表示内容だけを見ればいいため、比較的簡単に動作確認ができます。
ユーザー主導の操作
ユーザーがボタンを押す、リンクをクリックするなどの操作によるインタラクションです。Viewは、ブラウザのイベントを論理画面レベルのイベントに変換し、BFFに論理画面の更新を要求します。BFFの結果を受け取って、動的にViewの内容を変更します。動的な変更内容を含めて動作確認が必要です。Reactを使ってコンポーネントに状態を持たせたり、適宜DOMの構造を変更したりします。
静的なコンポーネント
静的なコンポーネントは、初期描画のタイミングで内容が確定し、それ以降全くBFFに依存しないコンポーネントです。具体的には、次のようなものが該当します。
- BFFの情報を必要としないコンポーネント
- 全画面共通のヘッダーやフッター
- 装飾用画像
- レイアウト
- 最初に描画されてからは、BFFからの情報を必要としないコンポーネント
- 検索結果のリストのアイテム
- 「今週のアクセス数」
これらのコンポーネントは、Astroでは次のように実装されるはずです。
---
interface Props {
title: string;
description?: string;
}
const { title, description } = Astro.props as Props;
---
<article>
<h1>{title}</h1>
{description && <p>{description}</p>}
<p>これは静的なAstroコンポーネントの例です。</p>
</article>
静的なコンポーネントは、必要な情報をすべてAstro.props
を通じて読み取るべきです。BFFを直接呼び出さないことで、特定の論理画面および論理画面を提供するエンドポイントに依存しなくなり、コンポーネントの再利用性が高まります。
静的なコンポーネントは初期描画のときにPropsを1回だけ受け取るので、
Astroでは、src/pages
配下のコンポーネントに対応するページを生成します。そのため、大まかなコードは次のような形になるでしょう。
---
// src/pages/[slug].astro
// 1. BFFをインポート
import { getPageData } from 'bff';
// 2. 画面構築用のコンポーネントをインポート
import PageComponent from '../components/PageComponent.astro';
// 3. BFFを利用して、論理画面を取得
const { slug } = Astro.params;
const pageData = await getPageData(slug);
---
<PageComponent {...pageData} />
pages配下のコンポーネントのみがBFFに直接依存します。静的なコンポーネント自体は単に引数のPropsにより挙動を変更させられます。
したがって、DIはシンプルです。情報の取得コード、つまり、直接BFFに依存するコードがsrc/pages
にあり、これは通常の起動時(本番環境、BFFありの環境)で使われます。そして、View単体で動作させたいときは、静的なコンポーネントだけを取り出して、Propsを変更すればよいのです。
上の例で言えば、<PageComponent>
のPropsだけを書き換えれば、BFFとの参照関係など何も気にせずにViewだけで動作させることが可能です。
初期描画のみのコンポーネント
初期描画のみのコンポーネントは後からの動的変更がないため、Astroコンポーネントとして静的なコンポーネントとして実装する。必要な情報はすべてPropsから受け取り、通信はしない。
動的なコンポーネント
初期描画以外に、ユーザー主導のインタラクションにも対応するコンポーネントです。必要に応じてBFFに問い合わせを行い、画面の構造を書き換えます。
技術的な概要
Astroでは、クライアント側で実行されるJavaScriptによって動的に画面を書き換えることになります。現時点でのメジャーな選択肢は、Reactを利用することでしょう。
また、BFFの呼び出しは、Astro Actionを使って静的型付けの上で行うことができます。
簡単なサンプルコードです。
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';
export const server = {
getGreeting: defineAction({
input: z.object({
name: z.string(),
}),
handler: async (input) => `Hello, ${input.name}!`,
}),
};
import React, { useState } from 'react';
import { actions } from 'astro:actions';
const GreetingButton: React.FC = () => {
const [greeting, setGreeting] = useState('');
const handleClick = async () => {
// Astro Action を呼び出して挨拶メッセージを取得
// dataが論理画面である
const { data } = await actions.getGreeting({ name: 'Astro User' });
setGreeting(data);
};
return (
<div>
<button onClick={handleClick}>挨拶を取得</button>
{greeting && <p>{greeting}</p>}
</div>
);
};
export default GreetingButton;
これらのコードは、ブラウザ上で動的に状態を保持し、適宜BFFに接続を行います。
DIの実装
Astro Actionsではインターフェースではなく、defineAction
で指示した構造をもとにコード生成が行われるため、依存先を処理ごとに動的に変更するのが面倒です。
そこで、より低レイヤーのブラウザとレンダリングサーバーの通信部分でDIを行います。Mock Service Worker
は、ブラウザの通信を覗き見て、必要に応じて処理し、レスポンスを生成することができる技術です。
必要に応じてモック関数を定義し、BFFへの通信を本物のBFFから差し替えることで、View単体で動作させることができます。
なお、Actionを呼び出す起点となるコンポーネント以外は、静的なコンポーネントとして定義できるため、子コンポーネント単体での動作はPropsの差し替えのみで実現可能です。
BFF
BFFは、汎用的な機能を組み合わせて、論理画面を実現するための整形、並べ替え、突き合わせ処理を行います。処理の中心はデータの意味的な内容を変えないままの加工であり、BFF自体がビジネスロジックの関連する処理をすることは少ないです。あくまで、機能の結果を画面の論理構造に合わせることが目標です。
DIが不要な関数
Viewとは異なり、バックエンドの処理とBFFは一対一で対応しているわけではありません。そのため、Viewよりも複雑になります。BFFは、すべてが静的なコンポーネントのように外部依存なく構成できるわけでは無いからです。
とはいえ、外部依存を少なくした関数を実現することは可能です。具体的には、バックエンドから受け取ってきたデータを引数にし、何らかの処理をして返すような処理です。このような関数は、Viewにおける静的なコンポーネントに相当します。自力でバックエンドを呼び出さないため、直接の依存がありません。
わざわざバックエンドの処理をDIで注入することなく単体で動作させられるため、単体テストが容易です。
BFFのすべての処理がDIを必要とするわけでは無い。直接通信を必要としない部分を抽出することで、テストしやすい関数を実装できる。
バックエンドのDI
Viewでは、BFF呼出しは1回だけだったので、コードの形もボイラープレートであり、呼び出し自体の部分は単体で動作させる必要が薄かったです。実際、src/pages
のAstroコンポーネントではBFFのDIについて考慮していません。
しかし、BFFはViewと違って、バックエンド呼出しは最初の1回だけというわけにはいきません。そのため、複数回のバックエンド呼出しを含めたロジックを単体で動作させる必要があります。単にデータを差し替えるだけでは不十分で、関数の動作を含めて差し替えたいのです。
BFFでのバックエンド呼出しは、fetch
を使って行われます。モダンな構成では、Open API Specから自動生成したクライアントを利用することが多いでしょう。
技術的には、次のようなライブラリを使うことができます。
import { createClient } from 'openapi-ts-fetch';
import type { paths } from "./my-openapi-3-schema";
// バックエンド呼出し用のグローバル変数をエクスポート
export const client = createClient<path>({ baseUrl: 'http://localhost:3000' });
import { client } from "./apiClient";
export async function getBlogPost(postId: string, version: number) {
// 自動生成されたクライアントを呼び出す
const response = await client.GET("/blogposts/{post_id}", {
params: {
path: { post_id: postId },
query: { version },
},
});
return response;
}
これをDIするためには、vitest
のモック機構を使います。まず、BFFはブラウザとは関係ないため、動作確認にブラウザが不要です。そのため、純粋なテストフレームワークを使って動作させることができます。vitest
では、vi.mock
を使って、テストファイルでのインポート内容を差し替えることができます。
import { getBlogPost } from "./bff";
import { client } from "./apiClient";
import { vi, test, expect, beforeEach } from "vitest";
vi.mock("./apiClient");
beforeEach(() => {
vi.clearAllMocks();
});
test("getBlogPost はモックされた API Client を使ってブログポストを取得する", async () => {
const mockedClient = vi.mocked(client, { deep: true });
const fakeResponseBody = { id: "123", title: "Mocked Blog Post" };
mockedClient.GET.mockResolvedValue(new Response(JSON.stringify(fakeResponseBody)));
const response = await getBlogPost("123", 2);
const data = await response.json();
expect(data).toEqual(fakeResponseBody);
expect(mockedClient.GET).toHaveBeenCalledWith("/blogposts/{post_id}", {
params: {
path: { post_id: "123" },
query: { version: 2 }
}
});
});
このように、依存している内容をvitest
の機能を使ってモックすることができます。
セッションのDI
BFFは、独自のセッションをデータストアに依存しています。代表的な実装として、クッキーにセッションIDを記録し、対応するデータを保持するようなものがあります。
このようなデータストアは、外部依存となり、処理を遅くする原因になりえるため、DIが必要です。
データストアのDIは、バックエンドのDIに比べると少し複雑です。バックエンドは、API呼び出しごとに入力と出力を個別に設定していました。データストアでも同様にすることは可能ですが、あまりに煩雑です。基本的なデータの読み書き自体はプログラムによって自動的に実行されるべきです。
__mocks__
というディレクトリを作成することで、vitest
がモックしたときに参照する実装を自動的に切り替えることができます。
export interface DataStore {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
}
// (ここに実装の本体がある)
import type { DataStore } from "../dataStore";
// シンプルなインメモリ実装
const store: Record<string, string> = {};
export const redisDataStore: DataStore = {
async get(key: string): Promise<string | null> {
return key in store ? store[key] : null;
},
async set(key: string, value: string): Promise<void> {
store[key] = value;
},
// テスト用に操作を増やしてもよい
reset(): void {
Object.keys(store).forEach((key) => delete store[key]);
},
};
このように、実装単位で差し替えておくことで、テスト関数からvi.mock
でインポートを差し替えた場合に、軽量な実装に変更できます。
コラム:インターフェースベースのDI
フロントエンドでは、バックエンドほどインターフェースと初期化を利用したDIがメジャーではありません。DIライブラリの利用も基本的には限定的です。いくつか理由は考えられます。
- 実行環境や実行タイミング・参照関係がブラウザとレンダリングサーバーでバラバラであるため最初に全部注入というのが難しい
- ページごと、ファイルごとに実行され、そもそもエントリーポイントという概念があいまい
- 静的なインターフェースや型情報をベースにするとTypeScriptのみの仕組みになり、JavaScriptとの相性が悪い
いずれにせよ、DIの目的は単体での動作確認であるため、多少の実装方法が違ったところで、特に問題にはなりません。
おわりに
今回は、ViewとBFFでのDIの内容と実装について考察しました。ある程度技術的な詳細まで説明しました。これにより、レイヤーごとの動作確認が簡単になり、レイヤー分けのメリットをより享受できます。
次回は、DIと深く関係するテストについて説明します。