跳转到内容

第 27 章 · 解释器(Interpreter)

一句话:给一门很小的语言定好文法,每条文法规则写成一个类,句子变成这些类组成的树,对树调用 interpret 就得到结果。 解决的痛:运营每想一条新的筛选规则,开发就要写一段条件、发一次版;有人为了省事拿 eval 直接执行运营敲的字符串,后台输入框从此能执行任意代码。

多练网周边商城的首页有好几个专区:“百元新品”“有货图书”……每个专区背后是一条商品筛选规则,运营用一行文字写,比如 price < 100 and tag = 新品、category = 图书 or stock > 0。

商品能筛的字段有四个:价格 price、标签 tag、分类 category、库存 stock。现在要做的是:给定一条规则,从商品列表里挑出符合的商品。

规则不多,运营后台给一个下拉框,开发把每条规则写成一段条件,用规则文本当键:

from dataclasses import dataclass
from typing import Callable
@dataclass
class Product:
name: str
price: int
tag: str
category: str
stock: int
PRODUCTS = [
Product("机械键盘", 399, "热卖", "外设", 12),
Product("设计模式手册", 69, "新品", "图书", 0),
Product("鼠标垫", 29, "清仓", "外设", 40),
Product("算法图解", 59, "热卖", "图书", 8),
Product("帆布袋", 45, "新品", "周边", 0),
]
# 运营后台下拉框里的每条规则,都在这里对应一段写死的条件
RULES: dict[str, Callable[[Product], bool]] = {
"price < 100 and tag = 新品": lambda p: p.price < 100 and p.tag == "新品",
"category = 图书 or stock > 0": lambda p: p.category == "图书" or p.stock > 0,
}
def select(rule: str, products: list[Product]) -> list[str]:
check = RULES.get(rule)
if check is None:
raise ValueError(f"不支持的规则:{rule}")
return [p.name for p in products if check(p)]
if __name__ == "__main__":
for rule in RULES:
print(f"{rule} -> {'、'.join(select(rule, PRODUCTS))}")
# 输出:
# price < 100 and tag = 新品 -> 设计模式手册、帆布袋
# category = 图书 or stock > 0 -> 机械键盘、设计模式手册、鼠标垫、算法图解

TS 版一样:

type Product = { name: string; price: number; tag: string; category: string; stock: number };
const PRODUCTS: Product[] = [
{ name: "机械键盘", price: 399, tag: "热卖", category: "外设", stock: 12 },
{ name: "设计模式手册", price: 69, tag: "新品", category: "图书", stock: 0 },
{ name: "鼠标垫", price: 29, tag: "清仓", category: "外设", stock: 40 },
{ name: "算法图解", price: 59, tag: "热卖", category: "图书", stock: 8 },
{ name: "帆布袋", price: 45, tag: "新品", category: "周边", stock: 0 },
];
// 运营后台下拉框里的每条规则,都在这里对应一段写死的条件
const RULES: Record<string, (p: Product) => boolean> = {
"price < 100 and tag = 新品": (p) => p.price < 100 && p.tag === "新品",
"category = 图书 or stock > 0": (p) => p.category === "图书" || p.stock > 0,
};
function select(rule: string, products: Product[]): string[] {
const check = RULES[rule];
if (check === undefined) throw new Error(`不支持的规则:${rule}`);
return products.filter(check).map((p) => p.name);
}
for (const rule of Object.keys(RULES)) {
console.log(`${rule} -> ${select(rule, PRODUCTS).join("、")}`);
}
// 输出:
// price < 100 and tag = 新品 -> 设计模式手册、帆布袋
// category = 图书 or stock > 0 -> 机械键盘、设计模式手册、鼠标垫、算法图解

两条规则两段 lambda,一眼能看懂。规则一个月才加一两条时,大多数人第一版都这么写。

新需求是:规则由运营在后台直接输入,保存后立刻生效,不再走发版;还要支持括号和 not,比如 price < 100 and not tag = 清仓。

要改的地方:

  1. RULES 字典:运营能敲出的规则无穷多,字典只列得出写好的几条,每加一条还是改代码、发版。
  2. not 和括号:能和已有条件任意组合,a and not b、(a or b) and c……每种组合又是一个新条目。
  3. 有人想绕过发版:把规则里的 = 换成 ==,交给 Python 的 eval 去算。

