已发布 上游基线 bf46254 原文 ↗ 在 GitHub 编辑

管理 Layer

学习如何在 Effect 中使用 Layer 管理服务依赖,为应用构建高效、清晰的依赖图。

管理服务页面中,你学习了如何创建依赖某个服务才能执行的 effect,以及如何为该 effect 提供这个服务。

然而,如果 effect 程序中的某个服务在构建时依赖其他服务,该怎么办?我们希望避免把这些实现细节泄漏到服务接口中。

为了表示程序的“依赖图”并更有效地管理这些依赖,我们可以使用一个强大的抽象,称为 “Layer”。

Layer 充当创建服务的构造器,让我们能够在构造期间而非服务层面管理依赖。这种方式有助于保持服务接口的简洁与专注。

在深入细节之前,让我们先回顾一些关键概念:

概念说明
服务可复用的组件,提供特定功能,在应用的不同部分被使用。
tag代表某个服务的唯一标识符,让 Effect 能够定位并使用它。
context存储服务的集合,作用类似于一个以 tag 为键、以服务为值的 map。
layer用于构造服务的抽象,在构造期间而非服务层面管理依赖。

设计依赖图

假设我们正在构建一个 Web 应用。可以想象,对于需要管理配置、日志和数据库访问的应用,其依赖图大致如下:

  • Config 服务提供应用配置。
  • Logger 服务依赖 Config 服务。
  • Database 服务同时依赖 ConfigLogger 服务。

我们的目标是构建 Database 服务及其直接与间接依赖。这意味着需要确保 Config 服务对 LoggerDatabase 都可用,然后把这些依赖提供给 Database 服务。

避免需求泄漏

在构造 Database 服务时,重要的是避免在 Database 接口中暴露对 ConfigLogger 的依赖。

你可能会想按如下方式定义 Database 服务:

示例(在服务接口中泄漏依赖)

import { Effect, Context } from "effect"

// Declaring a tag for the Config service
class Config extends Context.Tag("Config")<Config, {}>() {}

// Declaring a tag for the Logger service
class Logger extends Context.Tag("Logger")<Logger, {}>() {}

// Declaring a tag for the Database service
class Database extends Context.Tag("Database")<
  Database,
  {
    // ❌ Avoid exposing Config and Logger as a requirement
    readonly query: (
      sql: string,
    ) => Effect.Effect<unknown, never, Config | Logger>
  }
>() {}

这里,Database 服务的 query 函数同时需要 ConfigLogger。这种设计泄漏了实现细节,使 Database 服务意识到自己的依赖,从而让测试变得复杂、难以 mock。

Keep Service Interfaces Simple

服务函数应避免直接声明依赖。在实践中,服务的操作应把 Requirements 参数设置为 never

                         ┌─── No dependencies required

Effect<Success, Error, never>

为演示这一问题,我们来创建一个 Database 服务的测试实例:

示例(创建带有泄漏依赖的测试实例)

import { Effect, Context } from "effect"

// Declaring a tag for the Config service
class Config extends Context.Tag("Config")<Config, {}>() {}

// Declaring a tag for the Logger service
class Logger extends Context.Tag("Logger")<Logger, {}>() {}

// Declaring a tag for the Database service
class Database extends Context.Tag("Database")<
  Database,
  {
    readonly query: (
      sql: string,
    ) => Effect.Effect<unknown, never, Config | Logger>
  }
>() {}

// Declaring a test instance of the Database service
const DatabaseTest = Database.of({
  // Simulating a simple response
  query: (sql: string) => Effect.succeed([]),
})

import * as assert from "node:assert"

// A test that uses the Database service
const test = Effect.gen(function* () {
  const database = yield* Database
  const result = yield* database.query("SELECT * FROM users")
  assert.deepStrictEqual(result, [])
})

//      ┌─── Effect<unknown, never, Config | Logger>
//      ▼
const incompleteTestSetup = test.pipe(
  // Attempt to provide only the Database service without Config and Logger
  Effect.provideService(Database, DatabaseTest),
)

由于 Database 服务接口直接包含对 ConfigLogger 的依赖,任何测试准备工作都被迫包含这些服务,即使它们与测试无关。这带来了不必要的复杂度,也让编写简单、隔离的单元测试变得困难。

与其把依赖直接绑定到 Database 服务接口上,不如在构造阶段管理依赖。

我们可以使用 Layer 来正确构造 Database 服务并管理其依赖,而不会把细节泄漏到接口中。

Use Layers for Dependencies

