Tomcat 对 Nio 和 Http 协议的实现

本文详细描述了 Tomcat 中 NIO 处理和 HTTP 协议实现过程。关于 NIO 模型,已在 Tomcat 架构概述 一文中描述,并且也提供了一份 Reactor 模型的实现源码,这里着重描述 Tomcat 内部处理流程的封装。

协议是什么?直白的说就是给你一堆字节,按照协议指定的规则进行解析就能得出这堆字节的意义。HTTP 解析的难点在哪?它没有固定长度的头部,也不像其他协议那样提供数据包长度字段,判断是否读取到一个完整的头部的唯一依据就是遇到一个仅包括回车换行符的空行,好在在找寻这个空行的过程中能够完成请求行和头域的分析,此外还有对请求体编码和传输编码的处理。

HTTP 报文简述

根据 HTTP/1.1-RFC2616 规范,HTTP 消息分为两类:请求和响应,基本结构由消息头和消息体组成。消息头又有请求头和响应头之分,常用的头域种类有请求头域、响应头域以及实体头域,下面是一个简单的报文结构:

Tomcat 对 Nio 和 Http 协议的实现

请求

由客户端到服务器,表明请求的资源以及自身对于 HTTP 协议的处理能力,常用头域如下:

  • Accept:表明客户端可以接收的媒体类型;
  • Accept-Encoding:表明可接收的内容编码,主要是文档压缩;
  • Host:表明资源所在主机和端口号,默认端口号80;
  • User-Agent:发起请求的用户代理的信息,一般是浏览器信息。
  • Connection:keep-alive 保持连接,处理多个请求、close 完成响应后直接关闭;

响应

接收和解析一个请求后,服务器通过解析判断出客户端的能力,进而构造合适的响应并发送,常用头域如下:

  • Server:服务器处理请求的软件信息;
  • Date:响应产生的日期和时间;
  • Vary:用于判断是否是同一个请求,对于反向代理和缓存服务器很重要。

消息体

分为请求体和响应体,其中请求体一般都是 POST 请求参数,常用头域如下:

  • Content-Encoding:内容编码
  • Content-Length:实体大小;
  • Content-Type:发送实体(请求体)的媒体类型;
  • Last-Modified:资源最后修改时间;
  • Transfer-Encoding:传输编码。

一般的,对于静态资源如图片、css来说,它们大小固定,请求或响应体都是完整的发送,而对于动态内容来说,发送之前内容长度未知,无法提前发送 Content-Length,从而引入传输编码,将数据分块传输,它能够与内容编码混合使用。初始注册的编码有:

  • chunked:块传输编码;
  • identity:内容不进行编码;
  • gzip、compress、deflate:内容压缩编码,常用 gzip。

块传输编码报文格式,此格式摘自 http-chunked-gzip.pcap(此网站有各种协议格式的 pcap 包,有兴趣可下载研究),使用 gzip 压缩后采用分块传输:

Tomcat 对 Nio 和 Http 协议的实现

上图是一个简单的 chunked 响应,解析时遇到一个大小为 0 的块表示结束,其中每个块也以 \r\n 为结束标识符,另外值得注意的是 chunk-size 是以十六进制数字字符的形式存在,具体实现可查看我在 Tomcat 源码中对 chunked-filter 的注释(文末有源码注释链接)。

HTTP 另一个比较重要的点是 Cookies 的处理(服务端 Session),它解决了 HTTP 协议无状态的问题。

Tomcat 连接和 HTTP 处理

Tomcat 中的 Poller 也就是 Reactor,它与 Doug Lea 介绍的有区别,在于 Poller 只负责事件通知,不参与实际的 I/O,实际读取均由线程池中的线程来执行,其具体设计如下:

Tomcat 对 Nio 和 Http 协议的实现

按照箭头说明一个连接从开始到结束的具体处理流程,为了便于阅读,将其分为三个部分:请求接收和分发、请求解析和发送响应。

