文章目录
  1. 1. 编码(encoding)
    1. 1.1. ASCII & EASCII & ISO-8859-1
    2. 1.2. GB2312 & GBK & GB18030 & ANSI
    3. 1.3. Unicode & UTF-8
    4. 1.4. base64 & UTF-7
  2. 2. 转义(escaping)
    1. 2.1. html & url
    2. 2.2. XML & YAML & JSON & CSV
    3. 2.3. Java & .NET & JS & SQL
  3. 3. 后记

作为一名天朝程序员,除了看惯了乱码之外,在日常的工作中经常会碰到编码和转义。如果能掌握这块领域的一些常识,就可以在开发和支持时游刃有余。

编码(encoding)

ASCII & EASCII & ISO-8859-1

要聊编码,就需要从ASCII开始。众所周知,计算机的世界里,数据都是0和1这样的二进制。用它们的组合来表示字母、数字和常用符号的最通用编码标准就是ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)。完整的ASCII编码可以从这里找到。Mac或Linux可以用以下命令来查看所有的ASCII字符:

1
man ascii

其中,0000000000011111的前32位字符和01111111是控制字符,0010000001111110之间的都是可显示字符,一个字符占8位(bit),第1位总是0,这样能够支持2的7次方即128个符号编码。虽然ASCII编码能搞定美国大多数的应用场景,但是对于其它发达国家的语言来说就无能为力了。于是在其上发展出了EASCII(extended ASCII),通过扩展最前面的一位为1来提供多达256个符号编码的支持。可是这样又带来了两个问题:一来即使是256个编码,对于世界范围尤其是像中日这样的汉字国家来说还是远远不够;二来各个国家规定的EASCII编码都不一样,比如对于希腊来说EASCII表示的就是希腊字母,而对于法国来说可能就是某个带有注音符号的字母。这样的背景下,ISO(International Standard Organization,国际标准化组织)设计了ISO/IEC 8859字符集(不包含ASCII),力图一统拉丁语系。其实现的编码表ISO-8859-1(包含ASCII)应用得非常广泛。

GB2312 & GBK & GB18030 & ANSI

本节介绍的是解决EASCII带来的第一个问题的方法。对于中文来说,8位的编码远远不够,于是就会想到用两个8位来表示一个汉字。为了与ASCII码兼容,如果碰到0~127的字符,需要认定为ASCII编码字符。只有当两个大于127的字符连在一起时,才表示一个汉字。前一个字符称为高字节,后一个称为低字节,这样就诞生了GB2312编码。每一个双字节字符就称为一个全角字符,而单字节字符就称为半角字符。再后来,发现编码还是不够用,干脆就允许低字节也使用0~127的字符,反正用高字节就能判断是否是汉字,这样就诞生了GBK(K表示“扩展”)编码。GBK里甚至还包含了日语的假名和俄语字母。GB2312和GBK这两种编码都是单字节(表示ASCII)和双字节(表示汉字)混合使用的编码。我国最新的汉字编码国标是GB18030,这是一种类似下文UTF-8那样的变长编码。

虽然中国解决了中文问题,但是世界各国都搞出了一套自己的编码系统,还是不能轻易相互转化。例如台湾用BIG5,日本用Shift-JIS。要想解决EASCII的第二个问题,还需要另寻他途。Window系统的记事本里,默认编码为ANSI,即根据系统语言的不同,而选用不同的编码。

Unicode & UTF-8

本节说的是解决EASCII带来的第二个问题的方法。ISO带来了一个囊括全球所有文字的编码:Unicode。它最初规定了所有的字符(包括ASCII)都使用两个字节来表示,这个版本称为UCS-2(Universal Multiple-Octet Coded Character Set)或UTF-16。对于ASCII码来说,在它的前面加上00000000作为高字节即可。这样的好处是,由于高低字节可以同时包含0~256,能表示的字符数量就更多了,理论上可以达到256×256=65536个。即使如此,也只能说是基本上够用,要囊括所有文明的文字,还需要更多的字节。目前最多支持4个字节代表一个字符,称为UCS-4或UTF-32,它的最高位规定必须为0,可以表示65536×65536÷2=2147483648个字符(这样是不是统一银河系也够用了)。与此同时,它包含的字符集也在不断的增加,甚至收录了emoji(绘文字),大大增加了文字符号的表现力,看看😂🐔📸🎸🎁🌀🌤🕘,是不是增加了很多乐趣呢。

Unicode就像是“书同文、车同轨”,极大地方便了各国的交流。可是它也有自身的缺点。一个问题是它与各国自身的标准不兼容(例如GB18030),但是这个问题貌似无解,因为各国的标准本来就是排斥的。另一个问题是随着Unicode标准的发展,出现了4个字节的字符。但是当设计Java的时候,是将unicode当做2个字节的定长字符来看待的。这样就导致Java里需要用两个char来表示一个4字节的字符,如emoji(😂=\uD83D\uDE02)。Java平台中的增补字符就是Oracle官方写来专门解决长字节Unicode的。打开链接就会看到一堆的乱码,说明编码问题还真是普遍存在并难以解决的啊。好在还有英文版可供阅读。还有一个问题就是对于英文来说,用高字节为固定值的两个字节来保存数据,就会使原来一个字节的数据量翻倍,对于传输和存储来说都是较大负担。

