コンポーザブルサービスでバックエンドを構築する
Composable Systemsについて
整然としたバックエンドコードの中で作業するのは、レゴブロックで遊ぶようなものですね。多くのバックエンド開発者は、慣例に則ったフレームワーク(SpringやRuby on Rails、NestJSなど)が好きです。なぜかというと、コードをシンプルなコンポーネントに分割しやすくなるからです。
この投稿では、コンポーザブルシステムの本質について考察し、それを意見を持たないExpressJSプロジェクトに応用してみます。
コンポーザブルシステムを構築するためには、各コンポーネントはシンプルで明確な境界を持つべきです。コンポーネントの組成性を損なう主な障壁は2つあります:
グローバル & トップレベル変数
グローバル変数やモジュールレベルの変数は、コンポーネント間の境界をぼやけさせます。環境変数がこのパターンの良い例です。どこでもprocess.env
にアクセスするのは簡単なので、あまり深く考えずに使いがちです。これにより、どの関数がどの環境変数に依存しているのかを確認するのが難しくなります。言い換えれば、関数の依存関係が不明瞭になります。
大きなインターフェース
時には、インターフェースが非常に明確だけど大きすぎることがあります。これは依存関係が不明瞭なのと同じくらい悪いことです。なぜなら、大きなインターフェースを持つコンポーネントを交換するのが難しいからです。ORM(オブジェクトリレーショナルマッピング)やデータベースアダプターがこのパターンの良い例です。
ORMを使用すると、開発者はクリーンなインターフェースを通じてデータベースにアクセスできますが、ほとんどのORMは非常に大きなインターフェースを持っています。別のコンポーネントにORMを交換することはほぼ不可能です。
よりシンプルな構造へのリファクタリング
さて、問題を特定しましたが、どうやって修正するのでしょうか?いくつかの例とリファクタリング技術を見てみましょう。
グローバル & トップレベル変数
例
環境変数はしばしばこのように使用されます:
// env.ts
import dotenv from "dotenv";
dotenv.config();
export const SESSION_SECRET = process.env.SESSION_SECRET;
export const DATABASE_URI = process.env.DATABASE_URI;
// 他のファイル
import { DATABASE_URI } from "./env";
mongoose.connect(DATABASE_URI);
この例では、トップレベルの変数が前述のようにコンポーネント間の境界をぼやけさせています。
リファクタリング
トップレベル変数としてDATABASE_URI
を公開するのではなく、関数スコープ内にそれを置くことができます。そのラッパー関数をEnvService
と呼ぶことにしましょう。
// env.ts
import dotenv from "dotenv";
export const EnvService = () => {
dotenv.config();
return {
SESSION_SECRET: process.env.SESSION_SECRET,
DATABASE_URI: process.env.DATABASE_URI,
};
};
export type EnvService = ReturnType<typeof EnvService>;
データベースが環境変数に依存していることを表現するために、データベースに関するラッパー関数も書くことができます。
import { EnvService } from "./env";
export const DatabaseService = (env: EnvService) => {
mongoose.connect(env.DATABASE_URI);
// ...
};
これで、データベースが環境変数に依存していることが超明確になりました。もうグローバル変数は必要ありません。
大きなインターフェース
では、大きなインターフェースはどうでしょうか?
例
リクエストハンドラの中で、直接ORMクラスを使用することが一般的です。
app.get("/user/:id", async (req, res, next) => {
try {
const user = await UserModel.findById(req.params.id);
// こういうやつです ^
res.send({ user });
} catch (err) {
next(err);
}
});
この実装には問題が2つあります。
UserModel
はトップレベルのクラスです。"/user/:id"ハンドラはUserModel
に依存していますが、この依存関係は明示的ではありません。UserModel
は大きなインターフェースを持っています。ほとんどのORMライブラリは膨大なインターフェースを公開しています。これは避けられないことです。ORMはさまざまなデータベースの機能と設定をサポートする必要があるからです。
要するに、リクエストハンドラはUserModel
と密結合しており、それらをクリーンに分割するシンプルな方法はありません。
リファクタリング
依存関係を明示的にするために同じアプローチを試みることができます。ORMから始めましょう。
// user.ts
export const UserService = () => {
return {
getById: (userId: string) => UserModel.findById(userId),
};
};
export type UserService = ReturnType<typeof UserService>;
// 型: { getById: (userId: string) => Promise<User> }
複雑なトップレベルのUserModel
をUserService
関数にラップしました。UserService
はUserModel
に比べてはるかに小さいインターフェースを持っています。
リクエストハンドラとUserService
の依存関係を表現する時が来ました。
まず、ハンドラを別の関数に抽出します。
const userHandler: RequestHandler = async (req, res, next) => {
try {
const user = await UserModel.findById(req.params.id);
res.send({ user });
} catch (err) {
next(err);
}
};
app.get("/user/:id", userHandler);
次に、リクエストハンドラを関数でラップし、依存関係を表現します。
const UserHandler = (userService: UserService): RequestHandler => async (req, res, next) => {
try {
const user = await userService.getById(req.params.id);
res.send({ user });
} catch (err) {
next(err);
}
};
app.get("/user/:id", UserHandler(userService));
素晴らしい、リクエストハンドラとサービス間のクリーンなインターフェースができました!
最終的な仕上げ
トップレベルの変数をラップし、依存関係をパラメータとして表現することは、コンポーネント間の境界を単純化する絶好の方法です(この技術は「制御の反転」と呼ばれます)。
しかし、気づかれたかもしれませんが、依存関係のパラメータとしてリクエストハンドラに必要なサービスを提供する方法はまだ持っていません。
app.get("/user/:id", UserHandler(userService));
// この`userService`はどこから来るのでしょう?
このために、メタサービスを作成することができます。他のサービスを提供するサービスです。
import { EnvService } from "./env";
import { SessionService } from "./session";
import { UserService } from "./user";
type ServiceMap = {
user: UserService;
env: EnvService;
session: SessionService;
};
// メタサービス
export const ServiceProvider = () => {
// サービスを初期化
const env = EnvService();
// サービスは別のサービスに依存できます。
const user = UserService(env);
// あるいは複数のサービスに。
const session = SessionService(env, user);
const serviceMap: ServiceMap = {
user,
env,
session,
};
/**
* サービス名でサービスを取得する。
*/
const getService = <TServiceName extends keyof ServiceMap>(
serviceName: TServiceName
) => serviceMap[serviceName];
return getService;
};
export type ServiceProvider = ReturnType<typeof ServiceProvider>;
このメタサービスは次のように使用できます:
const service = ServiceProvider();
const env = service("env"); // EnvService
アプリがServiceProvider
に依存していることを表現しましょう:
// app.ts
import express from "express";
import { ServiceProvider } from "./service";
import { UserHandler } from "./handlers";
export const App = (service: ServiceProvider) => {
const app = express();
app.get("/user/:id", UserHandler(
// `ServiceProvider`で必要なサービスを提供します。
service("user"),
));
return app;
};
最後にServiceProvider
を初期化して、App
に渡します!
// index.ts
import { App } from "./app";
import { ServiceProvider } from "./service";
const service = ServiceProvider();
const app = App(service);
app.listen(service("env").PORT);
このパターンの良い点は何ですか?
機能をサービスにまとめることで、考えるのが非常にストレートフォワードなプロジェクト構造を得られます。
- インターフェースが小さくて明確な場合、サービスの実装を変更するのが簡単です。FirestoreをMongoDBに交換するのですか?データベースと直接やり取りするサービスだけが影響を受けます。リクエストハンドラが直接ORMクラスを使用している場合は同じことが言えません。もちろん毎週データベースを変更することはないですが、変更が簡単になるということがポイントです。
- テストがとても簡単になります。これは最初のポイントに関連しています。なぜなら、テスト時にはサービスをモックに置き換えるからです。コンポーネントが小さくて明確なインターフェースを持っていれば、テストダブル(モック、フェイク、スタブとも呼ばれます)を作成するのが簡単になります。さらに、制御の反転により、モックにするコンポーネントをチェックするのが労力なく行えます。すべての依存関係がパラメータとして表現されるので、それらのパラメータをチェックするだけです。
まとめ
こちらが、我々が使用したリファクタリング技術の要約です。
- トップレベルの変数や関数をサービス関数にラップする。そのサービス関数のインターフェースは小さいべきです。
- リクエストハンドラを別々の関数として抽出する。
- 依存関係をパラメータとして表現する(制御の反転)。
- メタサービス(
ServiceProvider
)を作成する。 ServiceProvider
を通じてサービスをリクエストハンドラに提供する。
このパターンを実際に動かしてみたい場合は、私の個人プロジェクトをチェックしてみてください:
- サービス関数
- リクエストハンドラ
- ServiceProvider
- テストコード
こちらの記事はdev.toの良い記事を日本人向けに翻訳しています。
https://dev.to/0916dhkim/building-backend-with-composable-services-ijb