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

配置

使用内置类型、灵活的提供者,以及默认值、校验与脱敏等高级特性,高效管理应用配置。

配置是任何云原生应用都不可或缺的一环。Effect 为配置提供者提供了便捷的接口,从而简化了配置管理的过程。

Effect 中的配置前端让生态库和应用能够以声明式的方式描述自己的配置需求。它把复杂的任务交给 ConfigProvider 处理,而 ConfigProvider 可以由第三方库提供。

Effect 自带一个简单直接的默认 ConfigProvider,它从环境变量中读取配置数据。这个默认 provider 可以在开发阶段使用,也可以作为迁移到更高级的配置提供者之前的起点。

要让应用变得可配置,我们需要理解三个基本要素:

  • Config 描述:我们使用 Config<A> 的实例来描述配置数据。如果配置数据很简单,例如 stringnumberboolean,可以使用 Config 模块提供的内置函数。对于 HostPort 这类更复杂的数据类型,我们可以组合基础 config 来创建自定义的配置描述。

  • Config 前端:我们利用 Config<A> 的实例来加载该实例所描述的配置数据(Config 本身就是一个 effect)。这个过程会借助当前的 ConfigProvider 来读取配置。

  • Config 后端ConfigProvider 是管理配置加载过程的底层引擎。Effect 自带一个默认的 config provider,作为其默认服务的一部分。这个默认 provider 从环境变量中读取配置数据。如果想使用自定义的 config provider,可以利用 Effect.withConfigProvider API 来相应地配置 Effect 运行时。

基础配置类型

Effect 为配置值提供了若干内置类型,开箱即用:

类型说明
string将配置值读取为字符串。
number将值读取为浮点数。
boolean将值读取为布尔值(truefalse)。
integer将值读取为整数。
date将值解析为 Date 对象。
literal读取一个固定的字面量(*)。
logLevel将值读取为 LogLevel
duration将值解析为时间长度。
redacted读取敏感值,确保它在被记录到日志时受到保护。
url将值解析为合法的 URL。

(*) string | number | boolean | null | bigint

示例(加载环境变量)

下面是一个示例,用环境变量 HOSTPORT 来加载基础配置:

import { Effect, Config } from "effect"

// Define a program that loads HOST and PORT configuration
const program = Effect.gen(function* () {
  const host = yield* Config.string("HOST") // Read as a string
  const port = yield* Config.number("PORT") // Read as a number

  console.log(`Application started: ${host}:${port}`)
})

Effect.runPromise(program)

如果在没有设置所需环境变量的情况下运行:

npx tsx primitives.ts

你会看到一个提示配置缺失的错误:

[Error: (Missing data at HOST: "Expected HOST to exist in the process context")] {
  name: '(FiberFailure) Error',
  [Symbol(effect/Runtime/FiberFailure)]: Symbol(effect/Runtime/FiberFailure),
  [Symbol(effect/Runtime/FiberFailure/Cause)]: {
    _tag: 'Fail',
    error: {
      _op: 'MissingData',
      path: [ 'HOST' ],
      message: 'Expected HOST to exist in the process context'
    }
  }
}

要想成功运行该程序,请按下面所示设置环境变量:

HOST=localhost PORT=8080 npx tsx primitives.ts

输出:

Application started: localhost:8080

将 Config 与 Schema 一起使用

你可以使用 schema 来定义并解码配置值。

示例(解码配置值)

import { Effect, Schema } from "effect"

// Define a config that expects a string with at least 4 characters
const myConfig = Schema.Config("Foo", Schema.String.pipe(Schema.minLength(4)))

更多信息参见 Schema.Config 文档。

提供默认值

有时你会遇到环境变量缺失、导致配置不完整的情况。为此,Effect 提供了 Config.withDefault 函数,允许你指定一个默认值。这种回退机制能确保即使必需的环境变量没有设置,应用也能继续运行。

示例(使用默认值)

import { Effect, Config } from "effect"