解决上面这个问题的办法就是UTF-8。它是一种变长的编码方式。如果是ASCII码的字符,就用一个字节表示。否则就在前面增加一个高位字节(但是在8个bit之内)。这回英文符号是满意了,但是中文字符可能就会因为增加的高位字节从Unicode的占用两个字节变成UTF-8的占用三个字节。没有两全其美的事啊!这也是为什么GB2312和GBK今天仍被广泛使用的原因之一,我们也不想增加传输和存储的负担呀。

如果要打开一个文本文件,首先需要知道它的编码。位于文件头的BOM(Byte order mark,字节顺序标记)可以用来标记文件的编码类型。它分为BE(big-endian,大端序)和LE(little-endian,小端序),指的是高字节的位置在前还是在后。但是在类Unix系统中,它很可能因为无法被程序识别而带来一系列问题。所以一般的纯文本文件还是建议保存为不带BOM形式的编码。Window系统的记事本里,如果输入联通保存,便会将其保存为无BOM的GB格式,再次用记事本打开此文件时,因为没有BOM信息,记事本就需要自己推断这个文件的编码是什么。显然window是上这个推断很有问题,误认为是UTF-8格式(可以从文件菜单里的“另存为”看出来)。而mac上默认的文本编辑表现还是不错的。如果用word来打开它,便可以在一系列的编码中,自行寻找合适的编码来打开。如果用记事本另存为UTF-8格式,便不会有问题。Sublime Text可以支持用许多不同的编码来打开或是保存,光是UTF系列的就不少,如下图:
sublime-text-encoding

对于Java来说,内部的String编码默认为UTF-16,但如果由于用不着而觉得浪费内存的话,可以在JVM打开-XX:+UseCompressedStrings,就会变成ISO-8859-1了。Intellij IDEA的Preference里,有两个关于encoding的选项:
Intellij-IDEA-encoding

可以通过Project Encoding来指定项目的JVM里String的内部编码,默认为UTF-8。可以通过下面这两个表达式来看到,它们的编码是完全一致的:

1
2
"懒".getBytes()
"懒".getBytes("UTF-8")

Java里可以用Integer.toHexString来看到汉字的unicode编码:

1
2
System.out.println("\\u" + Integer.toHexString('懒'));
System.out.println("\u61d2");

通过下面的语句,可以将字节数组byte[]还原为原先的字符串。如果指定错了编码,就会看到乱码产生啦:

1
2
3
System.out.println(new String("懒".getBytes("UTF-8"), "UTF-8")); // 正常
System.out.println(new String("懒".getBytes("UTF-8"), "UTF-16")); // 乱码:�
System.out.println(new String("懒".getBytes("UTF-16"), "UTF-8")); // 乱码:��a�

读文件、流也是一样的道理,知道了它们的编码才能正确地读取,否则只好像微软的记事本那样去猜啦。Java还提供了一个小工具native2ascii,可以把本地编码的文件转换为各种格式:

1
2
3
4
echo 懒程序员 > ggg.txt
native2ascii -encoding UTF-8 ggg.txt out.txt
cat out.txt
native2ascii -reverse -encoding UTF-8 out.txt

base64 & UTF-7

Base64是一种在网络上传递信息时常见的编码。它相当于是一张64条记录的映射表,键从000000111111,值就是64个不同的字符。编码时,如果原字符的bit数正好能被6整除,那就查表得到每6个bit所对应的值,合起来就是base64编码的结果。如果不能被6整除,那就在末尾用0补足。每补两个0,就在最终结果的后面加一个=号。所以如果一段数据以等号结尾,那十有八九就是base64编码。Mac或Linux可以用以下命令来进行base64编码及解码:

1
2
3
4
5
6
echo -n A | base64
echo -n AB | base64
echo -n ABC | base64
echo -n QQ== | base64 --decode
echo -n QUI= | base64 --decode
echo -n QUJD | base64 --decode

JavaScript可以用以下命令来进行base64编码及解码:

1
2
window.btoa("A")
window.atob("QQ==")

UTF-7理论上也属于一种base64编码,只不过它的64行映射表不一样罢了。过去的SMTP协议仅能接受7个bit(ASCII)的字符,Unicode无法直接传输。所以通过UTF-7编码的方式,将Unicode字符转换为7个bit以内的字符。UTF-7本身并不是Unicode的标准,现在也已经由于邮件和传输都支持UTF-8而退出历史舞台了。

写到这里感觉得收一下了,不然MD5、SHA什么的都要出来了。对散列、加密有兴趣的童鞋们可以参考我以前写的另一篇文章《证书的那些事儿》

转义(escaping)

html & url

