你在做网关、代理或者安全组件的时候,如果需求只是简单地按 IP 和端口做放行或拦截,用 iptables、nftables 这类现成方案就够了。但真实项目里我经常遇到另一种需求:规则格式要跟着产品走,字段不能局限于五元组,客户希望用自己的语法描述"什么时候放行、什么时候阻断、什么时候改路由",甚至还要把应用层字段、自定义 Header、自定义业务 ID 拉进来一起参与判断。这种情况下你就得进入"自定义网络规则解析"的领域——自行设计一套规则格式,再写一个能读它、校验它、并且在网络事件到来时快速匹配的执行引擎。这篇文章围绕我最近一次完整实现这类解析器的工作展开,把语法设计、解析实现、匹配优化以及调试中踩过的坑原样分享出来,适合正在做规则引擎、协议网关或嵌入式过滤模块的工程师参考。
1. 什么时候需要自己写网络规则解析,而不是直接套现成方案
先回答最容易问的问题:现成的规则引擎那么多,为什么还要自己写?不是炫技,是需求确实落在现成方案的盲区里。拿我之前做的边缘网关举例,产品经理给出的需求是这样的:规则由运营后台生成,同一台设备上不同租户的规则不同,规则里要能判断"来源 IP 是否落在某网段""URL 是否以 /admin 开头""HTTP 请求里是否带了 X-Tenant 这个自定义头",而且判断完要返回三种动作之一。这个需求要落地,靠内核防火墙是解决不了的。
1.1 现成方案解决不了的需求边界
内核态的 netfilter/iptables 擅长的是五元组、连接状态、标记这类 L3/L4 语义,对于应用层字段基本无能为力。你要判断"HTTP User-Agent 是否包含特定字符串",要么自己在用户态先解析完 HTTP 再投递到别的组件,要么用 eBPF 挂到套接字层做 DPI,一套搞下来复杂度远超写一个规则解析器。商业 WAF 的规则语言确实强大,但它是封闭的,规则格式、执行语义、扩展方式都跟着厂商走,你没法在自家程序里内嵌使用,也没法把错误消息塞回给运营界面做友好提示。日志分析类的规则引擎(比如很多可观测平台自带的过滤语法)倒是通用,但往往绑定特定的数据模型,拉过来用还得做一层字段映射。
我并不是说现成方案不好,而是它们各自有很明确的适用范围。当你的规则需要长在自家业务字段上,需要给非技术人员提供可读的编辑体验,需要嵌入到进程内以库的形式随产品分发时,自己实现一个小而精的规则解析器反而是成本最低的路线。
1.2 "自定义网络规则"在工程里的典型形态
- 多租户网关的授权策略:规则按租户分组,字段包含租户 ID、来源 IP、目标端口、URL 前缀、自定义请求头,动作是 allow/deny/redirect。
- 工业协议网关的报文筛选:比如 CAN 报文场景里,规则要匹配 ID 和 payload 区间,动作是转发或报警,这正是"can 协议报文解析"这类需求在规则引擎中的映射。
- 接入层配置即规则:本地加虚拟机搭 Nginx 多端口开发环境、配置多站点自定义域名时,server_name 和 location 本质上就是一组按请求条件执行的规则表,读配置的过程就是解析规则的过程。理解好这些形态,你设计的规则语法才不会漏洞百出。
2. 规则 DSL 的语法设计:先定语法,再动手写解析器
很多人写自定义规则解析器踩的第一个坑,是直接开始写解析代码,边写边临时加语法。等规则越来越复杂,解析器里的判断分支多到改不动,这时候再回头规整语法,成本翻倍。我的建议永远是先花一天把语法定下来,哪怕只定一个最小可用子集。
2.1 一个最小可用的规则语法
我常用的规则外形是这样:
if <condition> then <action> [priority <n>]condition 支持逻辑与、逻辑或、逻辑非,以及带括号的组合。规则引擎长期维护下来有经验:语法越接近自然语言,运营人员上手越快;但语法绝不能大到完整编程语言的程度,否则后面的校验、执行、安全控制全部失控。我们用的是类似下面这种文法(简化表示):
condition ::= or_expr or_expr ::= and_expr ('or' and_expr)* and_expr ::= primary ('and' primary)* primary ::= '(' condition ')' | 'not' primary | predicate predicate ::= field op value op ::= '==' | '!=' | 'in' | 'not in' | 'contains' | 'startswith' | 'endswith' | '>' | '>=' | '<' | '<=' value ::= string | number | ip | cidr | list field ::= 'l3.src_ip' | 'l4.dport' | 'http.header["User-Agent"]' | ...举个例子,一条完整规则可能长这样:
if l3.src_ip in 192.168.1.0/24 and (http.uri startswith "/admin" or tcp.dport == 443) then deny priority 100为什么选这个形态而不是直接支持 if-else 分支、变量赋值、函数调用?因为网络规则的执行路径必须可预测。规则是跑在流量路径上的,一次匹配的延迟多几十微秒,在高 QPS 场景下的放大是明显的。可编程性越强,执行期能做文章的地方越多,出问题的面也越广。宁可把条件写得繁琐一些,也不要让规则变成可以被任意执行的脚本。
2.2 文本 DSL 与结构化格式的取舍
规则来源不同,承载格式也应该不同。规则由运营后台按模板生成时,后台直接输出 JSON 或 YAML 更稳妥,解析 JSON 的成熟库到处都是,转义和嵌套都由标准实现兜住,这部分和解析 XML、解析普通配置文件的逻辑是一样的。但规则由一线运维手工编写,或者需要出现在日志、审计信息里时,文本 DSL 的可读性优势非常明显。JSON 里多了一层花括号和引号,人眼排查问题的效率会下降不少。
我见过不少团队在文本可读性和结构可解析性之间摇摆,最后搞出"既要又要"的混杂格式:外层是 YAML,条件部分又内嵌一段字符串表达式。这种设计不是不行,但你必须给内嵌表达式单独写一套解析器,等于把复杂度又重新引入回来。所以我的建议很直接:要么全结构化,要么全文本 DSL,不要搞两套语义混在一起。如果非要二选一,从零实现时首选文本 DSL,因为它在产品演示、日志输出、错误提示三个场景下都更直观。
2.3 字段命名的确定性和扩展性
字段名是规则语义的基石。设计时我坚持"字段作用域前缀 + 具体字段"的约定,比如 l3、l4、http、meta,这样层次清晰,扩展自定义协议字段时不会和已有字段冲突。自定义 Header 用http.header["X-Tenant"]这种带索引的形式比http.header.X-Tenant好,因为 Header 名是动态的,用方括号语义更明确,解析器实现上也更简单。字段名大小写统一转小写,但字段值是否区分大小写必须另做约定,否则后面一堆麻烦——这一点在踩坑章节会细说。
3. 词法分析与语法解析:核心实现的细节
语法定了,接下来就是词法分析和语法解析。这一块是三部分里最工程化的:词法器把原始文本切成 token,语法分析器把 token 序列按文法规约成抽象语法树,最后语义校验保证这棵树是"合法且有意义的"。
3.1 手写词法器,还是用解析库?
我实现这类规模(几百行规则、几个操作符)的解析器时,一直倾向手写词法器加递归下降解析器。解析库如 lark、pyparsing 确实能快速出活,但错误消息的可控性比较差:默认报错经常是"期望 A 但遇到 B",缺少行号和列号,更缺少"根据当前上下文,我怀疑你少写了一个右括号"这类接近人类思维的提示。而规则引擎最终是要交给业务人员使用的,错误提示的质量直接决定他们会不会投诉。手写词法器大概三百行 Python,换来的是 token 携带精确的 line/col,以及完全可控的错误路径。
词法器需要切出的 token 类型大致是:关键字(if/then/and/or/not/in)、操作符(==、!=、>=、<=、contains、startswith 等)、字段路径、字符串字面量、数字、CIDR、括号和逗号。处理字符串字面量时要维护一个 in_quote 状态,引号内部出现的空格、括号、逗号都不能作为 token 边界。实现上我习惯先把所有 token 类型定义清楚,再写扫描函数:
class Token: __slots__ = ("type", "value", "line", "col") def __init__(self, type_, value, line, col): self.type = type_ self.value = value self.line = line self.col = col def tokenize(text): tokens = [] i = 0 line = col = 1 stack = [] # stack 用于记录引号状态,这里简化展示 while i < len(text): ch = text[i] if ch.isspace(): if ch == "\n": line += 1 col = 1 else: col += 1 i += 1 continue if ch == '"': j = i + 1 while j < len(text) and text[j] != '"': if text[j] == "\\": j += 1 j += 1 tokens.append(Token("STRING", text[i + 1 : j], line, col)) col += j - i i = j + 1 continue # 其余 token 按字符逐类判断,这里省略 i += 1 return tokens3.2 递归下降解析器和 AST 结构
解析器用递归下降是这类语法最自然的选择。优先级处理上,我把逻辑或放在最低层,逻辑与次之,逻辑非和括号最高,这样a or b and c解析成a or (b and c),符合大多数人的阅读习惯。AST 节点我设计了四个基本类型:Predicate、And、Or、Not。
class Predicate: def __init__(self, field, op, value): self.field = field self.op = op self.value = value class And: def __init__(self, children): self.children = children class Or: def __init__(self, children): self.children = children class Not: def __init__(self, child): self.child = child解析过程中最需要注意的是左递归。递归下降解析器对左递归文法会直接栈溢出,所以文法里我把表达式写成右递归或循环形式。上面的 and_expr 用 while 循环收集and子句,而不是写成and_expr ::= and_expr 'and' primary,就是为了绕开这个问题。解析完得到的 AST 是纯数据,不依赖源文本,后续校验和执行都在这棵树上进行。
3.3 语义校验:把"自定义校验"做进解析流程
AST 生成之后不能直接拿去匹配,必须先做语义校验。词法语法只能保证"这句话语法正确",不能保证"这个字段真的存在""比较的类型是对的"。字段校验我做成注册表模式:每个字段声明自己是什么类型,配套一个校验函数。校验器会检查字段是否存在、操作符是否适用于该类型、值是否合法、CIDR 是否正确、端口是否在 1 到 65535 范围内。这些自定义校验本质上是把业务约束前置到规则编辑阶段,而不是等规则跑起来发现匹配结果不对才回头查。
校验时我做了个细节优化:不是遇到第一个错误就停,而是遍历整棵树,把所有问题一次性收集起来,统一返回给前端。运营人员在编辑器里一次看到五个红色提示,比改了五次才看到五个错误要舒服得多,这一点直接决定了规则编辑体验的好坏。错误消息的格式是"行号、列号、字段路径、具体问题",比如:
line 3, col 17: unknown field "ip.src" line 5, col 9: expected "then", got "and"前者说明字段写错了,后者说明语法结构缺了关键字,这种粒度才值得回传给用户。
4. 匹配引擎:让解析结果在真实流量里跑起来
解析只是第一步,规则最终要落到每个网络事件上做判断。匹配引擎的核心动作是:解析阶段将文本变成 AST,运行时把 AST 编译成可执行的判定闭包,网络事件到达后用闭包求值。判定闭包可以理解为"给定事件上下文,返回 True/False 的函数",把 AST 递归解释执行改成闭包直接调用的好处是省掉了运行时反复遍历树节点的开销。
4.1 匹配语义:优先级与首条命中
匹配语义要提前定死,否则规则之间冲突时行为不可预知。我采用的方案是"按优先级从高到低排序,首个条件为真的规则胜出",优先级数字越大越靠前,权限默认 100。条件为空视为恒真,也就是兜底规则。动作定义为 allow、deny、redirect 三种,每个动作可以携带额外参数,例如 redirect 的目标地址。假如规则表如下:
| 规则 | 条件 | 动作 | 优先级 |
|---|---|---|---|
| r1 | l3.src_ip in 10.0.0.0/8 and tcp.dport == 443 | deny | 200 |
| r2 | http.uri startswith "/api" | allow | 100 |
| r3 | (无条件) | allow | 1 |
当事件满足 r1 时,直接 deny,不再继续判断 r2、r3。这里的教训是要明确告诉使用者"规则顺序即行为语义",不要让他们以为后写的规则可以覆盖先写的规则。
4.2 朴素匹配的性能瓶颈与常见优化
朴素实现就是把规则逐个求值,规则数量上到几千条以后,每个事件都得做几千次判定,压力会很大。我做过的优化按收益排序大概是这几类:
- 字段存在性预筛:每条规则预先声明依赖哪些字段字段,事件进来先按字段集合做一个快速过滤,没有相关字段的规则直接跳过,这一步能过滤掉一大半规则。
- 命中高频条件的短路:把高优先级规则里计算量小的谓词排到前面(例如端口相等判断早于正则匹配),使大多数事件在第二阶段就返回结果。
- 区间类谓词建索引:CIDR 用前缀树结构,端口区间用区间树,而不是每次规则求值都线性比较。
- 字符串谓词分组:
startswith类匹配按前缀分桶,同一前缀的规则共享一次前缀匹配结果,避免每条规则各算一遍。
还有一个经常被忽略的点:同一个事件往往要经过多份规则集(不同租户的规则不同),应该按租户先分桶,而不是把所有规则摊在一个大盘子里遍历。规则集之间天然隔离,分桶后不仅语义更清晰,性能也更好,因为你只评估当前租户命中的那部分规则。
4.3 动态加载与发布:规则变更不重启
网络规则是不能容忍"改规则必须重启进程"的。解析器要把可变部分和不可变部分分开:解析器和匹配器本身是纯函数,规则集是可变数据。规则更新走"解析新规则 → 校验 → 原子替换当前规则集"的流程,替换时用双缓冲或者读写锁,保证在途事件不受影响。这里我特别强调原子替换:不要一条条往里加,而是整体切换,否则规则 A 新、规则 B 旧的不一致状态会让线上行为变得无法解释。
5. 可扩展性:插件化字段与自定义协议解析
自定义网络规则解析器的生命线在于可扩展性。你今天支持五元组,明天就要支持 HTTP 头,后天客户要求支持自家协议字段,如果每个新字段都要改内核代码,这个系统活不过半年。解决方案是把"字段如何从事件里取值"做成插件化的注册表,这也是我个人非常推崇的插件模式,和 ROS 里 pluginlib 自定义插件的思想一致:框架定义接口,具体实现由外部注册进来,核心解析逻辑不动。
5.1 字段解析器的注册机制
字段解析器的接口可以精简为:输入事件上下文,输出字段值或 None(字段不存在)。每个自定义字段声明自己的路径名和类型,注册到全局字段注册表里。核心解析器和匹配器只认注册表,不硬编码任何字段。这样当http.header["X-Tenant"]需要接入时,你只需要写一个"从 HTTP 头部字典取值"的解析器注册进去,然后在规则里就能直接用该字段路径了。
class FieldResolver: def __init__(self): self._resolvers = {} def register(self, field_path, resolver): self._resolvers[field_path] = resolver def resolve(self, field_path, event): resolver = self._resolvers.get(field_path) if resolver is None: return None return resolver(event)这种设计跟 axios 自定义 headers 的场景也相通:服务端匹配规则里的字段实际上是对请求头的动态取值,头部名称是运行期才知道的,注册表模式天然支持这种动态性,因为字段路径本身是字符串。
5.2 自定义协议字段案例:CAN 报文解析
如果觉得自定义 Header 还不够"自定义",可以看 CAN 协议报文解析的例子。CAN 帧的典型结构是 ID(11 位或 29 位)、DLC、8 字节数据负载。假设我们要支持规则:
if can.id == 0x123 and can.data[2] > 200 then alert priority 10那么can.id和can.data都需要有对应的字段解析器:解析器从 CAN 帧对象里按偏移提取数据。can.data[2]这种带数组索引的字段路径,解析器实现时要支持索引文法,即字段名 + 方括号下标。这类二进制定长字段的解析非常简单,但它是整个插件机制很好的验证用例——只要解析器注册表允许任意字段路径存在,新增协议就只是新增几个解析器的问题,规则语法一个字符都不用改。
5.3 从 Nginx 的多站点自定义域名配置汲取规则匹配思想
搭建开发环境时我们会配置本地加虚拟机的多端口 Nginx、做多站点自定义域名映射,这套配置本质上就是一个规则系统。server_name 的匹配顺序是精确匹配优先于通配符前缀、再优先于正则,location 的匹配是前缀匹配取最长。这个"优先级 + 最长前缀"的思想直接借鉴进了我的规则引擎:字符串类的startswith和通配符类匹配在执行前会先计算一个静态权重,多个规则都匹配时,更长、更具体的条件优先。没有这个语义,运营人员会困惑为什么一条宽泛规则总是抢在精确规则前面命中。规则引擎这东西,先例太多了,多看看 Nginx 怎么处理匹配顺序很有价值。
6. 踩坑实录:一次完整的新规则解析器调试链
最后分享几个真实发生的线上事故和定位过程,每个都对应一类容易复发的解析器问题。这些坑不亲自踩一遍,很难在写代码的时候提前预防。
6.1 深层括号引发的递归栈溢出
上线后第一个线上事故来自一条运营人员手工拼出来的规则,条件里套了三十多层括号。递归下降解析器在这种输入下直接抛了递归深度错误,表现就是规则一保存,后台进程就报异常。定位方法很直接:把规则文本提取出来,最小化到栈溢出,发现是纯括号嵌套。根因是解析器没有对输入深度做限制,也没有兜底处理。修复做了两层:一是解析时限制嵌套深度为 64 层,超出后报"规则条件嵌套过深";二是递归入口处捕获递归深度异常,转成用户可读的错误。这里要记住,任何接收外部输入的技术组件都要把"恶意构造或粗心构造的输入"当成默认威胁模型对待。
6.2 引号内的空格和转义把词法器搞晕了
有客户提交规则时用了http.user_agent contains "Mozilla/5.0 (Windows NT 10.0)"这种条件。这个词法器最初没有正确处理引号内的空格,直接把字符串切成了三个 token,解析直接报语法错误。定位过程是逐步打印 token 流,发现引号内的空格截断了字符串。修复方案是在词法器里增加引号状态机:进入引号后忽略所有特殊分隔符,直到遇到匹配的结束引号;同时支持反斜杠转义,这样字符串里出现引号本身也能表达。这个问题很像解析 XML、JSON 时的转义问题,引号、反斜杠、控制字符都应该在同一层处理掉,别推到下游。
6.3 CIDR 表示和 IPv6 地址的兼容性
一个常见歧义:用户写了l3.src_ip in 10.0.0.0 /8,中间带空格,词法器按"空格是分隔符"的规则把 CIDR 切成了两部分,规则直接报错。定位时确认这是词法器设计缺陷,不是用户写法问题。修复方式是让 CIDR 作为一个整体 token 参与匹配,允许格式为地址/掩码,中间不留空格。同时把 IPv6 的情况也一并考虑:IPv6 地址本身带冒号,如果规则里还涉及端口,必须用方括号包裹地址,写成l3.dst_ip in [2001:db8::1]/64之类的形式。网络规则的受众可能同时写 IPv4 和 IPv6,前期不支持 IPv6 解析等于留下一个随时爆发的雷。
6.4 字符串匹配导致的性能回归
加了一批"User-Agent contains 某关键字"的规则后,网关整体延迟明显上升,原先每秒能处理数万事件,掉到了几千。先用 profile 工具压测,发现热点几乎全在字符串 contains 判定里。根因是 contains 的实现一开始是朴素子串搜索,且每条规则对同一事件都要重新扫一遍字符串,规则多了就变成 O(规则数 × 字符串长度 × 子串长度)。修复方案是把规则按关键字做前缀分组,先用一个集合快速判断事件字符串里是否包含组内任一关键字的前几个字符,过滤掉大部分不相关的规则之后再做完整 contains 判定。这次之后我养成习惯:任何新谓词上线前,先做一个不同规则数量下的压测对比,不能只看单条规则的耗时。
6.5 Windows 换行导致的错误行列号偏移
有一阵子测试人员反馈:同样的规则,在 Linux 上错误提示行列号是对的,在 Windows 上列号错位。排查到最后发现是词法器在统计行号时,把\r\n里的\r当成了普通字符,列号因此多加了一位。修复很简单:统一使用 Python 的通用换行模式,把\r\n和\n都只算一次换行。但这个小问题让我记住了更大的一课:解析器对输入文本的处理一定要从"字符视角"去看,换行符、制表符、BOM 头这些看不见的东西,往往会让最精密的定位信息失真。
写完整套规则解析器,我最大的体感是:解析器的代码量通常只占整个系统的一小半,但设计决策占了一大半。语法定得好,后面校验和匹配都顺;语法定得随意,坑会一个接一个冒出来。如果你也要在项目里做类似的东西,我建议把词法器、解析器、校验器、匹配器拆成四个独立模块,分别写单测和压测,这比什么都快。