ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

手机号校验到SQL Server:正则表达式入门与实战

手机号校验到SQL Server:正则表达式入门与实战 第一次真正需要正则表达式多数人不是主动去学的而是被需求推着走的——校验一个手机号、从一堆日志里捞 IP、把表格里乱七八糟的日期统一格式、批量替换一批命名不规范的文件。这些活儿用split、in、find硬写也能干但代码会迅速膨胀成一坨嵌套判断改一次错一次加一个条件就崩。正则表达式regex解决的就是这类问题把看起来没规律、其实有规律的文本描述成一条几十个字符的规则剩下的事交给引擎去比对。它不是什么高级魔法本质是一套几十年没怎么变过的模式描述语言Python 能用SQL Server、MySQL、Oracle 能用编辑器、构建工具、日志平台也都能用学一次到处都使。这篇按零基础但想马上能用的路线走不背语法表从一个具体需求出发把每个符号到底在干什么讲透最后给一批能直接抄的模板和调试方法。1. 先把正则难这件事拆开难在哪不难在哪1.1 真正的门槛不是符号表而是用文字描述形状很多人背了一堆\d、\w、*、写的时候还是卡住。原因不在记忆力而在思维方式没转过来。普通字符串处理的思路是这个文本等于什么正则的思路是这个文本长什么形状。13812345678 phone是等值判断而1[3-9]\d{9}描述的是以 1 开头、第二位是 3 到 9、后面跟 9 位数字这个形状只要形状对具体数字是什么无所谓。这个视角一换很多原本觉得别扭的写法就顺了。比如要判断一段文本里有没有邮箱你不需要知道那个邮箱具体是谁的只需要描述一串非空字符 一串非空字符 点 若干字母这个形状。所以学正则的第一步不是记符号而是练习把需求翻译成形状描述哪些位置是固定的哪些位置是变化的变化的范围有多大出现的次数有没有上限。1.2 入门阶段只需要掌握三类东西从实际使用频率看日常八成的活儿只用到三类语法字符类这一位能是什么、量词这一位重复几次、分组与锚点边界在哪、怎么把结果取出来。剩下的断言、反向引用、条件组属于进阶等遇到具体问题再补硬背反而是负担。我建议的上手顺序是这样的先用一条真实的规则跑通看到效果再回头理解它由哪几块拼成。下面就从最高频的手机号规则开始逐字符拆开看。这样学的好处是每一步都有即时的正反馈不会出现学了半天不知道能干嘛的挫败感。提示不要在没跑通任何一条规则之前去背元字符表。正则是一门手感很重的技能看十遍不如写一遍。2. 从一条手机号规则切入引擎眼里的文本长什么样2.1 11位、13位、带区号先搞清楚你到底要匹配什么热搜里经常出现13位数字手机号码正则表达式怎么写这个问题本身就藏着坑。国内手机号是 11 位不带区号。所谓13位一般是指前面加了国家代码 86 的情况也就是8611位 13 位。还有一种是用户输入时习惯性写成86 138 1234 5678带加号、带空格去掉分隔符之后核心还是 11 位。所以写规则之前必须先确定三件事要不要允许国际区号、要不要允许分隔符空格、短横线、要不要严格卡开头。这三件事不同写出来的规则差别很大需求场景推荐写法说明只要干净 11 位^1[3-9]\d{9}$最严格适合入库校验允许带 86 或 86^(?:\?86)?1[3-9]\d{9}$区号部分整体可选允许空格和短横线^(?:\?86[-\s]?)?1[3-9]\d(?:[-\s]?\d){8}$宽松适合解析用户输入只要从长文本里捞出来(?!\d)1[3-9]\d{9}(?!\d)不加首尾锚点用边界断言防误伤注意最后一行和前几行的思路差异校验和抽取是两回事。校验的目标是整串是不是手机号所以要^...$把首尾锁死抽取的目标是这段文本里哪里藏着手机号如果加了^$反而一条都匹配不到。新手最常犯的错误就是在做抽取时习惯性加上^和$然后对着结果发懵。2.2 把1[3-9]\d{9}拆到不能再拆现在逐块看这条规则。第一个字符1是普通字符表示这里必须是一个字面的 1它不参与任何特殊含义写什么就是什么。接着[3-9]是一个字符类方括号表示这里可以取括号里列出的任意一个字符3-9是范围写法等价于3456789。再后面\d是预定义字符类表示一个数字等价于[0-9]。最后{9}是量词表示前面那个\d重复 9 次也就是连着 9 位数字。合起来读就是一个 1一个 3 到 9 之间的数字再跟 9 位数字。总长度 11911 位。那为什么第二位不直接用[0-9]或\d因为号段有实际规则第二位不会是 0、1、2。用[3-9]能一次性过滤掉大量明显的错误输入这叫用规则本身承载业务约束。同理第一位是1也是硬约束因为国内手机号没有以 2 开头的。这里有个细节值得说{}量词里的数字必须是确定的如果你想表达9 到 11 位可以写成\d{9,11}逗号前后分别是下限和上限写\d{9,}表示至少 9 位上不封顶。量词的范围写法在长度校验里非常常用比如密码8 到 20 位就是. {8,20}。3. 字符类与量词把可选和变长写进一条规则3.1 字符类解决这一位能是哪些字符方括号的核心作用是把一个位置的取值范围圈出来。[abc]表示 a、b、c 三选一[a-z]表示小写字母[A-Za-z0-9_]表示字母数字下划线。方括号内还有一个反义写法[^abc]表示除了 a、b、c 之外的任意字符这个^只在方括号内部、紧跟在左括号后的位置才有反义含义放在别处就是行首锚点初学者经常在这里绕不出来。预定义字符类是字符类的简写糖常用的就这么几个\d数字等价[0-9]\D非数字等价[^0-9]\w单词字符一般等价[A-Za-z0-9_]\W非单词字符\s空白包含空格、制表符、换行\S非空白在方括号内部这些简写仍然可用比如[\dA-F]表示数字或者 A 到 F 的字母用来匹配十六进制字符很顺手。但有个坑必须提前说在 Python 的 str 模式下\d和\w默认是 Unicode 语义也就是说它会匹配全角数字、中文汉字等。如果你的校验逻辑只想要 ASCII 数字就得显式写[0-9]或者给匹配加上re.ASCII标志。这个差异在中文输入法环境下极其容易出问题用户输入一个全角数字\d放过去[0-9]拦下来。3.2 量词解决这一位出现几次字符类管是什么量词管几次。常用量词一共四个基础形式加上它们的懒惰版本量词含义贪婪/懒惰*0 次或多次默认贪婪1 次或多次默认贪婪?0 次或 1 次默认贪婪{n}恰好 n 次固定{n,}至少 n 次默认贪婪{n,m}n 到 m 次默认贪婪*????{n,m}?对应量词的懒惰版尽可能少?有两副面孔要注意区分跟在一个字符或字符类后面时它是量词表示可选跟在另一个量词后面时它把量词变成懒惰模式。colou?r里的?管的是u匹配 color 和 colour 都行而.*?里的?管的是*改变的是匹配策略不是次数范围。量词的另一个隐性作用是影响匹配位置。\d在一串数字里会尽可能多地把连续数字吃进去\d{4}只会吃 4 个。做日期解析时如果写成\d会把20240315整个吞掉而你想要的是 4 位年份这时候{4}就比精确得多。凡是长度已知的字段优先用精确量词少用和*这是减少误匹配最直接的办法。3.3 贪婪与懒惰.*为什么总吃掉你不想要的东西贪婪的意思是能多拿就多拿拿完发现后面匹配不上再一个个往回吐这个过程叫回溯。举个典型的例子从 HTML 片段里取标题import re html h1第一篇/h1h1第二篇/h1 print(re.findall(rh1.*/h1, html))结果是[h1第一篇/h1h1第二篇/h1]一整串而不是两个标题。因为.*贪婪地一路吃到行尾发现后面还有/h1才慢慢往回收收到最后一个/h1才凑齐。改成.*?就对了print(re.findall(rh1.*?/h1, html)) # [h1第一篇/h1, h1第二篇/h1].*?的策略是能少拿就少拿先试 0 个字符看后面能不能接上接不上再多吃一个所以第一个/h1就停住了。这就是懒惰量词的价值。不过我更推荐的写法是用排除型字符类代替.*?h1[^]*/h1。因为[^]*明确表示只要不是左尖括号就一直吃引擎根本不需要回溯语义更清晰性能也更好。.*?虽然写起来短但它只是碰运气式地少拿遇到嵌套结构照样会出错。这是个经验判断能用排除型字符类说清楚的地方就不要用.*?。4. 分组、捕获与命名组从判断有没有到取出来用4.1 圆括号的第一个作用是把结果框出来圆括号在正则里身兼两职一是把若干字符打包成一个整体好让量词作用在整体上二是把匹配到的内容捕获下来方便后续读取。(ab)表示 ab 这个整体重复一次以上如果写成ab加号只管b语义完全不同。捕获组的编号规则是从左到右数左括号从 1 开始。Python 里用group(n)取值group(0)或group()表示整个匹配结果。看个例子import re m re.search(r(\d{4})-(\d{2})-(\d{2}), 订单日期2024-03-15) print(m.group(0)) # 2024-03-15 print(m.group(1)) # 2024 print(m.group(2)) # 03 print(m.group(3)) # 15这里的关键认知是组编号是按左括号出现的位置排的不是按语义重要性排的。所以一旦规则变复杂编号会迅速变得难以维护group(5)到底是哪一段写完第二天自己都忘了。4.2 非捕获组和命名组该在什么时候用如果你只是想把一段打包起来加个量词并不需要把内容单独取出来就用非捕获组(?:...)。它和普通括号功能一样只是不占用编号也不产生额外的捕获开销。像前面手机号规则里的(?:\?86)?区号这一整块是可选的但我们不关心它到底匹没匹配上就用非捕获组。需要取值的场景则推荐命名组Python 的写法是(?Pname...)pattern r(?Pyear\d{4})-(?Pmonth\d{2})-(?Pday\d{2}) m re.search(pattern, 2024-03-15) print(m.group(year), m.group(month), m.group(day)) print(m.groupdict()) # {year: 2024, month: 03, day: 15}命名组最大的好处是可读性和可维护性。三个月后回头看这段代码不用去数括号也知道每个值是什么。而且用groupdict()能一次性拿到字典直接塞进后续逻辑里比一个个group(1)、group(2)清爽得多。我的习惯是只有一处捕获的简单规则用编号组只要组数超过两个一律改命名组。4.3 反向引用匹配前后必须一样的结构有些文本结构要求前后字符一致比如引号、括号、标签的闭合。普通的字符类做不到这一点因为它只能描述是什么不能描述和前面那个一样。这时候用反向引用\1、\2引用前面第 n 个捕获组已经匹配到的内容。import re # 匹配成对的引号中间不能有引号 text 他说今天天气不错然后就走了 print(re.findall(r([\])(?:(?!\1).)*\1, text))这条规则稍微绕拆开看([])捕获开头的引号(?:(?!\1).)*是一串不是同一个引号字符的任意字符用负向断言一步步推进最后的\1要求结尾引号和开头那个完全一致。这样就不会出现单引号开头、双引号结尾的情况。反向引用在解析嵌套或成对结构时很有用但有两点要注意。第一\1引用的是实际匹配到的文本不是组里的模式所以如果组匹配的是\1就代表字面的。第二反向引用会影响引擎的优化策略长文本上性能会下降能用边界断言替代的地方优先用断言。5. 锚点、边界与断言别让长文本被半截匹配骗过去5.1^、$、\b到底卡在哪^卡在字符串或行的开头$卡在结尾。默认情况下 Python 的^只认整串开头加上re.MULTILINE标志后才会认每一行的开头$同理。这一点在做多行文本处理时非常关键不加MULTILINE你写^ERROR只能匹配到第一行开头的 ERROR后面行的全部漏掉。\b是词边界含义比较特殊它不是一个真实字符而是位置卡在\w和\W之间的缝隙上。\bcat\b能匹配a cat!里的 cat但不会匹配category里的 cat因为后者 cat 后面紧跟的是字母没有边界。这里有个中文场景的坑必须提在 Unicode 模式下汉字属于\w。所以\b卡在汉字与汉字之间是成立的只有汉字和标点、空格之间才会被认定成边界。如果你写\b手机号\b去一段全中文文本里找很可能一个都匹配不到因为前后都是汉字没有边界。这时候更靠谱的做法是用普通的字面匹配或者用自定义的边界断言。5.2 零宽断言匹配位置而不消耗字符断言分四类统称零宽断言因为它们在匹配过程中不吃掉任何字符只对当前位置做条件判断(?...)正向先行断言后面必须是……(?!...)负向先行断言后面不能是……(?...)正向后行断言前面必须是……(?!...)负向后行断言前面不能是……回到手机号抽取那个场景。为什么不加首尾锚点也能防误伤因为(?!\d)1[3-9]\d{9}(?!\d)用前后各一个负向断言锁住了相邻位置不能还有数字。这样从1381234567890这种超长数字串里就不会截出一段假的手机号。如果不用断言写成1[3-9]\d{9}引擎会从第一个数字开始贪婪匹配结果可能就是错的。断言的实际用途远不止这些。比如要提取金额但不要货币符号(?)\d(?:\.\d{2})?只取数字部分符号留在原地。要做密码强度校验必须含数字但不能全是数字可以组合多个先行断言# 8-20位必须同时包含字母和数字 pattern r^(?.*[A-Za-z])(?.*\d)[A-Za-z\d]{8,20}$这条规则的读法是先在开头位置做两次往前看的检查确认后面某处有字母、某处有数字检查完位置不动再老老实实匹配 8 到 20 位的字母数字。断言的本质是只检查不前进理解这一点很多复杂校验就能组合出来。5.3 一次真实的半截匹配事故我之前处理过一批日志解析规则写的是\d{4}-\d{2}-\d{2}用来捞日期。跑了两周没出问题直到某天上游系统开始输出类似2024-03-1509:30这种没有分隔符的拼接格式结果日期被解析成2024-03-15后面多出来的09被当成了别的时间字段整批数据错位。修复方式就是加边界(?!\d)\d{4}-\d{2}-\d{2}(?!\d)。这件事给我的教训是凡是不带锚点或断言的规则都要问一句它有可能匹配到更长文本的中间吗。如果答案是有可能就必须加边界。这个检查在提交代码前花三秒钟能省掉后面几小时的排查。6. Python re 模块的四个入口match、search、findall、sub6.1 先分清 match 和 search 的区别这是新手最容易踩的第一坑。re.match()只从字符串开头尝试匹配re.search()会扫描整个字符串。所以re.match(r\d, abc123)返回 None而re.search(r\d, abc123)能匹配到123。还有一个re.fullmatch()要求整串完全匹配等价于手动加^...$。我的使用建议是这样的校验场景整串必须符合用fullmatch别自己写^$在已知格式的开头找内容用match性能稍好在大段文本里找内容用search或findall需要迭代所有匹配并逐个处理用finditerfinditer经常被忽略但它比findall更适合大文本因为它返回的是迭代器不一次性把所有结果堆到内存里而且每个元素是 Match 对象能拿到位置信息start()、end()和分组内容。6.2 findall 返回什么取决于你写没写分组这个行为非常反直觉一定要记住findall的返回结构由捕获组数量决定。没有捕获组时返回字符串列表只有一个捕获组时返回该组的字符串列表有多个捕获组时返回元组列表。import re text a1, b2, c3 print(re.findall(r\w\d, text)) # [a1, b2, c3] print(re.findall(r(\w)\d, text)) # [a, b, c] print(re.findall(r(\w)(\d), text)) # [(a, 1), (b, 2), (c, 3)]三种写法只差一对括号返回结构完全不同。无数人在这里被坑过本来想拿完整匹配结果因为加了个分组拿到的是一堆子串。如果确实需要分组又不想改变返回结构就把分组写成非捕获组(?:...)或者改用finditer加group(0)。6.3 sub 与替换中的反向引用re.sub()做替换第二个参数里同样可以用\1、\gname引用捕获组。典型场景是把2024-03-15换成2024年03月15日import re print(re.sub(r(\d{4})-(\d{2})-(\d{2}), r\1年\2月\3日, 2024-03-15)) # 2024年03月15日 print(re.sub(r(?Py\d{4})-(?Pm\d{2})-(?Pd\d{2}), r\gy年\gm月\gd日, 2024-03-15))注意命名组的引用写法是\gname不能用\name因为反斜杠加字母会和转义序列混淆。另外一个实用参数是count控制最多替换几次只替换第一处就写count1。替换里还有一个容易忽略的点替换字符串里的\本身要转义。如果你要替换成 Windows 路径C:\data直接写会报错得写成rC:\\data或者用 lambda 函数返回字面量。这类问题在批量改路径、改配置文件的脚本里非常常见。7. SQL Server 里没有原生正则替代路线怎么选7.1 老版本LIKE 和 PATINDEX 能做的事很有限在数据库里做文本校验是另一个高频需求。SQL Server 的处境比较特殊不像 MySQL、Oracle 那样长期内置REGEXP_LIKE老版本只有LIKE和PATINDEX。LIKE支持的通配符只有四个%任意多字符、_单个字符、[]字符集、[^]排除集。它能做的校验其实不少比如-- 校验 11 位手机号简化版不校验号段 SELECT * FROM Users WHERE Phone LIKE 1[3-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9];写法很笨但能用。PATINDEX返回模式第一次出现的位置配合LIKE或CHARINDEX可以做部分匹配。它的局限在于没有量词没有分组没有断言没有反向引用。凡是涉及重复 n 次前后必须一致排除特定组合的需求LIKE都表达不了。很多人会试图用LIKE %[0-9]{11}%这种写法这是错的——{}在LIKE里不是量词就是普通字符。这类误解在跨语言迁移时特别常见从 Python 正则切到 SQLLIKE一定要重新审视每一条规则。7.2 较新版本的内置 REGEXP 家族可以优先考虑较新版本的 SQL Server 已经引入了一组以REGEXP_开头的内置函数覆盖了最常用的几个动作函数作用对应其他数据库REGEXP_LIKE判断是否匹配MySQL/Oracle 同名REGEXP_REPLACE按模式替换MySQL/Oracle 同名REGEXP_SUBSTR提取匹配片段MySQL/Oracle 同名REGEXP_INSTR返回匹配位置类似PATINDEXREGEXP_COUNT统计匹配次数无直接对应如果你手上的实例支持这些函数优先用它们语义清晰且性能通常优于自己拼LIKE。使用前建议先执行一次版本确认因为具体函数的可用性和参数细节在不同版本上会有差异写代码前确认一遍能省掉不少返工。语法上它们和主流数据库的写法接近模式串用的是标准正则语法所以其他平台的经验可以直接迁移过来。7.3 CLR 集成与入库前先清洗的取舍如果版本不支持内置正则又确实需要完整能力历史上常见的做法是注册 CLR 程序集把 .NET 的System.Text.RegularExpressions暴露成 SQL 函数。这条路能走通但代价不小需要开启 CLR 集成、要处理程序集部署和权限、后续升级和迁移都要额外照顾运维复杂度明显上升。我更推荐的思路是把正则校验前移到应用层。数据入库前先在 Python 或应用代码里用正则清洗一遍数据库只负责存储。这样做的理由有三点一是应用层的正则调试工具成熟得多改起来快二是复杂正则放在数据库里执行一旦出现回溯灾难拖慢的是整个实例影响面比应用层大得多三是校验规则通常跟着业务变放在代码里走正常的发布流程更可控。真要在数据库里做那就守住一个原则只做简单校验不做复杂解析。长度、是否纯数字、是否含某几个固定字符这些用LIKE就够了涉及分组提取、结构化解析的交给应用层。8. 高频模板与排错清单8.1 一批可以直接抄的模板下面这些是我日常用得最多的规则都验证过可以按需取用。注意 Python 里建议一律用原始字符串r...包裹避免反斜杠被 Python 自己先解释一遍这是最基本的习惯。用途模式备注用户名^[A-Za-z_][A-Za-z0-9_]{5,19}$字母下划线开头6-20 位强密码^(?.*[A-Za-z])(?.*\d)[A-Za-z\d$!%*?]{8,20}$至少含字母和数字邮箱宽松^[\w.-][A-Za-z0-9-](?:\.[A-Za-z0-9-])$别追求完全符合 RFCIPv4^(?:(?:25[0-5]2[0-4]\d日期^\d{4}-(?:0[1-9]1[0-2])-(?:0[1-9]时间^(?:[01]\d2[0-3]):[0-5]\d(?::[0-5]\d)?$身份证号^[1-9]\d{5}(?:1920)\d{2}(?:0[1-9]金额^\d(?:\.\d{1,2})?$最多两位小数这些规则有一个共同的取舍格式校验和真实性校验是两件事。正则能确认身份证号长得像身份证号但确认不了它是否真实存在、校验位对不对邮箱正则能确认格式合理但确认不了域名能不能收信。业务上如果需要真实性正则只是第一道筛子后面还得接校验位算法或验证码。8.2 回溯灾难最贵的性能坑有一种正则能让 CPU 直接跑满叫回溯灾难catastrophic backtracking。典型形态是嵌套量词比如(a)b去匹配aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!。引擎在本该贪婪的地方一层层尝试组合可能性数量随字符串长度指数级增长几十个字符就能让程序卡死。排查这类问题有几个明显信号正则写完后在短字符串上跑得很快换个长字符串就卡住或者某条规则在测试环境没问题上线后接口 P99 飙升。遇到这两种情况第一反应就该是查嵌套量词。避免手段主要有三个。第一能用排除型字符类就不要用.*比如[^]*比.*?更安全因为它限定了搜索空间不会反复回溯。第二嵌套量词改成非嵌套写法(a)b可以改写为ab语义在多数场景下等价。第三给不可信输入设长度上限超长就拒绝这是最省事的兜底。Python 标准库的re模块本身没有提供匹配超时参数正则一旦卡住当前线程就出不来。如果你的场景里有用户可控的输入和复杂正则建议改用第三方的regex模块它支持timeout参数超时直接抛异常风险可控得多。8.3 一条正则老是写不对按这个顺序查最后分享一套我自己的排错顺序基本上能覆盖九成的问题确认用了原始字符串。Python 里不加r前缀\d、\b这些会被当普通转义处理行为诡异。这是排查的第一站。确认 match 还是 search。明明字符串里有内容却返回 None八成是用了match而目标不在开头。确认findall的返回结构。拿到一堆元组而不是字符串检查是不是有多个捕获组。确认锚点有没有多余。做抽取时出现空结果检查^$是不是误加了。确认特殊字符转义了。匹配.得写\.匹配[得写\[匹配\得写\\。这一条在实际项目里出现频率最高。用在线工具逐步可视化。把规则和目标文本贴进去看引擎每一步走在哪里、在哪一步停住比盯着代码猜快得多。工具不重要能显示匹配过程就行。从复杂规则里砍到最小可复现版本。把规则一段段删直到它能匹配再一段段加回来哪一段加上去就失效问题就在那。还有个小技巧值得一试写复杂规则时先把所有分组临时改成命名组跑通之后再决定哪些降级成非捕获组。命名组能让调试信息一眼看懂等规则稳定了再优化效率比一上来就纠结括号类型高得多。我个人在实际操作中的体会是正则真正难的地方从来不是语法而是想清楚要匹配的东西长什么样。同样是校验一个手机号是为了入库做严格校验还是为了从一堆用户随手输入的文本里把号码捞出来这两个目标下写出来的规则完全不是一回事。想清楚目标再动手写符号是能省掉最多返工的一步。
返回列表