下面说说转义,不少人都把它与编码混而一谈,以至于它也算作编码的一部分了。从最简单的html聊起吧。在html里,如果只写上一些文本,那当我们用浏览器打开这个html时,就会完完整整地显示这些文本的内容。我们也知道,html里无论输入多少个空格,只会显示一个空格。因为在html里,把空格当成了特殊字符。在这种情况下,如果想要在html里放上空格,就需要对空格编码,也就是大家熟知的&nbsp;。其中nbsp大名唤作Non-Breaking Space(不换行空格),除了名字以外,它也有自己的编码:&#160;。除了空格,常见的还有代表标签的<>。完整的html转义可以从这里找到。奇怪的是这么常用的转义,js居然没有原生的函数支持。如果要转义<div>,可以使用下面这条语句来得到&lt;div&gt;

1
2
3
4
function htmlEncode(html) {
return document.createElement('a').appendChild(document.createTextNode(html)).parentNode.innerHTML;
};
htmlEncode('<div>');

解码的话,这样做:

1
2
3
4
5
6
function htmlDecode(html) {
var a = document.createElement('a');
a.innerHTML = html;
return a.textContent;
};
htmlDecode('&lt;div&gt;');

如果使用jQuery,思路一致,但是代码可以稍微短一点:

1
2
3
4
5
6
7
8
9
function htmlEncode(value){
return $('<div/>').text(value).html();
}
htmlEncode('<div>');
function htmlDecode(value){
return $('<div/>').html(value).text();
}
htmlDecode('&lt;div&gt;');

可惜的是上面的函数并不能解决空格和&nbsp;之间的转换。想要个万能的?也许只好使用replace一个个地慢慢替换了。

想要请求一个html,需要先输入一个url。这里就涉及到了url转义。因为url里可能会有类似?name=ggg这样的参数,所以起码就需要对?=进行转义。转义之后分别为%3F%3D,这与ASCII码是相对应的。完整的url编码可以从这里找到。这回js终于有原生的函数支持了:

1
2
encodeURI('http://qinghua.github.io?name=g gg');
encodeURIComponent('http://qinghua.github.io?name=g gg');

encodeURI函数的网址,不会去碰http://,所以编码后还是一个合法的网址。而encodeURIComponent会将一切都进行编码,网址也就不是网址了。不过它很适合将网址作为参数来使用。解码的话,这样做:

1
2
decodeURI('http://qinghua.github.io?name=g%20gg');
decodeURIComponent('http%3A%2F%2Fqinghua.github.io%3Fname%3Dg%20gg');

在Java里可以用以下语句来完成url的转义:

1
2
URLEncoder.encode("懒", "UTF-8");
URLDecoder.decode("%E6%87%92", "UTF-8");

XML & YAML & JSON & CSV

在这些数据格式中,对xml的转义基本上跟html差不多,这里就不再赘述了。对于yaml来说,规则如下:

  1. 在一个单引号标注的字符串中,一个单引号需要转义成两个单引号
  2. 在一个双引号标注的字符串中,大部分符号都需要用反斜杠来转义
  3. 如果字符串中有控制字符(如\0\n等),需要用双引号来标注
  4. 如果字符串看起来像下面的样子,需要用引号(无所谓哪种)来标注:
    • truefalse
    • null~
    • 看起来像数字,如214.912e7
    • 看起来像日期,如2014-12-31

完整的规则可以参考yaml规范

对与json来说,需要转义的字符如下图:
json string escape

对于csv来说,转义的规则只有两条:

  1. 如果值里有逗号、换行或是双引号,需要用双引号来标注
  2. 如果值里有双引号,需要把它转义成两个双引号""

Java & .NET & JS & SQL

对于大部分的编程语言,例如Java、.NET还有JavaScript,甚至C、GO、Ruby等等来说,通常的转义都是通过反斜杠\来实现的。一般都包括如下几项:

  • 退格: \b
  • 换行: \n
  • 制表符: \t
  • 回车: \r
  • 换页: \f
  • 双引号: \"
  • 反斜杠: \\

不过C和C++支持的16进制\x,在java里不被支持。所以\x61\xd2的这个“懒”字,在java中可以通以下这两个表达式来得到真实的字符:

1
2
"\u61d2"
new String(new byte[] {(byte) 0x61, (byte) 0xd2}, "unicode")

SQL有些不一样。它从语法层面支持模糊查询,所以即使在完全匹配中使用了%也不需要转义。但是代表字符串的单引号'还是不得不转义成两个单引号''

后记

以上是关于编码和转义的一些常识,知乎上转的这篇回答,系统地介绍了从ASCII到UTF-8,写得非常赞。平时需要编码和转义的时候,可以使用这个网站在线转换,也挺方便的。

文章目录
  1. 1. 编码(encoding)
    1. 1.1. ASCII & EASCII & ISO-8859-1
    2. 1.2. GB2312 & GBK & GB18030 & ANSI
    3. 1.3. Unicode & UTF-8
    4. 1.4. base64 & UTF-7
  2. 2. 转义(escaping)
    1. 2.1. html & url
    2. 2.2. XML & YAML & JSON & CSV
    3. 2.3. Java & .NET & JS & SQL
  3. 3. 后记