运营在后台敲了第一条新规则。把 before.py 的入口换成下面这样(文件头加 import ast),看看会发生什么:

if __name__ == "__main__":
typed = "price < 100 and not tag = 清仓" # 运营在后台敲的新规则
try:
select(typed, PRODUCTS)
except ValueError as e:
print(e)
for text in ["100 < 200", "price < 100"]: # 有人提议用"安全的" ast.literal_eval
try:
ast.literal_eval(text)
except ValueError as e:
print(f"literal_eval({text!r}):{type(e).__name__}")

运行结果如下:

不支持的规则:price < 100 and not tag = 清仓
literal_eval('100 < 200'):ValueError
literal_eval('price < 100'):ValueError

第一行就是 ①②:规则文本对不上字典里的键,专区直接空了。

第 ③ 条更糟。eval 会把整个字符串当 Python 代码执行:有人在输入框里填 __import__('os') 开头、后面接删文件调用的一串,服务器就照着执行。把 __builtins__ 清空也挡不住,绕过的写法很多。何况它本身也不好用:tag == 新品 里的“新品”被当成变量名,报 NameError;price <= 60 换完成了 price <== 60,语法错误。

有人提议换成只认字面量的 ast.literal_eval,安全是安全,但后两行说明它连 100 < 200 都不算,更别说读商品字段。

问题的形状是:规则是一门小语言,写死只能覆盖列出来的几句,用 eval 又把整门 Python 交给了输入框。

直觉写法:规则文本查字典,查不到就报错;绕过发版的 eval 让输入框能执行代码

浅红的 ①② 是字典和组合爆炸,③ 是 eval 应急;加粗的两个框是事故和风险。

先把这门小语言的文法(grammar)写出来,一共四行:

or_expr = and_expr ("or" and_expr)*
and_expr = unary ("and" unary)*
unary = "not" unary | "(" or_expr ")" | compare
compare = field op value field 只能是 price tag category stock,op 是 < <= > >= =

解释器模式的做法是:每条文法规则一个类,Compare(field, op, value)、And、Or、Not,都实现 interpret(product) -> bool。一条规则就是这些对象组成的一棵树,对树根调 interpret,它再调子节点的 interpret,层层往下。

把字符串变成树的是 Parser,一个几十行的递归下降解析器(recursive descent parser):文法前三行各对应它的一个方法,第四行写在 unary 的末尾。解析器是配角,不属于 GoF 的解释器模式。GoF 只管“树已经有了,怎么解释”,怎么建树留给客户端自己解决。