当服务自身带有需求时,最好把实现细节分离到 Layer 中。Layer 充当创建该服务的构造器, 让我们能够在构造层面而非服务层面处理依赖。

创建 Layer

Layer 类型的结构如下:

        ┌─── The service to be created
        │                ┌─── The possible error
        │                │      ┌─── The required dependencies
        ▼                ▼      ▼
Layer<RequirementsOut, Error, RequirementsIn>

Layer 表示构造 RequirementsOut(即服务)的蓝图。它以 RequirementsIn(依赖)作为输入,并可能在构造过程中产生 Error 类型的错误。

参数说明
RequirementsOut要创建的服务或资源。
Error构造服务时可能发生的错误类型。
RequirementsIn构造服务所需的依赖。

通过使用 Layer,你可以更好地组织服务,确保其依赖被清晰定义,并与实现细节分离。

为简单起见,我们假设在值构造过程中不会遇到任何错误(即 Error = never)。

现在,让我们确定实现依赖图需要多少个 Layer:

Layer依赖类型
ConfigLiveConfig 服务不依赖任何其他服务Layer<Config>
LoggerLiveLogger 服务依赖 Config 服务Layer<Logger, never, Config>
DatabaseLiveDatabase 服务依赖 ConfigLoggerLayer<Database, never, Config | Logger>
Naming Conventions

为某个服务的 Layer 命名时,一个常见约定是:为 “live” 实现添加 Live 后缀, 为 “test” 实现添加 Test 后缀。例如,对于 Database 服务, DatabaseLive 是你在应用中提供的 Layer,而 DatabaseTest 是你在测试中提供的 Layer。

当一个服务有多个依赖时,它们表示为联合类型。在我们的例子中,Database 服务同时依赖 ConfigLogger 服务。因此,DatabaseLive Layer 的类型为:

Layer<Database, never, Config | Logger>

Config

Config 服务不依赖任何其他服务,因此 ConfigLive 是最容易实现的 Layer。正如管理服务页面中那样,我们必须为该服务创建一个 tag。由于该服务没有依赖,我们可以直接使用 Layer.succeed 构造器创建 Layer:

import { Effect, Context, Layer } from "effect"

// Declaring a tag for the Config service
class Config extends Context.Tag("Config")<
  Config,
  {
    readonly getConfig: Effect.Effect<{
      readonly logLevel: string
      readonly connection: string
    }>
  }
>() {}

// Layer<Config, never, never>
const ConfigLive = Layer.succeed(
  Config,
  Config.of({
    getConfig: Effect.succeed({
      logLevel: "INFO",
      connection: "mysql://username:password@hostname:port/database_name",
    }),
  }),
)

观察 ConfigLive 的类型,我们可以发现:

  • RequirementsOutConfig,表明构造该 Layer 将产出 Config 服务
  • Errornever,表明 Layer 构造不会失败
  • RequirementsInnever,表明该 Layer 没有依赖

注意,为了构造 ConfigLive,我们使用了 Config.of 构造器。然而,这只是一个用于确保实现具有正确类型推断的辅助方法。 也可以跳过这个辅助方法,直接把实现构造成一个简单对象:

import { Effect, Context, Layer } from "effect"

// Declaring a tag for the Config service
class Config extends Context.Tag("Config")<
  Config,
  {
    readonly getConfig: Effect.Effect<{
      readonly logLevel: string
      readonly connection: string
    }>
  }
>() {}

// Layer<Config, never, never>
const ConfigLive = Layer.succeed(Config, {
  getConfig: Effect.succeed({
    logLevel: "INFO",
    connection: "mysql://username:password@hostname:port/database_name",
  }),
})

Logger

现在我们继续实现 Logger 服务,它依赖 Config 服务来获取一些配置。

正如我们在管理服务页面中所做的那样,我们可以 yield Config tag,从 Context 中“提取”该服务。

由于使用 Config tag 是一个会产生 effect 的操作,我们使用 Layer.effect 从得到的 effect 创建 Layer。

import { Effect, Context, Layer } from "effect"

// Declaring a tag for the Config service
class Config extends Context.Tag("Config")<
  Config,
  {
    readonly getConfig: Effect.Effect<{
      readonly logLevel: string
      readonly connection: string
    }>
  }
>() {}

// Layer<Config, never, never>
const ConfigLive = Layer.succeed(Config, {
  getConfig: Effect.succeed({
    logLevel: "INFO",
    connection: "mysql://username:password@hostname:port/database_name",
  }),
})

// Declaring a tag for the Logger service
class Logger extends Context.Tag("Logger")<
  Logger,
  { readonly log: (message: string) => Effect.Effect<void> }
