如果你平时要处理 access.log、接口返回、CSV 导出,可能你也有过这样的时刻:打开系统自带文本编辑器,搜了半天正则语法,一次一次地 Ctrl+C 粘贴测试,好不容易把结果 copy 出来,下一批数据换了格式又得从头再来。过去的一年里,我最大的精力其实没有花在更复杂的正则规则上,而是花在这类“临时文本操作”的反复试错上。直到我把原来脚本里的re_analyze()抽出来,做成了一个叫rea的命令行工具,日常才舒服了不少。rea 解决的事情非常聚焦:让“写正则、看匹配、取字段、做替换”这四个动作全部发生在终端里,并且可以复用、可以进管道、可以沉淀成配置。如果你也在做日志分析、数据抽取、批量清洗之类的工作,这篇文章值得花十分钟看一看。
1. 为什么会有 rea:一个很普通的日常困境
1.1 以前处理文本的方式有多折腾
假设现在有一份接口调用日志,每行大概是这个形状:
2025-06-18 14:23:01.984 INFO [http-nio-8080] POST /api/order/create 200 92ms 2025-06-18 14:24:33.102 WARN [http-nio-8080] GET /api/user/detail 500 1083ms我需要抽出时间、HTTP 方法、请求路径、状态码、耗时。大多数人的第一反应是拿出 Python 或者直接翻 grep、sed 手册。我见过的最常见做法,是在终端里敲一段临时命令:
python3 -c "import sys, re; [print(re.findall(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', line)) for line in sys.stdin]" < access.log这个做法本身没有错,但问题出在工程效率上:每次改规则都要重新编辑整行命令,写出来的临时脚本基本不会被保留。跑第一次往往不对,于是反复调整转义符、分隔符、标志位,十分钟就这么过去了。按照我的经验,这类“一次性脚本”平均会浪费 15 到 20 分钟,而且下个月再遇到同样需求时完全无法复用。
我也见过几位同事用表格软件逐列拆分,用 sed 分步替换,或者打开在线正则测试网站把日志复制进去。这些方法不能说没有用,但它们有个共同问题:分析过程和产出结果是分离的。在线工具反馈快,但数据不能自动进入下一个处理环节;脚本能复用,但写脚本本身门槛又太高。rea 想做的,就是压缩“写规则、看结果、改规则”这个循环的周期,让每一次修改都有即时反馈。
1.2 rea 给自己划出的边界
做工具第一件事不是列功能,而是划边界。rea 不是又一个正则引擎,它底层用的还是常规语言自带的正则库,没有发明新的语法;rea 也不是一个编辑器,不会给你弹悬浮窗,不想学习复杂界面。我当时给自己定了三条硬边界:
- 输入可以是文件,也可以是标准输入。这样它才能进入管道生态,跟
cat、grep、jq这类命令组合。 - 输出必须是纯文本,并且能被下一个步骤继续消费。不管是给人看的高亮表格,还是给程序用的 JSON,都不锁死在交互界面里。
- 必须支持命名分组。这是它相比 grep、sed 更有价值的地方,也是我后面三个核心命令的基石。
这三条边界决定了它的体量。我不需要图形界面,不需要自动补全,不需要常驻服务。一条终端命令能完成的事,就不应该打开一个编辑器。
“如果只是需要一个在线测试工具,直接用网页不好吗?”我的回答是:网页工具不能进管道,不能处理几个 GB 的文件,不能在没有外网的环境运行,也不能把同一个规则沉淀成团队里可复用的配置。终端工具的复用性,才是它在这些场合下存在的理由。
1.3 为什么叫 rea
名字其实没什么高深解释。最初这个工程目录用来分析日志,里面只有一个re_analyze()函数,我把函数名缩写成了rea,后来整个工具就叫这个名字。发音是“蕊阿”,无论你在 Bash 还是在 PowerShell 里敲都很顺。我特别看重命令的输入成本,名越短越好,否则一天敲几十次的工具,每多敲两个键都是意志力的损耗。
不过名字短也会带来一个问题:搜索引擎里几乎找不到参考。好处是你必须自己把文档写清楚。后面我会专门讲,为什么一个工具的可维护性不完全来自代码,而来自自己的记录习惯。
2. rea 的三个核心命令:设计逻辑与典型用法
2.1 rea test:先看见匹配结果,再决定下一步
test命令是整个工具的起点。它解决的问题是“这条正则到底匹不匹配、每个分组到底抓到什么”。调用方式很直接:
rea test -t '2025-06-18 14:23:01.984 INFO POST /api/order/create 200 92ms' \ -p '(?P<date>\d{4}-\d{2}-\d{2}) (?P<time>\d{2}:\d{2}:\d{2})'默认输出会包含三块信息:原始文本中命中的区域用颜色标出;下面列出每个命名分组的名字和值;再下面显示每个分组在原文本中的起止位置。你不需要靠肉眼在整段文字里找,因为分组边界都列出来了。
为什么分组信息比“匹配到没有”更重要?因为正则的难点通常不是能不能命中,而是捕获到了什么。比如上面这个例子,我想从时间戳里拆出日期和时刻,如果工具只告诉我整段匹配成功,我还是不知道下一步该怎么提取。把分组值直接打印成表,就能立刻发现分组名写错、边界多吞了一个字符这类问题。
实际工作里,我一般先拿日志的第一行做test,确认字段全对之后,再切到extract做全量抽取。
2.2 rea extract:把文本变成可消费的结构化数据
extract是我用得最多的命令,它的作用不是看匹配,而是批量地把命中的分组输出为结构化数据。典型场景是从几万个请求里抽出一张表:
rea extract -f access.log \ -p '(?P<time>success\S*) POST (?P<path>/\S+) (?P<status>\d{3}) (?P<cost>\d+)\s*ms' \ --format json输出是合法的 JSON 数组:
[ {"time": "2025-06-18 14:23:01.984", "path": "/api/order/create", "status": "200", "cost": "92"}, {"time": "2025-06-18 14:24:33.102", "path": "/api/user/detail", "status": "500", "cost": "1083"} ]除了--format json,还支持csv、tsv和markdown。我自己的习惯是:先跑一次--format markdown在终端里扫一眼,确认几十行结果没问题,再重新执行命令生成 JSON 喂给下游分析脚本。这种“人眼先看,程序再用”的顺序,能挡掉相当一部分脏数据。
需要留意的是,extract只输出命名分组。正则里写成普通括号的捕获组会被忽略,只有(?P<name>...)这种格式才会出现在结果里。这么设计的原因很简单:普通括号大多只是为了改变优先级,真正想保留到结果里的字段,应该用名字明确标出来。
2.3 rea replace:基于分组重建输出,而不是简单字符串替换
很多人在做批量替换时,第一时间会想到 sed。但 sed 处理“只修改一部分、保留另一部分”的需求时,可读性会随着复杂度快速下降。rea replace 的核心思路是先用命名分组拆解原文,再用模板重建一行:
rea replace -f urls.txt \ -p '(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<path>\S+)' \ --template '/archive/{year}/{month}/{day}/{path}'--template里直接写分组名,不需要记忆\1这种数字引用的位置。对不存在的分组,工具会直接报错,而不是默默输出空字符串。这个行为在数据处理时极其重要:如果某一行没匹配到某个字段,你应当立刻知道,而不是等下游程序拿到缺字段的数据时才发现。
三个命令的边界,我用一个简单表格来概括:
| 命令 | 解决的问题 | 常用方式 |
|---|---|---|
rea test | 这条规则到底对不对 | 粘贴单条文本反复调规则 |
rea extract | 从大批文本中抽取字段 | 文件/标准输入,输出 JSON/CSV |
rea replace | 保留部分字段,重建输出 | 文件批量清洗、字段重排 |
2.4 为什么把这三个动作拆开,而不是合成一个
我曾经也想过做一个“全能命令”:一个参数既匹配又抽取又替换。但实践下来发现,每次面对的真实任务通常只会落到其中一个场景。test对应“探索”,extract对应“读”,replace对应“改写”。把它们拆开后,每条命令的用法和输出格式都足够简单,组合起来却可以完成很复杂的工作。工具的易用性,很多时候来自你能提前猜对用户下一步想干什么。
3. 核心实现:数据流与统一的 MatchResult 模型
3.1 四层管道:Input、Parse、Match 与 Output
有一个常见误区是,写命令行工具时先把“功能”堆出来,再把它们串起来。我的做法反过来:先定义一条固定的数据流,再让每条命令都嵌进这条数据流里。rea 内部只有四层:
Input -> Parse -> Match/Reconstruct -> Output无论是 test、extract 还是 replace,都复用同一套输入读取和输出渲染组件,只有中间一层行为不同。这样做有一个明显的好处:输入层面的编码处理、文件拆分、大文件迭代,输出层面的配色、格式化、JSON 序列化,只需要修一次,三种命令同时受益。
Input层负责做的事情有:判断参数里给的是文件还是标准输入;统一把文本按行拆开;保留行号供后续报错。Parse层只负责一件事:把用户传入的正则字符串编译成可执行的正则对象,同时记录命名分组的名字列表。Match/Reconstruct层是核心,不同命令在这里分开。Output层则把统一的结果模型转成表格、JSON 或者纯文本。
3.2 为什么不直接用 re.finditer 后分叉
如果只是自己写临时脚本,直接调用re.finditer()就能拿到所有匹配。但我发现它带到工具场景里有三个短板:
- 没有行号概念。处理一个几万行的文件,如果某一行匹配不上,后续排错会非常头疼。
- 分组缺失难发现。
finditer不会主动告诉你“这个分组在这行不存在”。 - 无法统一输出。正则对象里返回的
Match结构跟最终要打印的 JSON、表格之间还隔着一层转换逻辑。
所以我定义了这样一个结果对象:
@dataclass class MatchResult: line_no: int group_names: list group_values: dict start: int end: int所有内部组件只跟这个对象打交道。比如extract拿到结果后,把group_values放到字典里再序列化;replace拿到结果后,把匹配区间之外的部分拼接起来。没有各写各的数据结构,后续想增加新功能时,只需要在一个地方加字段。
对应的高层代码大概长这样:
def process_stream(stream, pattern, mode): compiled = compile_pattern(pattern) for line_no, line in enumerate(stream, start=1): matches = compiled.finditer(line.rstrip("\n")) results = [make_result(line_no, m) for m in matches] if mode == "extract": yield [r.group_values for r in results] elif mode == "replace": yield rebuild_line(line, results, template)3.3 为什么选择标准库正则引擎,而不是 PCRE
这是我在设计初期纠结最久的一个问题。PCRE 功能强大,支持零宽断言里更复杂的语法,在多数现代语言里都有实现。但把它作为工具依赖,就意味着:
- 需要引入第三方库,安装成本变高;
- 不同系统的默认行为可能不一致;
- 用户从别的脚本里学来的正则,不一定能在 PCRE 的特殊语法下跑通。
rea 的定位是“日志、CSV、接口返回这类日常数据处理”,标准库自带的正则已经覆盖了绝大多数需求。\d、\w、命名分组、懒惰匹配、正反断言,全都够用。我于是定了规则:默认使用当前语言的标准正则引擎,不引入 PCRE 兼容层。如果未来有用户真的需要高级语法,可以再加一个可选开关,但默认值必须保持稳定。
3.4 参数优先级与配置文件落点
三种命令共享同一套参数体系。全局参数包括:
-f, --file:输入文件路径;-p, --pattern:正则表达式;-t, --text:直接传入文本,优先级高于文件;--mode:当前处理模式,由子命令隐含;--no-color:禁用颜色输出。
参数优先级从高到低为:命令行参数 > 环境变量 > 配置文件 > 默认值。配置文件默认放在用户配置目录下,格式用常见的 TOML。之所以把配置文件放在最底层,是为了确保命令行里显式传入的值永远不会被配置文件覆盖。否则一个在服务器上全局设置的默认值,很可能会悄悄改变你在本地跑的结果。
4. 实测阶段遇到的坑和对应修正
4.1 转义地狱:\d和\\d的两种命运
我第一次把 rea 拿给一位同事用时,他传进来的参数是这样的:
rea test -t "2025-06-18" -p "\d{4}-\d{2}-\d{2}"在 Bash 的双引号里,\d一般会原样保留,但是在部分 Shell 环境下,反斜杠会被解释掉一层,最终正则对象收到的是d{4}。这样的结果就是:数字没有匹配到,反而匹配到了字母 d。当时我一度以为是正则引擎的问题,后来才发现是 Shell 参数传递搞的鬼。
为了解决这类困惑,rea 在test命令里增加了一个--explain参数。启用后,输出里会展示“实际传给正则引擎的字符串”和“你输入的原字符串”。两个字符串不一致时,立刻就能看出来是 Shell 转义锅,还是规则本身写错了。
到后来,我自己的使用习惯变成了:正则规则能加单引号就绝不加双引号。-p '(\d{4})-\d{2}'在大多数不需要变量展开的场景下,都能最大程度保留原始内容。这个经验也写进了 rea 的 help 信息里。
4.2 大文件场景下的内存与行模式
第一版实现里,我图省事用了text = f.read(),一次把整个文件读进内存。直到有人拿一个 3GB 的日志来跑,程序直接卡死。后来我改成生成器,按行迭代处理。但立刻又遇到另一个问题:很多正则本身是跨行的,比如需要匹配一段多行日志的完整堆栈。按行迭代天然不支持跨行匹配。
我没有急着二选一,而是给工具增加了--mode lines和--mode block两个选项:
lines模式:逐行读取,内存占用很低,适合单行日志和 CSV;block模式:一次读入全部文本,适合多行堆栈和需要re.DOTALL的场景。
默认是lines,因为日志分析里绝大多数场景都是单行。block模式给了跨行支持,代价是吃内存。后来还加了一个保护逻辑:当检测到文件超过一定体积时,block模式会给出提醒,防止有人误用导致内存溢出的问题再次出现。
4.3 Windows 换行、路径反斜杠与编码问题
有一段时间,rea 在 Windows 上运行结果和 Linux 上完全对不上。查了半天,原因有两层。第一层是换行符:Windows 文本默认\r\n,在按行拆分时如果不去掉\r,正则里的$经常匹配不到行尾。解决方式是在迭代时统一调用line.rstrip("\r\n"),同时把换行符信息保留到重建阶段。
另一层是路径反斜杠。Windows 命令行传进来的路径本身包含\,而正则在很多语言里也把\当转义符。为这我还写了一段测试用例,发现用字符串拼接路径极易出错。最终方案是:文件路径统一用Path对象包装,正则只作为独立参数解析,两者永远不混在同一根字符串里。
编码问题更隐蔽。默认 UTF-8 在 Linux 和 macOS 上没问题,但 Windows 上某些编辑器生成的文件是带 BOM 的 UTF-8 或 GBK。如果工具不显式指定编码,很容易出现“读一半报 UnicodeDecodeError”。我选择了这样一套保守策略:
open(file_path, encoding="utf-8", errors="replace")errors="replace"意味着遇到非法字节时不会中断,而会替换成占位符。对于日志分析来说,宁可让某一行出现奇怪字符,也不能让整个任务崩溃。这个取舍我觉得是对的,但也会在输出里加一条告警,提示哪些行存在编码异常。
4.4 替换模板遇到缺失分组怎么办
replace命令最揪心的场景是这样:一份字典文件里,第一万零一行没有user_id字段,模板却引用了{user_id}。第一版实现里,我直接输出空字符串,于是几万条结果里混进了不少残缺行。事后排查的成本远比报错高。
后来我改成两种策略。默认策略是严格模式:只要模板引用了某个分组,而当前行没有该分组,就报错并停在该行。另一种是--ignore-missing宽松模式:缺失字段输出为空,但会在扫描结束后统计缺失数量。同时增加了一个--required参数,指定一个模板之外的必读字段,用于提前过滤不合格行。这些改进让 replace 从“文本工具”变成了“数据清洗工具”,真正能拿去做批量处理。
5. 从“自己能跑”到“别人能用”的稳定性打磨
5.1 用配置文件固化高频参数
如果每次运行都要敲域名、路径、正则模板,再顺手的工具也会被弃用。rea 的配置里我固定了几个常用场景:
[test] default_file = "./logs/demo.log" color = true [extract] default_pattern = "(?P<year>\\d{4})-(?P<month>\\d{2})-(?P<day>\\d{2})" default_format = "markdown"这样每次进入新的数据目录后,只需要在命令行补一个文件名即可:
rea extract -f access.log此前的正则模板和输出格式自动从配置里读取。对个人工具来说,配置不是写给别人看的,而是省掉自己重复敲命令的时间。对于命令行工具,高频参数的固化优先级应该排在“新增功能”之前。
5.2 一次完整的日志清洗实战
下面是一个真实工作流的缩略版,我想用它解释“为什么 rea 能节省时间而不是增加负担”。假设要统计一天中每个接口的失败率:
rea extract -f access.log \ -p '(?P<path>/api/\S+) (?P<status>\d{3}) (?P<cost>\d+)ms' \ --format json | jq 'group_by(.path) | map({path: .[0].path, total: length, fail: map(select(.status | startswith("5"))) | length})'第一步rea extract拿到干净字段,第二步交给jq做聚合。整个过程没有写任何临时 Python 脚本,也没有手写循环。如果换成另一种工具,可能需要先写一个几十行的脚本,再调试半天输入输出格式。而在 rea 这里,字段抽取的表达成本一次写完,之后每一步都能看到中间结果。
另一个常用场景是用 replace 生成批量更新语句。比如把/2025/06/18/order-123重新组织成/archive/2025/06/18/123,只需要一个模板文件,几千条记录瞬间重写。
5.3 用回归用例锁住输出格式
正则工具最怕“改一处、错一片”。我修第二个版本的输出格式时,就曾把 Markdown 表格的表头弄坏,导致下游脚本解析失败。后来我建立了一个非常轻量的回归机制:在tests/目录放一批样例文件,每个样例对应一个期望输出文件,每次修改代码后跑一遍对比。
rea test -f tests/sample1.log -p '...' --expected tests/expected1.txt这个机制虽然简单,但在后续持续迭代时帮了大忙。凡是涉及输出结构、默认编码、参数优先级的改动,回归用例能立刻告诉我“哪些用户可见行为发生了变化”。对一个个人项目来说,这不叫测试体系,叫给自己留后路。
6. 如果重来一次,我会优先做的三件事
6.1 优先支持标准输入
第一版只支持-f传文件,导致它没法放在管道里。后来我补上了.read()标准输入支持,整条命令链的价值立刻翻倍:
cat access.log | grep "status=500" | rea extract -p '...'标准输入应该是命令行文本工具的基础设施,因为它让你永远不用落地临时文件。如果重来一次,我会让-f和标准输入从一开始就并存,而不是等用户主动提需求。
6.2 错误信息要包含行号和具体字段
“正则匹配失败”是有用的信息,但不是好用的信息。好用的错误应该是这样:
第 12 行未找到命名分组 user_id用户不需要再去翻原文件猜是哪一行出的问题。我把这个原则写进了 rea 的错误设计规范里:凡是跟数据内容相关的报错,必须带上行号或者字段名。这个习惯后来也用在了其他脚本上,确实能显著降低排查成本。
6.3 少即是多,但“少”要用案例证明
每加一个参数,工具就多一分复杂度。后来我给自己立了一条规矩:没有真实案例支持的新参数,不进主分支。比如--ignore-missing是因为真的有一份字典文件缺少字段才加的;block模式是因为真的有人拿多行堆栈来跑才加的。功能数量本身不是指标,能不能覆盖你每天重复触发的操作才是指标。
最后再分享一个我在实际使用中的体会:多数开发者在正则上浪费的时间,并不是因为不懂语法,而是因为缺少一个能快速闭环的调试环境。工具链的价值往往不在单点能力,而在反馈速度。rea 是这类工具里很小的一员,但它的思路可以复用到任何你经常重复的文本操作上。如果你也有攒了半年的临时脚本,不妨挑一个高频场景,把它拆成 test、extract、replace 三类的标准动作,效果可能会出乎意料。