字符编码的那些事

最近在处理一个需求时发现业务字段出现了一串异常的字符。熟悉 web 开发的同学应该一眼就能看出,诸如%C2%D6%BB%D8%CA%AF之类的字符串是一个 URL Encoded 的字符串。导致这些未经 decode 的数据直接展示到界面上的原因是,业务日志中该字段格式不统一,有些是未经 URL Encoded 的,但有些又经过 URL Encoded 了,ETL 没有对这种情况进行处理就直接入库了。

为了解决这个问题,ETL 需要做的就是先要判断输入的字符串是否是 URL Encoded 的,如果不是,直接返回即可,如果不是则需要做进一步的处理

什么是 URL Encoding

URL encoding是Uniform Resource Identifier(URI)规范文档中对特殊字符编码制定的规则。本质是把一个字符转为百分号(%)加上其字符编码对应的16进制数字。故又称之为Percent-encoding。一般来说,URL只能使用英文字母、阿拉伯数字和某些标点符号,不能使用其他文字和符号。比如,世界上有英文字母的网址”http://www.abc.com",但是没有希腊字母的网址"http://www.aβγ.com"(读作阿尔法-贝塔-伽玛.com)。所以如果 URL 中有中文,那么就必须进行 URL Encoding 。但是麻烦的是,相关规范并没有规定具体的编码方法,而是交给应用程序(浏览器)自己决定,比如对于中文字符,应用程序可以先使用 UFT-8 编码后,再 URL encoding,也可以使用 GBK 编码后,再 URL encoding,这两种实现方式都是合法的,但最终产生的结果并不一样。这导致”URL编码”成为了一个混乱的领域(详细可以参考:关于URL编码)。

字符编码识别

我们从数据中抽取了一些 URL encoded 数据并使用一些在线解析工具进行了解析,发现它们的原始编码是 GBK 的,由于担心数据中混有其他的编码格式,我们想如何自动的识别字符的编码格式。

使用juniversalchardet做字符编码识别

经过一番搜索,找到了一个叫juniversalchardet的工具。 它是Mozilla 公司的 firefox 使用的 universalchardet 编码自动检测工具的 Java 版本。自动编码主要是根据统计学的方法来判断。具体原理,可以看:A composite approach to language/encoding detection

下面写个小例子来验证他的特性,首先使用 maven 引入依赖

<!-- Mozilla的编码识别包 -->
<dependency>
<groupId>com.googlecode.juniversalchardet</groupId>
<artifactId>juniversalchardet</artifactId>
<version>1.0.3</version>
</dependency>

写个简单的Demo

import java.io.File;
import java.io.IOException;
import looly.github.hutool.FileUtil;
import org.mozilla.universalchardet.UniversalDetector;
/**
* 编码识别工具类
* @author allanzheng
*
*/
public class CharsetDetectUtil {
public static String detect(byte[] content) {
UniversalDetector detector = new UniversalDetector(null);
//开始给一部分数据,让学习一下啊,官方建议是1000个byte左右(当然这1000个byte你得包含中文之类的)
detector.handleData(content, 0, content.length);
//识别结束必须调用这个方法
detector.dataEnd();
return detector.getDetectedCharset();
}
public static void main(String[] args) throws IOException {
byte[] bytes = FileUtil.readBytes(new File("E:/workspace/python/htmlUtil.txt"));
System.out.println(detect(bytes));
}
}

经过实际验证,发现该类库在识别长文本的时候准确率还是挺高的(如 1000 个byte左右),但是短文本就,比如我们的道具名称字段,就无能为力了。类似的编码识别库还有 ICU

使用java.nio.charset.CharsetDecoder自动识别字符集

一般用两种方法构建InputStreamReader:

InputStreamReader reader = new InputStreamReader(in, charsetName);

or

InputStreamReader reader = new InputStreamReader(in, charset);

如果charset不匹配,则输出乱码。还有一种构建方法,即利用CharsetDecoder:

CharsetDecoder cd = charset.newDecoder();
InputStreamReader reader = new InputStreamReader(in, cd);

这时如果不匹配,则抛出异常:

java.nio.charset.MalformedInputException: Input length = 1
at java.nio.charset.CoderResult.throwException(CoderResult.java:277)
at sun.nio.cs.StreamDecoder.implRead(StreamDecoder.java:338)
at sun.nio.cs.StreamDecoder.read(StreamDecoder.java:177)
....

这样,就可以用作字符集探测。所以我们的解决方案如下,AutoCharsetReader 用于探测字符集

  public class AutoCharsetReader {
private final static String[] _defaultCharsets = {
"US-ASCII",
"UTF-8",
"GBK",
"GB2312",
"BIG5",
"GB18030",
"UTF-16BE",
"UTF-16LE",
"UTF-16",
"UNICODE"};



public static Charset detectCharset(byte[] bytes, String[] charsets) {

Charset charset = null;

for (String charsetName : charsets) {
charset = detectCharset(bytes, Charset.forName(charsetName));
if (charset != null) {
break;
}
}

return charset;
}

private static Charset detectCharset(byte[] bytes, Charset charset) {
try {
BufferedInputStream input = new BufferedInputStream(new ByteArrayInputStream(bytes));

CharsetDecoder decoder = charset.newDecoder();
decoder.reset();

byte[] buffer = new byte[512];
boolean identified = false;
while ((input.read(buffer) != -1) && (!identified)) {
identified = identify(buffer, decoder);
}

input.close();

if (identified) {
return charset;
} else {
return null;
}

} catch (Exception e) {
return null;
}
}

private static boolean identify(byte[] bytes, CharsetDecoder decoder) {
try {
decoder.decode(ByteBuffer.wrap(bytes));
} catch (CharacterCodingException e) {
return false;
}
return true;
}
}
private[ams] def decodeItemName(str: String): String = {
// 由于item name
val decodedStr = URLDecoder.decode(str, "UTF-8")
if (decodedStr.equals(str)) {
str
} else {
val bytes = URLCodec.decodeUrl(str.getBytes(StandardCharsets.US_ASCII))
val charSet = AutoCharsetReader.detectCharset(bytes, Array[String]("UTF-8", "GBK"))
new String(bytes, charSet)
}
}
生活不止眼前的苟且,还有那片海