HTTP 协议中的 Transfer-Encoding

提醒:本文最后更新于 942 天前,文中所描述的信息可能已发生改变,请谨慎使用。

本文作为我的博客「HTTP 相关」专题新的一篇,主要讨论 HTTP 协议中的 Transfer-Encoding。这个专题我会根据自己的理解,以尽量通俗的讲述,结合代码示例和实际场景来说明问题,欢迎大家关注和留言交流。

Transfer-Encoding,是一个 HTTP 头部字段,字面意思是「传输编码」。实际上,HTTP 协议中还有另外一个头部与编码有关:Content-Encoding(内容编码)。Content-Encoding 通常用于对实体内容进行压缩编码,目的是优化传输,例如用 gzip 压缩文本文件,能大幅减小体积。内容编码通常是选择性的,例如 jpg / png 这类文件一般不开启,因为图片格式已经是高度压缩过的,再压一遍没什么效果不说还浪费 CPU。

而 Transfer-Encoding 则是用来改变报文格式,它不但不会减少实体内容传输大小,甚至还会使传输变大,那它的作用是什么呢?本文接下来主要就是讲这个。我们先记住一点,Content-Encoding 和 Transfer-Encoding 二者是相辅相成的,对于一个 HTTP 报文,很可能同时进行了内容编码和传输编码。

Persistent Connection

暂时把 Transfer-Encoding 放一边,我们来看 HTTP 协议中另外一个重要概念:Persistent Connection(持久连接,通俗说法长连接)。我们知道 HTTP 运行在 TCP 连接之上,自然也有着跟 TCP 一样的三次握手、慢启动等特性,为了尽可能的提高 HTTP 性能,使用持久连接就显得尤为重要了。为此,HTTP 协议引入了相应的机制。

HTTP/1.0 的持久连接机制是后来才引入的,通过 Connection: keep-alive 这个头部来实现,服务端和客户端都可以使用它告诉对方在发送完数据之后不需要断开 TCP 连接,以备后用。HTTP/1.1 则规定所有连接都必须是持久的,除非显式地在头部加上 Connection: close。所以实际上,HTTP/1.1 中 Connection 这个头部字段已经没有 keep-alive 这个取值了,但由于历史原因,很多 Web Server 和浏览器,还是保留着给 HTTP/1.1 长连接发送 Connection: keep-alive 的习惯。

浏览器重用已经打开的空闲持久连接,可以避开缓慢的三次握手,还可以避免遇上 TCP 慢启动的拥塞适应阶段,听起来十分美妙。为了深入研究持久连接的特性,我决定用 Node 写一个最简单的 Web Server 用于测试,Node 提供了 http 模块用于快速创建 HTTP Web Server,但我需要更多的控制,所以用 net 模块创建了一个 TCP Server:

JSrequire('net').createServer(function(sock) {
    sock.on('data', function(data) {
        sock.write('HTTP/1.1 200 OK\r\n');
        sock.write('\r\n');
        sock.write('hello world!');
        sock.destroy();
    });
}).listen(9090, '127.0.0.1');

启动服务后,在浏览器里访问 127.0.0.1:9090,正确输出了指定内容,一切正常。去掉 sock.destroy() 这一行,让它变成持久连接,重启服务后再访问一下。这次的结果就有点奇怪了:迟迟看不到输出,通过 Network 查看请求状态,一直是 pending。

这是因为,对于非持久连接,浏览器可以通过连接是否关闭来界定请求或响应实体的边界;而对于持久连接,这种方法显然不奏效。上例中,尽管我已经发送完所有数据,但浏览器并不知道这一点,它无法得知这个打开的连接上是否还会有新数据进来,只能傻傻地等了。

Content-Length

要解决上面这个问题,最容易想到的办法就是计算实体长度,并通过头部告诉对方。这就要用到 Content-Length 了,改造一下上面的例子:

JSrequire('net').createServer(function(sock) {
    sock.on('data', function(data) {
        sock.write('HTTP/1.1 200 OK\r\n');
        sock.write('Content-Length: 12\r\n');
        sock.write('\r\n');
        sock.write('hello world!');
    });
}).listen(9090, '127.0.0.1');

