4.2 模式匹配:自成一派的文本武器


文档摘要

4.2 模式匹配:自成一派的文本武器 本节摘要:Lua 没有完整正则,只有一套自带的"模式"(pattern):字符类用百分号家族、量词四个、捕获用圆括号、外加平衡匹配与边界匹配两个特色件。整套实现不到一千行 C 代码,运行不回溯失控、没有嵌入安全黑历史。本节过完语法后进入实战:日志解析、头部提取、脱敏、格式互转,gsub 的函数替换与表替换两个高阶形态配完整例子。 不叫正则的模式 为什么 Lua 不内嵌一个标准正则引擎?把三方诉求摆出来就清楚了。 体积。PCRE 这类完整引擎编译后几百 KB,还有平台差异;Lua 的模式匹配实现不足一千行 C,核心语法一页表。对"核心两百 KB"的目标,正则引擎一个就翻倍。 安全。

4.2 模式匹配:自成一派的文本武器

本节摘要:Lua 没有完整正则,只有一套自带的"模式"(pattern):字符类用百分号家族、量词四个、捕获用圆括号、外加平衡匹配与边界匹配两个特色件。整套实现不到一千行 C 代码,运行不回溯失控、没有嵌入安全黑历史。本节过完语法后进入实战:日志解析、头部提取、脱敏、格式互转,gsub 的函数替换与表替换两个高阶形态配完整例子。

不叫正则的模式

为什么 Lua 不内嵌一个标准正则引擎?把三方诉求摆出来就清楚了。

体积。PCRE 这类完整引擎编译后几百 KB,还有平台差异;Lua 的模式匹配实现不足一千行 C,核心语法一页表。对"核心两百 KB"的目标,正则引擎一个就翻倍。

安全。完整正则的回溯引擎存在灾难性回溯——构造一段恶意输入能让匹配时间指数膨胀,DoS 的经典入口。Lua 模式不回溯(贪心量词一次吞到位、不回头重试全局组合),匹配时间是线性的,宿主敢把它暴露给用户输入。Redis 沙箱、OpenResty 网关把模式匹配用在请求文本上,靠的就是这份"不会失控"的保证。

够用。协议解析、日志切片、字段提取这类脚本的日常文本工作,90% 用不到正则的完备能力。Lua 用一页换一册,换来的是每个读者都能把语法记全——你正在读的这节就是证明。

代价也要说清:没有交替(a|b)、没有前后断言、没有反向引用、分组不捕获时也得写。复杂的文本分析(嵌套结构、上下文语法)确实做不了,该上 C 库或分步处理时不要硬撑。

一页语法表

字符类:百分号加字母是预定义类,点是任意字符:

写法 含义 补集
%d 数字 %D
%a 字母 %A
%s 空白 %S
%w 字母数字 %W
%l / %u 小写 / 大写 %L / %U
%p 标点 %P
%c 控制字符 %C
%x 十六进制数字 %X
. 任意字符

大写即补集,这条规则省掉一半记忆。自定义类用方括号:[abc] 任一、[^abc] 除此外任一、[a-z] 范围。标点字符要按字面匹配必须转义(如 %. 匹配点、%- 匹配减号),魔法字符一共有十二个:^$()%.[]*+-?

量词四个,都只作用于前一个字符类:

写法 语义 记法
* 零个及以上,贪心 多多益善
+ 一个及以上,贪心 至少一个
- 零个及以上,懒惰 能少则少
? 零个或一个 可有可无

贪心与懒惰的差别用一段日志体会:

> local log = "2026-08-20 level=info msg=hello" > print(log:match("%d+-%d+-%d+")) 2026-08-20 > print(("<a><b>"):match("<(.-)>")) a

.-> 懒惰到第一个右尖括号就停,贪心的 .*> 会吞到最后一处。"-)与(*"的选择决定了提取到第一段还是最后一段,是模式写作里最常见的一处纠结。

锚点^ 锚串首、$ 锚串尾:

> print(("error: x"):match("^error")) error > print(("x: error"):match("^error")) nil

在 gsub 里 ^ 还表示"只替换第一处"(后面见)。

捕获:圆括号圈住的部分成为捕获,match 依次返回、gsub 替换串里用 %1%2 回引:

> local k, v = ("retry=3"):match("(%w+)=(%w+)") > print(k, v) retry 3 > print(("2026-08-20"):gsub("(%d+)-(%d+)-(%d+)", "%3/%1/%2")) 2026/08/20 1

位置捕获 () 不圈字符,捕获的是位置数字——断点续读的利器:

> local line = "hdr: body" > local pos = line:match("()%s") > print(pos, line:sub(1, pos - 1), line:sub(pos + 1)) 5 hdr body

平衡匹配 %bxy 匹配一对平衡的 x 与 y 之间的内容,处理括号串的专属武器:

> print(("f(a, g(b), c) + 1"):match("%b()")) (a, g(b), c)

边界匹配 %f[字符类] 匹配"从非此类跨到此类"的零宽位置,比如"词首":