>() {}

// Layer<Logger, never, Config>
const LoggerLive = Layer.effect(
  Logger,
  Effect.gen(function* () {
    const config = yield* Config
    return {
      log: (message) =>
        Effect.gen(function* () {
          const { logLevel } = yield* config.getConfig
          console.log(`[${logLevel}] ${message}`)
        }),
    }
  }),
)

观察 LoggerLive 的类型:

Layer<Logger, never, Config>

我们可以发现:

  • RequirementsOutLogger
  • Errornever,表明 Layer 构造不会失败
  • RequirementsInConfig,表明该 Layer 有一个需求

Database

最后,我们可以使用 ConfigLogger 服务来实现 Database 服务。

import { Effect, Context, Layer } from "effect"

// Declaring a tag for the Config service
class Config extends Context.Tag("Config")<
  Config,
  {
    readonly getConfig: Effect.Effect<{
      readonly logLevel: string
      readonly connection: string
    }>
  }
>() {}

// Layer<Config, never, never>
const ConfigLive = Layer.succeed(Config, {
  getConfig: Effect.succeed({
    logLevel: "INFO",
    connection: "mysql://username:password@hostname:port/database_name",
  }),
})

// Declaring a tag for the Logger service
class Logger extends Context.Tag("Logger")<
  Logger,
  { readonly log: (message: string) => Effect.Effect<void> }
>() {}

// Layer<Logger, never, Config>
const LoggerLive = Layer.effect(
  Logger,
  Effect.gen(function* () {
    const config = yield* Config
    return {
      log: (message) =>
        Effect.gen(function* () {
          const { logLevel } = yield* config.getConfig
          console.log(`[${logLevel}] ${message}`)
        }),
    }
  }),
)

// Declaring a tag for the Database service
class Database extends Context.Tag("Database")<
  Database,
  { readonly query: (sql: string) => Effect.Effect<unknown> }
>() {}

// Layer<Database, never, Config | Logger>
const DatabaseLive = Layer.effect(
  Database,
  Effect.gen(function* () {
    const config = yield* Config
    const logger = yield* Logger
    return {
      query: (sql: string) =>
        Effect.gen(function* () {
          yield* logger.log(`Executing query: ${sql}`)
          const { connection } = yield* config.getConfig
          return { result: `Results from ${connection}` }
        }),
    }
  }),
)

观察 DatabaseLive 的类型:

Layer<Database, never, Config | Logger>

我们可以发现 RequirementsIn 类型是 Config | Logger,也就是说 Database 服务同时需要 ConfigLogger 服务。

组合 Layer

Layer 可以通过两种主要方式组合:合并(merging)组合(composing)

合并 Layer

Layer 可以通过 Layer.merge 函数进行合并:

import { Layer } from "effect"

declare const layer1: Layer.Layer<"Out1", never, "In1">
declare const layer2: Layer.Layer<"Out2", never, "In2">

// Layer<"Out1" | "Out2", never, "In1" | "In2">
const merging = Layer.merge(layer1, layer2)

当我们合并两个 Layer 时,得到的 Layer:

  • 需要它们两者所需的所有服务("In1" | "In2")。
  • 产出它们两者产出的所有服务("Out1" | "Out2")。

例如,在上面的 Web 应用中,我们可以把 ConfigLiveLoggerLive 合并成单个 AppConfigLive Layer,它保留两个 Layer 的需求(never | Config = Config)以及两个 Layer 的输出(Config | Logger):

import { Effect, Context, Layer } from "effect"

// Declaring a tag for the Config service
class Config extends Context.Tag("Config")<
  Config,
  {
    readonly getConfig: Effect.Effect<{
      readonly logLevel: string
      readonly connection: string
    }>
  }
>() {}

// Layer<Config, never, never>
const ConfigLive = Layer.succeed(Config, {
  getConfig: Effect.succeed({
    logLevel: "INFO",
    connection: "mysql://username:password@hostname:port/database_name",
  }),
})

// Declaring a tag for the Logger service
class Logger extends Context.Tag("Logger")<
  Logger,
  { readonly log: (message: string) => Effect.Effect<void> }
>() {}

// Layer<Logger, never, Config>
const LoggerLive = Layer.effect(
  Logger,
  Effect.gen(function* () {
    const config = yield* Config
    return {
      log: (message) =>
        Effect.gen(function* () {
          const { logLevel } = yield* config.getConfig
          console.log(`[${logLevel}] ${message}`)
        }),
    }
  }),
)

