🎯 什麼情境該想到我

當你「使用者需要自己表達邏輯」的時候——篩選條件、告警規則、計價公式、權限表達式、模板語法。
★ 但這是 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。

🔗 相關工具