协议,直白的说就是存在一堆字节,按照协议指定的规则解析就能得出这堆字节的意义。HTTP 解析分为两个部分:解析请求头和请求体。

请求头解析的难点在于它没有固定长度的头部,也不像其他协议那样提供数据包长度字段,判断是否读取到一个完整的头部的唯一依据就是遇到一个仅包括回车换行符的空行,好在在找寻这个空行的过程中能够完成请求行和头域的分析。

请求体的解析就是按照头域的传输编码内容编码进行解码。那么 Tomcat 是如何设计和实现 HTTP 协议的呢?

1. 请求头的解析

请求头由 Ascii 码组成,包含请求行和请求头域两个部分,下面是一个简单请求的字符串格式:

POST /index.jsp?a=1&b=2 HTTP/1.1\r\n
Host: localhost:8080\r\n
Connection: keep-alive
Content-Length: 43
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Accept:*/*\r\n
Accept-Encoding: chunked, gzip\r\n
\r\n
account=Rvcg%3D%3D&passwd=f63ebe&salt=vpwMy

解析过程的本质就是遍历这些字节,以空格、回车换行符和冒号为分隔符提取内容。在具体实现时,Tomcat 使用的是一种有限状态机的编程方法,状态机在非阻塞和异步编程中很重要,因为如果因数据不完整导致处理中断,在读取更多数据后,可以很方便的从中断的地方继续。Tomcat 对请求行和头域分别设计了各种解析状态。

1.1 解析请求行的状态

InternalNioInputBuffer 有一个成员变量 parsingRequestLinePhase,它的不同值表示不同的解析阶段:

  • 0: 表示解析开始前跳过空行
  • 2: 开始解析请求方法
  • 3: 跳过请求方法和请求uri之间的空格或制表符
  • 4: 开始解析请求URI
  • 5: 与3相同
  • 6: 解析协议版本,如 HTTP/1.1

1.2 解析请求头域的状态

解析头域信息设计了两种状态,分别是 HeaderParseStatus 和 HeaderParsePosition。HeaderParseStatus 表示整个头域解析的情况,它有三个值:

  • DONE: 表示整个头域解析结束
  • HAVE_MORE_HEADERS: 解析完一个 header的 name-value,开始解析下一个
  • NEED_MORE_DATA: 表示头域数据不完整需要继续从通道读取

HeaderParseStatus 表示一个 Header 的 name-value 解析的状态,它有6个状态:

  • HEADER_START:开始解析新的 Header,如果读取到了一个 \r\n,即遇到空行,整个解析结束
  • HEADER_NAME:解析 Header 的名称
  • HEADER_VALUE_START:此状态是为了跳过 name 和 value 之间的空格
  • HEADER_VALUE:解析 Header 的值
  • HEADER_MULTI_LINE:解析value后,如果新的一行开始的字符为 LWS(线性空白 - 空格、制表符),表示上一个value占多行
  • HEADER_SKIPLINE:解析名称时遇到非法的字符,跳过这一行,忽略这个 Header

1.3 实现

这块的代码实现分别在 InternalNioInputBuffer 的 parseRequestLine、parseHeaders 和 parseHeader 方法中,具体的代码注释这里不在贴出,原理就是遍历字节数组按状态取值。

那请求元素如何表示?整个解析过程都是在操作字节数组,一个简单的做法是直接转字符串存储,而 Tomcat 为了节省内存,设计了一个 MessageBytes 类,它用于表示底层 byte[] 子字节数组的视图,只在有需要的时候才转为字符串,并缓存,后续的读取操作也是尽可能的减少内存复制

从通道读取数据的功能,由 InternalNioInputBuffer 的 fill 和 readSocket 方法完成。fill 方法主要做一些逻辑判断,读取请求体时,重置 pos 位置,以重复使用请求头数据后的缓冲区,具体代码如下:

protected boolean fill(boolean timeout, boolean block)
throws IOException, EOFException {
// 尝试将一些数据读取到内部缓冲区
boolean read = false; // 是否有数据读取
if (parsingHeader) { // 如果当前处于解析请求头域的状态
if (lastValid == buf.length) {
// 判断已读字节是否超过缓冲区的大小
// 这里应该使用 headerBufferSize 而不是 buf.length
throw new IllegalArgumentException("Request header is too large");
}
// 从通道读取数据
read = readSocket(timeout,block)>0;
} else {
// end 请求头数据在缓冲区结束的位置下标,也是请求体数据开始的下标
lastValid = pos = end; // 重置 pos 的位置,重复利用 end 后的缓冲区
read = readSocket(timeout, block)>0;
}
return read;
}

readSocket 执行读取操作,它有两种模式,阻塞读和非阻塞读:

private int readSocket(boolean timeout, boolean block) throws IOException {
int nRead = 0; // 读取的字节数
socket.getBufHandler().getReadBuffer().clear(); // 重置 NioChannel 中的读缓冲区
if ( block ) { // true 模拟阻塞读取请求体数据
Selector selector = null;
try { selector = getSelectorPool().get(); }catch ( IOException x ) {}
try {
NioEndpoint.KeyAttachment att = (NioEndpoint.KeyAttachment)socket.getAttachment(false);
if ( att == null ) throw new IOException("Key must be cancelled.");
nRead = getSelectorPool().read(socket.getBufHandler().getReadBuffer(),
socket,selector,att.getTimeout());
} catch ( EOFException eof ) { nRead = -1;
} finally {
if ( selector != null ) getSelectorPool().put(selector);
}
} else { // false 非阻塞读取请求头数据
nRead = socket.read(socket.getBufHandler().getReadBuffer());
}
if (nRead > 0) {
// 切换读取模式
socket.getBufHandler().getReadBuffer().flip();
socket.getBufHandler().getReadBuffer().limit(nRead);
expand(nRead + pos); // 缓冲区没有必要扩展,高版本已移除
socket.getBufHandler().getReadBuffer().get(buf, pos, nRead);
lastValid = pos + nRead;
return nRead;
} else if (nRead == -1) { // 客户端关闭连接
//return false;
throw new EOFException(sm.getString("iib.eof.error"));
} else { // 读取 0 个字节,说明通道数据还没准备好,继续读取
return 0;
}
}

2. 请求体读取

Tomcat 把请求体的读取和解析延迟到了 Servlet 读取请求参数的时候,此时的请求已经从 Connector 进入了 Container,需要再次从底层通道读取数据,来看下 Tomcat 是怎么设计的(可右键直接打开图片查看大图):

上面的类图包含了处理请求和响应的关键类、接口和方法,其中 ByteChunk 比较核心,有着承上启下的作用,它内部有 ByteInputChannel 和 ByteOutputChannel 两个接口,分别用于实际读取和实际写入的操作。请求体数据读取过程的方法调用如下(可右键直接打开图片查看大图):

2.1 identity-body 编码

identity 是定长解码,直接按照 Content-Length 的值,读取足够的字节就可以了。IdentityInputFilter 用一个成员变量 remaining 来控制是否读取了指定长度的数据,核心代码如下:

public int doRead(ByteChunk chunk, Request req) throws IOException {
int result = -1; // 返回 -1 表示读取完毕
if (contentLength >= 0) {
if (remaining > 0) {
// 使用 ByteChunk 记录底层数组读取的字节序列
int nRead = buffer.doRead(chunk, req);
if (nRead > remaining) { // 读太多了
// 重新设置有效字节序列
chunk.setBytes(chunk.getBytes(), chunk.getStart(), (int) remaining);
result = (int) remaining;
} else {
result = nRead;
}
if (nRead > 0) {
// 计算还要在读多少字节,可能为负数
remaining = remaining - nRead;
}
} else {
// 读取完毕,重置 ByteChunk
chunk.recycle();
result = -1;
}
}
return result;
}

InputFilter 里的 buffer 引用的是 InternalNioInputBuffer 的内部类 SocketInputBuffer,它的作用就是控制和判断是否读取结束,实际的读取操作以及存储读取的内容都还是由 InternalNioInputBuffer 完成。一次读取完毕,容器通过 ByteChunk 类记录底层数组的引用和有效字节的位置,拉取实际的字节和判断是否还要继续读取。

2.2 chunked-body 编码

chunked 是用于不确定请求体大小时的传输编码,解码读取的主要工作就是一直解析 chunk 块直到遇到一个大小为 0 的 Data chunk。rfc2616#section-3.6.1 中定义了 chunk 的格式:

Chunked-Body   = *chunk
last-chunk
trailer
CRLF chunk = chunk-size [ chunk-extension ] CRLF
chunk-data CRLF
chunk-size = 1*HEX
last-chunk = 1*("0") [ chunk-extension ] CRLF chunk-extension= *( ";" chunk-ext-name [ "=" chunk-ext-val ] )
chunk-ext-name = token
chunk-ext-val = token | quoted-string
chunk-data = chunk-size(OCTET)
trailer = *(entity-header CRLF)

其中,关键字段的意义是:

  • chunk-size:是小写 16 进制数字字符串,表示 chunk-data 的大小,比如 15 的十六进制是 f,字符 'f' 的 ASCII 是 0x66,所以数字 15 就被编码成了 0x66
  • chunk-extension:一个或多个以逗号分割放在 chunk-size 后面的扩展选项
  • trailer:额外的 HTTP 头域

下面是一个尽可能展示上面的定义的 chunked 编码的例子:

HTTP/1.1 200 OK\r\n
Content-Type: text/html\r\n
Transfer-Encoding: chunked\r\n
Content-Encoding: gzip\r\n
Trailer: Expires\r\n
\r\n
Data chunk (15 octets)
size: 66 0d 0a
data: xx xx xx xx 0d 0a
Data chunk (4204 octets)
size: 4204;ext-name=ext-val\r\n
Data chunk (3614 octets)
Data chunk (0 octets)
size: 0d 0a
data: 0d 0a
Expires: Wed, 21 Jan 2016 20:42:10 GMT\r\n
\r\n

rfc2616 中提供了一个解码 chunked 传输编码的伪代码:

length := 0
read chunk-size, chunk-extension (if any) and CRLF
while (chunk-size > 0) {
read chunk-data and CRLF
append chunk-data to entity-body
length := length + chunk-size
read chunk-size and CRLF
}
read entity-header
while (entity-header not empty) {
append entity-header to existing header fields
read entity-header
}
Content-Length := length
Remove "chunked" from Transfer-Encoding

逻辑还是比较清晰的,有问题可留言交流。Tomcat 实现 chunked 编码解析读取的类是 ChunkedInputFilter,来分析一下核心代码的实现,整个解析逻辑都在 doRead 方法中:

public int doRead(ByteChunk chunk, Request req) throws IOException {
if (endChunk) {// 是否读取到了最后一个 chunk
return -1; // -1 表示读取结束
}
checkError();
if(needCRLFParse) {// 读取一个 chunk 前,是否需要解析 \r\n
needCRLFParse = false;
parseCRLF(false);
}
if (remaining <= 0) {
if (!parseChunkHeader()) { // 读取 chunk-size
throwIOException(sm.getString("chunkedInputFilter.invalidHeader"));
}
if (endChunk) {// 如果是最后一个块
parseEndChunk();// 处理 Trailing Headers
return -1;
}
}
int result = 0;
if (pos >= lastValid) { // 从通道读取数据
if (readBytes() < 0) {
throwIOException(sm.getString("chunkedInputFilter.eos"));
}
}
// lastValid - pos 的值是读取的字节数
if (remaining > (lastValid - pos)) {
result = lastValid - pos;
// 还剩多少要读取
remaining = remaining - result;
// ByteChunk 记录读取的数据
chunk.setBytes(buf, pos, result);
pos = lastValid;
} else {
result = remaining;
chunk.setBytes(buf, pos, remaining); // 记录读取的数据
pos = pos + remaining;
remaining = 0; // 这时已经完成 chunk-body 的读取,解析 \r\n
if ((pos+1) >= lastValid) {
// 此时如果调用 parseCRLF 就会溢出缓冲区,接着会触发阻塞读取
// 将解析推迟到下一个读取事件
needCRLFParse = true;
} else {
// 立即解析 CRLF
parseCRLF(false); //parse the CRLF immediately
}
}
return result;
}

parseChunkHeader 就是读取并计算 chunk-size,对于扩展选项,Tomcat只是简单忽略:

// 十六进制字符转十进制数的数组,由 ASCII 表直接得来
private static final int[] DEC = {
00, 01, 02, 03, 04, 05, 06, 07, 8, 9, -1, -1, -1, -1, -1, -1,
-1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, 10, 11, 12, 13, 14, 15,
};
public static int getDec(int index){
return DEC[index - '0'];
}
protected boolean parseChunkHeader() throws IOException {
...
int result = 0;
// 获取字符对应的十进制数
int charValue = HexUtils.getDec(buf[pos]);
if (charValue != -1 && readDigit < 8) {
readDigit++;
// 一个16进制数 4bit,左移4位合并低4位
// 相当于 result = result * 16 + charValue;
result = (result << 4) | charValue;
}
...
}

3. 请求参数的解析

服务端接收到的参数形式可能有这种特殊格式:

tonwu.net/search?scope=b%20bs&q=%E5%88%9B
account=Rvcg%3D%3D&passwd=f63ebe&salt=vpwMy

这样的解析就涉及到了编码问题,GET 和 POST 请求的编码方法由页面设置的编码决定,也就是 <meta http-equiv="Content-Type" content="text/html;charset=utf-8"。如果 URL 中有查询参数,并且包含特殊字符或中文,就会使用它们的编码,格式为:%加字符的 ASCII 码,下面是一些常用的特殊符号和编码:

%2B   +     表示空格
%20 空格 也可使用 + 和编码
%2F / 分割目录和子目录
%3F ? 分割URI和参数
%25 % 指定特殊字符
%23 # 锚点位置
%26 & 参数间的分隔符
%3D = 参数赋值

Tomcat 对请求参数解析的代码在 Parameters 类的 processParameters 方法中,其中要注意的是对 % 编码的处理,因为%后面跟的是实际数值的大写的16进制数字字符串,就和chunk-size类似要进行一次转换。

由于篇幅的原因下一篇将会继续分析 HTTP 响应的处理。

小结

本文对请求头和请求体的读取和解析进行了分析。为了简单直观的理解具体的处理流程,尽可能的使用简洁的代码仿写了这部分功能。

源码地址https://github.com/dwosc/rxtomcat 位于 rxtomcat-http 模块

Tomcat 对 HTTP 协议的实现(上)的更多相关文章

  1. Tomcat 对 HTTP 协议的实现(下)

    在<Tomcat 对 HTTP 协议的实现(上)>一文中,对请求的解析进行了分析,接下来对 Tomcat 生成响应的设计和实现继续分析.本文首发于(微信公众号:顿悟源码) 一般 Servl ...

  2. tomcat重启或关闭后,上传文件消失 .

    tomcat重启或关闭后,上传文件消失的问题,是因为在断电前myeclipse是启动的,断电时造成myeclipse异常关闭,再重新启动myeclipse时会重新发布项目,把先前发布的项目给覆盖了,所 ...

  3. HTTP协议一次上传多个文件的方法

    如何通过HTTP协议一次上传多个文件呢?在这里有两个思路,是同一个方法的两种实现.具体程序还需自己去设计 1. 在form中设置多个文件输入框,用数组命名他们的名字,如下: < form act ...

  4. 用c++开发基于tcp协议的文件上传功能

    用c++开发基于tcp协议的文件上传功能 2005我正在一家游戏公司做程序员,当时一直在看<Windows网络编程> 这本书,把里面提到的每种IO模型都试了一次,强烈推荐学习网络编程的同学 ...

  5. TCP协议详解---上

    TCP头格式 注意以下几点: TCP的包是没有IP地址的,那是IP层上的事.但是有源端口和目标端口. 一个TCP连接需要四个元组来表示是同一个连接(src_ip, src_port, dst_ip, ...

  6. tomcat配置https协议

    tomcat配置https协议 1.找到本地jdk底下的bin目录,bin目录底下存在文件keytool.exe(在bin目录下空白处,按住shift右击,打开命令窗口,如下图) 2.在cmd的命令窗 ...

  7. 配置Tomcat使用https协议(配置SSL协议)

    配置Tomcat使用https协议(配置SSL协议) 2014-01-20 16:38 58915人阅读 评论(3) 收藏 举报 转载地址:http://ln-ydc.iteye.com/blog/1 ...

  8. Tomcat配置https协议访问

    Tomcat9配置https协议访问: https://blog.csdn.net/weixin_42273374/article/details/81010203 配置Tomcat使用https协议 ...

  9. tomcat从manager部署war项目上传失败

    tomcat从manager部署war项目上传失败, 查看manager.2018-07-17.log 日志,可以看到如下信息. less manager.2018-07-17.log 17-Jul- ...

  10. 我们检测到您的浏览器不兼容传输层安全协议 (TLS) 1.1 或更高版本,此协议在 Internet 上用于确保您通信的安全性。

    早上使用.Net WebClient类采集亚马逊数据,返回http 400 Bad Request错误,内容里面有“我们检测到您的浏览器不兼容传输层安全协议 (TLS) 1.1 或更高版本,此协议在 ...

随机推荐

  1. 【codeforces 718E】E. Matvey's Birthday

    题目大意&链接: http://codeforces.com/problemset/problem/718/E 给一个长为n(n<=100 000)的只包含‘a’~‘h’8个字符的字符串 ...

  2. 【bzoj 3233】[Ahoi2013]找硬币 ——搜索

    Description 小蛇是金融部部长.最近她决定制造一系列新的货币.假设她要制造的货币的面值为x1,x2,x3… 那么x1必须为1,xb必须为xa的正整数倍(b>a).例如 1,5,125, ...

  3. 【爆料】-《英博夏尔大学毕业证书》BPP一模一样原件

    英博夏尔大学毕业证[微/Q:2544033233◆WeChat:CC6669834]UC毕业证书/联系人Alice[查看点击百度快照查看][留信网学历认证&博士&硕士&海归&a ...

  4. springboot2.x里面访问静态资源的坑

    在spring boot的自定义配置类继承 WebMvcConfigurationSupport 后,发现自动配置的静态资源路径( classpath:/META/resources/,classpa ...

  5. javascript && php &&java 轰炸!!!

    java && javascript && php 轰炸!!!恢复 1.javascript简介 *是基于对象和时间的驱动语言,应用于客户端. -----基于对象: * ...

  6. 【工具篇】接口测试神器 -- Postman 入门教程

    一.Postman概述 (1)工具介绍 Postman是一个接口测试工具,一款非常流行的API调试工具.在做接口测试的时候,Postman相当于一个客户端,它可以模拟用户发起的各类HTTP请求,将请求 ...

  7. 『OGG 03』Win7 配置 Oracle GoldenGate 一次性成功(包括Adapter Java)

    安装Oracle: 安装 Oracle_11g 32位[Oracle 32位的话,OGG 也必须是 32位,否则会有0xc000007b无法正常启动 错误] 安装目录为 D:\oracle\produ ...

  8. C# 填充Excel图表、图例背景色

    填充背景色,一般可以选择多种不同样式来填充背景,包括填充为纯色背景.渐变背景.图片背景或者纹理背景等.下面的内容将分别介绍通过C#来设置Excel中图表背景色.以及图表中的图例背景色的方法. 使用工具 ...

  9. Mybatis插入数据返回主键ID

    <insert id="add" parameterType="com.dsa.core.base.model.ProductSync">      ...

  10. 文本输入框input text输入字母自动转大写

    现在需要把一个input输入框内的字母自动转变为大写. 查了下资料,目前收集到的方法有两种: 使用JavaScript,在input标签添加onkeyup方法,将字符转为大写. <input n ...