Java正则匹配失效

背景

最近生产上出现一个正则匹配失效的情况,排查后发现是因为包含终止符NEL,需要用DOTALL模式匹配。原本是以为客户错误输入导致,再深入排查发现是由于utf8字符错误解码成iso8859导致。

Java: jdk8

排查流程

问题复现

省略掉排查日志,本地复现的步骤,这里直接用抽象的核心问题代码复现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static void bugRecur(){
// 模拟实际情况中的utf8字节数组
byte[] bugBytes=new String("\r\n\r\n公司123\r\n").getBytes(StandardCharsets.UTF_8);
byte[] normalBytes=new String("\r\n\r\n普通字符123\r\n").getBytes(StandardCharsets.UTF_8);

// 模拟业务需要,以iso8859-1编码将字节数组转回字符串(实际上上面和下面在实际业务的不同系统做的)
String bugStr=new String(bugBytes,StandardCharsets.ISO_8859_1);
String normalStr=new String(normalBytes,StandardCharsets.ISO_8859_1);

// 进行正则匹配
String patternStr = "\\r\\n\\r\\n(.*?)\\r\\n";
Pattern pattern = Pattern.compile(patternStr);
Matcher bugMatcher = pattern.matcher(bugStr);
Matcher normalMatcher = pattern.matcher(normalStr);

System.out.println("问题字符匹配结果: " + bugMatcher.find());
System.out.println("普通字符匹配结果: " + normalMatcher.find());
}

结果:

1
2
问题字符匹配结果: false
普通字符匹配结果: true

通过上面代码很容易发现两段字符串一个能匹配一个不能匹配明显有问题,且应该就是字符集不一致导致的问题,而且此问题不是必现。实际上相关的业务代码之前仅处理英文不处理中文所以一直没有问题,直到最近业务改造有了中文且通过了联调测试阶段一直到生产才发现问题。

问题根源

既然发现了是字符集问题,尝试去找到问题根源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private static void deepReason(){
// 模拟实际情况中的utf8字节数组
byte[] bugBytes=new String("\r\n\r\n好公司123\r\n").getBytes(StandardCharsets.UTF_8);
byte[] normalBytes=new String("\r\n\r\n普通字符123\r\n").getBytes(StandardCharsets.UTF_8);

// 模拟业务需要,以iso8859-1编码将字节数组转回字符串(实际上上面和下面在实际业务的不同系统做的)
String bugStr=new String(bugBytes,StandardCharsets.ISO_8859_1);
String normalStr=new String(normalBytes,StandardCharsets.ISO_8859_1);


System.out.println("错误编码后的问题字符串字节数组: "+ Arrays.toString(bugStr.getBytes(StandardCharsets.ISO_8859_1)));
System.out.println("错误编码后的问题字符串字节数组: "+ Arrays.toString(normalStr.getBytes(StandardCharsets.ISO_8859_1)));
// 这里是往控制台输出的结果,一般流会通过utf8编码传输,对分析函数内部行为没什么意义
// System.out.println("错误编码后的问题字符串: "+bugStr);

// 更关键的是看字符串在java内存中的utf16编码后的结果
// System.out.println("错误编码后的问题字符串在java内存中的utf16编码字符数组: "+ Arrays.toString(bugStr.toCharArray())); // 这一行会输出失败
System.out.println("错误编码后的问题字符串在java内存中的utf16编码字节数组: "+ Arrays.toString(bugStr.getBytes(StandardCharsets.UTF_16)));
// System.out.println("错误编码后的普通字符串在java内存中的utf16编码字符数组: "+ Arrays.toString(normalStr.toCharArray())); // 这一行会输出失败
System.out.println("错误编码后的普通字符串在java内存中的utf16编码字节数组: "+ Arrays.toString(normalStr.getBytes(StandardCharsets.UTF_16)));

String patternStr = "\\r\\n\\r\\n(.*?)\\r\\n";
Pattern pattern = Pattern.compile(patternStr);
Matcher bugMatcher = pattern.matcher(bugStr);
Matcher normalMatcher = pattern.matcher(normalStr);

System.out.println("问题字符匹配结果: " + bugMatcher.find());
System.out.println("普通字符匹配结果: " + normalMatcher.find());

}

结果输出:

1
2
3
4
5
6
7
错误编码后的问题字符串字节数组: [13, 10, 13, 10, -27, -91, -67, -27, -123, -84, -27, -113, -72, 49, 50, 51, 13, 10]
错误编码后的问题字符串字节数组: [13, 10, 13, 10, -26, -103, -82, -23, -128, -102, -27, -83, -105, -25, -84, -90, 49, 50, 51, 13, 10]