const program = Effect.gen(function* () {
  const host = yield* Config.string("HOST")
  // Use default 8080 if PORT is not set
  const port = yield* Config.number("PORT").pipe(Config.withDefault(8080))
  console.log(`Application started: ${host}:${port}`)
})

Effect.runPromise(program)

只设置 HOST 环境变量来运行这个程序:

HOST=localhost npx tsx defaults.ts

得到如下输出:

Application started: localhost:8080

在这个例子中,尽管没有设置 PORT 环境变量,程序仍然会继续运行,并让端口使用默认值 8080。这确保了应用无需显式提供每一项配置也能正常工作。

处理敏感值

有些配置值,比如 API 密钥,不应该被打印到日志中。

Config.redacted 函数用于安全地处理敏感信息。 它会解析配置值,并将其包装成 Redacted<string> —— 一种专门用于保护机密信息的数据类型

当你使用 console.log 打印 Redacted 值时,实际内容会保持隐藏,从而多了一层安全保障。要访问真实的值,必须显式使用 Redacted.value

示例(保护敏感数据)

import { Effect, Config, Redacted } from "effect"

const program = Effect.gen(function* () {
  //      ┌─── Redacted<string>
  //      ▼
  const redacted = yield* Config.redacted("API_KEY")

  // Log the redacted value, which won't reveal the actual secret
  console.log(`Console output: ${redacted}`)

  // Access the real value using Redacted.value and log it
  console.log(`Actual value: ${Redacted.value(redacted)}`)
})

Effect.runPromise(program)

执行这个程序时:

API_KEY=my-api-key tsx redacted.ts

输出会是这样:

Console output: <redacted>
Actual value: my-api-key

如你所见,使用 console.log 打印 Redacted 值时,输出是 <redacted>,从而确保敏感数据始终被隐藏。不过,通过 Redacted.value 可以访问并显示真实的值("my-api-key"),从而对机密信息实现受控访问。

用 Redacted 包装 Config

默认情况下,当你向 Config.redacted 传入一个字符串时,它返回 Redacted<string>。你也可以传入一个 Config(例如 Config.number),以确保只接受经过校验的值。这通过确保敏感数据在被脱敏之前先得到正确校验,多加了一层安全保障。

示例(脱敏并校验数值)

import { Effect, Config, Redacted } from "effect"

const program = Effect.gen(function* () {
  // Wrap the validated number configuration with redaction
  //
  //      ┌─── Redacted<number>
  //      ▼
  const redacted = yield* Config.redacted(Config.number("SECRET"))

  console.log(`Console output: ${redacted}`)
  console.log(`Actual value: ${Redacted.value(redacted)}`)
})

Effect.runPromise(program)

组合配置

Effect 提供了若干内置组合子,让你可以定义和操作配置。 这些组合子接受一个 Config 作为输入并产出另一个 Config,从而支持更复杂的配置结构。

组合子说明
array构造一个用于数组值的配置。
chunk构造一个用于值序列的配置。
option返回一个可选配置。如果数据缺失,结果将是 None;否则将是 Some
repeat描述一个值序列,其中每个值都遵循给定 config 的结构。
hashSet构造一个用于值集合的配置。
hashMap构造一个用于键值映射的配置。

此外,还有三个用于特定场景的特殊组合子:

组合子说明
succeed构造一个包含预定义值的 config。
fail构造一个以指定错误消息失败的 config。
all将多个配置组合成元组、结构体或参数列表。

示例(使用 array 组合子)

下面的示例演示如何使用 Config.array 构造器把一个环境变量加载为字符串数组。

import { Config, Effect } from "effect"

const program = Effect.gen(function* () {
  const config = yield* Config.array(Config.string(), "MYARRAY")
  console.log(config)
})

Effect.runPromise(program)
// Run:
// MYARRAY=a,b,c,a npx tsx index.ts
// Output:
// [ 'a', 'b', 'c', 'a' ]

示例(使用 hashSet 组合子)

import { Config, Effect } from "effect"

