你的应用程序在周五晚上三小时后抛出了 TypeError。堆栈跟踪指向配置对象中一个深层嵌套的属性。值是 undefined。有人在未更新验证逻辑的情况下,将 .env 更改推送到了生产环境。

这不是运行时缺陷。这是策略失败。你让不受信任的数据未经检查就进入了系统。

为什么运行时配置验证为时已晚

大多数团队被动地验证配置。缺少环境变量会导致崩溃。无效的 URL 字符串会一直传播,直到 fetch() 抛出。JSON 配置文件中的命名错误键会一直处于休眠状态,直到读取它的功能最终被激活——数天或数周后。

随着你在限界上下文内采用领域特定语言,问题会变得更糟。每个上下文都有自己的配置形态。支付服务关心 STRIPE_WEBHOOK_SECRET。通知服务关心 SNS_TOPIC_ARN。形态不同,但失效模式相同:无效配置进入系统,然后系统在无效数据最终被使用时失败。

TypeScript 在这里救不了你。process.env 返回 string | undefined。你的 Config 接口承诺一个字符串,但在这个接口被强制执行之前,它只是一个谎言。

解决方案是在边界验证配置,在它们到达运行时之前。

系统边界的模式验证

将配置视为不受信任的输入,因为它确实是。环境变量、JSON 文件和远程配置服务都应在应用程序使用它们之前通过模式验证器。

这是有效的形态:

  1. 定义一个与你的领域期望相匹配的模式。
  2. 通过该模式解析原始配置。
  3. 在其他所有地方使用解析后的带类型结果。

如果解析失败,应用程序会立即以清晰的错误退出。没有部分启动。没有缺少字段的配置对象漂移到代码的其余部分。

以下是使用 Zod 的具体示例:

import { z } from "zod";

const PaymentConfig = z.object({
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  STRIPE_WEBHOOK_SECRET: z.string().min(20),
  MAX_RETRY_ATTEMPTS: z
    .string()
    .transform((s) => parseInt(s, 10))
    .pipe(z.number().min(1).max(10)),
});

const raw = {
  STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
  STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
  MAX_RETRY_ATTEMPTS: process.env.MAX_RETRY_ATTEMPTS ?? "3",
};

const config = PaymentConfig.parse(raw);

如果缺少 STRIPE_SECRET_KEY,应用程序会在挂载任何路由之前抛出。如果 MAX_RETRY_ATTEMPTS"fifteen",解析会以清晰的错误失败。解析后 config 的类型正是 PaymentConfig 所描述的,而不是 string | undefined

按上下文的模式,而非单一全局对象

当你按限界上下文对配置建模时,你可以避免将每个环境变量都倾倒到每个模块都导入的单一 Config 对象中的诱惑。该全局对象会产生隐藏的耦合。通知服务配置的更改需要触及支付服务也导入的文件。

相反,每个限界上下文定义自己的模式:

// contexts/payment/config.ts
export const PaymentConfig = z.object({
  STRIPE_SECRET_KEY: z.string(),
  WEBHOOK_ENDPOINT: z.string().url(),
});

// contexts/notification/config.ts
export const NotificationConfig = z.object({
  SNS_TOPIC_ARN: z.string().startsWith("arn:aws:sns:"),
  RATE_LIMIT_PER_MINUTE: z.number().default(100),
});

每个上下文只解析它需要的内容。支付服务看不到通知变量,反之亦然。这反映了你在代码中已经划定的领域边界。

使用生成类型进行构建时验证

启动时解析可以捕获大多数问题,但你可以更进一步。如果你的配置位于静态文件中,请在构建时验证它们。

考虑一个定义每个环境功能标志的 JSON 配置文件:

{
  "features": {
    "newCheckout": {
      "enabled": true,
      "rolloutPercentage": 50
    }
  }
}

定义一个模式,然后在构建步骤中验证 JSON 文件:

import { z } from "zod";
import featureFlags from "./feature-flags.json";

