背景概述
我们都知道计算机是不能直接存储字母,数字,图片,符号等,计算机能处理和工作的唯一单位是”比特位(bit)”,一个比特位通常只有 0 和 1,是(yes)和否(no),真(true)或者假(false)等等我们喜欢的称呼。利用比特位序列来代表字母,数字,图片,符号等,我们就需要一个存储规则,不同的比特序列代表不同的字符,这就是所谓的”编码”。反之,将存储在计算机中的比特位序列(或者叫二进制序列)解析显示出来成对应的字母,数字,图片和符号,称为”解码”。提到编码不得不说到字符集,因为任何一种编码方式都会有一个字符集与之对应。
然而计算机领域通常所讲的字符集实际上是指字符编码集,而不是传统意义上的字符库,除了包括一个系统支持的所有抽象字符(如国家文字、标点符号、图形符号、数字等)外,还包括字符所对应的一个数值,所以通常是一个二维表,下文提到的字符集都是指字符编码集。常见字符集如 ASCII 字符集、ISO-8859-X、GB2312 字符集(简中)、BIG5 字符集(繁中)、GB18030 字符集、Shift-JIS 等等。之前的很长一段时间内,字符集和字符编码区分不是很严格,因为一般一种字符集只对应一种编码方式,但是这些编码方式存在下面的问题:
- 没有一种编码可以覆盖全世界所有国家的字符;
- 各种编码之间也会存在冲突的现象,两种不同编码方式可能使用同一个编码代表不同的字符,亦或用不同的编码代表同一个字符;
- 一个指定的机器(比如我们的服务器)将需要支持许多不同的编码方式,当数据在不同的机器之间传输或者在不同的编码之间转换时,很容易产生乱码问题。
多语言软件制造商组成的统一码联盟(The Unicode Consortium)于 1991 年发布的统一码标准(The Unicode Standard),定义了一个全球统一的通用字符集即 Unicode 字符集解决了上述的问题。统一码标准为每个字符提供一个唯一的编号,旨在支持世界各地的交流,处理和显示现代世界各种语言和技术学科的书面文本。此外,它支持许多书面语言的古典和历史文本,不管是什么平台,设备,应用程序或语言,都不会造成乱码问题, 它已被所有现代软件供应商采用,是目前所有主流操作系统,搜索引擎,浏览器,笔记本电脑和智能手机以及互联网和万维网(URL,HTML,XML,CSS,JSON 等)中表示语言和符号的基础。统一码标准的一个版本由核心规范、Unicode 标准、代码图、Unicode 标准附件以及 Unicode 字符数据库(Unicode Character Database 简写成 UCD)组成,同时也是开发的字符集,在不断的更加和增加新的字符,最新的版本为 Unicode 10.0.0。
在 Unicode 字符集发布的第三年,ISO/IEC 联合发布了通用多八位编码字符集简称为 UCS(Universal Character Set 通用字符集),也旨在使用全球统一的通用字符集。两套通用字符集使用起来又存在麻烦,后来统一码联盟与 ISO/IEC 经过协商,到到 Unicode 2.0 时,Unicode 字符集和 UCS 字符集基本保持了一致。现在虽然双方都发布各自的版本,但两套字符集是相互兼容的,Unicode 字符集使用比 UCS 字符集更加广泛。
Unicode 介绍
世界经济日益全球化的同时,一个应用程序需要在全球范围内使用势在必然,基于 Unicode 的应用程序能够很好地处理来自世界各地的用户文本,并适应其文化习俗。 它通过消除每种语言的构建,安装和维护更新来最大限度地降低成本。Unicode(与其并行 ISO 10646 标准)标准除了覆盖全球所有地区国家使用的字符外,它还定义了一系列文本处理的数据和算法,极大简化了 Unicode 的应用,并确保所有遵守其标准的软件产生相同的结果。在过去十年的广泛应用中,Unicode 成为互联网的基石。
Unicode 编码字符集旨在收集全球所有的字符,为每个字符分配唯一的字符编号即代码点(Code Point),用 U+紧跟着十六进制数表示。所有字符按照使用上的频繁度划分为 17 个平面(编号为 0-16),即基本的多语言平面和增补平面。基本的多语言平面(英文为 Basic Multilingual Plane,简称 BMP)又称平面 0,收集了使用最广泛的字符,代码点从 U+0000 到 U+FFFF,每个平面有 2^16=65536 个码点;增补平面从平面 1~16,分为增补多语言平面(平面 1)、增补象形平面(平面 2)、保留平面(平 3~13)、增补专用平面等,每个增补平面也有 2^16=65536 个码点。所以 17 个平面总计有 17 × 65,536 = 1,114,112 个码点。图 1 是 Unicode 平面分布图,图 2 是 Unicode 各个平面码点空间。
下面是一些相关术语的解释:
- Coded Character Set(CCS): 即编码字符集,给字符表里的抽象字符编上一个数字,也就是字符集合到一个整数集合的映射。这种映射称为编码字符集,Unicode 字符集就是属于这一层的概念;
- Character Encoding Form (CEF) :即字符编码表,根据一定的算法,将编码字符集(CCS) 中字符对应的码点转换成一定长度的二进制序列,以便于计算机处理,这个对应关系被称为字符编码表,UTF-8、 UTF-16 属于这层概念;
- Code Point: 码点,简单理解就是字符的数字表示。一个字符集一般可以用一张或多张由多个行和多个列所构成的二维表来表示。二维表中行与列交叉的点称之为码点,每个码点分配一个唯一的编号,称之为码点值或码点编号,除开某些特殊区域(比如代理区、专用区)的非字符码点和保留码点,每个码点唯一对应于一个字符。
- Code Unit :代码单元,是指一个已编码的文本中具有最短的比特组合的单元。对于 UTF-8 来说,代码单元是 8 比特长;对于 UTF-16 来说,代码单元是 16 比特长。换一种说法就是 UTF-8 的是以一个字节为最小单位的,UTF-16 是以两个字节为最小单位的。
- Code Space :码点空间,字符集中所有码点的集合。
- BOM ( Byte Order Mark ): 字节序,出现在文件头部,表示字节的顺序,第一个字节在前,就是”大头方式”(Big-Endian),第二个字节在前就是”小头方式”(Little-Endian)。这两个古怪的名称来自英国作家斯威夫特的《格列佛游记》,在该书中,小人国里爆发了内战,战争起因是人们争论,吃鸡蛋时究竟是从大头(Big-Endian)敲开还是从小头(Little-Endian)敲开。为了这件事情,前后爆发了六次战争,一个皇帝送了命,另一个皇帝丢了王位。
Unicode 几种常见编码方式
Unicode 字符集中的字符可以有多种不同的编码方式,如 UTF-8、UTF-16、UTF-32、压缩转换等。这里的 UTF 是 Unicode transformation format 的缩写,即统一码转换格式,将 Unicode 编码空间中每个码点和字节序列进行一一映射的算法。下面详细介绍一下这三种常见的 Unicode 编码方式。
UTF-8 编码方式
UTF-8 是一种变长的编码方式,一般用 1~4 个字节序列来表示 Unicode 字符,也是目前应用最广泛的一种 Unicode 编码方式,但是它不是最早的 Unicode 编码方式,最早的 Unicode 编码方式是 UTF-16。UTF-8 编码算法有以下特点:
- 首字节码用来区分采用的编码字节数 :如果首字节以 0 开头,表示单字节编码;如果首字节以 110 开头,表示双字节编码;如果首字节以 1110 开头,表示三字节编码,以此类推;
- 除了首字节码外, 用 10 开头表示多字节编码的后续字节 ,图 3 列出 UTF-8 用 1~6 个字节所表示的编码方式和码点范围(实际上 1~4 个字节基本可以覆盖大部分 Unicode 码点);
- 与 ASCI I 编码方式完全兼容 :U+0000 到 U+007F 范围内(十进制为 0~127)的 Unicode 码点值所对应的字符就是 ASCII 字符集中的字符,用一个字节表示,编码方式和 ASCII 编码一致;
- 无字节序 ,在 UTF-8 编码格式的文本中,如果添加了 BOM,则表示该文本是由 UTF-8 编码方式编码的,而不用来说明字节序。
在实际的解码过程中:
- 情况 1: 读取到一个字节的首位为 0,表示这是一个单字节编码的 ASCII 字符;
- 情况 2: 读取到一个字节的首位为 1,表示这是一个多字节编码的字符,如继续读到 1,则确定这是首字节,在继续读取直到遇到 0 为止,一共读取了几个 1,就表示该字符为几个字节的编码;
- 情况 3: 当读取到一个字节的首位为 1,紧接着读取到一个 0,则该字节是多字节编码的后续字节。
图 4 概述了 UTF-8 编码方式的特点,其中的”自同步“表示在传输过程中如果有字节序列丢失,并不会造成任何乱码现象,或者存在错误的字节序列也不会影响其他字节的正常读取。例如读取了一个 10xxxxxx 开头的字节,但是找不到首字节,就可以将这个后续字节丢弃,因为它没有意义,但是其他的编码方式,这种情况下就很可能读到一个完全不同或者错误的字符。
UTF-16 编码方式
UTF-16 是最早的 Unicode 字符集编码方式,在概述 UTF-16 之前,需要解释一下 USC-2 编码方式,他们有源远流长的关系,UTF-16 源于 UCS-2。UCS-2 将字符编号(同 Unicode 中的码点)直接映射为字符编码,亦即字符编号就是字符编码,中间没有经过特别的编码算法转换。图 5 对 UCS-2 编码方式的概括。
由图 5 可知,UCS-2 编码方式只覆盖基本多语言平面的码点,因为 16 位二进制表示的最大值为 0xFFFF,而对于增补平面中的码点(范围为 0x10000~0x10FFFF,十进制为 65536~1114111),两字节的 16 位二进制是无法表示的。为了解决这个问题,The Unicode Consortium 提出了通过代理机制来扩展原来的 UCS-2 编码方式,也就是 UTF-16。其编码算法如下:
- 基本多语言平面(BMP)中有效码点用固定两字节的 16 位代码单元为其编码,其数值等于相应的码点,同 USC-2 的编码方式;
- 辅助多语言平面 1-16 中的有效码点采用代理对(surrogate pair)对其编码:用两个基本平面中未定义字符的码点合起来为增补平面中的码点编码,基本平面中这些用作”代理”的码点区域就被称之为”代理区(Surrogate Zone)”,其码点编号范围为 0xD800~0xDFFF(十进制 55296~57343),共 2048 个码点。代理区的码点又被分为高代理码点和低代理码点,高代理码点的取值范围为 0xD800~0xDBFF,低代理码点的取值范围为 0xDC00~0xDFFF,高代理码点和低代理码点合起来就是代理对,刚好可以表示增补平面内的所有码点,如图 6 所示。
但是在实际的编码过程中通过查表是很麻烦的,图 7 列出了 UTF-16 编码算法是如何对增补平面中的码点进行编码的。
从U+10000到U+10FFFF的码位:
辅助平面(Supplementary Planes)中的码位,大于等于0x10000,在UTF-16中被编码为一对16比特长的码元(即32bit,4Bytes),称作代理对(surrogate pair),具体方法是:
Ø 码位减去0x10000, 得到的值的范围为20比特长的0..0xFFFFF(因为Unicode的最大码位是0x10ffff,减去0x10000后,得到的最大值是0xfffff,所以肯定可以用20个二进制位表示),写成二进制形式:yyyy yyyy yyxx xxxx xxxx。
Ø 高位的10比特的值(值的范围为0..0x3FF)被加上0xD800得到第一个码元或称作高位代理(high surrogate), 值的范围是0xD800..0xDBFF。由于高位代理比低位代理的值要小,所以为了避免混淆使用,Unicode标准现在称高位代理为前导代理(lead surrogates)。
Ø 低位的10比特的值(值的范围也是0..0x3FF)被加上0xDC00得到第二个码元或称作低位代理(low surrogate), 现在值的范围是0xDC00..0xDFFF。 由于低位代理比高位代理的值要大,所以为了避免混淆使用,Unicode标准现在称低位代理为后尾代理(trail surrogates)。
Ø 最终的UTF-16(4字节)的编码(二进制)就是:110110yyyyyyyyyy 110111xxxxxxxxxx。
按照上述规则,Unicode编码0x10000-0x10FFFF的UTF-16编码有两个WORD,第一个WORD的高6位是110110,第二个WORD的高6位是110111。可见,第一个WORD的取值范围(二进制)是11011000 00000000到11011011 11111111,即0xD800-0xDBFF。第二个WORD的取值范围(二进制)是11011100 00000000到11011111 11111111,即0xDC00-0xDFFF。上面所说的从U+D800到U+DFFF的码位(代理区),就是为了将一个WORD(2字节)的UTF-16编码与两个WORD的UTF-16编码区分开来。
由于高位代理、低位代理、BMP中的有效字符的码位,三者互不重叠,搜索是简单的: 一个字符编码的一部分不可能与另一个字符编码的不同部分相重叠。这意味着UTF-16是自同步(self-synchronizing):可以通过仅检查一个码元就可以判定给定字符的下一个字符的起始码元。 UTF-8也有类似优点,但许多早期的编码模式就不是这样,必须从头开始分析文本才能确定不同字符的码元的边界。
UTF-16 对增补平面中的码点编码还是很复杂的,但是由于 UTF-16 是最早的 Unicode 编码方式,已经存在于很多程序中。
UTF-32(UCS-4) 编码方式
UTF-32 是一个以固定四字节编码方式,ISO 10646 中称其为 UCS-4 的编码机制的子集。优点是每一个 Unicode 码点都和 UTF-16 的 Code Unit 一一对应,程序中如果采用 UTF-32 处理起来比较简单,但是所有的字符都用四个字节,特别浪费空间,所以实际上使用比较少。其特性如图 6 所示。
BOM(Byte Order Mark)字节序标记
BOM即Byte Order Mark字节序标记。BOM是为UTF-16和UTF-32准备的,用来标记字节序(byte order)。拿UTF-16来举例,其是以两个字节为编码单元,在解释一个UTF-16文本前,首先要弄清楚每个编码单元的字节序。例如收到一个“奎”的Unicode编码是594E,“乙”的Unicode编码是4E59。如果我们收到UTF-16字节流"594E",那么这是“奎”还是“乙”?
UTF-8是以单字节为编码单元,没有字节序的问题。
UTF-16、UTF-32是分别以2个字节和4个字节为编码单位,即1次读取2个字节或4个字节,这样一来,在存储和网络传输时就要考虑1个编码单位内2个字节或4个字节之间顺序的问题。因此这两种编码方式都存在字节序标记(BOM)问题。
- Big-Endian ( BE )即大端序 : 就是高位字节(即大端字节)存放在内存的低地址,低位字节(即小端字节)存放在内存的高地址。UTF-16(BE)以 FEFF 作为开头字节,UTF-32(BE)以 00 00 FE FF 作为开头字节;
- Little-Endian (LE) 即小端序 : 低位字节(即小端字节)存放在内存的低地址,而高位字节(即大端字节)存放在内存的高地址。UTF-16(LE)以 FFFE 作为开头字节,UTF-32(LE)以 FF FE 00 00 作为开头字节。
- UTF-8 不需要 BOM 来表明字节顺序: 可以用 BOM(EF BB BF 称为零宽无间断间隔)来表明编码方式,如果接收者收到以 EF BB BF 开头的字节流,就知道这是 UTF-8 编码。
备注:表 1 中标为 BOM 的地方表示根据文本头部的字节序确定,如果没有字节序,默认为 BE 即大端序编码。
表 2 是几种 Unicode 编码方式示例对比:
Unicode 支持在 Java Web 程序中应用实践
Unicode 是互联网的基石,为了保证我们的 Web 应用程序很好的支持 Unicode 标准。从代码角度需要考虑 Unicode 支持的地方有(不限于这些,下面是基于笔者项目实践):
Java 默认字符集设定 :通常情况下如 Windows 系统,我们会通过设置环境变量 JAVA_TOOL_OPTIONS 将默认编码更改为 UTF-8,下面是 Java 程序默认字符集的优先级顺序:
- 程序执行时使用 Properties 指定的字符集,如 System.getProperties().put("file.encoding","UTF-8);
- 参数-Dfile.encoding 指定的字符集,如 Windows 可以通过设置环境变量 JAVA_TOOL_OPTIONS=-Dfile.encoding=UTF-8
- 操作系统默认的字符集;
- JDK 中 JVM 默认的字符集。
JSP: 通过 JSP 页面命令<%@ page language=”java” contentType=”text/html; charset=UTF-8″ pageEncoding=”UTF-8″%>设置 JSP 页面的编码方式(pageEncoding)以及提交表单时所使用的编码方式(charset 属性);
HTML:
设置 Content-Type 属性 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">;
Servlet: 设置 response.setContentType("text/html;charset=UTF-8"),即服务器端编码方式;
POST 请求:
通过 request.setCharacterEncoding (“UTF-8”)设置解码方式、response.setCharacterEncoding(“UTF-8”)设置服务器端响应数据的编码方式,如清单 3 所示;
清单 1. POST 请求字符编码设置
protected void service(HttpServletRequest request, HttpServletResponse
response) throws ServletException, IOException {
// TODO Auto-generated method stub
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
}
Get 请求: Get 请求方式中请求参数会被附加到地址栏的 URL 之后,URL 组成:域名:端口/contextPath/servletPath/pathInfo?queryString,不同的服务器对 URL 中 pathInfo 和 queryString 解码方式设定不一样,比如 Tomcat 服务器一般在 server.xml 中设定的,Tomcat8 之前的版本默认使用的是 ISO-8859-1,但是 Tomcat 8 默认使用的是 UTF-8,如清单 4 所示;另外对于双字节字符如中日韩等作为 URL 的参数时,最好先 URI 编码后在放到 URL 中 URLEncoder.encode(String.valueOf(c),”UTF-8″);
清单 2. Tomcat server.xml 设置
Tomcat server.xml
<Connector port="8080" protocol="HTTP/1.1"
maxThreads="150"
connectionTimeout="20000"
redirectPort="8443"
URIEncoding="UTF-8"
useBodyEncodingForURI="true">
XML文档: 设置 transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
调用 API 时指定字符集: 调用字符串操作相关或者其他 I/O 操作 API 时,最好指定字符集,如 String.getBytes(“UTF-8”),而不是 String.getBytes();根据情况必要的时候需要写入 BOM 头,例如 Microsoft Office 在读取 csv 文件的时候,它将首先读取 BOM,然后使用 BOM 中的编码打开文件,这种情况下我们在创建或者导出到 CSV 文件的时候,我们需要在代码里面写入 BOM 头。图 9所示的例子是创建一个 csv 文件的时候没有写入 BOM,用 Office 打开看到的都是乱码,但是用其他编辑器比如 notepad++显示是正常。当加上 BOM 文件头的时候 fos.write(new byte[]{(byte)0xEF,(byte)0xBB, (byte)0xBF}) 用 Office 再次打开就是正确的字符,代码如清单3 所示。备注一下,UTF-8 虽然不分大端小端序,但是可以加 BOM 头,表示这个文本是 UTF-8 编码。
清单 3. BOM 标记例子
private static void createCsvFile() {
try {
FileOutputStream fos = new FileOutputStream("c:\\test.csv");
String str ="姓名,性别,年龄,出生年月";
fos.write(new byte[]{(byte)0xEF,(byte)0xBB, (byte)0xBF});
OutputStreamWriter osw = new OutputStreamWriter(fos, "UTF-8");
BufferedWriter b_writer = new BufferedWriter(osw);
b_writer.write(str);
b_writer.close();
} catch (IOException e) {
System.out.println(e.toString());
}
}