// Layer<Config | Logger, never, Config>
const AppConfigLive = Layer.merge(ConfigLive, LoggerLive)

组合 Layer

Layer 可以使用 Layer.provide 函数进行组合:

import { Layer } from "effect"

declare const inner: Layer.Layer<"OutInner", never, "InInner">
declare const outer: Layer.Layer<"InInner", never, "InOuter">

// Layer<"OutInner", never, "InOuter">
const composition = Layer.provide(inner, outer)

Layer 的顺序组合意味着一个 Layer 的输出被作为内层 Layer 的输入提供,结果得到单个 Layer:它拥有外层 Layer 的需求和内层 Layer 的输出。

现在我们可以把 AppConfigLive Layer 与 DatabaseLive Layer 组合起来:

import { Effect, Context, Layer } from "effect"

// Declaring a tag for the Config service
class Config extends Context.Tag("Config")<
  Config,
  {
    readonly getConfig: Effect.Effect<{
      readonly logLevel: string
      readonly connection: string
    }>
  }
>() {}

// Layer<Config, never, never>
const ConfigLive = Layer.succeed(Config, {
  getConfig: Effect.succeed({
    logLevel: "INFO",
    connection: "mysql://username:password@hostname:port/database_name",
  }),
})

// Declaring a tag for the Logger service
class Logger extends Context.Tag("Logger")<
  Logger,
  { readonly log: (message: string) => Effect.Effect<void> }
>() {}

// Layer<Logger, never, Config>
const LoggerLive = Layer.effect(
  Logger,
  Effect.gen(function* () {
    const config = yield* Config
    return {
      log: (message) =>
        Effect.gen(function* () {
          const { logLevel } = yield* config.getConfig
          console.log(`[${logLevel}] ${message}`)
        }),
    }
  }),
)

// Declaring a tag for the Database service
class Database extends Context.Tag("Database")<
  Database,
  { readonly query: (sql: string) => Effect.Effect<unknown> }
>() {}

// Layer<Database, never, Config | Logger>
const DatabaseLive = Layer.effect(
  Database,
  Effect.gen(function* () {
    const config = yield* Config
    const logger = yield* Logger
    return {
      query: (sql: string) =>
        Effect.gen(function* () {
          yield* logger.log(`Executing query: ${sql}`)
          const { connection } = yield* config.getConfig
          return { result: `Results from ${connection}` }
        }),
    }
  }),
)

// Layer<Config | Logger, never, Config>
const AppConfigLive = Layer.merge(ConfigLive, LoggerLive)

// Layer<Database, never, never>
const MainLive = DatabaseLive.pipe(
  // provides the config and logger to the database
  Layer.provide(AppConfigLive),
  // provides the config to AppConfigLive
  Layer.provide(ConfigLive),
)

我们得到了一个 MainLive Layer,它产出 Database service:

Layer<Database, never, never>

该 Layer 是我们应用完全解析后的 Layer。

合并与组合 Layer

假设我们希望 MainLive Layer 同时返回 ConfigDatabase 这两个 service。可以通过 Layer.provideMerge 实现:

import { Effect, Context, Layer } from "effect"

// Declaring a tag for the Config service
class Config extends Context.Tag("Config")<
  Config,
  {
    readonly getConfig: Effect.Effect<{
      readonly logLevel: string
      readonly connection: string
    }>
  }
>() {}

const ConfigLive = Layer.succeed(Config, {
  getConfig: Effect.succeed({
    logLevel: "INFO",
    connection: "mysql://username:password@hostname:port/database_name",
  }),
})

// Declaring a tag for the Logger service
class Logger extends Context.Tag("Logger")<
  Logger,
  { readonly log: (message: string) => Effect.Effect<void> }
>() {}

const LoggerLive = Layer.effect(
  Logger,
  Effect.gen(function* () {
    const config = yield* Config
    return {
      log: (message) =>
        Effect.gen(function* () {
          const { logLevel } = yield* config.getConfig
          console.log(`[${logLevel}] ${message}`)
        }),
    }
  }),
)

// Declaring a tag for the Database service
class Database extends Context.Tag("Database")<
  Database,
  { readonly query: (sql: string) => Effect.Effect<unknown> }
>() {}

const DatabaseLive = Layer.effect(
  Database,
  Effect.gen(function* () {
    const config = yield* Config
    const logger = yield* Logger
    return {
      query: (sql: string) =>
        Effect.gen(function* () {
          yield* logger.log(`Executing query: ${sql}`)
          const { connection } = yield* config.getConfig
          return { result: `Results from ${connection}` }
        }),
    }
  }),
)

