校验密码格式时,有这样一个要求:长度为 8~16 位,同时包含数字、大写字母、小写字母和特殊字符,但不限制它们出现的顺序。

如果把 [A-Z]、[a-z]、[0-9] 直接连起来,就变成了按顺序匹配字符,和这个要求不一样。这里可以用前瞻断言(lookahead),先分别检查几个条件,再匹配整个字符串。

从密码校验开始

import org.apache.commons.lang3.StringUtils;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public static boolean validatePassword(String password) {
    if (StringUtils.isBlank(password)) {
        return false;
    }
    Pattern pattern = Pattern.compile("(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[^\\w\\s]).{8,16}");
    Matcher matcher = pattern.matcher(password);
    return matcher.matches();
}

先不看整个表达式,单独看第一段 (?=.*?[A-Z])。(?=...) 是正向前瞻(positive lookahead):从当前位置向后检查,里面的表达式能匹配成功,这个条件就通过。

这里检查的是后面是否存在一个大写英文字母。关键在于,检查通过后,匹配位置不会随它向前移动。

“不消耗字符”是什么意思

用一个短例子看一下:

Pattern pattern = Pattern.compile("a(?=b)");
Matcher matcher = pattern.matcher("ab ac");

while (matcher.find()) {
    System.out.println(matcher.group());
    System.out.println(matcher.start() + ", " + matcher.end());
}

输出:

a
0, 1

a 匹配了第一个字符,当前位置来到索引 1。(?=b) 检查这里能否匹配 b,检查成功,但 b 没有进入这次的匹配结果。end() 返回 1,表示匹配区间在索引 1 之前结束。

如果在断言后再写一个 b:

Matcher matcher = Pattern.compile("a(?=b)b").matcher("ab");
System.out.println(matcher.matches()); // true
System.out.println(matcher.group());   // ab

断言检查过 b 后,位置仍停在它前面,所以后面的 b 还能匹配同一个字符。

“不消耗”说的是匹配位置,不是说断言内部没有查找过程。断言里面仍然可以有字符匹配和回溯,只是成功后不会推进外层的匹配位置。

多个前瞻为什么能同时检查

回到密码正则,前半部分是四个连续的断言:

(?=.*?[A-Z])
(?=.*?[a-z])
(?=.*?[0-9])
(?=.*?[^\w\s])

调用 matches() 时,从字符串开头开始匹配。第一个断言找到大写字母后,外层位置仍在开头;第二个断言也从开头检查小写字母,后面的两个同样如此。

以 Abc@1234 为例:

检查步骤检查内容检查通过后的外层位置
(?=.*?[A-Z])存在大写字母 A开头
(?=.*?[a-z])存在小写字母 b开头
(?=.*?[0-9])存在数字 1开头
(?=.*?[^\w\s])存在字符 @开头
.{8,16}匹配整个字符串的长度末尾

前四段都通过后,才由最后一段实际匹配字符。因此,这四类字符可以出现在不同位置,调整它们的顺序也可以通过:

System.out.println(validatePassword("Abc@1234")); // true
System.out.println(validatePassword("1234@bcA")); // true
System.out.println(validatePassword("Abc12345")); // false,缺少特殊字符
System.out.println(validatePassword("Abc@1234Abc@12345")); // false,共17位

这里的 .*? 是非贪婪匹配,先尝试匹配尽可能少的字符,再向后寻找满足条件的位置。在这个表达式中换成 .*,检查的仍是“后面是否存在某类字符”,不会因此变成对出现顺序的限制。贪婪与非贪婪影响尝试匹配的过程,前瞻是否消耗字符则由 (?=...) 决定。

matches() 和 find() 的区别

matches() 要求整个匹配区域符合表达式。这里没有另外设置区域,所以检查的是整个字符串。

find() 则从输入中寻找下一段匹配,下面的区别比较直接:

Pattern pattern = Pattern.compile("[0-9]{3}");

System.out.println(pattern.matcher("x123y").matches()); // false

Matcher matcher = pattern.matcher("x123y");
System.out.println(matcher.find());  // true
System.out.println(matcher.group()); // 123

密码方法里使用 matches(),所以不用再加 ^ 和 $ 来要求整个输入匹配。长度超过 16 位时,不能只取其中一段通过校验。

还有一个容易混淆的地方:前瞻检查的内容可以超出最终匹配到的那段字符。

Matcher matcher = Pattern.compile("(?=.*[A-Z]).{3}").matcher("abcD");
System.out.println(matcher.find());  // true
System.out.println(matcher.group()); // abc

System.out.println(
        Pattern.compile("(?=.*[A-Z]).{3}").matcher("abcD").matches()); // false

find() 从开头尝试,前瞻能看到后面的 D,而 .{3} 只匹配了 abc。断言成立,并不表示那三个匹配到的字符里一定包含大写字母。换成 matches(),输入一共四个字符,.{3} 无法匹配全部,所以返回 false。

这也是前面的密码校验不能直接把 matches() 换成 find() 的原因。

特殊字符到底包含什么

原表达式用 [^\w\s] 判断特殊字符。Java 默认模式下,\w 对应英文字母、数字和下划线;\s 包括普通空格、制表符和部分换行控制字符,并不代表所有 Unicode 空白字符。

所以这个写法没有把“特殊字符”限定成某几个标点:

System.out.println(validatePassword("Abc_1234")); // false
System.out.println(validatePassword("Abc中1234")); // true
System.out.println(validatePassword("Abc 1234")); // false
System.out.println(validatePassword("Abc@ 1234")); // true
System.out.println(validatePassword("Abc@1234\n")); // false

下划线属于 \w,不能满足特殊字符条件;中文不属于默认的 \w 或 \s,反而能通过这个条件。

空格也有两层含义:[^\w\s] 不把普通空格算作特殊字符,但没有禁止它出现在其他位置。最后的 . 可以匹配普通空格,所以 Abc@ 1234 仍然通过。换行符则不同,默认的 . 不匹配行终止符,最后一个例子无法整体匹配。

用负向前瞻排除空格

如果再增加一项要求:不允许出现普通空格,可以在开头加 (?!.* )。(?!...) 是负向前瞻(negative lookahead),里面的表达式匹配失败,断言才通过。

下面单独验证这个条件:

Pattern pattern = Pattern.compile("(?!.* ).{8,16}");
System.out.println(pattern.matcher("Abc@1234").matches());  // true
System.out.println(pattern.matcher("Abc@ 1234").matches()); // false

这里明确排除的是普通空格。如果要排除默认 \s 所包含的空白字符,Java 字符串中写成 "(?!.*\\s).{8,16}"。这只是新增的空白限制,完整校验还要保留前面的四项字符组成检查。

正向前瞻检查“需要有什么”,负向前瞻检查“不能有什么”;真正匹配哪些字符,仍由后面的表达式决定。

最后修改:2019 年 07 月 31 日
如果觉得我的文章对你有用,请随意赞赏