一个校验用的正则
^[A-Z0-9]+$拒绝了一个看起来完全合法的编号:全是大写字母和数字,长度也对,肉眼盯了十分钟找不出毛病。最后是ascii()把真相打了出来——第一个字符不是拉丁字母 H,是西里尔字母 Н。
🧩 现象:字符串和自己长得一样,但不相等
手上有个编号 Н241481Р01454,规则很简单:只允许大写字母和数字。校验直接判了不通过。
import re
pat = re.compile(r'^[A-Z0-9]+$')
bad = 'Н241481Р01454'
good = 'H241481P01454'
print(bool(pat.match(bad)), len(bad))
print(bool(pat.match(good)), len(good))
print(bad == good)
跑出来是这样:
False 13
True 13
False
两串在屏幕上、在编辑器里、在日志里完全一样,长度也都是 13,但 == 说它们不是同一个东西。到这一步基本可以确定:问题不在正则,在输入里有我看不见的字符。
🔍 第一步不是分析,是把差异显示出来
排查不可见字符,核心动作只有一个——换一种不依赖字形的方式把它打出来。Python 里最省事的是 ascii(),它会把所有非 ASCII 字符转成 \uXXXX 转义:
print(ascii(bad))
print(ascii(good))
'\u041d241481\u042001454'
'H241481P01454'
一行就结案了。第 0 位和第 7 位是 U+041D 和 U+0420,剩下全是老老实实的数字。
想看得更细就逐字符打码点和 Unicode 官方名字:
import unicodedata
for i, (a, b) in enumerate(zip(bad, good)):
mark = ' <-- DIFF' if a != b else ''
print(f'{i:2d} {a} U+{ord(a):04X} {unicodedata.name(a):<32}'
f' | {b} U+{ord(b):04X} {unicodedata.name(b)}{mark}')
0 Н U+041D CYRILLIC CAPITAL LETTER EN | H U+0048 LATIN CAPITAL LETTER H <-- DIFF
1 2 U+0032 DIGIT TWO | 2 U+0032 DIGIT TWO
...
7 Р U+0420 CYRILLIC CAPITAL LETTER ER | P U+0050 LATIN CAPITAL LETTER P <-- DIFF
不在 Python 里也一样能查。写进文件之后 xxd 看字节,UTF-8 编码的西里尔字母是两字节、以 d0 开头,混在一串 ASCII 数字里非常扎眼:
$ xxd id.txt
00000000: d09d 3234 3134 3831 d0a0 3031 3435 340a ..241481..01454.
00000010: 4832 3431 3438 3150 3031 3435 340a H241481P01454.
或者直接让 grep 把非 ASCII 的位置找出来,-b 顺便给字节偏移:
$ grep -o -b -P '[^\x00-\x7F]' id.txt
0:Н
8:Р
顺带说一个坑:不是所有语言的「转义打印」都会帮你。Python 的 ascii() 会转义,但 Go 的 %q 和 JavaScript 的 JSON.stringify 都直接把西里尔字母原样吐回来,看着还是一模一样:
Go fmt.Printf("%q", bad) => "Н241481Р01454"
JS JSON.stringify(bad) => "Н241481Р01454"
所以这两边要么自己按码点打,要么落到字节层面看。Go 里 len(bad) 是 15、len([]rune(bad)) 是 13,字节数和字符数对不上,本身就是个信号。
🪞 同形字:几个长得几乎没有区别的字母
西里尔字母表里有一批大写字母,字形和拉丁字母基本重合,但码点完全无关:
Н U+041D CYRILLIC CAPITAL LETTER EN vs H U+0048 LATIN CAPITAL LETTER H
Р U+0420 CYRILLIC CAPITAL LETTER ER vs P U+0050 LATIN CAPITAL LETTER P
А U+0410 CYRILLIC CAPITAL LETTER A vs A U+0041 LATIN CAPITAL LETTER A
Е U+0415 CYRILLIC CAPITAL LETTER IE vs E U+0045 LATIN CAPITAL LETTER E
О U+041E CYRILLIC CAPITAL LETTER O vs O U+004F LATIN CAPITAL LETTER O
С U+0421 CYRILLIC CAPITAL LETTER ES vs C U+0043 LATIN CAPITAL LETTER C
这类「不同码点、相同或近似字形」的字符,Unicode 官方叫 confusables,中文一般叫同形字。真实世界里最出名的应用是 IDN 同形域名钓鱼:注册一个把某个字母换成西里尔同形字的域名,浏览器地址栏渲染出来和 example.com 难分真假。原理和我这个编号被拒是同一件事,只是攻守方向反过来了。
⚠️ 方向别搞反:这是假阴性,不是「混进来被放行」
我一开始下意识以为是「脏数据绕过了校验」,其实完全相反。
[A-Z] 在正则里是一个码点区间,等价于 U+0041–U+005A。西里尔字母在 U+0400 往上,根本不在这个区间里。所以结果是校验拒绝了它——一个假阴性:数据是脏的,但表现出来是「合法输入被误杀」,于是排查方向很容易一头扎进正则本身。
这一点各语言行为一致,我在 Go、JavaScript、Python 三边都跑了同一组输入,^[A-Z0-9]+$ 对 Н241481Р01454 全都是 false。[A-Z] 是码点区间这件事是规范层面的,不存在某个实现「顺手帮你把西里尔算进去」。
顺便一提,大小写转换也救不了:bad.casefold().upper() 之后再匹配,仍然是 False——Cyrillic EN 的大写还是 Cyrillic EN。
🧪 NFKC 不能解决这个问题(我验证过)
看到「Unicode 字符不一致」,第一反应通常是上 unicodedata.normalize('NFKC', s)。这次不行,实测四种规范化形式全都原样返回:
for form in ('NFC', 'NFD', 'NFKC', 'NFKD'):
n = unicodedata.normalize(form, bad)
print(form, bool(pat.match(n)), ascii(n))
NFC False '\u041d241481\u042001454'
NFD False '\u041d241481\u042001454'
NFKC False '\u041d241481\u042001454'
NFKD False '\u041d241481\u042001454'
原因是 NFKC 处理的是兼容等价——同一个字符的不同表现形式,比如全角、上标、连字。西里尔 Н 和拉丁 H 是两个语义完全不同的字母,只是碰巧画得像,它们之间不存在任何等价关系,所以规范化没有任何理由去动它。
拿全角字母做个对照就很清楚了,同样是 NFKC,这个就折叠成功了:
fullwidth '\uff28241481\uff3001454' -> NFKC 'H241481P01454' match True
cyrillic '\u041d241481\u042001454' -> NFKC '\u041d241481\u042001454' match False
要真做同形字检测,得靠 Unicode 的 confusables 数据(confusables.txt 里的 skeleton 映射),或者走「限制脚本混用」的路子。
🛡 实际能用的防御
我更倾向于收紧输入,而不是事后去猜用户想输什么。按性价比排:
1. 白名单码点。 编号这种字段的合法字符集是明确的,直接逐字符查表,不合法就报出位置和码点,让错误信息自己解释清楚:
import unicodedata
ALLOWED = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
def check_charset(s):
bad = [(i, c) for i, c in enumerate(s) if c not in ALLOWED]
if bad:
detail = ', '.join(
f'pos {i}: {c!r} U+{ord(c):04X} {unicodedata.name(c, "?")}'
for i, c in bad
)
raise ValueError(f'非法字符 -> {detail}')
return s
非法字符 -> pos 0: 'Н' U+041D CYRILLIC CAPITAL LETTER EN, pos 7: 'Р' U+0420 CYRILLIC CAPITAL LETTER ER
区别只在错误信息,但这条信息足以把半小时的排查压缩成十秒钟。^[A-Z0-9]+$ 只会告诉你「不匹配」,它不会告诉你不匹配在哪、为什么。
2. 检测脚本混用。 统计字段里出现了几种 Unicode script,多于一种就拒绝。这条对付「一串里混了两种字母表」很有效:
'H\u041d241481P01454' scripts: ['CYRILLIC', 'LATIN'] <- 混用,拒绝
但要老实说一句:这条对本文这个输入无效。Н241481Р01454 里的字母恰好全是西里尔的,脚本检测跑出来是单一 ['CYRILLIC'],一点问题都看不出来:
'\u041d241481\u042001454' scripts: ['CYRILLIC'] <- 单一脚本,放行
所以 single-script 检查必须配上「预期脚本」才有意义——不是「只要不混用就行」,而是「这个字段只允许 Latin」。这也是为什么第 1 条的白名单更实在:它天然就带着预期。
3. 在入口处规范化并落库。 先 NFKC(能收掉全角、兼容字符这一类),再过白名单,通过了才写进存储。别让脏码点漏到下游——一旦存进去,之后所有基于等值比较的查询都会静默地查不到,那时候排查成本比现在高得多。
收束
三条经验:
- 肉眼不是验证手段。 字符串排查的第一步永远是换个表示形式打出来:
ascii()、逐字符码点、xxd。字形骗人,码点不骗人。 - 「合法输入被拒」和「脏输入被放行」要分清。 这次是前者,方向搞反了就会一直去改正则。
- 校验器要能解释自己。 一个只会返回布尔值的正则,把「为什么不通过」这个信息整个丢掉了;换成逐字符白名单,多写十行,省下的是一个下午。
至于同形字本身——它不是 bug,Unicode 就是这么设计的:字形归字形,码点归码点。指望编码层帮你把「看起来一样」变成「就是一样」,方向从一开始就错了。
💬 评论
加载评论中...
发表评论