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