Python技术迷

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 re

line = "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 re

remark = "客户   张三\t\t购买了  2 件商品\n收货地址正常"

clean_remark = re.sub(r"\s+", " ", remark).strip()
print(clean_remark)

输出:

客户 张三 购买了 2 件商品 收货地址正常

re.sub(pattern, repl, string),三个位置别记反:匹配规则、替换内容、原字符串。

我一般会把这种清洗写得保守一点,不喜欢上来就一把梭。比如订单备注里有手机号,脱敏可以这么处理:

import re

msg = "退款申请,联系人: 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 re

conf = """
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 Path

defpatch_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() 也是改,但多给一个替换次数,适合批处理、迁移脚本、需要确认命中次数的场景。

写正则时别一上来追求复杂。先拿两三条真实脏数据跑一下,把没命中、误命中的情况打印出来。正则最怕看着对,跑完才发现多替了一片。