const FeatureFlagConfig = z.object({
  features: z.record(
    z.object({
      enabled: z.boolean(),
      rolloutPercentage: z.number().min(0).max(100),
    })
  ),
});

// This runs during `tsc` or `vite build` if you import the result
export const validatedFlags = FeatureFlagConfig.parse(featureFlags);

如果 JSON 文件格式错误,TypeScript 将拒绝编译。错误会出现在 CI 中,而不是生产环境。

对于环境变量,它们本质上是运行时数据,你无法在构建时完全验证它们。但你可以生成一个在启动时快速失败的类型安全访问器:

function env(name: string, schema: z.ZodTypeAny): z.infer<typeof schema> {
  const value = process.env[name];
  const result = schema.safeParse(value);
  if (!result.success) {
    console.error(`Invalid env var ${name}:`, result.error.flatten());
    process.exit(1);
  }
  return result.data;
}

const dbUrl = env("DATABASE_URL", z.string().url());

这种模式使失败集中化。缺少或无效的环境变量导致崩溃的地点恰好只有一个,而且它发生在服务器开始接受流量之前。

权衡:严格性与部署摩擦

模式验证并非没有代价。主要成本是部署摩擦。

如果你的模式过于严格,添加到暂存环境但尚未添加到生产环境的新环境变量将阻止部署。队友将 FEATURE_X_ENABLED=false 添加到暂存 .env 并推送读取它的代码。生产环境在启动时崩溃,因为那里缺少该变量。

你有两种处理方法。

首先,对具有安全回退的变量使用显式默认值:

const config = z.object({
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
  ENABLE_METRICS: z.string().transform((s) => s === "true").default("false"),
});

其次,清楚地区分必需配置和可选配置。必填字段应代表应用程序真正无法在没有它们的情况下运行的事物。可选字段应具有默认值或在缺失时作为禁用功能处理。

另一个权衡是模式库的大小。Zod 向你的包中添加大约 10KB。如果你处于受限环境中,请考虑 Valibot 或 ArkType,它们以较小的体积提供类似的 API。模式比库更重要。

当生成类型不够时

一些团队从他们的配置文件中生成 TypeScript 类型,并称其为已验证。类型定义在运行时不会强制执行任何操作。如果 JSON 文件在类型生成后改变了形态,除非在编译期间导入并检查该文件,否则 TypeScript 不会捕获它。

运行时解析弥补了这一差距。模式既是验证器也是类型的单一真相来源。没有可能不同步的单独类型定义。

在限界上下文中整合

以下是单个上下文的完整模式:

// contexts/billing/config.ts
import { z } from "zod";

const RawBillingConfig = z.object({
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  STRIPE_WEBHOOK_SECRET: z.string(),
  INVOICE_GRACE_PERIOD_DAYS: z
    .string()
    .transform(Number)
    .pipe(z.number().min(0)),
});

export type BillingConfig = z.infer<typeof RawBillingConfig>;

export function loadBillingConfig(): BillingConfig {
  const raw = {
    STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
    STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
    INVOICE_GRACE_PERIOD_DAYS: process.env.INVOICE_GRACE_PERIOD_DAYS,
  };

  const result = RawBillingConfig.safeParse(raw);
  if (!result.success) {
    const issues = result.error.issues
      .map((i) => `${i.path.join(".")}: ${i.message}`)
      .join("\n");
    throw new Error(`Billing config invalid:\n${issues}`);
  }

  return result.data;
}

在你的入口点导入 loadBillingConfig(),调用一次,并将结果传递到你上下文的初始化中。此上下文之外的任何模块都不应直接读取 process.env

下一步该做什么

审核你的代码库中配置文件之外直接读取 process.env 的地方。每一个都是等待正确部署触发的潜在运行时故障。

用按上下文的模式替换它们。选择一个库,定义形态,并在启动时快速失败。当缺少的环境变量在 CI 中干净地退出你的应用程序,而不是在生产环境中抛出时,你将第一次知道边界正在起作用。