Python 的 re 模块中 split()、sub()、subn() 方法有什么作用?
一行清洗脚本跑完,日志字段少了两列。
我第一眼没去怀疑正则写错,先看的是分隔符。因为这种问题太常见了:业务日志里一会儿逗号,一会儿竖线,一会儿多个空格,拿字符串的 split(',') 硬切,跑得快,错得也快。
Python 的 re 模块里,split()、sub()、subn() 这三个方法,平时不算显眼,但处理脏文本、日志、导入文件时很顺手。
看一段原始日志:
line = "uid=10086 | action=pay, amount= 129.90 ; status=ok"
这个日志就挺别扭,分隔符不统一,有 |,有 ,,有 ;,还有一堆空格。你要是这么写:
line.split(",")
只能切掉逗号,其他地方都还粘在一起。
换成 re.split():
import reline = "uid=10086 | action=pay, amount= 129.90 ; status=ok"
parts = re.split(r"\s*[|,;]\s*", line)
print(parts)
输出大概是:
['uid=10086', 'action=pay', 'amount= 129.90', 'status=ok']
re.split() 做的事很简单:按正则规则切字符串。
这里的 r"\s*[|,;]\s*",意思是:分隔符可以是 |、,、;,前后空格顺手吃掉。
这地方有个坑,我见过不止一次。正则里如果加了括号,分隔符会被保留下来。
text = "a,b; c|d"print(re.split(r"[,;|]\s*", text))
print(re.split(r"([,;|])\s*", text))
第二行会把分隔符也放进结果里:
['a', 'b', 'c', 'd']
['a', ',', 'b', ';', 'c', '|', 'd']
有时候这是好事,比如你要保留原始符号做回放;大部分清洗场景下,这就是事故苗头。
再看 sub()。
它不是切,是替换。字符串也有 replace(),但 replace() 只能认死字符串。遇到“多个空格都压成一个空格”“手机号脱敏”“订单号格式统一”这种活,还是得用正则。
比如导入用户备注时,经常能看到这种东西:
remark = "客户 张三\t\t购买了 2 件商品\n收货地址正常"
想把连续空白统一成一个空格:
import reremark = "客户 张三\t\t购买了 2 件商品\n收货地址正常"
clean_remark = re.sub(r"\s+", " ", remark).strip()
print(clean_remark)
输出:
客户 张三 购买了 2 件商品 收货地址正常
re.sub(pattern, repl, string),三个位置别记反:匹配规则、替换内容、原字符串。
我一般会把这种清洗写得保守一点,不喜欢上来就一把梭。比如订单备注里有手机号,脱敏可以这么处理:
import remsg = "退款申请,联系人: 13812345678,备用电话: 17700001111"
masked = re.sub(
r"(?<!\d)(1[3-9]\d{9})(?!\d)",
lambda m: m.group(1)[:3] + "****" + m.group(1)[7:],
msg
)
print(masked)
这里替换值不是字符串,而是一个函数。
这点挺关键。sub() 的第二个参数可以传函数,每匹配到一次,就把匹配对象丢给这个函数,函数返回什么,就替换成什么。
如果只是写:
re.sub(r"\d", "*", text)
那只是机械替换。传函数之后,就能根据匹配内容做判断。
比如线上有一批接口日志,里面 userId 有的带前缀,有的不带:
log = "u_1024 paid, user=7788 failed, u_2048 refund"fixed = re.sub(
r"\b(?:u_)?(\d{4})\b",
lambda m: f"user_id={m.group(1)}",
log
)
print(fixed)
输出:
user_id=1024 paid, user=user_id=7788 failed, user_id=2048 refund
这段代码故意留了一个问题:user=7788 被替换成了 user=user_id=7788。
这就是正则清洗最烦人的地方。不是能匹配就完事,得看上下文。改一下规则,只处理独立的 u_数字 或者纯数字字段前面不是 user= 的情况:
log = "u_1024 paid, user=7788 failed, u_2048 refund"fixed = re.sub(
r"(?<!user=)\b(?:u_)?(\d{4})\b",
lambda m: f"user_id={m.group(1)}",
log
)
print(fixed)
这个时候输出更接近预期:
user_id=1024 paid, user=7788 failed, user_id=2048 refund
再说 subn()。
这个方法平时很多人不用,但我挺喜欢在批处理脚本里用它。原因很简单:它不光返回替换后的字符串,还返回替换了几次。
sub() 返回字符串。
subn() 返回一个元组:
(new_string, number)
比如清洗一批配置文件,把里面的老域名替换成新域名:
import reconf = """
callback=http://old-pay.internal/api/callback
notify=http://old-pay.internal/api/notify
timeout=3000
"""
new_conf, changed = re.subn(
r"http://old-pay\.internal",
"https://pay-gateway.internal",
conf
)
print(new_conf)
print("changed:", changed)
输出里 changed 就是替换次数。
这个数字在线上脚本里很有用。比如你预期每个文件至少替换一次,结果某个文件 changed=0,那就别急着覆盖原文件,先打日志。
import re
from pathlib import Pathdefpatch_conf(path: Path):
raw = path.read_text(encoding="utf-8")
patched, hit = re.subn(
r"http://old-pay\.internal",
"https://pay-gateway.internal",
raw
)
if hit == 0:
print(f"[skip] {path.name}, old domain not found")
return
backup = path.with_suffix(path.suffix + ".bak")
backup.write_text(raw, encoding="utf-8")
path.write_text(patched, encoding="utf-8")
print(f"[ok] {path.name}, replaced={hit}, backup={backup.name}")
这种脚本我不建议写得太“聪明”。先备份,再覆盖。subn() 给的次数就是一个很便宜的校验点。
还有两个参数容易被忽略。
re.split() 有 maxsplit,控制最多切几次:
row = "1001|pay|amount=99|remark=a|b|c"print(re.split(r"\|", row, maxsplit=3))
输出:
['1001', 'pay', 'amount=99', 'remark=a|b|c']
日志字段里 remark 经常带分隔符,这种时候不能无脑全切。
re.sub() 和 re.subn() 有 count,控制最多替换几次:
text = "token=aaa token=bbb token=ccc"print(re.sub(r"token=\w+", "token=***", text, count=1))
只替换第一个。
这玩意儿在处理请求头、链路日志时挺实用。有些字段重复出现,你只想处理第一个主字段,后面的原样保留,方便排查。
最后捋一下这三个方法的使用感觉。
split() 负责拆,适合日志字段、导入行、混乱分隔符。
sub() 负责改,适合清洗、脱敏、格式归一。
subn() 也是改,但多给一个替换次数,适合批处理、迁移脚本、需要确认命中次数的场景。
写正则时别一上来追求复杂。先拿两三条真实脏数据跑一下,把没命中、误命中的情况打印出来。正则最怕看着对,跑完才发现多替了一片。