可以看到,这次发送完数据并没有关闭 TCP 连接,但浏览器能正常输出内容并结束请求,因为浏览器可以通过 Content-Length 的长度信息,判断出响应实体已结束。那如果 Content-Length 和实体实际长度不一致会怎样?有兴趣的同学可以自己试试,通常如果 Content-Length 比实际长度短,会造成内容被截断;如果比实体内容长,会造成 pending。

由于 Content-Length 字段必须真实反映实体长度,但实际应用中,有些时候实体长度并没那么好获得,例如实体来自于网络文件,或者由动态语言生成。这时候要想准确获取长度,只能开一个足够大的 buffer,等内容全部生成好再计算。但这样做一方面需要更大的内存开销,另一方面也会让客户端等更久。

我们在做 WEB 性能优化时,有一个重要的指标叫 TTFB(Time To First Byte),它代表的是从客户端发出请求到收到响应的第一个字节所花费的时间。大部分浏览器自带的 Network 面板都可以看到这个指标,越短的 TTFB 意味着用户可以越早看到页面内容,体验越好。可想而知,服务端为了计算响应实体长度而缓存所有内容,跟更短的 TTFB 理念背道而驰。但在 HTTP 报文中,实体一定要在头部之后,顺序不能颠倒,为此我们需要一个新的机制:不依赖头部的长度信息,也能知道实体的边界。

Transfer-Encoding: chunked

本文主角终于再次出现了,Transfer-Encoding 正是用来解决上面这个问题的。历史上 Transfer-Encoding 可以有多种取值,为此还引入了一个名为 TE 的头部用来协商采用何种传输编码。但是最新的 HTTP 规范里,只定义了一种传输编码:分块编码(chunked)。

分块编码相当简单,在头部加入 Transfer-Encoding: chunked 之后,就代表这个报文采用了分块编码。这时,报文中的实体需要改为用一系列分块来传输。每个分块包含十六进制的长度值和数据,长度值独占一行,长度不包括它结尾的 CRLF(\r\n),也不包括分块数据结尾的 CRLF。最后一个分块长度值必须为 0,对应的分块数据没有内容,表示实体结束。按照这个格式改造下之前的代码:

JSrequire('net').createServer(function(sock) {
    sock.on('data', function(data) {
        sock.write('HTTP/1.1 200 OK\r\n');
        sock.write('Transfer-Encoding: chunked\r\n');
        sock.write('\r\n');

        sock.write('b\r\n');
        sock.write('01234567890\r\n');

        sock.write('5\r\n');
        sock.write('12345\r\n');

        sock.write('0\r\n');
        sock.write('\r\n');
    });
}).listen(9090, '127.0.0.1');

上面这个例子中,我在响应头中表明接下来的实体会采用分块编码,然后输出了 11 字节的分块,接着又输出了 5 字节的分块,最后用一个 0 长度的分块表明数据已经传完了。用浏览器访问这个服务,可以得到正确结果。可以看到,通过这种简单的分块策略,很好的解决了前面提出的问题。

前面说过 Content-Encoding 和 Transfer-Encoding 二者经常会结合来用,其实就是针对进行了内容编码(压缩)的内容再进行传输编码(分块)。下面是我用 telnet 请求测试页面得到的响应,可以看到对 gzip 内容进行的分块:

BASH> telnet 106.187.88.156 80

GET /test.php HTTP/1.1
Host: qgy18.qgy18.com
Accept-Encoding: gzip

HTTP/1.1 200 OK
Server: nginx
Date: Sun, 03 May 2015 17:25:23 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: keep-alive
Content-Encoding: gzip

