Effectでサービス間の依存関係をコントロールする
アプリケーションの開発において、複雑な依存関係や異なるコンポーネント間の強い結びつきはテストの難しさやコードの保守性の低下を引き起こす主な原因の一つです。EffectライブラリのContextとLayerいう仕組みは、コンポーネント間を疎結合に保ちつつ、ポリモーフィズムを利用した柔軟なコード設計を可能にし課題を解決する助けとなります。本記事ではEffectライブラリの中でも、モジュールの依存関係をコントロールするこれらの機能について紹介します。
実行環境
- Node.js v20.11.1
- effect 3.2.5
- TypeScript 5.4.5
用語
サービス
Effectライブラリでは、アプリケーションのさまざまな場所で再利用できると規定の機能が凝集されたコンポーネントのことをサービスと呼んでいる。このときサービスは実装が隠蔽され、Effect内の計算定義では、インタフェースを介してアクセスすることができます。Effect型はEffect<A, E, R>
と表現されますが、三番目の型パラメータR
(Requirement)はそのEffectが依存するサービスのインタフェースを表しています。
Context
Context
はサービスの実装を管理するためのコンテナオブジェクトです。Effect型はEffect<A, E, R>
と表現されますが、型パラメータR
はこのEffectが要求するサービスを表しています。何かしらのサービスR
に依存するEffectは、そのサービスの実装を管理するContext<R>
を提供することによって動かすことができます。
Layer
サービスの中には、別のサービスを再利用したりインフラストラクチャに相当する部分を差し替え可能にしたサービスを作りたいといったことがあります。たとえば、サーバサイドJavaScriptにおいてHTTPサーバといったWebアプリケーション開発に利用してもらうためのサービスを作ろうとしたとき、ランタイムとしてNode.js、Deno、Bunなどユーザの好きなものを選んでもらいつつ、ランタイムが違っていてもHTTPサーバサービスの使い方に差異をもたせないように実装したい、といったケースです。このような別のサービスを再利用したサービスを実装するための手段がLayer
です。
Contextの使い方
サービスのインタフェースを定義する
type
やinterface
を使ってサービスのインタフェースを定義します。ここでは例として外部のデータソースから特定のユーザ情報を取得するサービスを定義します。
import { Effect } from 'effect'
type User = {
id: string
name: string
}
// IDを指定して特定のユーザの情報を取得するEffect
// アクセス失敗時にはErrorが発生する
type FetchUser = (id: string) => Effect.Effect<User, Error>
Contextを作成する
Context
はサービスの実装を管理するためのコンテナとなります。サービスに依存したEffectはContext
から実装を取り出すのですが、この取り出し時のキーとなるタグオブジェクトを生成します。
次にサービスの実装と紐づくタグオブジェクトを作成します。
import { Context } from 'effect'
const FetchUser = Context.GenericTag<FetchUser>('FetchUser')
なお、公式ドキュメントはクラスオブジェクトを生成するパターンで説明されています。機能に差異はないので書き味が好みの方を使えばよいと思います。
class FetchUser extends Context.Tag('FetchUser')<
FetchUser, // サービスのインタフェースではなくクラス名
(userId: string) => Effect.Effect<User, Error>
>() {
}
キーであるタグオブジェクトが出来上がったので、これとサービスの実装を使ってContext
を作ります。
import { Context } from 'effect'
const FetchUserContext: Context.Context<FetchUser> = Context.make(
FetchUser,
(id: string) => {
// 本来はREST APIやDBからユーザ情報を取得することが多いと思いますが、
// この記事の本質とは関係ないので簡易な実装にしています。
const user: User = { id, name: 'Alice' }
return Effect.succeed(user)
})
サービスを利用するEffectを実装する
FetchUser
を利用するEffectを作成する。
import { Effect } from 'effect'
const program: Effect.Effect<void, Error, FetchUser> = Effect.gen(function* (_) {
// タグオブジェクトを介してContextからサービスの実装を取り出す
const fetchUserService = yield* _(FetchUser)
// ユーザ情報を取得する
const user = yield* _(fetchUserService('USER000001'))
yield* _(Console.log(`ID: ${user.id}, Name: ${user.name}`))
})
const fetchUserService = yield* _(FetchUser)
が最も重要なところです。実はタグオブジェクトもEffectのサブタイプとなっています。タグオブジェクトはContextからタグをキーとしてサービスの実装を取り出すEffectとも言えます。
また、タグオブジェクトを介してFetchUser
サービスにアクセスしているため、このEffectはFetchUser
サービスの実装についての知識は持っていません。すなわちこのEffectからはFetchUser
サービスの実装が隠蔽されています。
コード上は実装したEffectの型定義を明示していますが、Effectが依存しているサービスはEffectの内容からTypeScriptにて推論してくれます。
EffectにContextを注入する
前節で実装したEffectをそのまま動かそうとするようなコードを書くとコンパイルエラーが発生します。
import { Effect } from 'effect'
Effect.runFork(program)
// Error: Service not found: Symbol(FetchUser)
エラーメッセージにある通り、このEffectが依存しているFetchUser
サービスの依存関係が解決できないためです。このEffectに先に作成したContext
をEffect.provide
を使って注入することによって動かせるようになります。
import { Effect } from 'effect'
// 第三型パラメータがneverになっているため、依存関係が解決されていることがわかる
const runnable: Effect.Effect<void, Error, never> = Effect.provide(program, FetchUserContext)
Effect.runFork(runnable)
// > ID: USER000001, Name: Alice
Layerの使い方
さきほどのFetchUser
サービスの例を拡張して説明します。FetchUser
サービスに対して以下のような要件が発生したとします。
- ユーザ情報はデータベースから取得する
- データベースエンジンではPostgreSQL、もしくはMySQLの両方を使用できるものとする
この要件を達成するため、データベースアクセスを担うサービスDatabase
のインタフェースを定義し、PostgreSQL用の実装とMySQL用の実装を用意する。そしてFetchUser
サービスはDatabase
サービスを使って実装することにします。
Layer型
Layer
は別のサービスを使って新しいサービスを実装するために使用する、すなわちサービスの生成の役割を担うものです。Layer
型は3つの型パラメータを持っています。
interface Layer<in ROut, out E, out RIn>
このインタフェースは、「RIn
サービスを使ってROut
サービスを生成する、ただし生成時にエラーE
が発生する可能性がある、ということを表しています。
Layer
はContext
と同様、Effectに注入することができます。
// サービスを利用するEffectを定義する
const effect: Effect.Effect<void, Error, FetchUser> = Effect.gen(function* () {
// TagオブジェクトもEffectの一種
const fetchUser = yield* FetchUser // Tagオブジェクトも実はEffectの一種なので、yield*したりEffect.mapで使ったりできる
const user = yield* fetchUser('001')
// userを使って何かしらの処理をする...
})
declare const FetchUserLive: Layer.Layer<FetchUser, never, never>
// 第三型パラメータがneverになっているため、依存関係が解決されていることがわかる
const runnable: Effect.Effect<void, Error, never> = Effect.provide(effect, FetchUserLive)
どうして生成を担うものをContext
と同じように注入することができるのか不思議に思うかもしれませんが、実はLayer
は「Context
を生成するEffect」と可逆の関係にあるためです。Effectを実行するときに、Layer
をContext
を生成するEffectに変換してこのEffectを実行しContext
を得て元のEffectを実行することによりサービスの依存関係が解決できます。(詳細な実装まで把握しているわけではないのであくまでイメージです)
declare const FetchUserLive: Layer.Layer<FetchUser, never, never>
// Layer -> Contextを生成するEffect
const constructServiceEffect: Effect.Effect<Context.Context<FetchUser>> =
Layer.build(FetchUserLive).pipe(Effect.scoped)
// Contextを生成するEffect -> Layer
const FetchUserLiveAlt: Layer.Layer<FetchUser, never, never> = Layer.effectContext(constructServiceEffect)
続いて、別のサービスに依存するサービスをどのようにレイヤーで扱うかですが、Database
サービスを使ったFetchUser
サービスの型は次のように表現できます。
type Database = {
select: (query: string) => Effect.Effect<Record<string, string | number | boolean>[], Error>
}
const Database = GenericTag<Database>('Database')
declare const DatabaseLive: Layer.Layer<Database, never, never>
declare const FetchUserLive: Layer.Layer<FetchUser, never, Database>
このFetchUser
サービスを先ほどと同様に注入してやります。先ほどと違って今回注入したFetchUser
サービスはDatabase
サービスに依存しているので、注入した結果出来上がったEffectもDatabase
サービスを必要とするEffectとなっています。
const effect: Effect.Effect<void, Error, FetchUser> = Effect.gen(function* () { /* 再掲なので中略.. */})
// FetchUserサービスを必要とするEffectに、Databaseサービスに依存するFetchUserサービスを注入すると、Databaseサービスを必要とするEffectに変化した
const injectedEffect: Effect.Effect<void, Error, Database> = Effect.provide(effect, FetchUserLive)
// Databaseサービスを注入してやれば実行可能なEffectになる
const runnableEffect: Effect.Effect<void, Error, never> = Effect.provide(injectedEffect, DatabaseLive)
サービスを段階的に注入する以外にも、Layer自体に依存関係を注入することもできます。
const injectedLayer: Layer.Layer<FetchUser, never, never> = Layer.provide(FetchUserLive, DatabaseLive)
const runnableEffect: Effect.Effect<void, Error, never> = Effect.provide(effect, injectedLayer)
Layerを作る
まずはDatabase
のLayer
を作りましょう。Database
サービスは別のサービスに依存しません。ですのでLayer
ではなくContext
として実装することが可能ですが、後ほど作るFetchUser
サービスのLayer
に注入することを考えるとLayer
として実装しておきたいところです。こういったときのためにEffectライブラリではContext
からLayer
を作るLayer.succeedContext
という関数が提供されています。
type Database = {
select: (query: string) => Effect.Effect<Record<string, string | number | boolean>[], Error>
}
// タグオブジェクト
const Database = GenericTag<Database>('Database')
// PostgreSQLを使用したDatabaseサービスからContextを作る。MySQL版も同じように作ればよい
const pgDatabaseContext: Context.Context<Database> = Context.make(
Database,
{
select: (query: string) => {
// 擬似コード、Postgres.jsを使うイメージ
const sql = postgres({ /* config */})
return Effect.tryPromise({
try: () => sql`${query}`,
catch: () => new Error('Database Error')
})
}
}
)
// ContextをLayerに変換する
const PgDatabaseLive: Layer.Layer<Database, never, never> = Layer.succeedContext(pgDatabaseContext)
続いてDatabase
サービスに依存するFetchUser
サービスのLayer
を作りましょう。Layer
はサービスの生成に関心を持つため、FetchUser
サービスを生成するEffectを実装すればLayer
を作ることができます。そのようなLayer
を作るための関数はいくつか用意されているのですが、サービスを生成するEffectからLayer
を作るLayer.effect
という関数があるので今回はそちらを利用します。
// FetchUserサービスを生成するEffect
const serviceGenerationEffect: Effect.Effect<FetchUser, never, Database> = Effect.gen(function* () {
const database = yield* Database
const fetchUserService =
(userId: string) => Effect.gen(function* () {
const users = yield* database.select(`SELECT * FROM users WHERE id = ${userId}`)
return users[0] as unknown as User
})
return fetchUserService
})
const FetchUserLive: Layer.Layer<FetchUser, never, Database> = Layer.effect(FetchUser, serviceGenerationEffect)
作成したLayer
を使ってEffectの依存関係を解決するとEffectを実行できるようになります。
const FetchUserLive: Layer.Layer<FetchUser, never, Database> = Layer.effect(FetchUser, serviceGenerationEffect)
const runnable = pipe(
effect,
Effect.provide(FetchUserLive),
// PostgreSQLを使うかMySQLを使うかはこちらで選べばよい
Effect.provide(PgDatabaseLive)
)
await Effect.runPromise(runnable)
このようにContext
やLayer
を使うことによって、サービス間の依存関係を抽象表現であるインタフェースに依存を集中させることができたり、サービスの機能と生成についてのロジックを分離することが可能となります。
終わりに
モジュール、サービス間のソースコードの依存関係をコントロールすることは、保守性の高いソフトウェアを開発する上で重要なテーマです。保守性の高さを測るための安定依存の原則や開放閉鎖の原則、そういった原則に則った実装を行うための方法論である依存関係逆転、生成についての関心を分離すためのAbstract FactoryやBuilderといったデザインパターン。Effectライブラリではそういった知識を背景にしてメンテナンスがしやすく全体を見通しやすいソフトウェアを実装するための機能も備わっています。
今回紹介した機能は依存関係を管理するものの一部です。他にも有用な機能やテクニックがあるので機会があれば別の記事で紹介したいと思います。
Discussion