const program = Effect.gen(function* () {
  const config = yield* Config.hashSet(Config.string(), "MYSET")
  console.log(config)
})

Effect.runPromise(program)
// Run:
// MYSET=a,"b c",d,a npx tsx index.ts
// Output:
// { _id: 'HashSet', values: [ 'd', 'a', 'b c' ] }

示例(使用 hashMap 组合子)

import { Config, Effect } from "effect"

const program = Effect.gen(function* () {
  const config = yield* Config.hashMap(Config.string(), "MYMAP")
  console.log(config)
})

Effect.runPromise(program)
// Run:
// MYMAP_A=a MYMAP_B=b npx tsx index.ts
// Output:
// { _id: 'HashMap', values: [ [ 'A', 'a' ], [ 'B', 'b' ] ] }

操作符

Effect 提供了若干内置操作符来处理配置,让你可以根据需要操作和转换它们。

转换操作符

这些操作符让你可以修改配置或校验其值:

操作符说明
validate确保配置满足特定条件,若不满足则返回校验错误。
map使用提供的函数转换配置的值。
mapAttemptmap 类似,但会捕获函数抛出的任何错误并将其转换为校验错误。
mapOrFail类似 map,但函数可以失败。如果失败,结果就是一个校验错误。

示例(使用 validate 操作符)

import { Effect, Config } from "effect"

const program = Effect.gen(function* () {
  // Load the NAME environment variable and validate its length
  const config = yield* Config.string("NAME").pipe(
    Config.validate({
      message: "Expected a string at least 4 characters long",
      validation: (s) => s.length >= 4,
    }),
  )
  console.log(config)
})

Effect.runPromise(program)

如果用一个无效的 NAME 值运行这个程序:

NAME=foo npx tsx validate.ts

输出将会是:

[Error: (Invalid data at NAME: "Expected a string at least 4 characters long")] {
  name: '(FiberFailure) Error',
  [Symbol(effect/Runtime/FiberFailure)]: Symbol(effect/Runtime/FiberFailure),
  [Symbol(effect/Runtime/FiberFailure/Cause)]: {
    _tag: 'Fail',
    error: {
      _op: 'InvalidData',
      path: [ 'NAME' ],
      message: 'Expected a string at least 4 characters long'
    }
  }
}

回退操作符

当你希望在出现错误或数据缺失时提供备选配置,回退操作符会很有用。这些操作符确保即使某些配置值不可用,程序仍然能够运行。

操作符说明
orElse首先尝试使用主 config。如果它失败或缺失,就回退到另一个 config。
orElseIforElse 类似,但只有当错误满足某个条件时才会切换到回退 config。

示例(使用 orElse 进行回退)

在这个示例中,程序需要两个配置值:AB。我们设置了两个配置提供者,每个只包含其中一个所需的值。使用 orElse 操作符,我们把这两个提供者组合起来,使程序能够同时取得 AB

import { Config, ConfigProvider, Effect } from "effect"

// A program that requires two configurations: A and B
const program = Effect.gen(function* () {
  const A = yield* Config.string("A") // Retrieve config A
  const B = yield* Config.string("B") // Retrieve config B
  console.log(`A: ${A}, B: ${B}`)
})

// First provider has A but is missing B
const provider1 = ConfigProvider.fromMap(new Map([["A", "A"]]))

// Second provider has B but is missing A
const provider2 = ConfigProvider.fromMap(new Map([["B", "B"]]))

// Use `orElse` to fall back from provider1 to provider2
const provider = provider1.pipe(ConfigProvider.orElse(() => provider2))

Effect.runPromise(Effect.withConfigProvider(program, provider))

如果我们运行这个程序:

npx tsx orElse.ts

输出将会是:

A: A, B: B
提示

在这个示例中,我们使用 ConfigProvider.fromMap 从一个简单的 JavaScript Map 创建配置提供者。这在测试中特别有用,正如 在测试中模拟配置 一节所述。

自定义配置类型

Effect 允许你使用组合子操作符组合基础配置,从而为自定义类型定义配置。

例如,我们创建一个 HostPort 类,它有两个字段:hostport

class HostPort {
  constructor(
    readonly host: string,
    readonly port: number,
  ) {}
  get url() {
    return `${this.host}:${this.port}`
  }
}

要为这个自定义类型定义配置,我们可以组合 stringnumber 的基础 config:

示例(定义自定义配置)

import { Config } from "effect"

class HostPort {
  constructor(
    readonly host: string,
    readonly port: number,
  ) {}
  get url() {
    return `${this.host}:${this.port}`
  }
}

// Combine the configuration for 'HOST' and 'PORT'
const both = Config.all([Config.string("HOST"), Config.number("PORT")])

// Map the configuration values into a HostPort instance
const config = Config.map(both, ([host, port]) => new HostPort(host, port))

在这个示例中,Config.all(configs) 把两个基础配置 Config<string>Config<number> 组合成一个 Config<[string, number]>。随后使用 Config.map 操作符把这些值转换成一个 HostPort 类的实例。

示例(使用自定义配置)

import { Effect, Config } from "effect"

class HostPort {
  constructor(
    readonly host: string,
    readonly port: number,
  ) {}
  get url() {
    return `${this.host}:${this.port}`
  }
}

// Combine the configuration for 'HOST' and 'PORT'
const both = Config.all([Config.string("HOST"), Config.number("PORT")])

// Map the configuration values into a HostPort instance
const config = Config.map(both, ([host, port]) => new HostPort(host, port))

// Main program that reads configuration and starts the application
const program = Effect.gen(function* () {
  const hostPort = yield* config
  console.log(`Application started: ${hostPort.url}`)
})

Effect.runPromise(program)

运行这个程序时,它会尝试从环境变量中读取 HOSTPORT 的值:

HOST=localhost PORT=8080 npx tsx App.ts

如果成功,它会打印:

Application started: localhost:8080

嵌套配置

我们已经看到如何在顶层定义配置,无论是基本类型还是自定义类型。不过在有些情况下,你可能希望以更嵌套的方式组织配置,把它们按共同的命名空间归类,从而更清晰、更易管理。

例如,考虑下面这个 ServiceConfig 类型:

class ServiceConfig {
  constructor(
    readonly host: string,
    readonly port: number,
    readonly timeout: number,
  ) {}
  get url() {
    return `${this.host}:${this.port}`
  }
}

如果在应用中使用这个配置,它会期望在顶层提供 HOSTPORTTIMEOUT 这几个环境变量。但在许多情况下,你可能希望把配置组织到某个共享的命名空间下——例如把 HOSTPORT 归入 SERVER 命名空间,同时让 TIMEOUT 留在根层级。

为此,你可以使用 Config.nested 操作符,它允许你把配置值嵌套到指定的命名空间下。我们来修改前面的示例以体现这一点:

import { Config } from "effect"

class ServiceConfig {
  constructor(
    readonly host: string,
    readonly port: number,
    readonly timeout: number,
  ) {}
  get url() {
    return `${this.host}:${this.port}`
  }
}

const serverConfig = Config.all([Config.string("HOST"), Config.number("PORT")])

const serviceConfig = Config.map(
  Config.all([
    // Read 'HOST' and 'PORT' from 'SERVER' namespace
    Config.nested(serverConfig, "SERVER"),
    // Read 'TIMEOUT' from the root namespace
    Config.number("TIMEOUT"),
  ]),
  ([[host, port], timeout]) => new ServiceConfig(host, port, timeout),
)

现在,如果用这套配置运行应用,它会查找以下环境变量:

  • host 值使用 SERVER_HOST
  • port 值使用 SERVER_PORT
  • timeout 值使用 TIMEOUT

这种结构化的方式能让配置更有条理,尤其是在处理多个 service 或复杂应用时。

在测试中模拟配置

在测试 service 时,有时需要为测试提供特定的配置。为了模拟这种情况,可以模拟读取这些值的配置后端。

