Skip to content

Latest commit

 

History

History
executable file
·
159 lines (106 loc) · 12 KB

http-encoding.md

File metadata and controls

executable file
·
159 lines (106 loc) · 12 KB

本文主要介绍 JSON,base64 和 urlencode 这三种在 HTTP 协议中经常出现的编码方式,编码本身很简单,但难的是了解为什么需要这种编码;这种编码的输入输出分别是什么;多次编码,解码但结果是否一样,有什么特殊字符需要转义;以及如何快速但识别具体的编码方式。由于这几种编码都是可逆的,只要掌握了编码方式就可以解码,所以我不会对解码方式做太多介绍。

JSON

JSON 的全文是 JavaScript Object Notation,也就是 JavaScript 中的对象表示方法,它诞生的目的是为了取代 XML。过去,XML 通常用于文件传输,然而它的效率过于低下,参杂了太多与实际内容无关的东西,比如下面这段 XML 可以用于描述一个人的年龄和姓名:

<Person>
	<name>bestswifter</name>
	<age>22</age>
</Person>

同样的内容用 JSON 来写就是:

{"name": "bestswifter", "age": 22}

可见 JSON 其实是字符串的一种描述方式,它的本质还是字符串,最大的优点就是废话少,效率高。很多人说 JSON 的最外层一定是一个对象或者数组,这个定义已经过时了,新的 RFC 7159 文档取代了老的 RFC 4627,其中 JSON 定义如下:

  1. JSON 串的最外层一定是数组,对象或者值
  2. 数组由若干个对象组成,用中括号表示:[]
  3. 对象用大括号表示:{},它由键值对组成,键一定是字符串,用双引号表示 ""
  4. 值可以是 falsenulltrue、对象、数组、数字和字符串

JSON 只是一种编码规范,它仅仅规定了 JSON 格式的字符串应该具备什么特征,至于原来的数据如何转化为 JSON 串,则是由各个编程语言自己实现。一般来说,内置的基本数据类型,比如数组、字典、布尔值、字符串、数字都可以转换成 JSON 串,其中字典会转换成 JSON 中对象,用户自定义的对象一般都不能转 JSON,除非实现了特定的协议。

理论上来说,用来组成 JSON 的字符都需要转义,以免发生歧义,不过括号并不需要,因为它们总是成对出现,即使不转义也不会影响解析,因此字符串中的双引号需要转义,比如 " 会被转为 \",同理反斜杠(backslash)自己也许要转义:\ -> \\。另一个我经常见到的转以后的字符是换行符 \n。下面是一个 Python 中的例子,用来简单演示一下 JSON 编码中的转义:

import json

s = '''[{h/e\l,l"o"}]
'''
j = json.dumps(s)
# "[{h/e\\l,l\"o\"}]\n"

一般来说,JSON 编码是将语言中特定类型的对象转换为满足特定协议的字符串,解码自然就是将字符串转换回语言中类型。不过有的语言比较奇怪,比如 Objective-C 中原生的 JSON 库,在编码时并不会返回 JSON 串,而是这个字符串在 UTF8 编码下的二进制数据。

很明显 JSON 不能多次解码。多次编码时,后面几次其实都是对字符串编码,而且会会经历 "\" 再到 \\\" 的过程,长度急剧增加。

base64 编码

字符串编码的作用是把字符串转换为二进制数据,以便存储和发送。base64 的作用则恰好相反,它把二进制数据映射到字符串。它使用了 a-zA-Z0-9+/26 + 26 + 10 + 2 = 64 种字符来接受二进制数据的映射,所以被称为 base64。

假设我有一句 “Hello,world” 需要通过网络发送,它背后的原理是先将字符串用 ASCII 或 UTF-8 等方式编码,然后发送这个二进制数据。然而有些古老的设备(比如交换机)和古老的协议(邮件的 SMTP 协议)只能处理标准的 ASCII 编码,也就是最高位是 0,剩下七位有效。但实际上一些 ASCII 码值在 128-255 之间的二进制数据也有可能被用到,而这部分数据在传输时可能就无法被正确处理。

base64 的好处在于,它转换得到的字符串一定是标准的 ASCII 码,发送这些字符串不会存在任何风险。base64 是一个相对比较底层,比较古老的编码方式,在应用层的场景并不多,这里简单举两个例子。

在 HTML 种如果需要传输图片,一般使用 <img> 标签,但这会额外建立一次连接,如果图片非常小,建立连接的开销甚至可以远大于下载图片本身的开销,因此可以考虑直接把图片嵌入到 HTML 的数据中。然而 HTML 是一个纯文本的协议,如果直接发送图片的二进制数据,里面的换行符可能就被当做换行符,用来换行了,然而它实际上只是图片数据不可或缺的一部分。这时候 base64 的作用就是把二进制转为字符串:

<img alt="Embedded Image" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIA..." />

在平时的开发中,我们也有可能会用到 base64。比如我需要把一段复杂的文本传递给后端并保存起来,如果直接发送字符串,那么接收方存储下来的可能是有换行的数据:

'name': 'bestswifter'
'age': 22
'descrption': '这里很长而且有换行'
'sex': 'male'

将来有一天,我需要读取这个人的 description,如果用 grep 来处理就出问题了:

grep 'description' log_20171106.txt

因为 grep 是按行查找,所以这里只能拿到第一行,想要获取完整版的 description 会非常困难。

所以应用层的 base64 主要是处理自定义的数据,防止其中的控制字符(比如换行符等)被当作文本错误的处理。这样能够保证接收端的应用层可以原封不动的获取数据。

