staging 環境運作正常。production 環境運作異常。它們的 .env 檔案之間的差異高達 400 行,其中一半是再也沒人相信的註解。上個月有人往 staging 裡加了 FEATURE_X_ENABLED=true。沒人往 production 裡加。應用程式還是啟動了,使用了硬式編碼的預設值,現在你的功能旗標不同步了。
這就是跨環境配置的常態。它不是金鑰管理問題。不是缺少 Terraform。它是一個語言問題。你正在用一種對結構、環境或依賴關係毫無概念的格式,來表達結構化且環境相關的配置。
扁平鍵值檔案在規模擴大時會腐敗
環境變數和扁平 YAML 檔案有著同樣的缺陷:它們把配置當作一個不加區分的字串袋子。無法區分哪些值應該在所有環境中保持一致,哪些值是故意不同的,哪些值是意外缺失的。
在典型的配置中,staging.env 和 production.env 一開始是彼此的副本。隨著時間推移,它們開始分化。一位同事往 staging 裡加了變數來測試功能。另一位同事因為部署緊急,只在 production 裡改了鍵名,staging 裡沒改。第三位同事因為某個檔案裡缺少變數,就在程式碼裡假設了一個預設值,而這個預設值對另一個環境來說是錯誤的。
腐敗在引發事故之前是看不見的。到那時,你的 .env 檔案已經成了唯寫工件。害怕破壞某些東西阻礙了清理工作。混亂不斷加劇。
為什麼限界上下文需要自己的配置語言
領域驅動設計(Domain-Driven Design)將系統劃分為限界上下文(bounded context),每個上下文都有自己的模型和語言。配置也應遵循同樣的邊界。支付服務關心 STRIPE_WEBHOOK_SECRET 和 INVOICE_GRACE_PERIOD_DAYS。通知服務關心 SNS_TOPIC_ARN 和 RATE_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 被顯式覆蓋為 5。logLevel 在 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 有 serde 和 validator。Go 有 go-playground/validator。DSL 層只是一個將基礎物件與覆蓋值合併並驗證結果的函式。
什麼時候這樣做是過度設計?
如果你的應用程式的配置值少於十二個,且只有一個環境,.env 檔案更簡單。當你擁有多個環境、多個團隊或有配置相關事故的歷史時,再採用 DSL 模式。
接下來該做什麼
稽核你目前的 .env 檔案或配置 YAML,找出跨環境相同的值。這些就是你的基礎預設值。任何不同的都是覆蓋值。選擇一個限界上下文,定義它的模式,並將這些值移入一個讓差異顯式的結構。當你的 CI 第一次在缺失的 staging 變數到達 production 之前捕獲它時,邊界就開始生效了。