import re
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Callable
@dataclass
class Product:
name: str
price: int
tag: str
category: str
stock: int
PRODUCTS = [
Product("机械键盘", 399, "热卖", "外设", 12),
Product("设计模式手册", 69, "新品", "图书", 0),
Product("鼠标垫", 29, "清仓", "外设", 40),
Product("算法图解", 59, "热卖", "图书", 8),
Product("帆布袋", 45, "新品", "周边", 0),
]
FIELDS = {"price", "tag", "category", "stock"} # 规则里只能出现这几个字段
OPS: dict[str, Callable[[Any, Any], bool]] = {
"<": lambda a, b: a < b, "<=": lambda a, b: a <= b,
">": lambda a, b: a > b, ">=": lambda a, b: a >= b,
"=": lambda a, b: a == b,
}
class Expr(ABC): # 抽象表达式:每个节点都能对一件商品给出真假
@abstractmethod
def interpret(self, product: Product) -> bool: ...
class Compare(Expr): # 终结符表达式:field op value
def __init__(self, field: str, op: str, value: int | str) -> None:
self.field, self.op, self.value = field, op, value
def interpret(self, product: Product) -> bool:
return OPS[self.op](getattr(product, self.field), self.value)
def __str__(self) -> str:
return f"{self.field} {self.op} {self.value}"
class And(Expr): # 非终结符表达式:两边都为真
def __init__(self, left: Expr, right: Expr) -> None:
self.left, self.right = left, right
def interpret(self, product: Product) -> bool:
return self.left.interpret(product) and self.right.interpret(product)
def __str__(self) -> str:
return f"({self.left} and {self.right})"
class Or(Expr): # 非终结符表达式:有一边为真
def __init__(self, left: Expr, right: Expr) -> None:
self.left, self.right = left, right
def interpret(self, product: Product) -> bool:
return self.left.interpret(product) or self.right.interpret(product)
def __str__(self) -> str:
return f"({self.left} or {self.right})"
class Not(Expr): # 非终结符表达式:取反
def __init__(self, operand: Expr) -> None:
self.operand = operand
def interpret(self, product: Product) -> bool:
return not self.operand.interpret(product)
def __str__(self) -> str:
return f"not {self.operand}"
class RuleError(ValueError):
pass
class Parser: # 配角:递归下降,把规则字符串变成表达式树;不属于 GoF 的解释器模式
def __init__(self, text: str) -> None:
self.tokens: list[str] = re.findall(r"<=|>=|[()<>=]|[^\s()<>=]+", text)
self.pos = 0
def parse(self) -> Expr:
expr = self.or_expr()
if self.pos < len(self.tokens):
raise RuleError(f"多出来的 {self.tokens[self.pos]}")
return expr
def peek(self) -> str | None:
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
def take(self) -> str:
if self.pos >= len(self.tokens):
raise RuleError("规则没写完")
self.pos += 1
return self.tokens[self.pos - 1]
def or_expr(self) -> Expr: # or_expr = and_expr ("or" and_expr)*
left = self.and_expr()
while self.peek() == "or":
self.take()
left = Or(left, self.and_expr())
return left
def and_expr(self) -> Expr: # and_expr = unary ("and" unary)*
left = self.unary()
while self.peek() == "and":
self.take()
left = And(left, self.unary())
return left
def unary(self) -> Expr: # unary = "not" unary | "(" or_expr ")" | compare
if self.peek() == "not":
self.take()
return Not(self.unary())
if self.peek() == "(":
self.take()
expr = self.or_expr()
if self.take() != ")":
raise RuleError("缺右括号")
return expr
field, op, value = self.take(), self.take(), self.take()
if field not in FIELDS:
raise RuleError(f"未知字段 {field}")
if op not in OPS:
raise RuleError(f"未知运算符 {op}")
return Compare(field, op, int(value) if value.isdigit() else value)
if __name__ == "__main__":
rules = [
"price < 100 and tag = 新品",
"category = 图书 or stock > 0",
"price < 100 and not tag = 清仓",
"(category = 图书 or category = 周边) and price <= 60",
"__import__('os')",
]
for text in rules: # 规则来自运营后台,不用发版
try:
tree = Parser(text).parse()
except RuleError as e:
print(f"{text} -> 规则有误:{e}")
continue
names = [p.name for p in PRODUCTS if tree.interpret(p)]
print(f"{tree} -> {'、'.join(names)}")
# 输出:
# (price < 100 and tag = 新品) -> 设计模式手册、帆布袋
# (category = 图书 or stock > 0) -> 机械键盘、设计模式手册、鼠标垫、算法图解
# (price < 100 and not tag = 清仓) -> 设计模式手册、算法图解、帆布袋
# ((category = 图书 or category = 周边) and price <= 60) -> 算法图解、帆布袋
# __import__('os') -> 规则有误:未知字段 __import__

TS 版一样:

type Product = { name: string; price: number; tag: string; category: string; stock: number };
type Field = "price" | "tag" | "category" | "stock";
type Value = number | string;
const PRODUCTS: Product[] = [
{ name: "机械键盘", price: 399, tag: "热卖", category: "外设", stock: 12 },
{ name: "设计模式手册", price: 69, tag: "新品", category: "图书", stock: 0 },
{ name: "鼠标垫", price: 29, tag: "清仓", category: "外设", stock: 40 },
{ name: "算法图解", price: 59, tag: "热卖", category: "图书", stock: 8 },
{ name: "帆布袋", price: 45, tag: "新品", category: "周边", stock: 0 },
];
const FIELDS: readonly string[] = ["price", "tag", "category", "stock"]; // 规则里只能出现这几个字段
const OPS: Record<string, (a: Value, b: Value) => boolean> = {
"<": (a, b) => a < b, "<=": (a, b) => a <= b,
">": (a, b) => a > b, ">=": (a, b) => a >= b,
"=": (a, b) => a === b,
};
interface Expr { // 抽象表达式:每个节点都能对一件商品给出真假
interpret(product: Product): boolean;
}
class Compare implements Expr { // 终结符表达式:field op value
constructor(readonly field: Field, readonly op: string, readonly value: Value) {}
interpret(product: Product): boolean {
return OPS[this.op](product[this.field], this.value);
}
toString(): string {
return `${this.field} ${this.op} ${this.value}`;
}
}
class And implements Expr { // 非终结符表达式:两边都为真
constructor(readonly left: Expr, readonly right: Expr) {}
interpret(product: Product): boolean {
return this.left.interpret(product) && this.right.interpret(product);
}
toString(): string {
return `(${this.left} and ${this.right})`;
}
}
class Or implements Expr { // 非终结符表达式:有一边为真
constructor(readonly left: Expr, readonly right: Expr) {}
interpret(product: Product): boolean {
return this.left.interpret(product) || this.right.interpret(product);
}
toString(): string {
return `(${this.left} or ${this.right})`;
}
}
class Not implements Expr { // 非终结符表达式:取反
constructor(readonly operand: Expr) {}
interpret(product: Product): boolean {
return !this.operand.interpret(product);
}
toString(): string {
return `not ${this.operand}`;
}
}
class RuleError extends Error {}
class Parser { // 配角:递归下降,把规则字符串变成表达式树;不属于 GoF 的解释器模式
readonly tokens: string[];
pos = 0;
constructor(text: string) {
this.tokens = text.match(/<=|>=|[()<>=]|[^\s()<>=]+/g) ?? [];
}
parse(): Expr {
const expr = this.orExpr();
if (this.pos < this.tokens.length) throw new RuleError(`多出来的 ${this.tokens[this.pos]}`);
return expr;
}
peek(): string | undefined {
return this.tokens[this.pos];
}
take(): string {
if (this.pos >= this.tokens.length) throw new RuleError("规则没写完");
return this.tokens[this.pos++];
}
orExpr(): Expr { // or_expr = and_expr ("or" and_expr)*
let left = this.andExpr();
while (this.peek() === "or") {
this.take();
left = new Or(left, this.andExpr());
}
return left;
}
andExpr(): Expr { // and_expr = unary ("and" unary)*
let left = this.unary();
while (this.peek() === "and") {
this.take();
left = new And(left, this.unary());
}
return left;
}
unary(): Expr { // unary = "not" unary | "(" or_expr ")" | compare
if (this.peek() === "not") {
this.take();
return new Not(this.unary());
}
if (this.peek() === "(") {
this.take();
const expr = this.orExpr();
if (this.take() !== ")") throw new RuleError("缺右括号");
return expr;
}
const [field, op, value] = [this.take(), this.take(), this.take()];
if (!FIELDS.includes(field)) throw new RuleError(`未知字段 ${field}`);
if (!(op in OPS)) throw new RuleError(`未知运算符 ${op}`);
return new Compare(field as Field, op, /^\d+$/.test(value) ? Number(value) : value);
}
}
const rules = [
"price < 100 and tag = 新品",
"category = 图书 or stock > 0",
"price < 100 and not tag = 清仓",
"(category = 图书 or category = 周边) and price <= 60",
"__import__('os')",
];
for (const text of rules) { // 规则来自运营后台,不用发版
let tree: Expr;
try {
tree = new Parser(text).parse();
} catch (e) {
if (!(e instanceof RuleError)) throw e;
console.log(`${text} -> 规则有误:${e.message}`);
continue;
}
const names = PRODUCTS.filter((p) => tree.interpret(p)).map((p) => p.name);
console.log(`${tree} -> ${names.join("、")}`);
}
// 输出:
// (price < 100 and tag = 新品) -> 设计模式手册、帆布袋
// (category = 图书 or stock > 0) -> 机械键盘、设计模式手册、鼠标垫、算法图解
// (price < 100 and not tag = 清仓) -> 设计模式手册、算法图解、帆布袋
// ((category = 图书 or category = 周边) and price <= 60) -> 算法图解、帆布袋
// __import__('os') -> 规则有误:未知字段 __import__

输出按树打印,括号显示了分组:and 比 or 结合得紧,not 只管紧跟它的一项。