请求接收和分发

  1. Acceptor 接收到 SocketChannel,设成非阻塞,封装成 NioChannel,声明对 OP_READ 事件感兴趣,再封装成 PollerEvent 添加到 events 队列中,唤醒 Poller;
  2. Poller 拿到连接注册到 Selector 上,等待事件,此时发生 OP_READ,首先它会移除此通道在本 Selector 注册的已就绪的事件(移除原因是为了后面的阻塞读,具体可查看源码注释),然后交给线程池处理,分发完发生的事件,继续等待其他事件;
  3. Executor 分配线程,ssl 先完成握手,握手成功,调用 Handler 处理,失败就断开连接,握手过程会把通道的 OP_READ 或 OP_WRITE 重新注册到 Poller 上,握手过程是非阻塞的;
  4. 处理 Socket。

请求解析

  1. NioProcessor 开始处理 Socket,首先在 NioInputBuffer 中解析请求行和请求头域,解析完后会根据是否使用 chunked 编码进行激活相应的 InputFilter:
  • 解析的本质就是一个找空格、冒号和 \r\n 的过程,然后封装成 Request;
  • 如果解析过程中只读取到部分请求行或头域字节,会重新在 Poller 上声明 OP_READ 事件,这个过程是非阻塞的。
  1. 解析完毕,Adapter 首先将原始 Request 和 Response 封装成 ServletRequest和ServletResponse,然后通过 URI 找到对应的 Servlet 后交给容器开始处理,具体的处理过程,会在下一篇文章中描述;
  2. 上述并没有涉及到请求体的读取,因为它的解析被延迟到了 Servlet 获取请求参数的时候。当调用 ServletRequest.getParameter 时开始解析请求体,字节的读取是由 ServletInputStream 执行,它继承自 InputStream,根据 API 说明,InputStream 的 read 方法是阻塞的,所以这里的 NIO 要模拟阻塞读;
  3. 最终请求体的读取解析会由 NioInputBuffer 中激活的 InputFilter 来执行,常用的是 IdentityInputFilter,它根据 Content-Length 读取指定长度的字节,然后返回一个 ByteChunk 视图,对于 ChunkedInputFilter 的解释,可查看我在源码中的详细注释(文末有源码注释链接):
  • 如果请求体字节不完整,Filter 需要进一步读取,此时模拟阻塞读,我们知道,该通道虽然已经在 Poller 上注册,但是它感兴趣的事件已经被取消,此时使用 NioSelectorPool 提供的阻塞 read 进行读取;
  • NioSelectorPool 将该通道注册到 BlockingPoller 上,声明 OP_READ 事件,使用 CountDownLatch 阻塞当前线程池线程;当 BlockingPoller 发现有可读事件时,首先移除此通道已就绪的事件,然后 Latch 减1变为0,线程开始读取,以此循环直到读取整个请求体。
  1. 响应发送

发送响应

  1. 当容器处理完毕后,通过 ActionHook(commit or close)发送响应,首先生成响应头字节;
  2. 构造响应头域,设置并激活相应的 OutputFilter,主要是根据响应体以及客户端之前的请求,来判断是否能够压缩,能压缩则强制使用 chunked 传输;
  3. 构造响应行字节,拷贝到 NioOutputBuffer 中的字节数组中,然后循环将头域信息也拷贝到字节数组中,将这个字节数组内容放到实际发送的 ByteBuffer 中;
  4. 响应头部构造完毕,通过 Filter 构造响应体,然后把响应体的字节拷贝到发送使用的 ByteBuffer 中,最后通过 BlockPoller 以阻塞模式写入到通道;
  5. 模拟阻塞写的方式与阻塞读类似,可对照理解,这里不再赘述。

小结

可以看出,Tomcat 的 NIO 也不是完全非阻塞的,请求头的解析和 SSL 握手是非阻塞的,消息体的处理是阻塞的,这也是由于 ServletInputStream 和 ServletOutputStream 的读取是阻塞的决定的。一篇文章无法覆盖整个过程的细节,最终还是要到源码中探究一番。

有几个问题可以思考一下:

源码注释下载:Tomcat NIO 模型相关类源码详细注释(https://github.com/rmwheel/rw-tomcat/tree/master/tomcat-6.0.53/tomcat-coyote/src/main/java/org/apache/coyote/http11)


分享到:


相關文章: