💉

Effectでサービス間の依存関係をコントロールする

2024/05/28に公開

アプリケーションの開発において、複雑な依存関係や異なるコンポーネント間の強い結びつきはテストの難しさやコードの保守性の低下を引き起こす主な原因の一つです。EffectライブラリのContextLayerいう仕組みは、コンポーネント間を疎結合に保ちつつ、ポリモーフィズムを利用した柔軟なコード設計を可能にし課題を解決する助けとなります。本記事では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の使い方

サービスのインタフェースを定義する

typeinterfaceを使ってサービスのインタフェースを定義します。ここでは例として外部のデータソースから特定のユーザ情報を取得するサービスを定義します。

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に先に作成したContextEffect.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サービスに対して以下のような要件が発生したとします。

  1. ユーザ情報はデータベースから取得する
  2. データベースエンジンではPostgreSQL、もしくはMySQLの両方を使用できるものとする

この要件を達成するため、データベースアクセスを担うサービスDatabaseのインタフェースを定義し、PostgreSQL用の実装とMySQL用の実装を用意する。そしてFetchUserサービスはDatabaseサービスを使って実装することにします。

Layer型

Layerは別のサービスを使って新しいサービスを実装するために使用する、すなわちサービスの生成の役割を担うものです。Layer型は3つの型パラメータを持っています。

interface Layer<in ROut, out E, out RIn>

このインタフェースは、「RInサービスを使ってROutサービスを生成する、ただし生成時にエラーEが発生する可能性がある、ということを表しています。

LayerContextと同様、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を実行するときに、LayerContextを生成する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を作る

まずはDatabaseLayerを作りましょう。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)

このようにContextLayerを使うことによって、サービス間の依存関係を抽象表現であるインタフェースに依存を集中させることができたり、サービスの機能と生成についてのロジックを分離することが可能となります。

終わりに

モジュール、サービス間のソースコードの依存関係をコントロールすることは、保守性の高いソフトウェアを開発する上で重要なテーマです。保守性の高さを測るための安定依存の原則や開放閉鎖の原則、そういった原則に則った実装を行うための方法論である依存関係逆転、生成についての関心を分離すためのAbstract FactoryやBuilderといったデザインパターン。Effectライブラリではそういった知識を背景にしてメンテナンスがしやすく全体を見通しやすいソフトウェアを実装するための機能も備わっています。

今回紹介した機能は依存関係を管理するものの一部です。他にも有用な機能やテクニックがあるので機会があれば別の記事で紹介したいと思います。

参考

Managing Services - Effect
Managing Layer - Effect

Discussion