HearyHTTPd - 写一个自己的HTTP服务器

自己研究用Java写一个HTTP服务器。一看JHTTPd,EasyHTTPd这些名字就用过了,那就叫HearyHTTPd吧,简称hhttpd。构想是做一个基于Reactor的多线程高并发HTTP服务器。

HearyHTTPd - 写一个自己的HTTP服务器

先写下开发日记,记录每天的进展和心得。

1 开源地址

GitHub:HearyShen / HearyHTTPd

2 开发日记

Day 1, 2020.6.15, Mon

先从最简单的例子看起,廖雪峰写的的Java教程 Web基础 中的最基本的HTTP代码跑起来看看效果。这个例子的原理是:用ServerSocket监听端口,主线程阻塞直到接受(accept)到请求,接收到请求后,启动一个线程来处理这个Socket对象,读取输入流以解析请求,写入输出流以进行响应。

我进行了改进,实现了MainReactor和SubReactor的模式。

  • MainReactor负责监听和接受请求,只负责将Socket对象加入(put)到BlockingQueue中;
  • SubReactor负责处理请求,做出响应,负责从BlockingQueue中取出(take)请求的Socket对象,读取输入流进行解析,写入输出流进行响应。

设计一个Launcher来初始化数据结构,启动MainReactor和SubReactor线程。

第一天只实现了一个MainReactor线程和一个SubReactor线程,处理能力也只是响应"Hello, World."。初步把基本的脚手架搭起来,跑通了。

Day 2, 2020.6.16, Tue

第二天想到我写的Hexo博客就是一个静态网站,完全可以用自己写的HTTP服务器来部署呀。

于是自己写了一个GET请求处理功能,能够根据请求的路径来读取WebRoot路径下的本地文件,响应给浏览器。

不过初步的效果只能返回HTML文件,也就是说文本型的CSS、Javascript文件,二进制型的字体文件、图像文件还无法正常返回。需要进一步处理Content-Type,并实现二进制文件的响应功能。

不巧的是今天遇到一些别的琐事,下午和晚上都被占用了,没来得及做下去。

Day 3, 2020.6.17, Wed

今天对模块分工进行了重新构思和命名,分为:

  • Reactor包
    • 包含MainReactor和SubReactor,是Reactor线程的实现;
  • Processor包
    • 包含GetProcessor等,提供对HTTP的GET方法进行整体处理的功能;
  • Resolver包
    • 包含RequestLineResolver、HeaderResolver,提供对报文请求行、请求头部的内容解析的功能;
  • Responser包
    • 包含TextResponser、BinaryResponser和NotFoundResponser。Processor对请求解析根据Content-Type进行分流,交给对应的Responser进行处理和响应。

今天整理好了常用的Content-Type,实现了BinaryResponser来处理二进制对象的请求,实现了多线程的SubReactor,并且改进了RequestLineResolver来提升对带参请求的兼容性。

接下来打算研究下NIO,提高IO效率。

Day 4, 2020.6.18, Thu

今天研究了下NIO,并且写了一份基于NIO的请求-响应双向Socket通信的验证代码。

笔记记录在:基于NIO的请求-响应双向Socket通信网络编程

接下来要基于NIO的ServerSocketChannel / SocketChannel以及Selector的IO复用技术对hhttpd进行改进。

初步构思基于NIO的hhttpd应该是一个MainReactor通过IO复用处理请求,将接收到的请求交给一个SubReactor,SubReactor通过IO复用处理可读请求,将其交给线程池进行处理(read/decode/compute/encode/response)。

简单例子是,比如说:client请求index.html,mainReactor select到这个acceptable的serverSocketChannel,就accept它建立一个socketChannel,register到subReactor的selector上,client的请求数据发到server后,subReactor就能select到readable&writable的socketChannel,于是将这个socketChannel交给threadPool去处理,由worker thread去read/decode/compute/encode/write。