// Layer<Config | Logger, never, Config>
const AppConfigLive = Layer.merge(ConfigLive, LoggerLive)

// Layer<Config | Database, never, never>
const MainLive = DatabaseLive.pipe(
  Layer.provide(AppConfigLive),
  Layer.provideMerge(ConfigLive),
)

为 Effect 提供 Layer

现在我们已经为应用组装好了完全解析的 MainLive,可以使用 Effect.provide 将它提供给程序,以满足程序的需求:

import { Effect, Context, Layer } from "effect"

class Config extends Context.Tag("Config")<
  Config,
  {
    readonly getConfig: Effect.Effect<{
      readonly logLevel: string
      readonly connection: string
    }>
  }
>() {}

const ConfigLive = Layer.succeed(Config, {
  getConfig: Effect.succeed({
    logLevel: "INFO",
    connection: "mysql://username:password@hostname:port/database_name",
  }),
})

class Logger extends Context.Tag("Logger")<
  Logger,
  { readonly log: (message: string) => Effect.Effect<void> }
>() {}

const LoggerLive = Layer.effect(
  Logger,
  Effect.gen(function* () {
    const config = yield* Config
    return {
      log: (message) =>
        Effect.gen(function* () {
          const { logLevel } = yield* config.getConfig
          console.log(`[${logLevel}] ${message}`)
        }),
    }
  }),
)

class Database extends Context.Tag("Database")<
  Database,
  { readonly query: (sql: string) => Effect.Effect<unknown> }
>() {}

const DatabaseLive = Layer.effect(
  Database,
  Effect.gen(function* () {
    const config = yield* Config
    const logger = yield* Logger
    return {
      query: (sql: string) =>
        Effect.gen(function* () {
          yield* logger.log(`Executing query: ${sql}`)
          const { connection } = yield* config.getConfig
          return { result: `Results from ${connection}` }
        }),
    }
  }),
)

const AppConfigLive = Layer.merge(ConfigLive, LoggerLive)

const MainLive = DatabaseLive.pipe(
  Layer.provide(AppConfigLive),
  Layer.provide(ConfigLive),
)

//      ┌─── Effect<unknown, never, Database>
//      ▼
const program = Effect.gen(function* () {
  const database = yield* Database
  const result = yield* database.query("SELECT * FROM users")
  return result
})

//      ┌─── Effect<unknown, never, never>
//      ▼
const runnable = Effect.provide(program, MainLive)

Effect.runPromise(runnable).then(console.log)
/*
Output:
[INFO] Executing query: SELECT * FROM users
{
  result: 'Results from mysql://username:password@hostname:port/database_name'
}
*/

注意 runnable 的需求类型是 never,表明该程序运行时不需要任何额外的 service。

把 Layer 转换为 Effect

有时你的整个应用可能就是一个 Layer,例如一个 HTTP server。你可以用 Layer.launch 把该 Layer 转换为 Effect。它会构造 Layer 并使其保持存活,直到被中断。

示例(启动一个 HTTP Server Layer)

import { Console, Context, Effect, Layer } from "effect"

class HTTPServer extends Context.Tag("HTTPServer")<HTTPServer, void>() {}

// Simulating an HTTP server
const server = Layer.effect(
  HTTPServer,
  // Log a message to simulate a server starting
  Console.log("Listening on http://localhost:3000"),
)

// Converts the layer to an effect and runs it
Effect.runFork(Layer.launch(server))
/*
Output:
Listening on http://localhost:3000
...
*/

Tap 操作

Layer.tapLayer.tapError 函数允许你根据 Layer 的成功或失败执行额外的 effect。这些操作不会修改 Layer 的签名,但在 Layer 构造期间用于日志记录或执行副作用时非常有用。

  • Layer.tap:当 Layer 成功获取时执行指定的 effect。
  • Layer.tapError:当 Layer 获取失败时执行指定的 effect。

示例(记录 Layer 获取过程中的成功与失败)

import { Config, Context, Effect, Layer, Console } from "effect"

class HTTPServer extends Context.Tag("HTTPServer")<HTTPServer, void>() {}

// Simulating an HTTP server
const server = Layer.effect(
  HTTPServer,
  Effect.gen(function* () {
    const host = yield* Config.string("HOST")
    console.log(`Listening on http://localhost:${host}`)
  }),
).pipe(
  // Log a message if the layer acquisition succeeds
  Layer.tap((ctx) => Console.log(`layer acquisition succeeded with:\n${ctx}`)),
  // Log a message if the layer acquisition fails
  Layer.tapError((err) =>
    Console.log(`layer acquisition failed with:\n${err}`),
  ),
)

