
做开发这些年正则表达式是我见过最容易被劝退、又最值得专门花时间啃下来的技能。早几年遇到输入校验我习惯写一堆if-else硬拼代码又长又脆直到某次日志提取任务被一个几十字符的正则表达式几句话解决我才下定决心把常用正则表达式符挨个过了一遍。这篇就按照我自己的学习路径把出现频率最高的正则符号拆开讲清楚配合Java和PHP里真实能跑的校验代码一块看。适合刚接触正则表达式、被各种符号绕晕的朋友也适合写过不少正则但总是边写边查的开发者把这套符号真正啃透后续写匹配逻辑就不用再开一堆页面查了。1. 正则表达式的底层逻辑与最基础符号的语义边界1.1 正则表达式到底在做什么正则表达式本质上是一条“模式字符串”正则引擎拿到这条字符串后会从头到尾扫描目标文本尝试找出一处或多处符合该模式的子串。它最常见的用途是三类校验、提取、替换。校验是判断整段字符串是否符合格式要求提取是从文本里把命中的片段摘出来替换是把命中的片段换成新内容。我在实操中习惯先把当前需求归到这三类里再去想符号怎么写。因为同一个正则符号在三类场景里的侧重点完全不同。比如要校验手机号你会更关心“整串是否合法”所以开头结尾的锚点一定要加上要提取日志里的日期你会更关心“命中哪几个数字片段”所以分组比锚点更重要。先分清用途符号的语义才不会混。1.2 字面量、点号与转义最容易出错的第一层正则里有一类符号叫“字面量”就是普通字符本身。字母a、数字9、汉字“中”写在正则里就表示匹配自身没有特殊含义。真正给你带来麻烦的是“元字符”也就是那些在正则语法里有特殊身份符号.、[]、^、$、*、、?、{}、()、|、\一共十来个。把它们的身份认清楚正则基本就学会一大半。其中最容易踩坑的就是.它表示“匹配除了换行符以外的任意一个字符”。这个概念很像文件搜索里的*通配符但又不完全一样.只占一个字符的位置。比如正则a.b能匹配a1b、aXb、a-b但不能匹配ab因为中间必须有一个字符。真正让初学者翻车的是“我想匹配一个真正的点号却忘了转义”。例如解析版本号v1.2正确写法是v\d\.\d其中\.表示匹配字面意义的点号。如果你写v\d.\d点号在正则里就是“任意字符”它会把v1a2这种脏数据也当成合法版本号放行。这就是转义的意义用反斜杠\把元字符“打回原形”让它只代表符号本身的字面含义。这里还要再提醒一层不同语言里反斜杠会经历“两次转义”。Java中正则\d是写在字符串里的源码里必须写成\\d想匹配真正的点号\.源码里得写\\.。PHP单引号里写/\./基本能直接用双引号字符串里也得注意反斜杠被二次解释的问题。很多人正则写不对其实不是正则语法错了而是字符串层转义没处理对。2. 字符类与预定义字符类型匹配“一类”而不是“一个”2.1 方括号字符类自定义一份候选清单如果你只想匹配“a、b、c三个字母中的任意一个”最直接的办法就是写[abc]。方括号[]在正则里叫“字符类”它把括号里的内容定义成一组候选字符匹配时只要命中其中一个就算成功。这个思路很贴近实际需求比如校验用户输入的“是否只由英文字母组成”不需要一个个枚举a|b|c|d写[A-Za-z]就行。字符类内部还有两个常用动作范围-和取反^。[a-z]表示a到z这个连续区间[A-Za-z0-9]表示大小写字母加数字这种范围写法比列一堆字符清爽得多。取反则是把^放在方括号的开头[^0-9]表示“不匹配数字的任意字符”。注意取反的^只有放在字符类开头才有特殊含义放在其他位置就只是普通字符。字符类里有个容易忽略的细节.、*、这些元字符放进方括号后会“自动降级”成普通字符不需要转义。比如[.\-]匹配点号或连字符这里的点号就是普通点号。但-字符需要小心它放在两个字符中间表示区间想匹配一个真正的连字符最好把它写在中括号开头或结尾比如[-.]或者直接转义为\-。2.2 预定义字符类型免检清单真的省事字符类好用但每次都写[0-9]还是烦。正则里为此预置了几个高频字符类型对应电脑键盘上最常见的几类字符符号含义等价写法\d数字字符[0-9]\D非数字字符[^0-9]\w单词字符字母、数字、下划线[A-Za-z0-9_]\W非单词字符[^A-Za-z0-9_]\s空白字符空格、制表符、换行等[ \t\r\n]\S非空白字符[^ \t\r\n]大写形式就是对小写形式的取反记住这一条六个符号就全记住了。我在做表单校验时特别常用它们^\d$判断非负整数^\S$判断不含空白[A-Z]判断字符串里有没有大写字母。这里有个坑值得重点说默认情况下Java和PHP里的\d、\w匹配的是ASCII字符不认中文数字和汉字。如果你要匹配“一二三”这种中文数字\d是不管的。需要处理中文时PHP的PCRE正则可以用\p{Han}匹配汉字或者用u修饰符结合Unicode编码范围Java则可以用[\u4e00-\u9fa5]或者开启Unicode支持。跨语言写正则字符类型的语义差异是最隐蔽的坑之一建议动手前先确认目标语言的默认行为。3. 量词、贪婪与懒惰正则怎么控制匹配次数3.1 量词符号让一个字符变成一串字符前面写的正则都默认匹配“恰好一个字符”但现实中很少这么凑巧。密码要至少8位身份证要正好18位手机号是11位这种“次数”的需求就交给量词处理。量词一共有六种写法写法含义*出现0次或多次出现1次或多次?出现0次或1次{n}恰好出现n次{n,}至少出现n次{n,m}出现n到m次其中?是一个要特别留意的符号它有两种身份做量词时表示“0次或1次”比如colou?r能同时匹配color和colour跟在其他量词后面时它又变成“懒惰模式”的开关让前面的量词改变匹配策略。这两个身份很容混淆我见过不少人在这个点上反复踩坑。3.2 贪婪、懒惰、占有三种截然不同的“吃法”默认情况下*、这些量词是“贪婪”的意思是“能多吞就多吞”一直吞到不能再动为止最后还会一步步回吐直到整体匹配成功。比如用b.*/b去匹配balpha/b and bbeta/b贪婪模式下它会从第一个b一直吃到最后一个/b把整段内容都吞进去。这往往不是你要的结果。解决办法是在量词后面加一个?变成懒惰模式。b.*?/b会从第一个b开始每多吞一个字符就检查一次能不能匹配到/b因此能匹配出两个独立的balpha/b和bbeta/b这通常才是提取内容时真正想要的。我用一句话帮助记忆贪婪是“先拿满再吐”懒惰是“先拿最少再慢慢加”。还有一种更少见的“占有”模式写法是在量词后面加比如*、、?。它和贪婪模式一样会尽量多吞但吞进去之后绝不回头也不回吐因此性能好但容易直接匹配失败。日常开发里用得不多只有在处理超长文本、又确认文本结构绝对规整时我才会考虑它。3.3 量词为什么会引发“回溯”理解回溯是理解正则性能的关键。正则引擎在贪婪模式下会先按最大长度去尝试比如.*一口气吞掉整行然后逐个吐出字符看剩余部分能不能匹配后续模式。这个“吐出再试”的过程就是回溯。懒惰模式则反过来从最短开始一点一点加长本质上也是在反复尝试。回溯本身不是问题但如果量词嵌套得太深回溯次数会指数级增长这就是后面要说的“灾难性回溯”的根源。现在你只需要建立两个基本判断能用懒惰量词解决的就别用贪婪量词硬扛看到(a)这类“量词套量词”的写法要多留个心眼。4. 锚点、分组与反向引用锁位置、留后手4.1 锚点符号把匹配钉在特定位置锚点不会匹配任何具体字符只表示“这里必须是某个位置”。最常用的是^和$分别表示目标文本的开头和结尾。加上它们正则就从“找一段符合规则的子串”变成“校验整段字符串是否完全符合规则”。这个差异在表单校验里极其重要。比如\d{6}能匹配到“abc123456def”中间那6位数字但^\d{6}$只能匹配“123456”这种恰好6位数字的整段字符串。我见过太多新手写手机号校验只写\d{11}结果一个13位的数字字符串也能通过就是因为少了锚点没有把匹配范围锁死在整段文本上。另外一个常用锚点是\b表示单词边界也就是字母数字和下划线与其它字符的交界处。\bcat\b能匹配句子“a cat sat”里的cat但不会匹配category里的前三个字符。\B则是它的反义。多行处理时要注意Java里默认^和$只对整段文本生效想要按行匹配需要开启MULTILINE模式PHP则是在正则末尾加m修饰符。4.2 捕获组、非捕获组与反向引用圆括号()在正则里不只是改变优先级它还有一个重要能力——捕获。正则(\d{4})-(\d{2})-(\d{2})匹配“2024-03-08”时会同时把年份、月份、日期分别存进编号为1、2、3的组里。Java里用matcher.group(1)读取年份PHP里用$matches[1]读取。这个能力在做提取任务时非常顺手解析日志、抓取接口返回里的关键字段都靠它。捕获组保存的内容还能在正则内部被“反向引用”。比如检测句子里的重复单词正则\b(\w)\b\s\1\b可以匹配“the the”这种连续重复的词这里的\1引用了第一组捕获到的内容。替换场景里也有对应写法Java和PHP普遍用$1表示第一个捕获组。需要注意不是所有括号都需要捕获。如果你只是想改变优先级而不想额外存一份数据建议用非捕获组(?:...)。比如(?:ab)表示匹配“ab”这个整体一次或多次但它不会产生编号。工程上我会默认优先使用(?:...)确实需要提取字段时才用捕获组这样分组编号不会乱也更省内存。还有个实用写法是命名分组。Java和PHP近几个版本都支持(?year\d{4})这种命名捕获组引用时用\kyear可读性比数字编号好不少。尤其是一个正则里有五六个分组时group(year)比group(3)让人省心得多。5. 实战拆解手机号、邮箱、密码、IP与身份证校验写法5.1 手机号校验简单不代表可以乱写中国大陆手机号目前是11位以1开头第二位一般在3到9之间后面跟9位数字。正则写起来很直白String phoneRegex ^1[3-9]\\d{9}$;这里^1锁死开头是1[3-9]限制第二位\d{9}确认后面刚好9位数字$收尾。看起来简单但我在项目里强调的是正则层只校验“形态”具体号段是否有效应该由后台数据字典或运营商号码段库确认。因为号段会不断更新正则写得太死后台一换号段你也得跟着改。工程上的取舍是正则管格式业务逻辑管有效。5.2 邮箱校验别追求重型武器邮箱地址的完整格式标准极其复杂网上能看到一条几十行字符的“终极邮箱正则”维护成本高还经常误伤。我在生产环境里更推荐这种实用版String emailRegex ^[\\w.-][\\w-](\\.[\\w-])$;它的逻辑是前面是单词字符、点、连字符组成的一段中间是后面是域名主体加至少一级后缀。足够挡住绝大多数明显错误真正判断邮箱是否真实存在靠的是发一封验证邮件。前端校验宽松、后端再验证有效性这是我处理邮箱验证的固定思路。不要试图让正则承担“证明邮箱存在”的任务。5.3 密码强度校验零宽断言的实战密码规则经常要求“必须同时包含大写字母、小写字母和数字”。用普通正则很难直接表达“全文同时满足多个条件”这时候零宽断言就派上用场了。看这个写法String passwordRegex ^(?.*[a-z])(?.*[A-Z])(?.*\\d)\\S{8,}$;(?...)是正向先行断言它只负责检查“从当前位置往后看能否满足条件”但不真正消费字符。所以(?.*[a-z])翻译过来是“从开头往后找必须能看到至少一个小写字母”而且断言结束后匹配光标还在原处继续检查下一个条件。三个断言做完再用\S{8,}确认总长度至少8位且不含空白。这套组合逻辑干净推荐直接收藏。顺便列一下常见断言写法以后遇上“既要又要”的校验时随时能用写法含义(?...)正向先行断言后面必须满足(?!...)负向先行断言后面不能满足(?...)正向后行断言前面必须满足(?!...)负向后行断言前面不能满足5.4 IPv4地址校验分段思想能救命IPv4地址的格式是四位0到255之间的数字用点号分隔。难点不在点号在于把0-255这个范围拆成几类合法数字。我常用的写法是String ipRegex ^((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)$;拆开看就清晰了25[0-5]覆盖250到2552[0-4]\d覆盖200到2491\d\d覆盖100到199[1-9]?\d覆盖0到99四段用|并列。前面三组后面跟一个转义点号\.整体用{3}重复最后再匹配一组不加点号的数字范围。这类正则的核心技巧是“把取值范围分段拆解”不要试图一口气用\d{1,3}模糊处理因为那会让256也混进去。5.5 身份证号码的格式校验与生日提取身份证号码的正则校验是很常见的面试题和业务需求。18位的号码结构可以描述为前6位区域信息中间8位出生日期后4位含顺序码和校验码校验码最后一位可能是数字或X。仅做格式初步校验可以这样写String idRegex ^\\d{6}(\\d{4})(\\d{2})(\\d{2})\\d{3}[\\dXx]$;这里用了三个捕获组把出生日期分别拆成“四位年、两位月、两位日”方便后续直接提取。更严格一点月份和日期也可以加合法性约束比如月份限制在(0[1-9]|1[0-2])日期限制在(0[1-9]|[12]\d|3[01])。但必须明确正则只负责格式层筛查千万别试图用它完成全部校验。18位身份证的末位其实是按统一规则计算出来的校验码日期还需要和真实日历比对这些都是字符串正则难以优雅完成的任务。工程上我会用正则先做格式筛选再写一段代码做日期和校验位计算职责分离代码也容易维护。6. Java与PHP正则匹配写法差异与踩坑点6.1 JavaPattern与Matcher的协作模式Java的标准做法是先通过Pattern.compile()把正则编译成模板再用Matcher去目标文本上执行匹配。比如手机号校验String phoneRegex ^1[3-9]\\d{9}$; Pattern pattern Pattern.compile(phoneRegex); Matcher matcher pattern.matcher(phone); if (matcher.matches()) { // 格式合法 }matches()要求整段字符串完全匹配适合做校验find()则是扫描“是否包含匹配的子串”适合做提取。两者用错结果会和你预期差很远。提取内容时的惯用写法则是配合分组Pattern birthPattern Pattern.compile(\\d{6}(\\d{4})(\\d{2})(\\d{2})\\d{3}[\\dXx]); Matcher matcher birthPattern.matcher(idNo); if (matcher.find()) { String year matcher.group(1); String month matcher.group(2); String day matcher.group(3); }能在循环外复用的Pattern尽量提前编译好尤其在批处理大量数据时重复编译的开销很可观。另外Java字符串里的反斜杠双写问题在这里体现得最明显源码里一个正则\d落盘到字符串里就是\\d。看到代码里双反斜杠不要觉得奇怪那是Java字符串层转义后的产物。6.2 PHP定界符与preg函数族的习惯PHP的正则函数以preg_开头最常用的是preg_match、preg_match_all和preg_replace。第一个参数是所有语言的“正则字符串”里最容易被新手卡住的地方PHP要求给模式加定界符也就是首尾各有一个一致的符号通常用/或#包起来。if (preg_match(/^1[3-9]\d{9}$/, $phone)) { echo 手机号格式合法; }如果你的正则里本身包含了斜杠比如要匹配路径/api/user用/做定界符就不得不写一堆转义改用#或~做定界符会清爽很多preg_match(#^/api/user$#, $path);PHP处理中文时还要注意编码PCRE默认按字节匹配直接写[一-龥]这类范围很容易出问题。稳妥的做法是给正则加u修饰符配合Unicode属性例如匹配一串汉字preg_match(/^\p{Han}$/u, $name);6.3 跨语言正则的差异清单正则语法整体一致但细节差异常年坑人。我自己维护项目时会专门记一份跨语言对照项目JavaPHP编译方式Pattern.compile()预编译每次调用preg_函数即时匹配整串匹配方法matcher.matches()preg_match(/^...$/, $str)提取子串matcher.group(n)$matches[n]字符串层转义双反斜杠\\d单引号里通常单反斜杠即可命名分组写法(?name...)(?name...)Unicode支持需开启Unicode字符类加u修饰符搭配\p{Han}写跨语言正则前先查一下目标版本的语法说明比踩完坑再回头改效率高得多。7. 正则调试经验写错之后总结的几个关键教训7.1 三个“翻车”现场第一个翻车是忘记转义点号。当时要校验版本号v1.2我图省事写了v\d.\d结果一个测试数据v1a2也通过了。排查了很久才发现点号在正则里是“任意字符”。从那以后凡是涉及用户输入的校验我都会把所有元字符按“是否需要匹配字面意义”过一遍。第二个翻车是没用锚点。给客户做手机号校验我写了1[3-9]\d{9}客户输了一串15位的数字也提示通过。原因很简单正则只匹配了整串中的前11位后面还有4位没人管。从那以后所有“整串格式校验”我都默认写成^...$没有例外。第三个翻车是贪婪量词吞掉了太多内容。从一个网页模板里提取b标签内容用了b.*/b结果把两个标签之间的全部文本都包进来了。换成b.*?/b之后才算对上。这类问题最容易出现在“你自己觉得内容结构很明确”的时候可实际文本比想象中复杂得多。7.2 灾难性回溯正则也可能把自己跑死有一次线上日志处理程序突然卡住最后定位到一条正则(a)配合一段高重复文本触发了灾难性回溯。简单说量词套量词会让引擎在匹配失败时反复尝试海量的组合路径几十个字符就能把CPU吃到满进程像死掉一样。工程上的预防手段主要有几条避免在同一个位置堆叠多个量词比如(a)、(\d*)*这类写法要警惕上线前用较长的极端输入做性能测试复杂场景里善用懒惰量词或非贪婪方式减少回溯分支还有一条很实用——能用普通字符串函数解决的简单查找就不要硬塞给正则。正则不是万能的也不是越复杂越“高级”。7.3 我现在的正则调试工作流现在我的写正则流程已经固定下来了。接到需求先想清楚是校验、提取还是替换然后用一个支持实时高亮和步骤统计的测试工具把边界用例先跑一遍包括正例和反例定稿后把正则写进代码时注明它到底要匹配什么以及哪些业务判断被有意排除在外。很多读代码的人不需要你解释正则每个符号的含义但需要知道“为什么这里只做格式层筛查”。最后再分享一个小技巧拿到任何陌生正则先把它按符号拆开逐个标注“这是什么、属于哪一类功能”再对照常见的量词、字符类、锚点和分组表几次下来你就能直接读懂大多数别人写的正则了。正则表达式不值得硬背但它绝对值得你手边常备一张常用符号表配合真实用例多写几次自然就熟了。