你可以使用 ConfigProvider.fromMap 构造器做到这一点。该方法允许你从一个 Map<string, string> 创建配置 provider,其中这个 map 表示配置数据。之后你只需调用 Effect.withConfigProvider,就可以用这个模拟 provider 替代默认 provider。

示例(为测试模拟 Config Provider)

import { Config, ConfigProvider, Effect } from "effect"

class HostPort {
  constructor(
    readonly host: string,
    readonly port: number,
  ) {}
  get url() {
    return `${this.host}:${this.port}`
  }
}

const config = Config.map(
  Config.all([Config.string("HOST"), Config.number("PORT")]),
  ([host, port]) => new HostPort(host, port),
)

const program = Effect.gen(function* () {
  const hostPort = yield* config
  console.log(`Application started: ${hostPort.url}`)
})

// Create a mock config provider using a map with test data
const mockConfigProvider = ConfigProvider.fromMap(
  new Map([
    ["HOST", "localhost"],
    ["PORT", "8080"],
  ]),
)

// Run the program using the mock config provider
Effect.runPromise(Effect.withConfigProvider(program, mockConfigProvider))
// Output: Application started: localhost:8080

这种方式有助于编写不依赖外部环境变量的隔离测试,确保测试在模拟配置下始终一致地运行。

处理嵌套配置值

对于更复杂的配置,键常常是嵌套的。默认情况下,ConfigProvider.fromMap 使用 . 作为嵌套键的分隔符。

示例(提供嵌套配置值)

import { Config, ConfigProvider, Effect } from "effect"

const config = Config.nested(Config.number("PORT"), "SERVER")

const program = Effect.gen(function* () {
  const port = yield* config
  console.log(`Server is running on port ${port}`)
})

// Mock configuration using '.' as the separator for nested keys
const mockConfigProvider = ConfigProvider.fromMap(
  new Map([["SERVER.PORT", "8080"]]),
)

Effect.runPromise(Effect.withConfigProvider(program, mockConfigProvider))
// Output: Server is running on port 8080

自定义路径分隔符

如果你的配置数据使用了别的分隔符(例如 _),可以通过 ConfigProvider.fromMappathDelim 选项更改分隔符。

示例(使用自定义路径分隔符)

import { Config, ConfigProvider, Effect } from "effect"

const config = Config.nested(Config.number("PORT"), "SERVER")

const program = Effect.gen(function* () {
  const port = yield* config
  console.log(`Server is running on port ${port}`)
})

// Mock configuration using '_' as the separator
const mockConfigProvider = ConfigProvider.fromMap(
  new Map([["SERVER_PORT", "8080"]]),
  {
    pathDelim: "_",
  },
)

Effect.runPromise(Effect.withConfigProvider(program, mockConfigProvider))
// Output: Server is running on port 8080

ConfigProvider

Effect 中的 ConfigProvider 模块允许应用从不同的来源加载配置值。默认 provider 从环境变量读取,但你可以在需要时自定义其行为。

从环境变量加载配置

ConfigProvider.fromEnv 函数会创建一个 ConfigProvider,它从环境变量加载值。除非另外指定,否则它就是 Effect 使用的默认 provider。

如果你的应用需要为嵌套配置键使用自定义分隔符,可以相应地配置 ConfigProvider.fromEnv

示例(更改路径分隔符)

下面这个示例修改了环境变量的路径分隔符("__")和序列分隔符("|")。

import { Config, ConfigProvider, Effect } from "effect"

const program = Effect.gen(function* () {
  // Read SERVER_HOST and SERVER_PORT as nested configuration values
  const port = yield* Config.nested(Config.number("PORT"), "SERVER")
  const host = yield* Config.nested(Config.string("HOST"), "SERVER")
  console.log(`Application started: ${host}:${port}`)
})

Effect.runPromise(
  Effect.withConfigProvider(
    program,
    // Custom delimiters
    ConfigProvider.fromEnv({ pathDelim: "__", seqDelim: "|" }),
  ),
)