Effect.runFork(Layer.launch(server))
/*
Output:
layer acquisition failed with:
(Missing data at HOST: "Expected HOST to exist in the process context")
*/

错误处理

在构造 Layer 时,处理潜在错误很重要。Effect 库提供了 Layer.catchAllLayer.orElse 等工具来管理错误,并定义失败时的回退 Layer。

catchAll

Layer.catchAll 函数允许你通过指定回退 Layer 从 Layer 构造期间的错误中恢复。这对于处理特定错误情况、确保应用能以替代方案继续运行很有用。

示例(从 Layer 构造期间的错误中恢复)

import { Config, Context, Effect, Layer } from "effect"

class HTTPServer extends Context.Tag("HTTPServer")<HTTPServer, void>() {}

// Simulating an HTTP server
const server = Layer.effect(
  HTTPServer,
  Effect.gen(function* () {
    const host = yield* Config.string("HOST")
    console.log(`Listening on http://localhost:${host}`)
  }),
).pipe(
  // Recover from errors during layer construction
  Layer.catchAll((configError) =>
    Layer.effect(
      HTTPServer,
      Effect.gen(function* () {
        console.log(`Recovering from error:\n${configError}`)
        console.log(`Listening on http://localhost:3000`)
      }),
    ),
  ),
)

Effect.runFork(Layer.launch(server))
/*
Output:
Recovering from error:
(Missing data at HOST: "Expected HOST to exist in the process context")
Listening on http://localhost:3000
...
*/

orElse

Layer.orElse 函数提供了一种更简单的方式:当初始 Layer 失败时回退到替代 Layer。与 Layer.catchAll 不同,它不会把错误作为输入接收。当你只需要提供一个默认 Layer、而无需针对特定错误作出反应时,可以使用它。

示例(回退到替代 Layer)

import { Config, Context, Effect, Layer } from "effect"

class Database extends Context.Tag("Database")<Database, void>() {}

// Simulating a database connection
const postgresDatabaseLayer = Layer.effect(
  Database,
  Effect.gen(function* () {
    const databaseConnectionString = yield* Config.string("CONNECTION_STRING")
    console.log(`Connecting to database with: ${databaseConnectionString}`)
  }),
)

// Simulating an in-memory database connection
const inMemoryDatabaseLayer = Layer.effect(
  Database,
  Effect.gen(function* () {
    console.log(`Connecting to in-memory database`)
  }),
)

// Fallback to in-memory database if PostgreSQL connection fails
const database = postgresDatabaseLayer.pipe(
  Layer.orElse(() => inMemoryDatabaseLayer),
)

Effect.runFork(Layer.launch(database))
/*
Output:
Connecting to in-memory database
...
*/

使用 Effect.Service 简化 Service 定义

Effect.Service API 提供了一种一步定义 service 的方式,包括它的 tag 和 Layer。它还允许预先声明依赖,使 service 的构造更加直接。

定义带依赖的 Service

下面的例子定义了一个依赖文件系统的 Cache service。

示例(定义 Cache Service)

import { FileSystem } from "@effect/platform"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect } from "effect"

// Define a Cache service
class Cache extends Effect.Service<Cache>()("app/Cache", {
  // Define how to create the service
  effect: Effect.gen(function* () {
    const fs = yield* FileSystem.FileSystem
    const lookup = (key: string) => fs.readFileString(`cache/${key}`)
    return { lookup } as const
  }),
  // Specify dependencies
  dependencies: [NodeFileSystem.layer],
}) {}

使用生成的 Layer

Effect.Service API 会自动为 service 生成 Layer。

Layer说明
Cache.Default提供 Cache service,并已包含其依赖。
Cache.DefaultWithoutDependencies提供 Cache service,但需要单独提供依赖。
import { FileSystem } from "@effect/platform"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect } from "effect"

// Define a Cache service
class Cache extends Effect.Service<Cache>()("app/Cache", {
  effect: Effect.gen(function* () {
    const fs = yield* FileSystem.FileSystem
    const lookup = (key: string) => fs.readFileString(`cache/${key}`)
    return { lookup } as const
  }),
  dependencies: [NodeFileSystem.layer],
}) {}

// Layer that includes all required dependencies
//
//      ┌─── Layer<Cache>
//      ▼
const layer = Cache.Default