解释器版:规则字符串经解析器变成表达式树,再对每件商品 interpret

黄色的是新加的:解析器只建树,四个表达式类负责解释;未知字段、未知运算符、括号不配对这类输入走 RuleError。

规则“价格小于 100 且标签不是清仓”解析出的树

蓝色的 And、Not 是非终结符,下面挂着子表达式;黄色的 Compare 是终结符,直接读商品字段。

对照 27.3:

  • 第 ① 处:规则是后台存的一行字符串,保存后下次筛选就按新树走,不发版。
  • 第 ② 处:not 和括号各在文法里占一行,组合多少种都是同一套类,不再一条条列。
  • 第 ③ 处:没有 eval。解析器只认四个字段和五个运算符,__import__('os') 在切词后就被当成“未知字段”拒绝,什么也没执行。

代价也要说清楚。第一,解析器得自己写、自己测,括号不配对、缺值的报错都要自己做,这部分比四个表达式类还长。第二,语言一长大(函数调用、日期比较),手写的递归下降就难维护了,该换现成的解析库,或干脆用已有的语言:存成 SQL 的 WHERE 子句交给数据库,或用 JSON Logic 这类规则格式。手写只适合很小的语言。

GoF 对解释器的定义是:给定一个语言,定义它的文法的一种表示,并定义一个解释器,这个解释器使用该表示来解释语言中的句子。

落到本章:“文法的一种表示”就是 Compare、And、Or、Not 这几个类,一个句子表示成它们组成的一棵抽象语法树(abstract syntax tree,AST);“解释器”就是每个类上的 interpret。GoF 书里的示例代码恰好也是布尔表达式,用 AndExp、OrExp、NotExp 这几个类。

解释器的结构:终结符直接读上下文,非终结符先解释子表达式再组合

对照案例图:Expr 是 AbstractExpression,Compare 是 TerminalExpression,And、Or、Not 是 NonterminalExpression;被筛选的商品就是 interpret(context) 里的 context。

角色 在本章案例里是谁 职责
抽象表达式(AbstractExpression) Expr 声明 interpret(context)
终结符表达式(TerminalExpression) Compare 文法里不再往下分的部分,直接读上下文给出结果
非终结符表达式(NonterminalExpression) And、Or、Not 持有子表达式,先让它们 interpret,再组合结果
上下文(Context) Product 解释时要读的外部数据
客户端(Client) 筛选入口(借 Parser 建树) 拿到树,对每件商品调 interpret

常见场景:

  • 规则引擎:促销条件、风控规则。
  • 过滤表达式:本章的商品筛选、日志查询语法。
  • 简单模板:你好,{name} 这类占位符替换。
  • 配置里的小表达式:告警配置里的 cpu > 80 and duration > 5m。

*.py、[a-c]?.txt 这种 shell 通配符(glob)是一门小语言:* 匹配任意多个字符,? 匹配一个,[a-c] 匹配范围里的一个。标准库 fnmatch 大致这样解释它:translate 把通配符翻译成正则,再交给 re 匹配,不建对象树。GoF 书里讲解释器用的例子正是正则表达式。下面先看真实的 translate,再手写一个对象版,只支持 * 和 ?:

import fnmatch
import re
from abc import ABC, abstractmethod
# 一、标准库:fnmatch 把 shell 通配符翻译成正则表达式,再交给 re 去匹配
regex = fnmatch.translate("*.py")
print(f"translate('*.py') = {regex}") # 不同 Python 版本写法略有不同,这是 3.12 的结果
files = ["main.py", "main.pyc", "a1.txt", "ab.txt", "d1.txt"]
print(f"re 匹配:{[f for f in files if re.match(regex, f)]}")
print(f"[a-c]?.txt:{[f for f in files if fnmatch.fnmatchcase(f, '[a-c]?.txt')]}")
# 二、自己写:每个通配符字符是一个表达式对象(只支持 * 和 ?)
class Glob(ABC): # 抽象表达式:从 text[i] 开始匹配自己,成功就把剩下的交给 rest
@abstractmethod
def interpret(self, text: str, i: int, rest: list["Glob"]) -> bool: ...
def run(parts: list[Glob], text: str, i: int) -> bool: # 顺序表达式:一个接一个
return i == len(text) if not parts else parts[0].interpret(text, i, parts[1:])
class Literal(Glob): # 普通字符:必须一模一样
def __init__(self, char: str) -> None:
self.char = char
def interpret(self, text: str, i: int, rest: list[Glob]) -> bool:
return i < len(text) and text[i] == self.char and run(rest, text, i + 1)
class AnyChar(Glob): # ?:任意一个字符
def interpret(self, text: str, i: int, rest: list[Glob]) -> bool:
return i < len(text) and run(rest, text, i + 1)
class AnyRun(Glob): # *:任意多个字符,挨个试吃掉 0、1、2……个
def interpret(self, text: str, i: int, rest: list[Glob]) -> bool:
return any(run(rest, text, j) for j in range(i, len(text) + 1))
def compile_glob(pattern: str) -> list[Glob]: # "解析器":一个字符一个节点
return [AnyRun() if c == "*" else AnyChar() if c == "?" else Literal(c) for c in pattern]
for pattern in ["*.py", "?1.txt"]:
parts = compile_glob(pattern)
mine = [f for f in files if run(parts, f, 0)]
real = [f for f in files if fnmatch.fnmatchcase(f, pattern)]
print(f"{pattern}:自己写 {mine},和 fnmatchcase 相同:{mine == real}")
# 输出:
# translate('*.py') = (?s:.*\.py)\Z
# re 匹配:['main.py']
# [a-c]?.txt:['a1.txt', 'ab.txt']
# *.py:自己写 ['main.py'],和 fnmatchcase 相同:True
# ?1.txt:自己写 ['a1.txt', 'd1.txt'],和 fnmatchcase 相同:True

translate 的输出写法随 Python 版本略有不同,上面是 3.12 的。对比用 fnmatchcase,因为 fnmatch.fnmatch 在 Windows 上不分大小写。Literal、AnyChar、AnyRun 是终结符,run 把它们串成顺序表达式,compile_glob 是只有一行的“解析器”。

例二:MongoDB 风格的查询对象(TypeScript)

Section titled “例二:MongoDB 风格的查询对象(TypeScript)”

MongoDB 的查询写成对象:{ price: { $lt: 100 }, tag: "新品" }。sift、mingo 这类库能在内存里对普通数组执行同样的查询,大致是把查询对象解释成一组判断。查询对象本身就是树,省掉了解析。下面手写一个只支持 $lt、$gt、$eq、$and、$or 的版本:

// 简化模型:sift、mingo 这类库在内存里解释 MongoDB 风格的查询对象(大致如此,真实的库支持的运算符多得多)
type Doc = Record<string, number | string>;
type Op = "$lt" | "$gt" | "$eq";
type Query = { [key: string]: number | string | Record<string, number | string> | Query[] };
interface Expr { // 抽象表达式
interpret(doc: Doc): boolean;
}
class FieldExpr implements Expr { // 终结符表达式:{ price: { $lt: 100 } } 里的一个条件
constructor(readonly field: string, readonly op: Op, readonly value: number | string) {}
interpret(doc: Doc): boolean {
const actual = doc[this.field];
if (this.op === "$eq") return actual === this.value;
return this.op === "$lt" ? actual < this.value : actual > this.value;
}
}
class AllOf implements Expr { // 非终结符表达式:$and,以及顶层的多个键
constructor(readonly parts: Expr[]) {}
interpret(doc: Doc): boolean { return this.parts.every((p) => p.interpret(doc)); }
}
class AnyOf implements Expr { // 非终结符表达式:$or
constructor(readonly parts: Expr[]) {}
interpret(doc: Doc): boolean { return this.parts.some((p) => p.interpret(doc)); }
}
function parse(query: Query): Expr { // 查询对象本身已经是一棵树,"解析"只是把它换成表达式对象
const parts: Expr[] = [];
for (const [key, value] of Object.entries(query)) {
if (key === "$and" || key === "$or") {
const subs = (value as Query[]).map(parse);
parts.push(key === "$and" ? new AllOf(subs) : new AnyOf(subs));
} else if (key.startsWith("$")) {
throw new Error(`不支持的运算符 ${key}`);
} else if (typeof value === "object") {
for (const [op, v] of Object.entries(value)) {
if (op !== "$lt" && op !== "$gt" && op !== "$eq") throw new Error(`不支持的运算符 ${op}`);
parts.push(new FieldExpr(key, op, v));
}
} else {
parts.push(new FieldExpr(key, "$eq", value)); // tag: "新品" 是 { $eq: "新品" } 的简写
}
}
return new AllOf(parts);
}
const products: Doc[] = [
{ name: "机械键盘", price: 399, tag: "热卖", category: "外设", stock: 12 },
{ name: "设计模式手册", price: 69, tag: "新品", category: "图书", stock: 0 },
{ name: "鼠标垫", price: 29, tag: "清仓", category: "外设", stock: 40 },
{ name: "帆布袋", price: 45, tag: "新品", category: "周边", stock: 0 },
];
const queries: Query[] = [
{ price: { $lt: 100 }, tag: "新品" },
{ $or: [{ category: "图书" }, { stock: { $gt: 0 } }] },
{ name: { $regex: "键盘" } }, // 真实的库支持 $regex,这个简化版不认识
];
for (const q of queries) {
try {
const tree = parse(q);
console.log(`${JSON.stringify(q)} -> ${products.filter((d) => tree.interpret(d)).map((d) => d.name).join("、")}`);
} catch (e) {
console.log(`${JSON.stringify(q)} -> ${(e as Error).message}`);
}
}
// 输出:
// {"price":{"$lt":100},"tag":"新品"} -> 设计模式手册、帆布袋
// {"$or":[{"category":"图书"},{"stock":{"$gt":0}}]} -> 机械键盘、设计模式手册、鼠标垫
// {"name":{"$regex":"键盘"}} -> 不支持的运算符 $regex