为匹配自定义分隔符("__"),请像这样设置环境变量:

SERVER__HOST=localhost SERVER__PORT=8080 npx tsx index.ts

输出:

Application started: localhost:8080

从 JSON 加载配置

ConfigProvider.fromJson 函数会创建一个 ConfigProvider,它从 JSON 对象加载值。

示例(从 JSON 读取嵌套配置)

import { Config, ConfigProvider, Effect } from "effect"

const program = Effect.gen(function* () {
  // Read SERVER_HOST and SERVER_PORT as nested configuration values
  const port = yield* Config.nested(Config.number("PORT"), "SERVER")
  const host = yield* Config.nested(Config.string("HOST"), "SERVER")
  console.log(`Application started: ${host}:${port}`)
})

Effect.runPromise(
  Effect.withConfigProvider(
    program,
    ConfigProvider.fromJson(
      JSON.parse(`{"SERVER":{"PORT":8080,"HOST":"localhost"}}`),
    ),
  ),
)
// Output: Application started: localhost:8080

使用嵌套配置命名空间

ConfigProvider.nested 函数允许把配置值分组到某个命名空间下。当需要按逻辑组织各项设置时,这很有帮助,比如把与 SERVER 相关的值归到一组。

示例(使用嵌套命名空间)

import { Config, ConfigProvider, Effect } from "effect"

const program = Effect.gen(function* () {
  const port = yield* Config.number("PORT") // Reads SERVER_PORT
  const host = yield* Config.string("HOST") // Reads SERVER_HOST
  console.log(`Application started: ${host}:${port}`)
})

Effect.runPromise(
  Effect.withConfigProvider(
    program,
    ConfigProvider.fromEnv().pipe(
      // Uses SERVER as a namespace
      ConfigProvider.nested("SERVER"),
    ),
  ),
)

由于我们把 "SERVER" 定义为命名空间,环境变量必须遵循这种形式:

SERVER_HOST=localhost SERVER_PORT=8080 npx tsx index.ts

输出:

Application started: localhost:8080

把配置键转换为常量命名(constant case)

ConfigProvider.constantCase 函数会把所有配置键转换为常量命名(大写,用下划线连接)。当需要让环境变量适配不同的命名约定时,这很有用。

示例(对环境变量使用 constantCase

import { Config, ConfigProvider, Effect } from "effect"

const program = Effect.gen(function* () {
  const port = yield* Config.number("Port") // Reads PORT
  const host = yield* Config.string("Host") // Reads HOST
  console.log(`Application started: ${host}:${port}`)
})

Effect.runPromise(
  Effect.withConfigProvider(
    program,
    // Convert keys to constant case
    ConfigProvider.fromEnv().pipe(ConfigProvider.constantCase),
  ),
)

由于 constantCase 会把 "Port" 转换为 "PORT"、把 "Host" 转换为 "HOST",因此环境变量必须按下面这样设置:

HOST=localhost PORT=8080 npx tsx index.ts

输出:

Application started: localhost:8080

废弃项

Secret Deprecated

自 3.3.0 版本起废弃:今后处理敏感信息请使用 Config.redacted

Config.secret 函数过去用于保护敏感信息,方式与 Config.redacted 类似。它把配置值包装成 Secret 类型,这种类型在记录日志时同样会隐藏细节,但允许通过 Secret.value 访问真实值。

示例(使用已废弃的 Config.secret

import { Effect, Config, Secret } from "effect"

const program = Effect.gen(function* () {
  const secret = yield* Config.secret("API_KEY")

  // Log the secret value, which won't reveal the actual secret
  console.log(`Console output: ${secret}`)

  // Access the real value using Secret.value and log it
  console.log(`Actual value: ${Secret.value(secret)}`)
})

Effect.runPromise(program)

执行这个程序时:

API_KEY=my-api-key tsx secret.ts

输出结果如下:

Console output: Secret(<redacted>)
Actual value: my-api-key