> print(("the theme"):gsub("%f[%a]the", "THE")) THE theme 1

只有作为独立词的 the 被替换,theme 里的 the 因前面也是字母(不构成边界穿越)幸免。这是 Lua 模式里最接近"词边界断言"的设施。

四个查探函数的分工

  • find(s, p):返回匹配的起止位置,没找到返回 nil。只要"有没有、在哪"时用它,最便宜;
  • match(s, p):返回捕获(无捕获则返回整段匹配);
  • gmatch(s, p)迭代所有匹配,每个匹配产出各自的捕获;
  • gsub(s, p, r)替换所有匹配(模式带 ^ 时只换第一处),返回新串与替换计数。
> local s = "id=1 id=2 id=3" > print(s:find("id=%d")) 1 4 > print(s:match("id=(%d)")) 1 > for id in s:gmatch("id=(%d)") do io.write(id, " ") end 1 2 3 > print(s:gsub("id=", "no=")) no=1 no=2 no=3 3

四个函数是同一台匹配引擎的四个出口,语法一份、语义四用。工作流程如下:

04-02-fig01

实战五题

题一:日志解析。每行一条 时间 级别 键值对,挖出结构:

local line = "2026-08-20T09:30:01 WARN user=ops action=reload zone=east" local pat = "^(%d+-%d+-%d+%a%d+:%d+:%d+)%s+(%a+)%s+(.+)$" local ts, level, rest = line:match(pat) print(ts, level, rest) -- 2026-08-20T09:30:01 WARN user=ops action=reload zone=east local kv = {} for k, v in rest:gmatch("(%w+)=(%w+)") do kv[k] = v end print(kv.user, kv.action) --> ops reload

两级模式:先粗切三段,再 gmatch 细挖键值。%a 吃掉 ISO 时间里的 T,(.+)$ 兜底其余——分而治之比一个巨型模式可读得多。

题二:头部提取(网关日常)。从原始头文本里取 Authorization:

local raw = "Host: api.local\r\nAuthorization: Bearer 9f2a\r\nAccept: */*" local scheme, token = raw:match("Authorization:%s*(%a+)%s+([%w%-_]+)") print(scheme, token) --> Bearer 9f2a

%s* 容忍冒号后任意空白,字符类 [%w%-_]+ 收 JWT 的点分内容时把 - 转义进类里。

题三:脱敏。手机号与邮箱打码,gsub 函数形态:

local function mask(s) s = s:gsub("1%d%d%d%d%d(%d%d%d%d)", function(mid) return "1****" .. mid -- 保留后四位 end) s = s:gsub("(%w+)@(%w+%.%w+)", function(user, domain) return user:sub(1, 1) .. "***@" .. domain end) return s end print(mask("call 13812345678 or ops@example.com")) -- call 1****5678 or o***@example.com

题四:格式互转。下划线与驼峰互换,函数替换加 gmatch 各显一次身手:

local function to_snake(s) return (s:gsub("%f[%u]%u+", function(up) return "_" .. up:lower() end)):gsub("^_", "") end local function to_camel(s) return (s:gsub("_(%w)", function(c) return c:upper() end)) end print(to_snake("maxRetryCount")) --> max_retry_count print(to_camel("upstream_host")) --> upstreamHost

%f[%u] 边界匹配找"大写词的起头",把 maxRetryCount 里的 R 与 C 前各插一根下划线。

题五:CSV 折腾。无引号转义的简单 CSV,一行拆数组:

local function split_csv(line) local fields = {} for f in (line .. ","):gmatch("(.-),") do fields[#fields + 1] = f end return fields end local cols = split_csv("id,name,score") print(#cols, cols[2], cols[3]) --> 3 name score

两个细节:(line .. ",") 补一个尾逗号让最后一段也被 gmatch 命中;.-, 懒惰匹配到第一个逗号,字段内含逗号的"真 CSV"需要引号感知,那就超出简单模式的辖区了。

补一题:URL 解析与重组

网关改写场景的标准题:把查询串拆成参数表、改一个值、再拼回去:

local function parse_query(qs) local params = {} for k, v in (qs .. "&"):gmatch("([^=&]+)=([^&]*)&") do params[k] = v end return params end local function build_query(t) local parts = {} for k, v in pairs(t) do parts[#parts + 1] = k .. "=" .. v end table.sort(parts) -- 排序让输出可测试 return table.concat(parts, "&") end local p = parse_query("page=3&size=20&zone=east") p.size = "50" print(build_query(p)) --> page=3&size=50&zone=east

拆分用"补一个尾分隔符加懒惰匹配"的惯用法(4.2 节题五同款);重组用表加 concat(拼接纪律)加排序(可测试性)。注意两个没做的事:没做百分号解码(生产要处理 %XX,用 (%%)(%x%x) 的 gsub 函数替换补上)、没处理重复键(同键多次出现时后者覆盖前者,要保留全部就得改成键对应数组的结构)。例子的价值在于标注边界:它演示骨架,也标明离生产差几步。

模式写作的三条军规

军规一:从最具体的字符类写起[%w%-]+.+ 好——宁可漏匹配后放宽,不要宽匹配后收窄(宽模式在意外文本上产生意外捕获)。

军规二:锚点能加就加^(%d+)%s(%d+)%s 抗噪——日志行首、头部字段名这些"结构性位置"永远值得锚定。没锚的模式会在串中任意位置开始匹配,多出来的命中常常在测试数据里藏几个月。

军规三:复杂模式拆成两步。先粗切大段(锚定结构),再对各段细挖字段——4.2 节题一的"两级模式"。一个三十字符的巨型模式没人能 review,两个十字符的模式人人能读。

-- 反面:一坨 local pat = "^(%d+)[%/-](%d+)[%/-](%d+)[%s]+(%a+)[%s]*:?[%s]*(.-)$" -- 正面:两步 local date, level, rest = line:match("^(%S+)%s+(%a+)%s+(.+)$") local y, m, d = date:match("(%d+)[%/-](%d+)[%/-](%d+)")

两步版每段都能独立测试、独立复用,错了也好定位——可维护性是模式代码的第一属性,因为它们天然难读。

第六题:结构化日志的完整解析器

把本节技巧合并成一个接近生产级的例子——把混合格式的日志行解析成结构化表,容错每一行的脏数据:

local PAT_TIMESTAMP = "^(%d%d%d%d%-%d%d%-%d%d[%dT ]%d%d:%d%d:%d%d)" local PAT_LEVEL = "%s+(DEBUG|INFO|WARN|ERR)" -- 语法糖替代:见下方说明 -- 注意:Lua 模式没有 | 交替,级别改用字符类一次写全或分两次匹配 local function parse_line(line) if line == "" then return nil end local ts = line:match(PAT_TIMESTAMP) if not ts then return nil, "no timestamp" end local rest = line:sub(#ts + 1) local level = rest:match("^%s*(%a+)") -- 先取单词再白名单 if not ({ debug = true, info = true, warn = true, err = true })[level:lower()] then return nil, "bad level: " .. level end rest = rest:match("^%s*%a+%s*(.-)$") -- 剩余正文 local fields = { ts = ts, level = level:lower(), kv = {}, msg = rest } for k, v in rest:gmatch("(%w+)=(%S+)") do fields.kv[k] = v:match("^%d+$") and tonumber(v) or v end return fields end for line in ("2026-08-20 09:30:01 WARN user=ops retries=3 zone=east"):gmatch("[^\n]+") do local f = parse_line(line) if f then print(f.level, f.kv.user, f.kv.retries, f.kv.zone) end end -- 输出:warn ops 3 east

五个设计决定值得复盘。时间戳锚死行首^ 加固定形状,抗变形);级别先取词再白名单——因为模式没有交替运算符,(%a+) 抓词加表校验是标准替代,注释里那行 PAT_LEVEL 是给从正则迁移来的读者的"翻译对照";剩余正文用懒惰尾匹配.-$);键值对二次挖掘且数字自动 tonumber(传输与比较两便);单行失败返回 nil 加原因,解析循环里 pcall 都不必上——纯函数失败就是返回值。三十行里本节军规全部在场:锚定、分步、白名单、容错返回。

从正则迁移的对照表

给带着正则经验的读者一张"翻译对照",迁移期最常查的六行:

正则写法 Lua 模式写法 备注
\d+ %d+ 反斜杠换百分号
\w+ %w+ 不含下划线,要加 [%w_]
\s %s
`a b`
(?:...) 非捕获组不存在,圆括号必捕获
(?=...) 无断言,用 %f[] 边界近似

第六行的翻译损失最大(边界匹配只有 %f 一个近似件),遇到强断言需求就是"该上 C 库或改分步策略"的信号——知道自己工具的边界,与会用它一样重要。

本节要点回顾

  • 不用完整正则的三角理由:体积小一截、线性不回溯(安全)、日常够用;代价是没有交替断言反向引用,复杂分析别硬撑;
  • 语法一页% 加字母是字符类(大写补集)、方括号自定义、四个量词中 - 懒惰 * 贪心、^$ 锚首尾、圆括号捕获、%b 平衡匹配、%f 边界匹配;
  • 魔法字符要转义,共十二个:^$()%.[]*+-?
  • 四函数一引擎:find 给位置、match 给捕获、gmatch 迭代、gsub 替换并报计数;
  • gsub 替换源三形态:串(%1 回引)、表(查表换名)、函数(动态生成、返回 nil 保留原文);
  • 实战心法:分而治之(粗切再细挖)、懒惰贪心按需选、位置捕获做断点、边界匹配当词边界用;
  • 三军规:字符类宁窄勿宽、结构性位置必加锚、长模式拆两步——模式代码的第一属性是可维护性。

文本之道至此齐备。下一章进入元表——Lua 把"行为如何定义"外挂出去的机制,面向对象将由它而来。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U