FieldExpr 对应 Compare,AllOf、AnyOf 对应 And、Or。不认识的运算符直接拒绝,查询内容不会被当成代码执行。

  • 语言会长大。下个月要加函数、日期运算、嵌套字段,每加一样都要改解析器、加类。这时用解析器生成器(如 Lark、ANTLR),或直接嵌入现成的语言(SQL、JSON Logic、Lua)。
  • 对性能敏感。每件商品都要从树根走到叶子,一层层方法调用。商品多、规则又不常变时,把树一次编译成函数或正则会更快,fnmatch 就是这么做的。
  • 其实只需要几个固定开关。运营要的若只是“价格上限”和“是否只看有货”,做成两个配置项就够了,用不着一门语言。
模式 意图 区别
解释器(Interpreter) 给一门小语言定义文法,按文法解释句子 树的形状由文法决定,每种节点对应一条文法规则,重点在“这句话什么意思”
组合(Composite) 把对象组织成树,单个和整体一样对待 表达式树本身就是组合:And 是容器,Compare 是叶子,都实现 interpret。见第 13 章
访问者(Visitor) 不改节点类,给一组节点类加新操作 同一棵表达式树要求值、打印、优化时,可以把这些操作写成访问者,节点只留 accept。见第 26 章

两组都能用一个问题当场判断:

  • 解释器还是组合:问“这棵树是不是一句话”。第 13 章的礼盒树是商品的包含关系;本章的树来自一句规则,节点种类由文法定死。
  • 解释器还是访问者:问“操作多不多”。只有求值,写在 interpret 上最直接;还要打印、化简,每加一种都得改四个类,这时换成第 26 章练一练那样的访问者。

给筛选语言加 in 运算符:tag in [新品, 热卖] 表示标签是两者之一,也能和 and、or、not 组合。写出文法要改的那一行,加一个 In 类,让解析器认识方括号和逗号。用 tag in [新品, 热卖] and not category = 图书 验证,再试一条少了右方括号的规则,看报错。Python 和 TS 各写一版。

提示:切词的正则里加上 [、] 和 ,;In 是终结符,和 Compare 一样直接读商品字段。

  • 解释器让一门小语言的每条文法规则对应一个类,句子是这些对象组成的树,对树根调 interpret 得到结果。
  • 规则成了数据:运营输入、保存即生效;not、括号这些组合由文法覆盖,不用一条条写死。
  • 不用 eval:解释器只认文法里有的东西,别的输入一律拒绝。
  • 解析器是配角,不属于模式本身;语言一长大,就换成现成的解析库或已有的语言。