// Layer without dependencies, requiring them to be provided externally
//
//      ┌─── Layer.Layer<Cache, never, FileSystem>
//      ▼
const layerNoDeps = Cache.DefaultWithoutDependencies

访问 Service

使用 Effect.Service 创建的 service 可以像其他任何 Effect service 一样被访问。

示例(访问 Cache Service)

import { FileSystem } from "@effect/platform"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Console } from "effect"

// Define a Cache service
class Cache extends Effect.Service<Cache>()("app/Cache", {
  effect: Effect.gen(function* () {
    const fs = yield* FileSystem.FileSystem
    const lookup = (key: string) => fs.readFileString(`cache/${key}`)
    return { lookup } as const
  }),
  dependencies: [NodeFileSystem.layer],
}) {}

// Accessing the Cache Service
const program = Effect.gen(function* () {
  const cache = yield* Cache
  const data = yield* cache.lookup("my-key")
  console.log(data)
}).pipe(Effect.catchAllCause((cause) => Console.log(cause)))

const runnable = program.pipe(Effect.provide(Cache.Default))

Effect.runFork(runnable)
/*
{
  _id: 'Cause',
  _tag: 'Fail',
  failure: {
    _tag: 'SystemError',
    reason: 'NotFound',
    module: 'FileSystem',
    method: 'readFile',
    pathOrDescriptor: 'cache/my-key',
    syscall: 'open',
    message: "ENOENT: no such file or directory, open 'cache/my-key'",
    [Symbol(@effect/platform/Error/PlatformErrorTypeId)]: Symbol(@effect/platform/Error/PlatformErrorTypeId)
  }
}
*/

由于该示例使用了 Cache.Default,它会与真实文件系统交互。如果文件不存在,就会产生错误。

注入测试依赖

为了在不依赖真实文件系统的情况下测试程序,我们可以使用 Cache.DefaultWithoutDependencies Layer 注入一个测试文件系统。

示例(使用测试文件系统)

import { FileSystem } from "@effect/platform"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Console } from "effect"

// Define a Cache service
class Cache extends Effect.Service<Cache>()("app/Cache", {
  effect: Effect.gen(function* () {
    const fs = yield* FileSystem.FileSystem
    const lookup = (key: string) => fs.readFileString(`cache/${key}`)
    return { lookup } as const
  }),
  dependencies: [NodeFileSystem.layer],
}) {}

// Accessing the Cache Service
const program = Effect.gen(function* () {
  const cache = yield* Cache
  const data = yield* cache.lookup("my-key")
  console.log(data)
}).pipe(Effect.catchAllCause((cause) => Console.log(cause)))

// Create a test file system that always returns a fixed value
const FileSystemTest = FileSystem.layerNoop({
  readFileString: () => Effect.succeed("File Content..."),
})

const runnable = program.pipe(
  Effect.provide(Cache.DefaultWithoutDependencies),
  // Provide the mock file system
  Effect.provide(FileSystemTest),
)

Effect.runFork(runnable)
// Output: File Content...

直接 Mock Service

另一种方式是不替换依赖,而是直接 mock Cache service 本身。

示例(Mock Cache Service)

import { FileSystem } from "@effect/platform"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Console } from "effect"

// Define a Cache service
class Cache extends Effect.Service<Cache>()("app/Cache", {
  effect: Effect.gen(function* () {
    const fs = yield* FileSystem.FileSystem
    const lookup = (key: string) => fs.readFileString(`cache/${key}`)
    return { lookup } as const
  }),
  dependencies: [NodeFileSystem.layer],
}) {}

// Accessing the Cache Service
const program = Effect.gen(function* () {
  const cache = yield* Cache
  const data = yield* cache.lookup("my-key")
  console.log(data)
}).pipe(Effect.catchAllCause((cause) => Console.log(cause)))

// Create a mock implementation of Cache
const cache = new Cache({
  lookup: () => Effect.succeed("Cache Content..."),
})

// Provide the mock Cache service
const runnable = program.pipe(Effect.provideService(Cache, cache))

Effect.runFork(runnable)
// Output: Cache Content...

定义 Service 的其他方式

Effect.Service API 支持多种定义 service 的方式:

方法说明
succeed提供服务的一个静态实现。
sync使用同步构造器定义 service。
effect使用带 effect 的构造器定义 service。
scoped创建带生命周期管理的 service。

示例(定义具有静态实现的 Service)

这是定义 service 最简单的方式。当你希望为 service 提供一个常量值时,它很有用。

import { Effect } from "effect"

class MagicNumber extends Effect.Service<MagicNumber>()("MagicNumber", {
  succeed: { value: 42 },
}) {}

