本篇要回答的问题:以"文本处理"这个具体问题域为例,怎么从不可穷尽的多变需求场景中,抽出正交分解后可复用的架构范式?
上一讲说架构师的武器库是不断完善的架构范式,并预告用"文本处理"做示范。本讲就围绕这一个问题域,先回顾作者二十年的技术栈演进,再提炼出文本内容处理与二进制内容处理两套通用范式。这里的"文本"指写入磁盘的非结构化数据,可以是真文本(HTML/CSS),也可以是二进制(Word/Excel)。
文本处理的典型需求场景:
| 场景 | 含义 |
|---|---|
| 数据验证(Data Validation) | 判断输入文本是否合法、值范围是否符合期望 |
| 数据抽取(Data Extraction) | 从 HTML 抽取结构化信息(如机票时间/出发地/价格) |
| 编译器(Compiler) | 文本是代码时,编译成机器码/字节码或边解释边执行 |
我的文本处理技术栈演进
| 时间 | 产物 | 关键意义 |
|---|---|---|
| 2000 初 | ExcelViewer / DocViewer | 用程序固化对二进制文件格式的理解,利于知识传承;只抽象界面呈现,但尚未抽象出文本处理范式 |
| 同期 | mk 程序 | 解析类 ini 配置 + C/C++ 依赖表做增量编译;仍无通用范式 |
| 2004 | KSDN 1.0 | 从源码生成全局文档;首次引入通用脚本(解析→XML→XSLT 渲染 HTML) |
| 2006 | C++ 版 TPL(KSDN 2.0) | Text Processing Language,类似 Boost Spirit 但更强;能力不弱于 LEX+YACC 却更轻量 |
| 2009 | SDL(CERL 网络库) | 用 TPL+JSPT 解析服务器网络协议描述语言 |
| 2011 | 转 Go | 大部分 C++ 基础库被 Go 标准库取代 |
| 2015 | Go 版 TPL + qlang | 爬虫抽取结构化信息催生;顺手实现 qlang 语言、eql 模板 |
| 2017 | BPL | Binary Processing Language,处理二进制文档/TCP 协议流,依赖 qlang 才得以诞生 |
洞见(Viewer 为什么重要):先有 Viewer 理解格式、再设计 Reader 才合理。Reader 夹带大量业务逻辑会干扰对格式本身的理解,且不支持的功能没有解析代码;Viewer 则尽可能记录对格式的理解、形成可传承知识,还能输出纯文本结果供 diff 工具对比。
文本内容的处理范式
标准方式分两阶段:词法分析(Lex) 与 语法分析(Parser),UNIX 提供经典的 lex 和 yacc。
| 阶段 | 角色 | 输入→输出 | 说明 |
|---|---|---|---|
| 词法分析 Lex | Scanner / Tokenizer | 字节流(Byte Stream)→ Token 流 | 很基础,平时不直接打交道(如 go/scanner 的 Scan()、html.Tokenizer) |
| 语法分析 Parser | Parser | Token 流 → 结构 | 平时主要打交道的对象,输出 AST 等 |
Parser 的两种使用模型:
| 模型 | 机制 | 倾向 | 例子 |
|---|---|---|---|
| SAX | 基于事件 | —— | TPL 默认属于 SAX |
| DOM | 基于结构化数据访问(如 AST 树) | 通常更倾向 DOM | go/parser 输出 AST、html.Parse 返回 *Node |
TPL 实现了通用 Scanner+Parser:词法上抽象 Tokenizer 接口(内置类 Go 词法、增加 ? ~ @ 等操作符);语法上用类 EBNF 文法表达。其类 EBNF 的约定(前缀化,便于书写):
| 写法 | 含义 | 与教科书区别 |
|---|---|---|
*G / +G |
重复 | 教科书是 G* / G+(后缀) |
?G |
可选 | 教科书是 G? |
G1 G2 |
串接 | 不用逗号 |
G1 % G2 / G1 %= G2 |
列表(后者可空) | —— |
G/action |
G 匹配成功后执行动作(回调 Go 函数) | 文法与执行代码尽量分离,比 yacc 可读 |
关键判断:TPL 默认是 SAX 模型,但在 extractor 模式下
G/action被视为G/marker,TPL 即变成 DOM 模型——同一套文法可在两种模型间切换。正则表达式是另一分支,简单场景方便,但可伸缩性与可读性都不强。
二进制内容的处理范式
二进制处理整体"容易但繁琐"。其基础是序列化机制(二进制 I/O 框架),逐字段 readUint32 / readString...;C++ 靠操作符重载更简洁,但遇到可选/重复/数组就不简单(Go 缺泛型,没有现成 readArray,C++ 可用模板)。
关键判断(反对"智能"序列化):常规序列化提供的 Object 动态序列化/反序列化属于过度设计。它的便捷背后是让使用者放弃了对磁盘文件格式的思考。数据是软件的灵魂,文件是软件最重要的资产——序列化最重要的是定义严谨的数据格式,而非耍智能。只保留序列化的形式即可。
由此诞生 BPL:用类 EBNF 文法(如 Foo = { N uint16; Bars [N]Bar })自动生成序列化代码。
| 特性 | 说明 |
|---|---|
| 设计核心 | 不破坏 TPL 的 EBNF 任何语义,作为 TPL 的扩展(“TPL 是 C,BPL 是 C++”) |
| 能力 | 处理任意二进制文件,也处理任意 TCP 协议流;可轻松实现 Excel/DocViewer |
| 文法贴近官方伪代码 | 以 MongoDB 线协议为例,BPL 文法几乎与官方伪代码一致({...} 为 Go 结构体文法,{/C ...} 为 C 结构体文法) |
| 执行 | 当前仅解释执行(暂时),TPL 已有 generator 生成 Go 代码静态编译 |
总结
文本处理虽庞大,但可正交分解为两套范式:文本内容走 Lex(Scanner)→ Parser(SAX/DOM),TPL 用类 EBNF 文法统一表达;二进制内容走严谨的序列化,BPL 把文法扩展到二进制。更重要的元方法是:有意识地从日常业务场景中提炼通用需求,沉淀成自己的架构范式。
先把"是什么"回答清楚
| 概念 | 一句话说明 |
|---|---|
| Lex / Scanner | 词法分析,字节流转 Token 流 |
| Parser | 语法分析,Token 流转结构 |
| SAX / DOM | 基于事件 / 基于结构化数据访问的两种 Parser 使用模型 |
| TPL | 通用文本处理语言,类 EBNF 文法,SAX 默认、extractor 下变 DOM |
| BPL | 二进制处理语言,是 TPL 在二进制领域的扩展 |
| Viewer 优先 | 先用 Viewer 固化格式理解,再设计 Reader |
一句话速记
文本处理 = 词法(Scanner)+ 语法(Parser,DOM 优先);二进制处理 = 严谨序列化(拒绝"智能"),靠 TPL/BPL 把文法与执行分离,沉淀成可复用范式。
几条值得记住的判断
- 先有 Viewer 再有 Reader:理解格式的代码不应被业务逻辑污染。
- Object 动态序列化是过度设计,它让人放弃思考文件格式。
- 文法与执行代码尽量分离(TPL 优于 yacc 之处),提升可读性。
思考题
你工作中是否有过"为了图省事用了某种自动/智能序列化,结果磁盘格式失控"的经历?反过来想:你常打交道的某类数据(日志、配置、协议),能否抽象出一套类 EBNF 的文法,让解析代码自动生成而非手写?

