你的应用程序在周五晚上三小时后抛出了 TypeError。堆栈跟踪指向配置对象中一个深层嵌套的属性。值是 undefined。有人在未更新验证逻辑的情况下,将 .env 更改推送到了生产环境。
这不是运行时缺陷。这是策略失败。你让不受信任的数据未经检查就进入了系统。
为什么运行时配置验证为时已晚
大多数团队被动地验证配置。缺少环境变量会导致崩溃。无效的 URL 字符串会一直传播,直到 fetch() 抛出。JSON 配置文件中的命名错误键会一直处于休眠状态,直到读取它的功能最终被激活——数天或数周后。
随着你在限界上下文内采用领域特定语言,问题会变得更糟。每个上下文都有自己的配置形态。支付服务关心 STRIPE_WEBHOOK_SECRET。通知服务关心 SNS_TOPIC_ARN。形态不同,但失效模式相同:无效配置进入系统,然后系统在无效数据最终被使用时失败。
TypeScript 在这里救不了你。process.env 返回 string | undefined。你的 Config 接口承诺一个字符串,但在这个接口被强制执行之前,它只是一个谎言。
解决方案是在边界验证配置,在它们到达运行时之前。
系统边界的模式验证
将配置视为不受信任的输入,因为它确实是。环境变量、JSON 文件和远程配置服务都应在应用程序使用它们之前通过模式验证器。
这是有效的形态:
- 定义一个与你的领域期望相匹配的模式。
- 通过该模式解析原始配置。
- 在其他所有地方使用解析后的带类型结果。
如果解析失败,应用程序会立即以清晰的错误退出。没有部分启动。没有缺少字段的配置对象漂移到代码的其余部分。
以下是使用 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 中干净地退出你的应用程序,而不是在生产环境中抛出时,你将第一次知道边界正在起作用。