//      ┌─── Effect<void, never, MagicNumber>
//      ▼
const program = Effect.gen(function* () {
  const magicNumber = yield* MagicNumber
  console.log(`The magic number is ${magicNumber.value}`)
})

Effect.runPromise(program.pipe(Effect.provide(MagicNumber.Default)))
// The magic number is 42

示例(定义具有同步构造器的 Service)

import { Effect, Random } from "effect"

class Sync extends Effect.Service<Sync>()("Sync", {
  sync: () => ({
    next: Random.nextInt,
  }),
}) {}

//      ┌─── Effect<void, never, Sync>
//      ▼
const program = Effect.gen(function* () {
  const sync = yield* Sync
  const n = yield* sync.next
  console.log(`The number is ${n}`)
})

Effect.runPromise(program.pipe(Effect.provide(Sync.Default)))
// Example Output: The number is 3858843290019673

示例(定义具有生命周期控制的 Service)

import { Effect, Console } from "effect"

class Scoped extends Effect.Service<Scoped>()("Scoped", {
  scoped: Effect.gen(function* () {
    // Acquire the resource and ensure it is properly released
    const resource = yield* Effect.acquireRelease(
      Console.log("Acquiring...").pipe(Effect.as("foo")),
      () => Console.log("Releasing..."),
    )
    // Register a finalizer to run when the effect is completed
    yield* Effect.addFinalizer(() => Console.log("Shutting down"))
    return { resource }
  }),
}) {}

//      ┌─── Effect<void, never, Scoped>
//      ▼
const program = Effect.gen(function* () {
  const resource = (yield* Scoped).resource
  console.log(`The resource is ${resource}`)
})

Effect.runPromise(
  program.pipe(
    Effect.provide(
      //       ┌─── Layer<Scoped, never, never>
      //       ▼
      Scoped.Default,
    ),
  ),
)
/*
Acquiring...
The resource is foo
Shutting down
Releasing...
*/

Scoped.Default Layer 不需要 Scope 作为依赖,因为 Scoped 自身管理其生命周期。

启用直接方法访问

通过设置 accessors: true,你可以直接用 service tag 调用 service 的方法,而不必先取出 service。

示例(定义支持直接方法访问的 Service)

import { Effect, Random } from "effect"

class Sync extends Effect.Service<Sync>()("Sync", {
  sync: () => ({
    next: Random.nextInt,
  }),
  accessors: true, // Enables direct method access via the tag
}) {}

const program = Effect.gen(function* () {
  // const sync = yield* Sync
  // const n = yield* sync.next
  const n = yield* Sync.next // No need to extract the service first
  console.log(`The number is ${n}`)
})

Effect.runPromise(program.pipe(Effect.provide(Sync.Default)))
// Example Output: The number is 3858843290019673
Limitation of Direct Method Access

直接方法访问不适用于泛型方法。

Effect.Service vs Context.Tag

Effect.ServiceContext.Tag 都是在 Effect 生态中建模 service 的方式。它们用途相似,但面向不同的使用场景。

特性Effect.ServiceContext.Tag
tag 的创建自动为你生成(类名充当 tag)你需要手动声明 tag
默认实现必需 —— 以内联方式提供(effectsync 等)可选 —— 可以稍后提供
现成的 Layer(.Default 等)自动生成由你自己构建 Layer
最适合用于具有明确运行时实现的应用代码库代码或动态作用域的值
当不存在合理的默认值时并不理想;你仍然得自己编一个更受推荐

要点

  • 更少的样板代码: Effect.ServiceContext.Tag 加上配套 Layer 与辅助函数的语法糖。

  • 必须提供默认实现: 继承 Effect.Service 的类必须声明内置构造器中的一个effectsyncsucceedscoped)。这个基线实现会成为 MyService.Default 的一部分,因此任何导入该 service 的代码都无需额外提供 Layer 即可运行。 对于存在合理运行时实现的应用级 service(日志、HTTP 客户端、真实数据库等)来说,这很方便。 如果你的 service 本质上依赖上下文(例如每个请求各自的数据库句柄),或者你正在编写一个不应假定具体实现的库,那么请优先使用 Context.Tag:你只发布 tag,而由调用方提供适合其环境的 Layer。

  • 就是 tag: 当你用 extends Effect.Service 创建类时,类构造函数本身就充当 tag。在组装 Layer 时,你可以为该类提供一个值,从而提供替代实现:

    const mock = new MyService({/* mocked methods */})
    program.pipe(Effect.provideService(MyService, mock))