staging 环境运行正常。production 环境运行异常。它们的 .env 文件之间的差异高达 400 行,其中一半是从无人再相信的注释。上个月有人往 staging 里加了 FEATURE_X_ENABLED=true。没人往 production 里加。应用程序还是启动了,使用了硬编码的默认值,现在你的功能标志不同步了。

这就是跨环境配置的常态。它不是密钥管理问题。不是缺少 Terraform。它是一个语言问题。你正在用一种对结构、环境或依赖关系毫无概念的格式,来表达结构化且环境相关的配置。

扁平键值文件在规模扩大时会腐烂

环境变量和扁平 YAML 文件有着同样的缺陷:它们把配置当作一个不加区分的字符串袋子。无法区分哪些值应该在所有环境中保持一致,哪些值是故意不同的,哪些值是意外缺失的。

在典型的配置中,staging.envproduction.env 一开始是彼此的副本。随着时间推移,它们开始分化。一位同事往 staging 里加了变量来测试功能。另一位同事因为部署紧急,只在 production 里改了键名,staging 里没改。第三位同事因为某个文件里缺少变量,就在代码里假设了一个默认值,而这个默认值对另一个环境来说是错误的。

腐烂在引发事故之前是看不见的。到那时,你的 .env 文件已经成了只写工件。害怕破坏某些东西阻碍了清理工作。混乱不断加剧。

为什么限界上下文需要自己的配置语言

领域驱动设计(Domain-Driven Design)将系统划分为限界上下文(bounded context),每个上下文都有自己的模型和语言。配置也应遵循同样的边界。支付服务关心 STRIPE_WEBHOOK_SECRETINVOICE_GRACE_PERIOD_DAYS。通知服务关心 SNS_TOPIC_ARNRATE_LIMIT_PER_MINUTE。它们的配置形态毫无共同之处,因此不应该共享配置文件。

大多数团队忽略的洞察:配置不仅仅是值。它是模式、一组默认值,以及一组环境特定的覆盖值。当你把这三者当作一个扁平文件处理时,你就失去了对其中任何一个进行推理的能力。

每个限界上下文一个配置 DSL,可以让结构变得显式。它将共享默认值与环境覆盖值分离,并在应用程序启动前验证合并后的结果。

TypeScript 中一个可工作的配置 DSL

以下是一个按上下文定义配置的小型 DSL。它使用 Zod 进行验证,使用纯对象表示环境覆盖值。这个模式在 Python、Rust 或 Go 中是一样的,只有验证库不同。

首先是 DSL 机制:

import { z } from "zod";

type ConfigDef<S extends z.ZodTypeAny> = {
  schema: S;
  base: z.infer<S>;
  environments: Record<string, Partial<z.infer<S>>>;
};

function defineConfig<S extends z.ZodTypeAny>(def: ConfigDef<S>) {
  return (env: string): z.infer<S> => {
    const overlay = def.environments[env] ?? {};
    const merged = { ...def.base, ...overlay };
    return def.schema.parse(merged);
  };
}

defineConfig 接收一个模式、一个基础配置对象和一个环境覆盖值的映射。它返回一个函数,该函数接收环境名称,将基础值与覆盖值合并,并验证结果。如果缺少必填字段或类型错误,解析会在服务器启动前抛出异常。

现在一个限界上下文使用它:

// contexts/payment/config.ts
const loadPaymentConfig = defineConfig({
  schema: z.object({
    stripeSecretKey: z.string().startsWith("sk_"),
    webhookEndpoint: z.string().url(),
    retryAttempts: z.number().min(1).max(10),
    logLevel: z.enum(["debug", "info", "warn", "error"]),
  }),
  base: {
    retryAttempts: 3,
    logLevel: "info",
  },
  environments: {
    development: {
      stripeSecretKey: "sk_test_dummy",
      webhookEndpoint: "http://localhost:3000/webhook",
      logLevel: "debug",
    },
    staging: {
      stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
      webhookEndpoint: "https://staging.example.com/webhook",
    },
    production: {
      stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
      webhookEndpoint: "https://api.example.com/webhook",
      retryAttempts: 5,
    },
  },
});

const config = loadPaymentConfig(process.env.APP_ENV ?? "development");

注意这里发生了什么。retryAttempts 在任何地方都默认为 3,除了 production 被显式覆盖为 5logLevel 在 development 中是 "debug",在其他地方是 "info",因为基础值是 "info",只有 development 覆盖了它。没有共享值的重复。无需猜测哪个文件拥有规范的默认值。

如果有人忘记在 staging 中设置 STRIPE_SECRET_KEY,应用程序会在启动时带着明确的错误退出。失败发生在 CI 中,而不是 production 中。

这如何阻止配置漂移

漂移发生在两个环境意外分化时。DSL 通过三种方式防止这种情况。

第一,共享值存在于一个地方:base 对象。如果你更改默认重试策略,只需更改一行。每个环境都会自动获得它,除非它有显式覆盖值。

第二,环境覆盖值是完整的对象,而不是文件之间的行级差异。查看一个对象就能看到与基础值不同的每个值。无需对两个 .env 文件做差异对比,也无需猜测哪些行是重要的。

第三,模式强制完整性。如果你向模式添加一个新的必填字段,每个未提供该字段的环境对象都会验证失败。你不能在 production 中添加字段却忘记 staging。编译器和验证器会提醒你。

权衡:显式结构需要显式投入

这种模式不是免费的。它需要前期设计。必须有人定义模式,决定哪些值是基础值、哪些是覆盖值,并维护 DSL 机制。

如果你的团队很小,配置只有十个变量,.env 文件就够了。在你感受到漂移的痛苦之前,模式和合并层的开销是不值得的。

另一个代价是过度抽象的诱惑。DSL 可能成长为覆盖组织中每个服务的通用配置框架。抵制这种诱惑。这个模式的全部意义在于让配置保持在每个限界上下文的本地。如果你的支付 DSL 和通知 DSL 被强制塞进同一个模式,你只不过是用额外的步骤重新创造了单体 .env 文件。

密钥是另一个考虑因素。示例从 process.env 读取 STRIPE_SECRET_KEY,但你可以从保险库中拉取。DSL 不关心值来自哪里。它只关心在应用程序使用它们之前,它们与模式匹配。

团队采用这种模式时常犯的错误

最常见的错误是把模式当作可选文档。团队定义了一个 TypeScript 接口,但从不进行运行时验证。接口是一种承诺,不是检查。如果缺少 staging 环境变量,TypeScript 不会捕获它,因为 process.env 是在运行时填充的。

验证必须在启动时、在提供任何请求之前发生。模式是类型和值的唯一可信来源(source of truth)。

第二个错误是过早地将环境合并到单个配置定义中。从一个限界上下文开始。将其配置提取到模式和覆盖值中。让这个模式自我证明。

整合:三步迁移

如果你从一堆 .env 文件开始,这里有一条不需要大爆炸式重写的路径。

第一步:选择配置相关事故最多的限界上下文。那是痛苦最大、团队最能快速感受到收益的地方。

第二步:清点该上下文读取的每个配置值。将它们分类为密钥、环境特定的非密钥和共享默认值。编写一个捕获形状和约束的 Zod 模式。

第三步:将该上下文中直接的 process.env 读取替换为使用模式和覆盖值的单个 loadConfig() 调用。部署到一个环境,观察它在 CI 中捕获缺失值,然后修复它。验证会立即证明其价值。

对下一个上下文重复上述步骤。经过三四个上下文后,这个模式会自我维持。团队会主动采用它,因为他们亲眼见过它防止事故。

FAQ

什么是配置 DSL?

配置 DSL 是一种小型的领域特定语言,用于在限界上下文内定义配置。它通常包括模式、基础值和环境覆盖值。它的结构刚好足以使配置显式,并在运行前对其进行验证。

为什么不直接使用集中式配置服务?

集中式服务对功能标志等动态值很有用。静态配置(如 API 端点和重试策略)很少在运行时更改。从远程服务获取会增加启动时的网络依赖。DSL 模式在本地处理静态配置,将动态值留给服务。

如何处理密钥?

密钥应通过环境变量或保险库进入系统,但仍应通过模式验证器。在模式中将它们定义为必填字符串。在环境覆盖对象内从 process.env 或保险库客户端读取它们。如果密钥缺失,验证失败,应用程序会干净退出。

可以不用 TypeScript 吗?

可以。这种模式适用于任何带有验证库的语言。Python 有 Pydantic。Rust 有 serdevalidator。Go 有 go-playground/validator。DSL 层只是一个将基础对象与覆盖值合并并验证结果的函数。

什么时候这样做是过度设计?

如果你的应用程序的配置值少于十二个,且只有一个环境,.env 文件更简单。当你拥有多个环境、多个团队或有配置相关事故的历史时,再采用 DSL 模式。

接下来该做什么

审计你当前的 .env 文件或配置 YAML,找出跨环境相同的值。这些就是你的基础默认值。任何不同的都是覆盖值。选择一个限界上下文,定义它的模式,并将这些值移入一个让差异显式的结构。当你的 CI 第一次在缺失的 staging 变量到达 production 之前捕获它时,边界就开始生效了。