每一个 ASCII 码占用 8 个比特,其中每 6 个比特会编码成一个 base64 中的字符,这是因为 64 恰好是 2 的 6 次方。根据这个规则我们可以得出两个结论:

  1. 使用 base64 编码会使原始二进制数据的大小增加三分之一
  2. 二进制数据的比特位不一定恰好是 6 的倍数,不够的位需要用 0 补齐

下图截自 维基百科 中的表格,用来演示 base64 的编码规则:

从二进制的 0 - 63 映射到 base64 编码的原则比较简单,就不列出来了,可以参考维基百科的链接。

base64 编码有两个小细节需要注意下,它的实际工作方式并不是每 6 个比特转换一个 ASCII 字符,而是选择 6 和 8 的最小公倍数 24,每次读取 24 个 bit,也就是三个字节转换成四个 base64 编码后的字符。如果被编码的字节数不是 3 的倍数,那么可能会多出 1 个或者 2 个字节。如果多出一个字节,可以转换成 2 个字节的 base64 编码,所以还缺两位,需要用两个等号补齐。如果多出两个字节,可以转换成三个字节的 base64 编码,需要用一个等号补齐。如下图所示:

上面我提到的这些规则,比如用等号补位,0-64 如何编码等,都是标准base64 的定义,实际上 base64 有很多变种,比如有的 base64 的字符集中没有 +/,而是用 ,.-_: 等字符,有的 base64 变种会强制每几十个字节插入一个换行符,有的没有补位要求。因此客户端如果想要借助 base64 编码的某些特性,需要明确自己调用的 base64 是哪一种,并且与解析方约定、联调好。

urlencode

并不是所有的字符都能作为 URL 的一部分,以下几种字符需要被编码:

  1. ASCII 控制字符:范围是 16 进制的 00-1F 以及 7F,这些字符无法被打印出来,而 URL 规范指出 URL 不仅可以通过网络传输,还应该可以被打印或者电台中读出来。
  2. 非 ASCII 字符:这不符合 URL 的定义
  3. 保留字符:一些特殊的字符比如 @ 用于表示电子邮件的地址,& 是 get 请求参数的分隔符,= 是请求参数中键值对的分隔符,/ 用于分割路径等等,如果应用层数据携带了这些关键字,就可能导致服务端解析错误
  4. 不安全字符:由于各种原因,有些字符在解析时可能会产生歧义,导致无法正确解析出原始数据,这些字符也要转义,最常见的就是空格,它会被编码为 %20

URL 编码的方式并不统一,从上面的分类中可以看出,控制类字符、非 ASCII 字符和不安全字符都属于不合法字符,无论如何都不可能出现在 URL 中。但保留字符的处理则需要分情况处理,比如有如下字符串:

https://baidu.com/search=你好

显然只有中文的“你好”才需要转义,保留字符则不需要,但如果我们定义了一个变量 search_word 并且需要拼接到字符串的结尾:

search_word = '/你好/'
url = 'https://baidu.com/search=' + search_word

那么在对 search_word 进行 urlencode 时,就必须把保留字符也进行转义。

因此在进行 urlencode 时,一定要阅读 api 的说明文档,了解它的编码规则,尤其是对保留字符的处理方式。比如 Python3 中的 quote 函数就是一个典型的坑,它有一个名为 safe 的参数,默认值是 /,表示斜杠不会被编码。

在 HTTP 协议的 post 方法中,一般需要通过 HEAD 头中的 Content-Type 指定数据的编码方式,一种常见的编码就是 application/x-www-form-urlencoded,默认的 Web 表单也使用这种方式,提交的数据格式如下:

key1=value1&key2=value2&...

其中的键值对都需要经过 urlencode 编码。由于这个属于 HTTP 规范,因此在客户端开发中一般并不需要开发者手动指定,而是由网络库封装。比如一个网络库可以接受字典格式的参数,并且自动进行 urlencode 编码并上传。然而我的建议是,客户端最好不要依赖于网络库的这个特性,尤其是某些大型应用的底层会有多个网络库随时切换,这时候就必须逐一验证。其实开发者完全可以在业务层手动编码一次,因为 urlencode 的特点就是可以多次 decode,如果解码以后再次解码,并不会改变字符串的内容。

常见字符的 urlencode 值如下表:

字符 urlencode
: %3A
空格 %20
/ %2F
? %3F
& %26
= %3D

总结

以上三种编码方式的特性可以用一张表格来概括:

编码方式 输入 输出 转义 鉴别特征 多次编码结果 多次解码
JSON 数据结构或对象 字符串 "\n\ 结构化的字符串 转义的 \ 越来越多 只能解一次
base64 二进制 字符串 末尾可能有等号,字符集单一 还是 base64,但内容发送改变 要和编码次数对应,否则结果无意义
urlencode 字符串 符合 URL 规范的字符串 控制符、非 ASCII 字符,保留字符和不安全字符 %xx 较多 会改变,因为百分号也要转义 可以多解但不能少解

base64 和 urlencode 的编/解码推荐 FE 助手 这个 Chrome 插件,但我的建议是了解一下脚本语言中如何编解码,自己封装一个命令行工具,JSON 的格式化查看则推荐这个网站

参考资料

  1. What is the minimum valid JSON?
  2. Base64-Chinese
  3. Base64-English
  4. Why do we use Base64?
  5. URL Encoding
  6. Uniform Resource Identifiers (URI): Generic Syntax
  7. 关于URL编码