Tomcat 对 HTTP 协议的实现(上)
协议,直白的说就是存在一堆字节,按照协议指定的规则解析就能得出这堆字节的意义。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 协议的实现(上)的更多相关文章
- Tomcat 对 HTTP 协议的实现(下)
在<Tomcat 对 HTTP 协议的实现(上)>一文中,对请求的解析进行了分析,接下来对 Tomcat 生成响应的设计和实现继续分析.本文首发于(微信公众号:顿悟源码) 一般 Servl ...
- tomcat重启或关闭后,上传文件消失 .
tomcat重启或关闭后,上传文件消失的问题,是因为在断电前myeclipse是启动的,断电时造成myeclipse异常关闭,再重新启动myeclipse时会重新发布项目,把先前发布的项目给覆盖了,所 ...
- HTTP协议一次上传多个文件的方法
如何通过HTTP协议一次上传多个文件呢?在这里有两个思路,是同一个方法的两种实现.具体程序还需自己去设计 1. 在form中设置多个文件输入框,用数组命名他们的名字,如下: < form act ...
- 用c++开发基于tcp协议的文件上传功能
用c++开发基于tcp协议的文件上传功能 2005我正在一家游戏公司做程序员,当时一直在看<Windows网络编程> 这本书,把里面提到的每种IO模型都试了一次,强烈推荐学习网络编程的同学 ...
- TCP协议详解---上
TCP头格式 注意以下几点: TCP的包是没有IP地址的,那是IP层上的事.但是有源端口和目标端口. 一个TCP连接需要四个元组来表示是同一个连接(src_ip, src_port, dst_ip, ...
- tomcat配置https协议
tomcat配置https协议 1.找到本地jdk底下的bin目录,bin目录底下存在文件keytool.exe(在bin目录下空白处,按住shift右击,打开命令窗口,如下图) 2.在cmd的命令窗 ...
- 配置Tomcat使用https协议(配置SSL协议)
配置Tomcat使用https协议(配置SSL协议) 2014-01-20 16:38 58915人阅读 评论(3) 收藏 举报 转载地址:http://ln-ydc.iteye.com/blog/1 ...
- Tomcat配置https协议访问
Tomcat9配置https协议访问: https://blog.csdn.net/weixin_42273374/article/details/81010203 配置Tomcat使用https协议 ...
- tomcat从manager部署war项目上传失败
tomcat从manager部署war项目上传失败, 查看manager.2018-07-17.log 日志,可以看到如下信息. less manager.2018-07-17.log 17-Jul- ...
- 我们检测到您的浏览器不兼容传输层安全协议 (TLS) 1.1 或更高版本,此协议在 Internet 上用于确保您通信的安全性。
早上使用.Net WebClient类采集亚马逊数据,返回http 400 Bad Request错误,内容里面有“我们检测到您的浏览器不兼容传输层安全协议 (TLS) 1.1 或更高版本,此协议在 ...
随机推荐
- jquery简易版xwPop.js弹出消息对话框插件
xwPop.js弹出消息对话框插件是一款含有多种情景模式的原生模态消息对话框代码,可用于替代浏览器默认的alert弹出对话框,它提供各种参数和方法,功能非常强大.目前已经在项目中有应用到xwpop开发 ...
- 文本分布式表示(二):用tensorflow和word2vec训练词向量
看了几天word2vec的理论,终于是懂了一些.理论部分我推荐以下几篇教程,有博客也有视频: 1.<word2vec中的数学原理>:http://www.cnblogs.com/pegho ...
- WebP 在减少图片体积和流量上的效果如何?MIP技术实践分享
作者 | Jackson 编辑 | 尾尾 不论是 PC 还是移动端,图片一直占据着页面流量的大头,在图片的大小和质量之间如何权衡,成为了长期困扰开发者们的问题.而 WebP 技术的出现,为解决该问题提 ...
- JAVA使用POI获取Excel的列数与行数
Apache POI 是用Java编写的免费开源的跨平台的 Java API,Apache POI提供API给Java程式对Microsoft Office格式档案读和写的功能. 下面这篇文章给大家介 ...
- Java语言编程 - Java第一个程序HelloWorld
3.1 新建Java文件 首先新建一个文件夹,用于存放写的Java程序,例如我存放Java程序的位置为” D:\Files\code\java”. 在该文件夹中,右键新建一个文本文档 将文件名重命名为 ...
- SqlServer 将纯数字的时间转换为DateTime
由于数据库存的是整个字符串组到一起了,C#代码是这个样子的. public static string time(DateTime dt) { ) ? ) ? ) ? ) ? ) ? " + ...
- 聊一聊C# 8.0中的await foreach
AsyncStreamsInCShaper8.0 很开心今天能与大家一起聊聊C# 8.0中的新特性-Async Streams,一般人通常看到这个词表情是这样. 简单说,其实就是C# 8.0中支持aw ...
- Unity 用ml-agents机器学习造个游戏AI吧(1)(Windows环境配置)
前言:以前觉得机器学习要应用于游戏AI,还远得很. 最近看到一些资料后,突发兴致试着玩了玩Unity机器学习,才发觉机器学习占领游戏AI的可能性和趋势. Unity训练可爱柯基犬Puppo 机器学习训 ...
- Android Studio Run项目出现Failure [INSTALL_FAILED_TEST_ONLY]
同名掘金博文:https://juejin.im/post/5c2e0c496fb9a049a711f09a 运行环境: AS 版 本:Android Studio 3.2.1 手机型号:vivo Y ...
- 小程序 wepy框架 + iview-weapp的用法
最近在弄wepy的时候在想有没有什么ui比较合适一点的wepy的,也是在网上看了好久发现iview还不错.引用简单,上手超快,组件绚丽!当然,这里还介绍下微信官方建议的框架也是和不错的,有需要的可以看 ...