你的應用程式在週五晚上三小時後拋出了 TypeError。stack trace指向設定物件中一個深層巢狀的屬性。值是 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 中乾淨地退出你的應用程式,而不是在生產環境中拋出時,你將第一次知道邊界正在起作用。