依存関係の構成
従来のフレームワークベースの依存性注入に対する不満に基づき、モジュールにコンテキストを注入するために部分適用を利用する構成戦略を採用しました。テスト駆動開発を設計プロセスとして組み合わせ、クラスよりも関数を重視することで、モジュールを明確、クリーン、そしてほとんど意図しない結合から解放することができます。
2023年5月23日
起源
すべては数年前、私のチームのメンバーが「依存性注入(DI)にはどのようなパターンを採用すべきか」と尋ねたことから始まりました。チームのスタックはNode.js上のTypeScriptであり、私はあまり詳しくなかったので、彼ら自身で解決することを奨励しました。しばらくして、チームが事実上決定しないことを決定し、モジュールを接続するための多数のパターンを残したことを知ってがっかりしました。一部の開発者はファクトリメソッドを使用し、他の開発者はルートモジュールで手動の依存性注入を行い、一部のオブジェクトはクラスコンストラクターで使用しました。
結果は理想的とは言えませんでした。オブジェクト指向と関数型のパターンがさまざまな方法で組み立てられた寄せ集めで、それぞれがテストに対して非常に異なるアプローチを必要としていました。一部のモジュールは単体テスト可能でしたが、他のモジュールはテスト用のエントリポイントがなく、単純なロジックで基本的な機能を実行するために複雑なHTTP対応の足場が必要でした。最も重要なことは、コードベースのある部分の変更が、無関係な領域で契約違反を引き起こすことがあったことです。一部のモジュールは名前空間をまたいで相互依存していました。他のモジュールは、サブドメイン間の区別のない、完全にフラットなモジュールのコレクションを持っていました。
後知恵の利点を得て、私はその最初の決定について考え続けました。どのDIパターンを選ぶべきだったのか。最終的に私は結論に達しました。それは間違った質問だったのです。
依存性注入は手段であり、目的ではない
振り返ってみると、チームに別の質問をするように導くべきでした。コードベースに必要な品質は何ですか?そして、それを達成するためにどのようなアプローチを使用するべきですか?以下のことを提唱できればよかったと思っています。
- 重複する型を犠牲にしても、偶発的な結合を最小限に抑えた個別のモジュール
- HTTPハンドラーやGraphQLリゾルバーのようなトランスポートを管理するコードと混ざらないようにするビジネスロジック
- トランスポートを認識しない、または複雑な足場を持たないビジネスロジックテスト
- 型に新しいフィールドが追加されても壊れないテスト
- モジュールの外部に公開される型はごくわずかであり、それらが存在するディレクトリの外部に公開される型はさらに少ない。
ここ数年、開発者をこれらの資質へと導くアプローチに落ち着きました。テスト駆動開発(TDD)のバックグラウンドを持つ私は、当然そこから始めます。TDDは漸進主義を奨励しますが、私はさらに先に進みたかったので、モジュール構成に対して最小限の「関数ファースト」アプローチを採用しました。プロセスを説明し続けるのではなく、デモンストレーションします。以下は、コントローラーモジュールがドメインロジックを呼び出し、ドメインロジックが永続化層のリポジトリ関数を呼び出すという比較的単純なアーキテクチャ上に構築されたWebサービスの例です。
問題の説明
次のようなユーザーストーリーを想像してみてください。
RateMyMealの登録ユーザーであり、何が利用可能かわからないレストランの常連客として、他の常連客の評価に基づいて、自分の地域で推奨されるレストランのランク付けされたセットを提供してほしいです。
受け入れ基準
- レストランリストは、最も推奨されるものから順にランク付けされています。
- 評価プロセスには、次の潜在的な評価レベルが含まれます。
- 素晴らしい(2)
- 平均以上(1)
- 平均(0)
- 平均以下(-1)
- ひどい(-2)。
- 全体的な評価は、すべての個々の評価の合計です。
- 「信頼できる」と見なされるユーザーは、評価に4倍の乗数が適用されます。
- ユーザーは、返されるレストランの範囲を制限するために都市を指定する必要があります。
ソリューションの構築
TypeScript、Node.js、PostgreSQLを使用してRESTサービスを構築するタスクが割り当てられました。私は、解決したい問題の境界を定義するウォーキングスケルトンとして、非常に粗い統合を構築することから始めます。このテストでは、可能な限り多くの基盤となるインフラストラクチャを使用します。スタブを使用する場合、それはサードパーティのクラウドプロバイダーまたはローカルで実行できない他のサービス用です。それでも、サーバースタブを使用するため、実際のSDKまたはネットワーククライアントを使用できます。これは、私が集中できるように、目の前のタスクの受け入れテストになります。堅牢に構築するには時間がかかるため、基本的な機能を実行する1つの「ハッピーパス」のみをカバーします。エッジケースをテストするための、よりコストのかからない方法を見つけます。この記事では、必要に応じて変更できるスケルトンデータベース構造があると仮定します。
テストは一般に、`given/when/then`構造を持っています。一連の所定の条件、参加アクション、および検証された結果です。解決しようとしている問題に焦点を当てるために、`when/then`から始めて`given`に戻ることを好みます。
「推奨エンドポイントを呼び出すと、OK応答と、評価アルゴリズムに基づいて上位にランク付けされたレストランを含むペイロードが返されると予想されます」。コードでは次のようになります。
test/e2e.integration.spec.ts…
describe("the restaurants endpoint", () => {
it("ranks by the recommendation heuristic", async () => {
const response = await axios.get<ResponsePayload>( ➀
"https://:3000/vancouverbc/restaurants/recommended",
{ timeout: 1000 },
);
expect(response.status).toEqual(200);
const data = response.data;
const returnRestaurants = data.restaurants.map(r => r.id);
expect(returnRestaurants).toEqual(["cafegloucesterid", "burgerkingid"]); ➁
});
});
type ResponsePayload = {
restaurants: { id: string; name: string }[];
};
注目すべき点がいくつかあります。
- `Axios`は、私が使用することを選択したHTTPクライアントライブラリです。Axiosの`get`関数は、応答データの予期される構造を定義する型引数(`ResponsePayload`)を取ります。コンパイラは、`response.data`のすべての使用がその型に準拠することを確認しますが、このチェックはコンパイル時に行われるだけなので、HTTP応答本文に実際にその構造が含まれていることを保証することはできません。私のアサーションはそれを行う必要があります。
- 返されたレストランの内容全体をチェックするのではなく、IDのみをチェックします。この小さな詳細は意図的なものです。オブジェクトの内容全体をチェックすると、テストは脆弱になり、新しいフィールドを追加すると壊れてしまいます。レストランリストの順序という、私が関心のある特定の条件を検証しながら、コードの自然な進化に対応できるテストを作成したいと思います。
`given`条件がないと、このテストはあまり役に立たないので、次に追加します。
test/e2e.integration.spec.ts…
describe("the restaurants endpoint", () => {
let app: Server | undefined;
let database: Database | undefined;
const users = [
{ id: "u1", name: "User1", trusted: true },
{ id: "u2", name: "User2", trusted: false },
{ id: "u3", name: "User3", trusted: false },
];
const restaurants = [
{ id: "cafegloucesterid", name: "Cafe Gloucester" },
{ id: "burgerkingid", name: "Burger King" },
];
const ratingsByUser = [
["rating1", users[0], restaurants[0], "EXCELLENT"],
["rating2", users[1], restaurants[0], "TERRIBLE"],
["rating3", users[2], restaurants[0], "AVERAGE"],
["rating4", users[2], restaurants[1], "ABOVE_AVERAGE"],
];
beforeEach(async () => {
database = await DB.start();
const client = database.getClient();
await client.connect();
try {
// GIVEN
// These functions don't exist yet, but I'll add them shortly
for (const user of users) {
await createUser(user, client);
}
for (const restaurant of restaurants) {
await createRestaurant(restaurant, client);
}
for (const rating of ratingsByUser) {
await createRatingByUserForRestaurant(rating, client);
}
} finally {
await client.end();
}
app = await server.start(() =>
Promise.resolve({
serverPort: 3000,
ratingsDB: {
...DB.connectionConfiguration,
port: database?.getPort(),
},
}),
);
});
afterEach(async () => {
await server.stop();
await database?.stop();
});
it("ranks by the recommendation heuristic", async () => {
// .. snip
私の`given`条件は、`beforeEach`関数に実装されています。`beforeEach`は、同じセットアップの足場を利用したい場合に、より多くのテストを追加できるようにし、前提条件をテストの残りの部分から明確に分離します。多くの`await`呼び出しに気付くでしょう。Node.jsのようなリアクティブプラットフォームでの長年の経験から、最も簡単な関数以外すべてに対して非同期コントラクトを定義することを学びました。データベース呼び出しやファイル読み取りなど、IOバウンドになるものはすべて非同期である必要があり、同期実装は必要に応じてPromiseで簡単にラップできます。対照的に、同期コントラクトを選択してから、それを非同期にする必要があることが判明すると、後で説明するように、解決するのがはるかに厄介な問題になります。
ユーザーとレストランの明示的な型を作成することを意図的に延期し、まだどのようなものかわからないことを認めました。TypeScriptの構造的型付けにより、その定義の作成を延期し続け、モジュールAPIが固まり始めるにつれて型安全性の利点を得ることができます。後で説明するように、これはモジュールを分離しておくための重要な手段です。
この時点で、テストの依存関係が欠落しているテストのシェルができました。次の段階は、最初にテストをコンパイルするためのスタブ関数を構築し、次にこれらのヘルパー関数を実装することによって、これらの依存関係を具体化することです。それはかなりの量の作業ですが、非常に文脈的でこの記事の範囲外でもあります。一般的に、それは次のもので構成されると言えば十分です。
- データベースなどの依存サービスを開始します。私は一般的に、docker化されたサービスを実行するためにtestcontainersを使用しますが、これらはネットワークフェイクまたはインメモリコンポーネントなど、何でもかまいません。
- テストに必要なエンティティを事前に構築するために、`create...`関数に入力します。この例の場
- サービス自体を起動します。現時点では単純なスタブです。サービスの初期化についてもう少し詳しく掘り下げます。これはコンポジションの議論に関連するためです。
テストの依存関係がどのように初期化されるかについて興味がある場合は、GitHubリポジトリの結果を参照してください。
先に進む前に、テストを実行して期待どおりに失敗することを確認します。サービスの `start` をまだ実装していないため、HTTPリクエストを行うと接続拒否エラーが発生すると予想されます。それが確認できたら、しばらくは成功しないため、大規模な統合テストを無効にしてコミットします。
コントローラーについて
一般的に外側から内側に向かって構築していくため、次のステップはメインのHTTP処理関数に対処することです。最初に、コントローラーの単体テストを作成します。期待されるヘッダーを持つ空の200レスポンスを保証するものから始めます。
test/restaurantRatings/controller.spec.ts…
describe("the ratings controller", () => {
it("provides a JSON response with ratings", async () => {
const ratingsHandler: Handler = controller.createTopRatedHandler();
const request = stubRequest();
const response = stubResponse();
await ratingsHandler(request, response, () => {});
expect(response.statusCode).toEqual(200);
expect(response.getHeader("content-type")).toEqual("application/json");
expect(response.getSentBody()).toEqual({});
});
});
約束した高度に疎結合されたモジュールになるように、すでに少し設計作業を始めました。コードのほとんどは非常に典型的なテストの足場ですが、強調表示された関数呼び出しをよく見ると、 unusualに思えるかもしれません。
この小さな詳細は、部分適用、つまりコンテキストを持つ関数を返す関数の第一歩です。次の段落では、それがコンポジションアプローチの基盤となる方法を示します。
次に、テスト対象のユニットのスタブ、今回はコントローラーを構築し、実行してテストが期待どおりに動作することを確認します。
src/restaurantRatings/controller.ts…
export const createTopRatedHandler = () => {
return async (request: Request, response: Response) => {};
};
テストは200を期待していますが、`status` の呼び出しがないため、テストは失敗します。スタブに少し手を加えると、合格します。
src/restaurantRatings/controller.ts…
export const createTopRatedHandler = () => {
return async (request: Request, response: Response) => {
response.status(200).contentType("application/json").send({});
};
};
コミットして、期待されるペイロードのテストを具体化します。このアプリケーションのデータアクセスまたはアルゴリズム部分をどのように処理するかはまだ正確にはわかりませんが、委任したいと考えており、このモジュールはHTTPプロトコルとドメイン間の変換のみを行います。また、デリゲートから何が欲しいのかもわかっています。具体的には、トップ評価のレストランをロードしたいと考えています。それが何であれ、どこから来ても、「依存関係」スタブを作成し、トップレストランを返す関数を持たせます。これは、ファクトリー関数の parametersになります。
test/restaurantRatings/controller.spec.ts…
type Restaurant = { id: string };
type RestaurantResponseBody = { restaurants: Restaurant[] };
const vancouverRestaurants = [
{
id: "cafegloucesterid",
name: "Cafe Gloucester",
},
{
id: "baravignonid",
name: "Bar Avignon",
},
];
const topRestaurants = [
{
city: "vancouverbc",
restaurants: vancouverRestaurants,
},
];
const dependenciesStub = {
getTopRestaurants: (city: string) => {
const restaurants = topRestaurants
.filter(restaurants => {
return restaurants.city == city;
})
.flatMap(r => r.restaurants);
return Promise.resolve(restaurants);
},
};
const ratingsHandler: Handler =
controller.createTopRatedHandler(dependenciesStub);
const request = stubRequest().withParams({ city: "vancouverbc" });
const response = stubResponse();
await ratingsHandler(request, response, () => {});
expect(response.statusCode).toEqual(200);
expect(response.getHeader("content-type")).toEqual("application/json");
const sent = response.getSentBody() as RestaurantResponseBody;
expect(sent.restaurants).toEqual([
vancouverRestaurants[0],
vancouverRestaurants[1],
]);
`getTopRestaurants` 関数がどのように実装されているかについての情報がほとんどない場合、どのようにスタブを作成すればよいでしょうか?依存関係スタブに暗黙的に作成したコントラクトの基本的なクライアントビューを設計するのに十分な知識があります。レストランのセットを非同期的に返す単純な非束縛関数です。このコントラクトは、単純な静的関数、オブジェクトインスタンスのメソッド、または上記のテストのようにスタブによって満たされる場合があります。このモジュールは知りませんし、気にしませんし、知る必要もありません。仕事をするために必要な最小限のものにのみさらされており、それ以上は何もありません。
src/restaurantRatings/controller.ts…
interface Restaurant {
id: string;
name: string;
}
interface Dependencies {
getTopRestaurants(city: string): Promise<Restaurant[]>;
}
export const createTopRatedHandler = (dependencies: Dependencies) => {
const { getTopRestaurants } = dependencies;
return async (request: Request, response: Response) => {
const city = request.params["city"]
response.contentType("application/json");
const restaurants = await getTopRestaurants(city);
response.status(200).send({ restaurants });
};
};
これらのことを視覚化したい人のために、ボールとソケット表記法を使用して、`getTopRatedRestaurants` インターフェースを実装する必要があるハンドラー関数として、これまでの本番コードを視覚化できます。
テストはこの関数と、必要な関数のスタブを作成します。テストに別の色を使用し、インターフェースの実装を示すためにソケット表記を使用することで、これを示すことができます。
この `controller` モジュールは現時点では脆いため、代替コードパスとエッジケースをカバーするためにテストを具体化する必要がありますが、それはこの記事の範囲を超えています。より徹底的なテストと結果のコントローラーモジュールに興味がある場合は、どちらもGitHubリポジトリで入手できます。
ドメインの深堀り
この段階では、存在しない関数を必要とするコントローラーがあります。次のステップは、`getTopRestaurants` コントラクトを満たすことができるモジュールを提供することです。そのプロセスは、大きくて扱いにくい単体テストを作成し、後でわかりやすくリファクタリングすることから始めます。以前に確立したコントラクトを実装する方法について考え始めるのは、この時点だけです。元の受け入れ基準に戻り、モジュールを最小限に設計しようとします。
test/restaurantRatings/topRated.spec.ts…
describe("The top rated restaurant list", () => {
it("is calculated from our proprietary ratings algorithm", async () => {
const ratings: RatingsByRestaurant[] = [
{
restaurantId: "restaurant1",
ratings: [
{
rating: "EXCELLENT",
},
],
},
{
restaurantId: "restaurant2",
ratings: [
{
rating: "AVERAGE",
},
],
},
];
const ratingsByCity = [
{
city: "vancouverbc",
ratings,
},
];
const findRatingsByRestaurantStub: (city: string) => Promise< ➀
RatingsByRestaurant[]
> = (city: string) => {
return Promise.resolve(
ratingsByCity.filter(r => r.city == city).flatMap(r => r.ratings),
);
};
const calculateRatingForRestaurantStub: ( ➁
ratings: RatingsByRestaurant,
) => number = ratings => {
// I don't know how this is going to work, so I'll use a dumb but predictable stub
if (ratings.restaurantId === "restaurant1") {
return 10;
} else if (ratings.restaurantId == "restaurant2") {
return 5;
} else {
throw new Error("Unknown restaurant");
}
};
const dependencies = { ➂
findRatingsByRestaurant: findRatingsByRestaurantStub,
calculateRatingForRestaurant: calculateRatingForRestaurantStub,
};
const getTopRated: (city: string) => Promise<Restaurant[]> =
topRated.create(dependencies);
const topRestaurants = await getTopRated("vancouverbc");
expect(topRestaurants.length).toEqual(2);
expect(topRestaurants[0].id).toEqual("restaurant1");
expect(topRestaurants[1].id).toEqual("restaurant2");
});
});
interface Restaurant {
id: string;
}
interface RatingsByRestaurant { ➃
restaurantId: string;
ratings: RestaurantRating[];
}
interface RestaurantRating {
rating: Rating;
}
export const rating = { ➄
EXCELLENT: 2,
ABOVE_AVERAGE: 1,
AVERAGE: 0,
BELOW_AVERAGE: -1,
TERRIBLE: -2,
} as const;
export type Rating = keyof typeof rating;
この時点で多くの新しい概念をドメインに導入したので、1つずつ見ていきます。
- 各レストランの評価のセットを返す「ファインダー」が必要です。まずはそれをスタブ化することから始めます。
- 受け入れ基準は全体的な評価を左右するアルゴリズムを提供していますが、今はそれを無視して、*どういうわけか*この評価グループが全体的なレストラン評価を数値として提供するとします。
- このモジュールが機能するには、2つの新しい概念に依存します。レストランの評価を見つけることと、その評価セットまたは評価が与えられた場合、全体的な評価を作成することです。2つのスタブ関数を含む別の「依存関係」インターフェースを作成します。これらのスタブ関数は、単純で予測可能なスタブ実装を使用して、先に進めることができます。
- `RatingsByRestaurant` は、特定のレストランの評価のコレクションを表します。`RestaurantRating` は、そのような単一の評価です。コントラクトの意図を示すために、テスト内でそれらを定義します。これらのタイプは、ある時点で消える可能性があります。または、本番コードに昇格させる可能性があります。今のところ、それはどこに向かっているのかの良いリマインダーです。TypeScriptのような構造的に型付けされた言語では、型は非常に安価であるため、そうするコストは非常に低いです。
- また、ACによると、「excellent (2), above average (1), average (0), below average (-1), terrible (-2)」の5つの値で構成される `rating` も必要です。これも、テストモジュール内にキャプチャし、「最後の責任ある瞬間」まで待って、本番コードにプルするかどうかを決定します。
テストの基本構造が整ったら、最小限の実装でコンパイルを試みます。
src/restaurantRatings/topRated.ts…
interface Dependencies {}
export const create = (dependencies: Dependencies) => { ➀
return async (city: string): Promise<Restaurant[]> => [];
};
interface Restaurant { ➁
id: string;
}
export const rating = { ➂
EXCELLENT: 2,
ABOVE_AVERAGE: 1,
AVERAGE: 0,
BELOW_AVERAGE: -1,
TERRIBLE: -2,
} as const;
export type Rating = keyof typeof rating;
- ここでも、部分的に適用された関数ファクトリパターンを使用して、依存関係を渡し、関数を返します。もちろん、テストは失敗しますが、期待どおりに失敗するのを見ることで、テストが健全であるという自信が高まります。
- テスト対象のモジュールの実装を開始すると、本番コードに昇格させる必要があるドメインオブジェクトがいくつか識別されます。特に、直接の依存関係をテスト対象のモジュールに移動します。直接の依存関係ではないものはすべて、テストコード内の場所に残します。
- また、予想される動きを1つ行います。`Rating` 型を本番コードに抽出します。普遍的で明示的なドメインの概念であるため、安心してそうすることができます。値は受け入れ基準で具体的に呼び出されました。これは、カップリングが偶発的である可能性が低いことを示しています。
本番コードで定義または移動する型は、モジュールからエクスポート*されない*ことに注意してください。それは意図的な選択であり、後で詳しく説明します。他のモジュールがこれらの型にバインドして、望ましくないことが判明する可能性のあるカップリングを作成するかどうかはまだ決定していません。
次に、`getTopRated.ts` モジュールの実装を完了します。
src/restaurantRatings/topRated.ts…
interface Dependencies { ➀
findRatingsByRestaurant: (city: string) => Promise<RatingsByRestaurant[]>;
calculateRatingForRestaurant: (ratings: RatingsByRestaurant) => number;
}
interface OverallRating { ➁
restaurantId: string;
rating: number;
}
interface RestaurantRating { ➂
rating: Rating;
}
interface RatingsByRestaurant {
restaurantId: string;
ratings: RestaurantRating[];
}
export const create = (dependencies: Dependencies) => { ➃
const calculateRatings = (
ratingsByRestaurant: RatingsByRestaurant[],
calculateRatingForRestaurant: (ratings: RatingsByRestaurant) => number,
): OverallRating[] =>
ratingsByRestaurant.map(ratings => {
return {
restaurantId: ratings.restaurantId,
rating: calculateRatingForRestaurant(ratings),
};
});
const getTopRestaurants = async (city: string): Promise<Restaurant[]> => {
const { findRatingsByRestaurant, calculateRatingForRestaurant } =
dependencies;
const ratingsByRestaurant = await findRatingsByRestaurant(city);
const overallRatings = calculateRatings(
ratingsByRestaurant,
calculateRatingForRestaurant,
);
const toRestaurant = (r: OverallRating) => ({
id: r.restaurantId,
});
return sortByOverallRating(overallRatings).map(r => {
return toRestaurant(r);
});
};
const sortByOverallRating = (overallRatings: OverallRating[]) =>
overallRatings.sort((a, b) => b.rating - a.rating);
return getTopRestaurants;
};
//SNIP ..
そうすることで、
- 単体テストでモデル化した依存関係タイプに入力しました
- ドメインの概念を捉えるために `OverallRating` 型を導入しました。これは、レストランIDと数値のタプルにすることもできますが、前述のように、型は安価であり、追加の明確さが最小限のコストを簡単に正当化できると考えています。
- テストから `topRated` モジュールの直接の依存関係となったいくつかの型を抽出しました
- ファクトリーによって返されるプライマリ関数の単純なロジックを完成させました。
メインの本番コード関数間の依存関係は次のようになります。
テストによって提供されるスタブを含めると、次のようになります。
この実装が完了したので(今のところ)、メインドメイン関数とコントローラーの合格テストができました。それらは完全に切り離されています。実際、それらが連携して動作することを証明する必要があると感じています。ユニットを構成し、より大きな全体に向かって構築を開始する時が来ました。
接続の開始
この時点で、決定を下す必要があります。比較的簡単なものを構築している場合は、モジュールを統合するときにテスト駆動型のアプローチをやめることを選択するかもしれませんが、この場合は、2つの理由でTDDパスを続けます。
- モジュール間の統合の設計に焦点を当てたいと考えており、テストの作成はそうするための優れたツールです。
- 元の受け入れテストを検証として使用できるようになるまでには、まだ実装する必要があるモジュールがいくつかあります。それまで統合を待つと、基礎となる仮定のいくつかに欠陥がある場合、多くの問題を解決する必要があるかもしれません。
最初の受け入れテストを巨石、ユニットテストを小石とすると、この最初の統合テストは拳大の岩になります。コントローラーからドメイン関数の最初の層への呼び出しパスを実行する、かなり大きなテストであり、その層を超えるものすべてにテストダブルを提供します。少なくとも最初はそうなるでしょう。作業を進めながら、アーキテクチャの後続の層を統合し続けるかもしれません。また、テストが役に立たなくなったり、邪魔になったりした場合は、破棄することもあります。
初期実装後、テストはルートが正しく配線されていることを検証する以上のことはほとんど行いませんが、すぐにドメイン層への呼び出しをカバーし、レスポンスが期待どおりにエンコードされていることを検証します。
test/restaurantRatings/controller.integration.spec.ts…
describe("the controller top rated handler", () => {
it("delegates to the domain top rated logic", async () => {
const returnedRestaurants = [
{ id: "r1", name: "restaurant1" },
{ id: "r2", name: "restaurant2" },
];
const topRated = () => Promise.resolve(returnedRestaurants);
const app = express();
ratingsSubdomain.init(
app,
productionFactories.replaceFactoriesForTest({
topRatedCreate: () => topRated,
}),
);
const response = await request(app).get(
"/vancouverbc/restaurants/recommended",
);
expect(response.status).toEqual(200);
expect(response.get("content-type")).toBeDefined();
expect(response.get("content-type").toLowerCase()).toContain("json");
const payload = response.body as RatedRestaurants;
expect(payload.restaurants).toBeDefined();
expect(payload.restaurants.length).toEqual(2);
expect(payload.restaurants[0].id).toEqual("r1");
expect(payload.restaurants[1].id).toEqual("r2");
});
});
interface RatedRestaurants {
restaurants: { id: string; name: string }[];
}
これらのテストは、Webフレームワークに大きく依存しているため、少し見苦しくなる可能性があります。これが、私が下した2つ目の決定につながります。JestやSinon.jsのようなフレームワークを使用して、`topRated`モジュールのような到達不能な依存関係へのフックを提供するモジュールスタブまたはスパイを使用することもできます。私はAPIでそれらを公開したくないので、テストフレームワークのトリックを使用することは正当化されるかもしれません。しかし、この場合は、より従来のエントリポイントを提供することにしました。`init()`関数でオーバーライドするオプションのファクトリ関数のコレクションです。これは、開発プロセス中に必要なエントリポイントを提供します。進捗状況に応じて、そのフックが不要になる場合があり、その場合は削除します。
次に、モジュールを組み立てるコードを書きます。
src/restaurantRatings/index.ts…
export const init = (
express: Express,
factories: Factories = productionFactories,
) => {
// TODO: Wire in a stub that matches the dependencies signature for now.
// Replace this once we build our additional dependencies.
const topRatedDependencies = {
findRatingsByRestaurant: () => {
throw "NYI";
},
calculateRatingForRestaurant: () => {
throw "NYI";
},
};
const getTopRestaurants = factories.topRatedCreate(topRatedDependencies);
const handler = factories.handlerCreate({
getTopRestaurants, // TODO: <-- This line does not compile right now. Why?
});
express.get("/:city/restaurants/recommended", handler);
};
interface Factories {
topRatedCreate: typeof topRated.create;
handlerCreate: typeof createTopRatedHandler;
replaceFactoriesForTest: (replacements: Partial<Factories>) => Factories;
}
export const productionFactories: Factories = {
handlerCreate: createTopRatedHandler,
topRatedCreate: topRated.create,
replaceFactoriesForTest: (replacements: Partial<Factories>): Factories => {
return { ...productionFactories, ...replacements };
},
};
モジュールに定義された依存関係があるが、そのコントラクトを満たすものがまだない場合があります。それは全く問題ありません。上記の`topRatedHandlerDependencies`オブジェクトのように、例外をスローする実装をインラインで定義できます。受け入れテストは失敗しますが、この段階では、それは期待どおりです。
問題の発見と修正
注意深い観察者は、`topRatedHandler`が構築された時点で、2つの定義の間に矛盾があるため、コンパイルエラーが発生していることに気付くでしょう。
- `controller.ts`によって理解されるレストランの表現
- `topRated.ts`で定義され、`getTopRestaurants`によって返されるレストラン
理由は単純です。`topRated.ts`の`Restaurant`型に`name`フィールドを追加していません。ここにはトレードオフがあります。各モジュールに1つずつではなく、レストランを表す単一の型があれば、`name`を1回追加するだけで、両方のモジュールが追加の変更なしでコンパイルされます。それでも、余分なテンプレートコードが作成されるとしても、型を分けておくことを選択します。アプリケーションの各層に2つの異なる型を維持することで、それらの層を不必要に結合する可能性がはるかに低くなります。いいえ、これはDRY (Don't Repeat Yourself) ではありませんが、モジュールコントラクトをできるだけ独立させておくために、ある程度の繰り返しをリスクにさらすことはよくあります。
src/restaurantRatings/topRated.ts…
interface Restaurant {
id: string;
name: string,
}
const toRestaurant = (r: OverallRating) => ({
id: r.restaurantId,
// TODO: I put in a dummy value to
// start and make sure our contract is being met
// then we'll add more to the testing
name: "",
});
私の非常に単純な解決策は、コードを再びコンパイルできるようにし、モジュールに関する現在の作業を続行できるようにします。すぐにテストに検証を追加して、`name`フィールドが期待どおりにマッピングされていることを確認します。これでテストに合格したので、次のステップに進みます。それは、レストランマッピングのより永続的なソリューションを提供することです。
リポジトリ層への到達
これで、`getTopRestaurants`関数の構造が多かれ少なかれ整い、レストラン名を取得する方法が必要になったので、`Restaurant`データの残りの部分をロードするために`toRestaurant`関数に入力します。以前、この高度に駆動される開発スタイルを採用する前は、おそらく`Restaurant`オブジェクトをロードするためのメソッドを持つリポジトリオブジェクトインターフェースまたはスタブを構築していたでしょう。今では、必要な最小限のもの、つまり実装について何も仮定せずにオブジェクトをロードするための関数定義を構築する傾向があります。それは、その関数にバインドするときに後で来ることができます。
test/restaurantRatings/topRated.spec.ts…
const restaurantsById = new Map<string, any>([
["restaurant1", { restaurantId: "restaurant1", name: "Restaurant 1" }],
["restaurant2", { restaurantId: "restaurant2", name: "Restaurant 2" }],
]);
const getRestaurantByIdStub = (id: string) => { ➀
return restaurantsById.get(id);
};
//SNIP...
const dependencies = {
getRestaurantById: getRestaurantByIdStub, ➁
findRatingsByRestaurant: findRatingsByRestaurantStub,
calculateRatingForRestaurant: calculateRatingForRestaurantStub,
};
const getTopRated = topRated.create(dependencies);
const topRestaurants = await getTopRated("vancouverbc");
expect(topRestaurants.length).toEqual(2);
expect(topRestaurants[0].id).toEqual("restaurant1");
expect(topRestaurants[0].name).toEqual("Restaurant 1"); ➂
expect(topRestaurants[1].id).toEqual("restaurant2");
expect(topRestaurants[1].name).toEqual("Restaurant 2");
ドメインレベルのテストでは、以下を導入しました。
- `Restaurant`のスタブ化されたファインダー
- そのファインダーの依存関係へのエントリ
- 名前が`Restaurant`オブジェクトからロードされたものと一致することを検証します。
データをロードする以前の関数と同様に、`getRestaurantById`は`Promise`でラップされた値を返します。関数をどのように実装するかわからないふりをするという小さなゲームを続けていますが、`Restaurant`は外部データソースから来ていることがわかっているので、非同期にロードしたいと思います。そのため、マッピングコードはより複雑になります。
src/restaurantRatings/topRated.ts…
const getTopRestaurants = async (city: string): Promise<Restaurant[]> => {
const {
findRatingsByRestaurant,
calculateRatingForRestaurant,
getRestaurantById,
} = dependencies;
const toRestaurant = async (r: OverallRating) => { ➀
const restaurant = await getRestaurantById(r.restaurantId);
return {
id: r.restaurantId,
name: restaurant.name,
};
};
const ratingsByRestaurant = await findRatingsByRestaurant(city);
const overallRatings = calculateRatings(
ratingsByRestaurant,
calculateRatingForRestaurant,
);
return Promise.all( ➁
sortByOverallRating(overallRatings).map(r => {
return toRestaurant(r);
}),
);
};
- 複雑さは、`toRestaurant`が非同期であるという事実から生じます
- `Promise.all()`を使用して呼び出しコードで簡単に処理できます。
これらのリクエストのそれぞれをブロックしたくありません。そうしないと、IOバウンドのロードが順番に実行され、ユーザーリクエスト全体が遅延します。しかし、すべてのルックアップが完了するまでブロックする必要があります。幸いなことに、Promiseライブラリは、Promiseのコレクションをコレクションを含む単一のPromiseに折りたたむために`Promise.all`を提供しています。
この変更により、レストランを検索するリクエストは並行して送信されます。同時リクエストの数が少ないため、上位10リストには問題ありません。ある程度の規模のアプリケーションでは、データベース結合を介して`name`フィールドをロードし、余分な呼び出しを排除するようにサービスコールを再構築する可能性があります。たとえば、外部APIをクエリしている場合など、そのオプションが利用できない場合は、手動でバッチ処理するか、Tiny Async Poolなどのサードパーティライブラリによって提供される非同期プールを使用して同時実行性を管理することをお勧めします。
ここでも、すべてがコンパイルされるようにダミー実装でアセンブリモジュールを更新し、残りのコントラクトを満たすコードを開始します。
src/restaurantRatings/index.ts…
export const init = (
express: Express,
factories: Factories = productionFactories,
) => {
const topRatedDependencies = {
findRatingsByRestaurant: () => {
throw "NYI";
},
calculateRatingForRestaurant: () => {
throw "NYI";
},
getRestaurantById: () => {
throw "NYI";
},
};
const getTopRestaurants = factories.topRatedCreate(topRatedDependencies);
const handler = factories.handlerCreate({
getTopRestaurants,
});
express.get("/:city/restaurants/recommended", handler);
};
最後の仕上げ:ドメイン層の依存関係の実装
コントローラーとメインドメインモジュールのワークフローが整ったので、依存関係、つまりデータベースアクセス層と加重評価アルゴリズムを実装します。
これは、以下の一連の高レベル関数と依存関係につながります
テストの場合、スタブの以下の配置があります
テストの場合、すべての要素はテストコードによって作成されますが、煩雑になるため、図には示していません。
これらのモジュールを実装するプロセスは、同じパターンに従います
- 基本的な設計と`Dependencies`型(必要な場合)を推進するテストを実装します
- テストに合格させるモジュールの基本的な論理フローを構築します
- モジュールの依存関係を実装します
- 繰り返します。
プロセスをすでに説明しているので、プロセス全体をもう一度説明することはありません。エンドツーエンドで動作するモジュールのコードは、リポジトリで入手できます。最終実装のいくつかの側面では、追加の解説が必要です。
これまでのところ、評価アルゴリズムは、部分的に適用された関数として実装された別のファクトリを介して利用可能になることが期待されるかもしれません。今回は、代わりに純粋な関数を記述することを選択しました。
src/restaurantRatings/ratingsAlgorithm.ts…
interface RestaurantRating {
rating: Rating;
ratedByUser: User;
}
interface User {
id: string;
isTrusted: boolean;
}
interface RatingsByRestaurant {
restaurantId: string;
ratings: RestaurantRating[];
}
export const calculateRatingForRestaurant = (
ratings: RatingsByRestaurant,
): number => {
const trustedMultiplier = (curr: RestaurantRating) =>
curr.ratedByUser.isTrusted ? 4 : 1;
return ratings.ratings.reduce((prev, curr) => {
return prev + rating[curr.rating] * trustedMultiplier(curr);
}, 0);
};
これは、常にシンプルでステートレスな計算であることを示すために選択しました。より複雑な実装、たとえばユーザーごとにパラメーター化されたデータサイエンスモデルによって支えられるものへの簡単な道筋を残したかった場合は、ファクトリパターンを再度使用したでしょう。多くの場合、正しい答えも間違った答えもありません。設計の選択は、いわば、ソフトウェアがどのように進化すると予想されるかを示す軌跡を提供します。方向に自信のない領域により柔軟性を持たせながら、変更すべきではない領域により厳密なコードを作成します。
私が「軌跡を残す」もう1つの例は、`ratingsAlgorithm.ts`で別の`RestaurantRating`型を定義するという決定です。この型は、`topRated.ts`で定義された`RestaurantRating`とまったく同じです。ここで別のパスを選択できます
- `topRated.ts`から`RestaurantRating`をエクスポートし、`ratingsAlgorithm.ts`で直接参照するか、
- `RestaurantRating`を共通モジュールに抽出します。多くの場合、`types.ts`と呼ばれるモジュールに共有定義が表示されますが、`domain.ts`のような、そこに含まれる型の種類に関するヒントを与える、よりコンテキストに合った名前を好みます。
この場合、これらの型が文字通り同じであるとは確信していません。これらは、異なるフィールドを持つ同じドメインエンティティの異なる投影である可能性があり、モジュール境界全体でそれらを共有して、より深い結合のリスクを負いたくありません。これは直感に反するように見えるかもしれませんが、正しい選択だと思います。この時点でエンティティを折りたたむのは非常に安価で簡単です。それらが分岐し始めたら、とにかくそれらをマージすべきではないでしょうが、バインドされた後にそれらを分離するのは非常に難しい場合があります。
アヒルのように見えるなら
型をエクスポートしないことが多い理由を説明すると約束しました。あるモジュールで定義された型を別のモジュールで利用できるようにするのは、そうすることで偶発的な結合が生じず、コードの進化を阻害しないと確信できる場合のみに限定したいと考えています。幸いなことに、TypeScriptの構造的部分型(ダックタイピング)のおかげで、型を共有しなくても、コンパイル時に契約が守られていることを保証しながら、モジュールの結合を疎に保つことが非常に容易になります。呼び出し側と呼び出し先の型に互換性がある限り、コードはコンパイルされます。
JavaやC#のような、より厳格な言語では、プロセスの初期段階でいくつかの決定を強制されます。たとえば、評価アルゴリズムを実装する場合、異なるアプローチを取らざるを得ません。
RestaurantRating型を抽出して、アルゴリズムを含むモジュールと、全体的なトップ評価ワークフローを含むモジュールの両方で利用できるようにすることもできます。しかし、その欠点は、他の関数もこの型に依存する可能性があり、モジュールの結合度が高まることです。- あるいは、2つの異なる
RestaurantRating型を作成し、これら2つの同一の型を変換するためのアダプター関数を提供することもできます。これは問題ありませんが、コンパイラが既に知っていることを伝えるためだけに、テンプレートコードの量が増えてしまいます。 - アルゴリズムを
topRatedモジュールに完全に組み込むこともできますが、そうすると、このモジュールに望ましい以上の責任を与えることになります。
言語の厳格さは、このようなアプローチでは、よりコストのかかるトレードオフを意味する可能性があります。依存性注入とサービスロケーターパターンに関する2004年の記事で、Martin Fowlerは、構造的部分型や第一級関数がなくても、Javaにおける依存関係の結合を減らすために役割インターフェースを使用することについて述べています。Javaで作業する場合、私は間違いなくこのアプローチを検討するでしょう。
このプロジェクトを他のいくつかの静的型付け言語に移植して、このパターンが他のコンテキストでどの程度うまく適用されるかを確認する予定です。KotlinとGoに移植したところ、このパターンが適用される兆候がありますが、いくつかの調整が必要となります。また、どのような調整が最良の結果をもたらすかをより深く理解するためには、各言語に複数回移植する必要があるかもしれません。私が行った選択と結果についての私の考えは、それぞれのレポジトリに文書化されています。
まとめ
クラスではなく関数で依存関係の契約を満たし、モジュール間のコード共有を最小限に抑え、テストを通じて設計を推進することで、高度に個別化され、進化可能でありながら、型安全なモジュールで構成されるシステムを作成できます。次のプロジェクトで同様の優先順位がある場合は、私が概説したアプローチのいくつかの側面を採用することを検討してください。ただし、プロジェクトの基本的なアプローチを選択することは、単に「ベストプラクティス」を選択するほど単純ではなく、技術スタックのイディオムやチームのスキルなど、他の要素を考慮する必要があることに注意してください。システムを構築する方法はたくさんあり、それぞれに複雑なトレードオフがあります。だからこそ、ソフトウェアアーキテクチャはしばしば難しく、常に魅力的なのです。私は他の方法では満足できません。
主な改訂
2023年7月3日: サンプルプロジェクトのGoとKotlinポートへの参照を追加
2023年5月23日: 公開