Day 5, 2020.6.19, Fri

今天把项目进行了改写,全面基于Java的NIO。

层次架构可分为:

  • MainReactor (single thread)

  • SubReactor (single thread)

    • HttpWorker (Runnable in thread pool)
      • GetProcesser
        • TextResponser
        • BinaryResponser
        • NotFoundResponser
        • etc.
      • PostProcesser
      • etc.

一些注意点:

注册新SocketChannel后唤醒SubReactor:MainReactor将accept的SocketChannel注册到SubReactor上后,SubReactor线程阻塞在select()操作处,需要通过selector.wakeUp()来唤醒阻塞,让SubReactor重新select得到新resigter上去的socketChannel;

处理SocketChannel时要取消其在SubReactor的Selector上的注册以免重复:SubReactor在select得到Readable的SocketChannel后,应该调用selectionKey.cancel()来取消注册该socketChannel,否则该通道在worker thread处理完毕将其关闭之前,会一直保持Readable状态,以至于被SubReactor反复重复地select出来,并启动很多woker thread去处理同一个socketChannel,不仅严重耗费资源,而且第一个执行完毕的线程关闭该socketChannel后,后续的其他线程就无法再读取该socketChannel,由此还会出现IOException异常。

读写NIO的ByteBuffer时要注意offset和length:检查好文档中的offset指的是谁的offset,读写的length必须Math.min(buffer.remaining(), bytesLen),即必须是内容长度和buffer长度两者中的最小值。

但是,我发现改写后,在Chrome浏览器的开发者工具中进行了验证,发现网络流耗时反而更长了。用NIO之前,基于Socket和输入输出流的每个资源文件的耗时大约50ms,可是使用NIO后,反而耗时高达300ms。这个反常现象需要进一步排查。

Day 6, 2020.6.20, Sat

使用NIO后反而变慢了一个数量级,这个问题让人费解。

我怀疑是否是Selector机制太慢了?因此尝试了不含Selector的阻塞式ServerSocketChannel,但是没有改善效果。

我还尝试了修改唤醒SubReactor的时机,调整线程池的类型和参数、排查通信过程中建立的每一个连接……,但可惜的时都无济于事。

就在我一筹莫展的时候,我发现我是习惯性用 http://localhost:8080 访问的,我觉得会不会和localhost的解析有关,因此尝试使用 http://127.0.0.1:8080 访问,发现就一切正常了。也就是说,当hhttpd代码里设置为监听的host为127.0.0.1时,在Chrome中用 http://localhost:8080 会有较大initial connection lantency从而变得较慢,而用 http://127.0.0.1:8080 访问则很快。

事实上,当我做了更多的测试后,我发现和浏览器实现也有关。例如:不管监听或访问如何设置,Firefox浏览器总是很快。

详细排查结果记录在了:Chromium内核浏览器访问localhost时的初始连接(initial connection)高延迟问题

Day 7, 2020.6.21, Sun

周日,给自己放个假,懒散的一天。

Day 8, 2020.6.22, Mon

准备和参加了招银网络科技的笔试。

Day 9, 2020.6.23, Tue

先实现了HTTP HEAD方法的处理响应。

然后实现了HTTP GET/POST方法的CGI响应功能。

  • 原理是:通过Java的runtime执行本地程序,利用环境变量传入基于CGI协议的参数,执行本地程序(CGI程序)产生的Process实例的标准输入输出可以传递信息,取得CGI程序的输出结果,把这个结果作为响应报文即可。
  • 具体地,我写了一个很基本Python的本地CGI程序,输出一个包含HTML页面的报文,显示CGI程序得到的环境变量。
  • 实际上,Java的Runtime可以以执行命令的方式调用各种形式的程序,CGI程序不仅限于Python。

相关笔记记录在:在Java中执行Python等本地程序(命令)

另外,还改写了HTTP request line的解析方式,改为通过Java的URI类库进行处理,不再自行解析处理。