1f
�H���W(�/�I�J

0

如何利用docker安装discuz

Discuz安装是依赖LAMP环境的。如何安装LAMP,有很多教程,里边涉及了很多繁琐的步骤。今天要简单介绍下利用docker安装discuz

1. 安装LAMP环境


在docker hub上找到一个已经封装好的lamp镜像,直接安装,代码如下:

# Launch a 16.04 (php5) based image
docker run -p “80:80” -v ${PWD}/app:/app mattrayner/lamp:latest-1604

# Launch a 14.04 (php5) based image
docker run -p “80:80” -v ${PWD}/app:/app mattrayner/lamp:latest-1404

# Launch a 16.04 (php7) based image
docker run -p “80:80” -v ${PWD}/app:/app mattrayner/lamp:latest-1604-php7

# Launch a 14.04 (php7) based image
docker run -p “80:80” -v ${PWD}/app:/app mattrayner/lamp:latest-1404-php7

Component latest-1404-php5 latest-1604-php5 latest-1404-php7 latest-1604-php7
Apache 2.4.7 2.4.18 2.4.7 2.4.18
MySQL 5.5.61 5.7.23 5.5.61 5.7.23
PHP 5.6.37 5.6.37 7.2.9 7.2.9
phpMyAdmin 4.8.2 4.8.2 4.8.2 4.8.2

地址:https://hub.docker.com/r/mattrayner/lamp

2. 下载discuz安装包


经过亲自测试,Discuz 3.3/3.4不支持php7,所以如果用php5,需要下载3.2的包
下载地址:
http://www.discuz.net/forum-10-1.html

3. 启动docker


docker run -p “80:80” -v ${PWD}/mysql:/var/lib/mysql -v ${PWD}/app:/app mattrayner/lamp:latest-1604-php7
docker run -p “80:80” -v ${PWD}/mysql2:/var/lib/mysql -v ${PWD}/app2:/app mattrayner/lamp:latest-1604

效果如下:

按上边的步骤,差不多10几分钟就可以搞定,省去了大量依赖环境的安装!

HTTP请求

HTTP大家都不陌生,但是HTTP的许多细节就并不是很多人都知道了,本文将讨论一些容易被忽略但又比较重要的点。

首先,怎么用原生JS写一个GET请求呢?如下代码,只需3行:

let xhr = new XMLHttpRequest();
xhr.open("GET", "/list");
xhr.send();
复制代码

xhr.open第一个参数是请求方法,第二个参数是请求url,然后把它send出去就行了。

如果需要加上请求参数,如果用jQuery的ajax,那么是这么写的:

$.ajax({
    url: "/list",
    data: {
        page: 5
    }
});复制代码

如果是用原生的话就得拼在请求url上面,即open的第二个参数:

并且参数需要转义,如下代码所示:

function ajax (url, data) {
    let args = [];
    for (let key in data) {
        // 参数需要转义
      args.push(`${encodeURIComponent(key)} = 
                                     ${encodeURIComponent(data[key])}`);
    }
    let search = args.join("&");
    // 判断当前url是否已有参数
    url += ~url.indexOf("?") ? `&${search}` : `?${search}`;

    let xhr = new XMLHttpRequest();
    xhr.open("GET", url);
    xhr.send();
}复制代码

那为什么用jq就不用呢?因为jq帮我们做了,jq的ajax支持一个叫processData的参数,默认为true:

$.ajax({
    url: "/list",
    data: {
        page: 5
    },
    processData: true
});
复制代码

这个参数的作用是用来处理传进来的data的,如下jq的源码:

如果传了data,并且processData为true,并且data不是一个string了,就会调param处理data。然后我们来看下这个param函数是怎么实现的:

可以看到,它也是跟我自己实现的ajax类似,把key和value转义用”=”拼接,然后push到一个数组,最后再join地一下。不一样的地方是它的判断逻辑比我的复杂,它会再调一个buildParams的函数去处理key/value,因为value可能是一个数组,也可能是一个Object。如果value是一个Object,那么直接encode一下就会变成”[object Object]”的转义了:

所以buildParams在处理每个key/value时,会先判断当前value是否是一个数组或者是Object,如下图所示:

如果它是一个数组的话,这个数组的每一个元素都会变成单独的一个请求字段,它的key是父级的key拼上数组的索引得到,如{ids: [1, 2, 3]}就会被拼成:ids[0]=1、ids[1] = 2、ids[2] = 3,如果是一个Object的话key的后缀就是子Object的key,如{user: {id: 1333, name: “yin”}}会被拼成:user[id]=1333、user[name]=yin,否则就认为它是一个简单的类型,就直接调一下param函数定义的add,把它push到s那个数组。这里用到了递归调用,会不断地拼key值,直到value是一个普通变量了,就到了最后面的else逻辑。

也就是说,以下代码:

$.ajax({
    url: "/list",
    data: {
        user: {
            name: "yin",
            age: 18
        }
    },
});复制代码

将会被拼成的url为:

/list?user[name]=yin&user[age]=18

注意上面的中括号还没有转义。而如果是一个数组的话:

$.ajax({
    url: "/list",
    data: {
        ids: [1, 2, 3]
    },
});复制代码

拼成的请求url为:

/list?ids[0]=1&ids[1]=2&ids[2]=3

如果后端用的Java的Spring MVC框架的话,是理解这种格式的,框架收到这样的参数后会生成一个Array,传递给业务代码,业务代码是不用关心怎么处理这种参数的。其它的框架应该也类似。

怎么用原生JS写一个POST请求呢?如下图所示:

POST请求的参数就不是放在url了,而是放在send里面,即请求体。你可能会问:难道就不能放url么?我就要放url。如果你够任性,那么可以,前提是后端所使用的http框架能够在url里面取数据,因为它一定会收到url,也一定会收到请求体,所以取决于它要怎么处理,按照http标准,如果请求方法是POST,那么应该是得去请求体拿的,就不会在url的search上取了,当然它可以改一下,改成两个都可以拿。

然后我们会发现请求的mime类型是text/plain:

并且查看请求参数的时候,并不是平时所看到能够按照字段一行行地展示:

这是为什么呢?这是因为我们没有设置它的Content-Type,如下代码:

let xhr = new XMLHttpRequest();
xhr.open("POST", "/add");
xhr.setRequestHeader("Content-type", 
                           "application/x-www-form-urlencoded");
xhr.send("id=5&name=yin");复制代码

如果设置Content-Type为x-www-form-urlencoded的话,那么在检查的话,Chrome也会按字段分行展示了:

这个也是jq默认的Content-Type:

它是一种最常用的一种请求编码方式,支持GET/POST等方法,特点是:所有的数据变成键值对的形式key1=value1&key2=value2的形式,并且特殊字符需要转义成utf-8编号,如空格会变成%20:

由于中文在utf-8需要占用3个字节,所以它有3个%符号。

我们刚刚是xhr.send一个字符串,如果send一个Object会怎么样呢?如下代码所示:

let xhr = new XMLHttpRequest();
xhr.open("POST", "/add");
xhr.send({id:5, name: "yin"});复制代码

检查控制台的时候是这样的:

也就是说,实际上是调了Object的toString方法。所以可以看到,在send的数据需要转成字符串

除了字符串之外,send还支持FormData/Blob等格式,如:

let form = $("form")[0];
xhr.send(new FormData(form));
复制代码

但最后都是被转成字符串发送。

我们再看下其它的请求格式,如Github的REST API是使用json的格式发请求:

这个时候要求格式要变成json,就需要指定Content-Type为application/json,然后send的数据要stringify一下:

let xhr = new XMLHttpRequest();
xhr.open("POST", "/add");
xhr.setRequestHeader("Content-type", "application/json");
let data = {id:5, name: "yin"};
xhr.send(JSON.stringify(data));复制代码

如果是用jq的话,那么可以这样:

$.ajax({
    processData: false,
    data: JSON.stringify(data),
    contentType: "application/json"
});
复制代码

这个时候processData为false,告诉jq不要处理数据了——即拼成key1=value1&key2=value2的形式,直接把传给它的数据send就好了。

我们可以比较json和urlencoded这两种形式的优缺点,json的缺点是parse解析的工作量要明显高于split(“&”)的工作量,但是json的优点又在于表达复杂结构的时候比较简洁,如二维数组(m * n)在urlencoded需要拆成m * n个字段,而json就不用了。所以相对来说,如果请求数据结构比较简单应该是使用常用的urlencoded比较有利,而比较复杂时使用json比较有利。通常来说比较常用的还是urlencoded.

还有第3种常见的编码是multipart/form-data,这种也可以用来发请求,如下代码所示:

let formData = new FormData();
formData.append("id", 5); // 数字5会被立即转换成字符串 "5"
formData.append("name", "#yin");
// formData.append("file", input.files[0]);
let xhr = new XMLHttpRequest();
xhr.open("POST", "/add");
xhr.send(formData);复制代码

它通常用于上传文件,上传文件必须要使用这种格式。上面代码发送的内容如下图所示:

每个字段之间用一个随机字符串隔开,保证发送的内容不会出现这个字符串,这样发送的内容就不需要进行转义了,因为如果文件很大的话,转义需要花费相当的时间,体积也可能会成倍地增长。

然后再讨论一个问题,我们知道在浏览器地址栏输入一个网址请求数据,这个时候是用的GET请求,而我们在代码里面用ajax发的GET请求也是GET,浏览器访问网址的GET和ajax的GET有什么区别吗

为了能够观察到浏览器自已发出去的GET,需要用一个抓包工具看一下这个GET是怎么样的,如下图所示:

浏览器自己发的GET有一个明显的特点,它会设置http请求头的Accept字段,并且把text/html排在第一位,即它最希望收到的是html格式。而动态的ajax抓包显示是这样的:

可以看到,使用地址栏访问的和使用ajax的get请求本质上都是一样的,只是使用ajax我们可以设置http请求头,而使用地址栏访问的是由浏览器添加默认的请求头。

上面是使用http抓的包,我们可以看到请求的完整url,包括请求的参数,而如果是抓的https的包的话,GET放在url上的参数就看不到了:

也就是说https的请求报文是加密的,包括请求的uri等,需要解密后才能看到。那是不是说使用https的GET也是安全的,而不是https的POST不会比GET安全?

我们先来看一下http的请求报文,如下图所示:

如果使用抓包工具的话,可以看到请求报文确实是按照上图排列的,如图所示的GET:

而POST是这样的:

所以本质上GET/POST是一样的,只是GET把数据拼到url,而POST是放到请求体。另外一点,url是有长度限制的,包括浏览器和接收的服务如nginx,而请求体是没有限制的(浏览器没有限制,但是一般nginx接收会有限制),还有POST的数据支持多种编码格式。

虽然如此,POST还是比GET安全的,体现在以下几点:

  1. GET参数是放在url上面,用户可以保存为书签、传播链接,如果参数有敏感数据,如登陆的密码,那么可能会泄露
  2. 搜索引擎在爬取网站的时候如果修改数据库的请求支持GET,那么很可能数据库会无意被搜索引擎修改
  3. script/img等标签是GET请求,会更加方便跨站请求伪造,在浏览器地址栏输入也是GET,也是为修改请求提供便利

接着讨论请求响应状态码。很多人都知道200、404、500这几个,对于其它的可能就不甚了解了。这里我把一些常用的状态码列一下。

1. 301 永久转移

当你想换域名的时候,就可以使用301,如之前的域名叫www.renfed.com,后来换了一个新域名fed.renren.com,希望用户访问老域名的时候能够自动跳转到新的域名,那么就可以使用nginx返回301:

server {
    listen       80;
    server_name  www.renfed.com;
    root         /home/fed/wordpress;
    return       301 https://fed.renren.com$request_uri;
}复制代码

浏览器收到301之后,就会自动跳转了。搜索引擎在爬的时候如果发现是301,在若干天之后它会把之前收录的网页的域名给换了。

还有一个场景,如果希望访问http的时候自动跳转到https也是可以用301,因为如果直接在浏览器地址栏输入域名然后按回车,前面没有带https,那么是默认的http协议,这个时候我们希望用户能够访问安全的https的,不要访问http的,所以要做一个重定向,也可以使用301,如:

server {
    listen       80; 
    server_name  fed.renren.com;

    if ($scheme != "https") {
         return 301 https://$host$request_uri;
    }   
}
复制代码

2. 302 Found 资源暂时转移

很多短链接跳转长链接就是使用的302,如下图所示:

3. 304 Not Modified 没有修改

在本地使用webpack-dev-server开发的时候,如果没有改js/css,那么刷新页面的时候请求本地的js/css文件将返回304,如下图所示:

webpack-dev-server的服务怎么知道没有修改呢,因为浏览器在请求的时候带上了etag,如:

W/”10e632-Oz38I6asQyS459XpsaJYkjMUoZI”

服务会计算当前文件的etag,它是一个文件的哈希值,然后比较一下传过来的etag,如果相等,则认为没有修改返回304。如果有修改的话就会返回200和文件的内容,并重新给浏览器一个新的etag。下次请求的时候浏览器就会带上这个新的etag。如果把控制台的disable cached打开的话,那么浏览器即使有etag也不会带上。另外一个判断有没有修改的字段是Last Modified Time,这是根据文件的修改时间。

4. 400 Bad Request 请求无效

当必要参数缺失、参数格式不对时,后端通常会返回400,如下图所示:

并带上了提示信息:

{“message”:”opportunityId type mismatch required type ‘long’ “}

通过400可以知道请求参数有误,结合提示信息,说明需要传一个数字,而不是字符串。

5. 403 Forbidden 拒绝服务

服务能够理解你的请求,包括传参正确,但是拒绝提供服务。例如,服务允许直接访问静态文件:

但是不允许访问某个目录:

否则,别人对你服务器上的文件就一览无遗了。

403和401的区别在于,401是没有认证,没有登陆验证之类的错误。

6. 500 内部服务器错误

如业务代码出现了异常没有捕获,被tomcat捕获了,就会返回500错误:

如:数据库字段长度限制为30个字符,如果没有判断直接插入一条31个字符的记录,就会导致数据库抛异常,如果异常没有捕获处理,就直接返回500。

当服务彻底挂了,连返回都没有的时候,那么就是502了。

7. 502 Bad Gateway 网关错误

如下图所示:

这种情况是因为nginx收到请求,但是请求没有打过去,可能是因为业务服务挂了,或者是打过去的端口号写错了:

server {
    location / {
        # webpack的服务
        proxy_pass https://127.0.0.1:7071;
    }
}
复制代码

nginx返回了502.

8. 504 Gateway Timeout 网关超时

通常是因为服务处理请求太久,导致超时,如PHP服务默认的请求响应最长处理时间为30s,如果超过30s,将会挂掉,返回504,如下图所示:

这种情况可能是因为服务还要请求第三方的服务,第三方服务处理时间较久没有返回,如在向FCM发送Push的时候,如果一个请求里面需要发送的订阅的浏览器(subscriptions)太多了,就经常会处理很久,导致504.

9. 101 协议转换

websocket是从http升级而来,在建立连接前需要先通过http进行协议升级:

还有一个600,600是一种不太常用的状态码,表示服务器没有返回响应头部,只返回实体内容。

这些状态码实际上就是一个数字,可以任意返回,但是最好是按照http的规定返回合适的状态码。如果返回4、5、6开头的http状态码,浏览器将会打印错误,认为当前请求失败。

我们还没有说怎么判断请求成功了,如下代码所示:

xhr.open("POST", UPLOAD_URL);
xhr.onreadystatechange = function() {
    // readyState为4表示请求完成
    if (this.readyState === 4){
        if (this.status === 200) {
            let response = JSON.parse(this.responseText);
            if (!response.status || response.status.code !== 0) {
                // 失败     
                callback.failed && callback.failed();
            } else {    
                // 成功     
                callback.success(response.data.url);
            }           
        } else if (this.status >= 400 || this.status === 0) {
            // 失败     
            callback.failed && callback.failed();
        // 正常不应该返回20几的状态码,这种情况也认为是失败
        } else {    
            callback.failed && callback.failed();
        }
    }
};
xhr.send(formData);复制代码

这里有个问题,如果返回的状态码是3开头的重定向,需要自己再去发一个请求吗?

实践证明,不需要,浏览器会自动重定向,如下图所示:

最后,本文提到了3种常用的请求编码,分别是application/www-x-form-urlencoded、application/json、multipart/form-data,第一种是最常用的一种,适用于GET/POST等,第二种常见于请求响应的数据格式,第三种通常用于上传文件。然后还比较了POST和GET,虽然两者的请求数据都在http报文里面,只是位置不一样,但是考虑到用户、搜索引擎等使用场景,POST还是会比GET更安全。最后说了几个常用的http状态码,并用一些实际的例子加深印象和理解。

d