🎯 什麼情境該想到我
當你「使用者需要自己表達邏輯」的時候——篩選條件、告警規則、計價公式、權限表達式、模板語法。
★ 但這是 GoF 二十三個模式裡最該「三思而後行」的一個——本頁一半在講什麼時候不該自己做。
⚙️ 怎麼用
意圖
給定一個語言,定義它的文法表示,並定義一個直譯器,使用該表示來解釋語言中的句子。
它由兩部分組成,而 GoF 只談了第二部分:
文字 ──【Parser(GoF 沒談)】──▶ AST ──【Interpreter(這個模式)】──▶ 結果
★ Go 的長相:一個表達式求值器
// AST 節點
type Expr interface {
Eval(ctx Context) (any, error)
}
type Literal struct{ Val any }
func (l Literal) Eval(Context) (any, error) { return l.Val, nil }
type FieldRef struct{ Name string }
func (f FieldRef) Eval(ctx Context) (any, error) {
v, ok := ctx[f.Name]
if !ok { return nil, fmt.Errorf("unknown field %q", f.Name) }
return v, nil
}
type BinaryOp struct {
Op string
L, R Expr
}
func (b BinaryOp) Eval(ctx Context) (any, error) {
l, err := b.L.Eval(ctx)
if err != nil { return nil, err }
// ★ 短路求值:and/or 要在算右邊之前就決定
switch b.Op {
case "and":
if !truthy(l) { return false, nil }
case "or":
if truthy(l) { return true, nil }
}
r, err := b.R.Eval(ctx)
if err != nil { return nil, err }
return apply(b.Op, l, r)
}
// 使用:status == "active" and amount > 1000
expr := BinaryOp{"and",
BinaryOp{"==", FieldRef{"status"}, Literal{"active"}},
BinaryOp{">", FieldRef{"amount"}, Literal{1000.0}},
}
ok, _ := expr.Eval(Context{"status": "active", "amount": 5000.0})★ AST 節點就是一個 組合模式(Literal 是葉、BinaryOp 是容器),
而「加新操作」(型別檢查、最佳化、轉成 SQL)用 訪問者模式 或 type switch。
★ 為什麼它可序列化很重要
// AST 可以存成 JSON → ★ 規則可以存進資料庫、由非工程師編輯、熱更新
{"op":"and","l":{"op":"==","l":{"field":"status"},"r":{"lit":"active"}},
"r":{"op":">","l":{"field":"amount"},"r":{"lit":1000}}}★ 這才是直譯器模式在實務上的真正價值:
把「邏輯」從程式碼變成資料 → 可儲存、可版控、可由 UI 產生、可不重新部署就更新。
如果你不需要這件事,直接寫 Go 函式就好。
★ 什麼時候不要自己做(這一段才是重點)
| 情況 | 用什麼取代 |
|---|---|
| 只要簡單的篩選條件 | ★ 結構化的條件物件:{Field, Op, Value} 的列表 + AND/OR 組合。不需要 parser,不需要語法,UI 直接產生 —— 90% 的需求這樣就夠 |
| 要類似程式語言的表達式 | ★ 用現成的:expr-lang/expr、google/cel-go(CEL 是 Kubernetes 與 Envoy 在用的)、antonmedv/expr |
| 要模板 | text/template、html/template |
| 要查詢語法 | 用既有的(SQL 子集、Lucene 語法、JSONPath) |
| 真的需要自訂語言 | 用 parser generator(ANTLR、goyacc)或 parser combinator,不要手刻 |
★ 面試與實務的共同答案:
「先問這個 DSL 值不值得。」 自己做一個語言的總成本遠超過寫 parser:
錯誤訊息、行號定位、語法高亮、自動完成、文件、版本相容、安全性、除錯工具、使用者教育——
這些加起來通常是 parser 的十倍工作量,而且會持續消耗維護力。
TypeScript 的長相
type Expr =
| { t: "lit"; v: unknown }
| { t: "field"; name: string }
| { t: "bin"; op: "=="|">"|"and"|"or"; l: Expr; r: Expr };
function evaluate(e: Expr, ctx: Record<string, unknown>): unknown {
switch (e.t) {
case "lit": return e.v;
case "field": return ctx[e.name];
case "bin": {
if (e.op === "and") return truthy(evaluate(e.l, ctx)) && truthy(evaluate(e.r, ctx));
if (e.op === "or") return truthy(evaluate(e.l, ctx)) || truthy(evaluate(e.r, ctx));
return apply(e.op, evaluate(e.l, ctx), evaluate(e.r, ctx));
}
}
}⚠️ JS 的
eval()和new Function()看起來是最快的捷徑——但它是嚴重的安全漏洞。
絕對不要對使用者輸入使用它們。 需要沙箱就用 QuickJS/isolated-vm 之類的隔離執行環境。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
- ★ 安全性是第一考量。使用者可寫的表達式是一個執行面:
- 無窮迴圈 / 遞迴炸彈 → 必須有步數上限與執行逾時
- 資源耗盡(大量字串拼接、巨大陣列)→ 記憶體上限
- 存取不該存取的東西 → 白名單欄位與函式,不要暴露反射或 I/O
- ★ CEL 的設計目標之一就是「保證終止」——這是它在 K8s 被採用的原因
- ★ 效能:直譯每次都要走一遍 AST。熱路徑上要編譯成 closure 或位元組碼(一次編譯多次執行),
或至少快取編譯結果。 - ★ 錯誤訊息決定使用者體驗。
syntax error是不可接受的——
要有行號、欄位、期望什麼、收到什麼。這通常比求值邏輯更花時間。 - 型別問題:動態求值下
"5" > 3該怎麼辦?要明確定義型別規則並寫進文件,否則會有無窮的邊界爭議。 - 短路求值不能漏(見上方程式碼)。
and/or若總是求值兩邊,會造成非預期的錯誤或副作用。 - ★ 文法一旦發布就很難改。使用者寫的規則存在資料庫裡,語法變更要能遷移舊規則。
- ★ 最常見的正確答案是「不要做」。先用結構化條件物件,真的撐不住再考慮 DSL。
🔗 相關工具
- 組合模式 —— AST 就是組合結構
- 訪問者模式 —— 對 AST 加新操作
- 迭代器模式 —— 走訪 AST
- 工具-用假API差分測試逆向CLI行為 —— 驗證直譯器行為的一種手法
- 工具-讓LLM產出可驗證的產物 —— 結構化輸出優於自由文字,同一個思路
- 工具-管理複雜度 —— 自製 DSL 是複雜度的重大來源
- 設計模式