ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

自定义网络规则解析器实战:从语法设计到匹配引擎优化

2026/10/6 3:14:35 拓冰建站 浏览量
自定义网络规则解析器实战:从语法设计到匹配引擎优化 你在做网关、代理或者安全组件的时候如果需求只是简单地按 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 的目标地址。假如规则表如下规则条件动作优先级r1l3.src_ip in 10.0.0.0/8 and tcp.dport 443deny200r2http.uri startswith /apiallow100r3无条件allow1当事件满足 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 帧的典型结构是 ID11 位或 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 头这些看不见的东西往往会让最精密的定位信息失真。写完整套规则解析器我最大的体感是解析器的代码量通常只占整个系统的一小半但设计决策占了一大半。语法定得好后面校验和匹配都顺语法定得随意坑会一个接一个冒出来。如果你也要在项目里做类似的东西我建议把词法器、解析器、校验器、匹配器拆成四个独立模块分别写单测和压测这比什么都快。