错误编码后的问题字符串在java内存中的utf16编码字节数组: [-2, -1, 0, 13, 0, 10, 0, 13, 0, 10, 0, -27, 0, -91, 0, -67, 0, -27, 0, -123, 0, -84, 0, -27, 0, -113, 0, -72, 0, 49, 0, 50, 0, 51, 0, 13, 0, 10]
错误编码后的普通字符串在java内存中的utf16编码字节数组: [-2, -1, 0, 13, 0, 10, 0, 13, 0, 10, 0, -26, 0, -103, 0, -82, 0, -23, 0, -128, 0, -102, 0, -27, 0, -83, 0, -105, 0, -25, 0, -84, 0, -90, 0, 49, 0, 50, 0, 51, 0, 13, 0, 10]
问题字符匹配结果: false
普通字符匹配结果: true

分析上面的结果前需要知道一个前提,字符串在Java内部使用的utf16进行编码保存,pattern.matcher(bugStr)正则去匹配时,matcher是按String中的Unicode单元操作,也就是字符char(暂不考虑代理对)

回到字节数组的分析上,开头的[-2, -1]是0xFE 0xFF表示UTF-16 BOM(大端)说明后续每两个字节代表一个char,可以发现原来的单字节iso8859编码内容在Java的String里面是由utf16两字节表示的,且第一个字节都是用0补上。

所以原输入”公“的unicode为U+516C -> utf8编码传输到java内的字节流数据是”E5 85 AC“,转为对应的字节数组是[-27, -123, -84],这个字节数组如果按照utf8解码则会将其作为整体解析为字符“公”,但如果按iso8859解码则是一个字节一个字节解析,结果没意义,但也没有变更整个字节流结果。但是当把这个解析结果作为String对象存在内存里面时,“公”变成的三个无意义字符,会用utf16将其一个个编码。最终结果可以看到除了最前面两字节添加的标识,是在原字节基础上补一个字节,内容为0。utf16将iso8859解码的字符又编码保存到内存,此时新的字符的字节数组最终长度是原长度*2+2

在内存中调用matcher方法时,匹配的便是以[0, 13],[0, 10]这样两字节为一单位char的字符,而恰巧[0,-123]以utf16解码结果是0x85是换行终止符,也就导致输入的正常中文在一系列编码解码后的字符串,经过正则匹配时匹配失败,因为默认情况“.”匹配除了终止符的任意字符

1
2
3
4
5
6
7
8
Java byte: -123
无符号值: -123 & 0xFF = 133
十六进制: 133 = 0x85

字节数组: [0, -123]
十六进制: [0x00, 0x85]
UTF-16解码: (0x00 << 8) | 0x85 = 0x0085
结果: Unicode码点 U+0085 代表:NEL (Next Line) 终止符

结论:由于中文字符“公”的utf8编码的三个字节的某个字节用byte表示是-123,经过iso8859单字节解码为一个无意义字符完整保留,而该无意义字符char在java内存里面是以utf16编码保存(用byte表示则是[0,-123],转为unicode则是U+0085)恰好是终止符NEL,导致最后在匹配正则时匹配失败

解决办法

  1. 字符集一致:这个是最好的。但实际上之前这边采用iso8859解码就是因为采用utf8有问题才换的。具体原因是在业务中,字符串是作为表单的一部分传入且表单还有二进制的文件,且传入的字节数组是完整的表单数据,如果用utf8解码会损坏文件。为什么不用Spring提供的注解或方法提取表单?因为业务代码实际在非mvc的reactor网关,用的ModifyRequestBodyGatewayFilterFactory类直接拿的二进制数组进行处理

  2. 采用正则匹配DOTALL模式

    1
    2
    3
    Pattern pattern = Pattern.compile(patternStr);
    // 改为使用DOTALL模式
    Pattern compatiblePattern = Pattern.compile(patternStr, Pattern.DOTALL);

此种方式需要重新测试是否有异常情况

找到基本中文字符中存在这样问题的中文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public static void wordWith0x75(){
int count=0;
// CJK统一汉字基本区:U+4E00 ~ U+9FFF
for (int codePoint = 0x4E00; codePoint <= 0x9FFF; codePoint++) {
char ch = (char) codePoint;
String s = String.valueOf(ch);

byte[] bytes = s.getBytes(StandardCharsets.UTF_8);

// UTF-8 编码的汉字应该是 3 字节
if (bytes.length == 3) {
boolean has85 = false;
for (byte b : bytes) {
// Java byte 范围是 -128~127,需要转成无符号
int unsigned = b & 0xFF;
if (unsigned == 0x85) {
has85 = true;
break;
}
}
if (has85) {
// 打印字符 + UTF-8字节的十六进制表示
System.out.printf("U+%04X %s -> %s%n", codePoint, ch, bytesToHex(bytes));
count++;
}
}
}
System.out.printf("基本中文共有%d个符合要求",count);
}

private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < bytes.length; i++) {
if (i > 0) sb.append(" ");
sb.append(String.format("0x%02X", bytes[i] & 0xFF));
}
sb.append("]");
return sb.toString();
}

基础中文共有643个符合要求