项目地址:https://github.com/stricklandYe/TinyHttpServer
本文概述一下如何来实现一个简单的HTTP服务器,没有实现其他的功能。如session,cookie,缓存,请求转发等等。主要是学习到浏览器中HTTP的基本格式,如何正确地解析HTTP报文。此外,借鉴了一些HttpServelet中的接口定义。还实用了@webServlet注解来模仿HttpServlet中URL对Servlet的映射。并且以此使用反射来动态的加载servlert。本文的目的是,对项目代码的做一个简要的概述,介绍一下我的实现思路和对HTTP报文的最基本的解释。
HTTP Request
HTTP是应用层协议,其底层是TCP协议。两台机器之间经过TCP握手之后,就可以开始发送HTTP报文了。首先先介绍下HTTP request,下面是一段我用wireshark抓包得到的HTTP request。
Reuqest Line:
HTTP报文的第一行叫作请求行。其中包含着HTTP的请求类型,是POST还是GET,还是说其他的PUT,DELET等等。接着跟着的是,请求的路径,表示要在服务器中访问的路径(这个路径并不代表着实际在服务器中的路径,取决于服务器是怎么来实现的,在后面代码中会说到这点)。最后跟着的是HTTP的版本信息,这里是使用的是HTTP1.1。在最后跟着的是\r\n。
PS:
\r和\n分别代表意思是回车和换行。之所以叫回车,是因为以前的打字机,图片来自wikipedia-typewirter:
这种东西如果要回到一行的行首,就需要按下一些里面那个负责打字的东西拨到行首,这个叫回车。然后切换到下一行,就叫做换行。所以一般来说,如果我们需要表示一行结束,就需要以回车和换行来实现。因此\r\n就表示新开一行。不过很多操作系统中\n就直接表示新起一行了,我也没具体深究。
Difference between \n and \r?---stackoverflow
Request Headers:
在请求行之后的都是请求头,它们都是以header-name:<空格>header-data
的形式出现。请求头的信息主要是为服务器提供了额外的信息,例如浏览器的商标名,浏览器是否希望保持keep-alive,浏览器可以接受的MIME类型等等。另外一个值得关注的就是Host字段,csapp中提到:
代理缓存(host proxy)会使用Host报头,这个代理缓存有时作为浏览器和管理被请求文件的原始服务器的中介。客户端和原始服务器之间,可以有多个代理,即所谓的代理连。Host报头中的数据指示了原始服务器的域名,使得代理连中的代理能判断它是否有在本地缓存中有被请求内容的副本。
代理服务器,最有名的应该就是Nginx服务器了。
Request Body:
如果没有任何数据了,最后一个\r\n就表示http请求就结束了,如果还有数据的话,\r\n后面跟着的就是报文数据。在post请求中,紧跟在\r\n之后的就是一个报文的数据了。对于这种情况(即有数据的http请求),在header中有一个字段来表示是否所携带的数据的字节数。引用一段来自MDN的示例报文:
POST /test HTTP/1.1
Host: foo.example
Content-Type: application/x-www-form-urlencoded
Content-Length: 27
\r\n<--------注意,这里插入了一个\r\n
field1=value1&field2=value2
在对于multipart/form-data
稍微复杂一些,就留到后面再说吧。
Http Response
Http Response和HttpRequest非常相似。下面也是用wireshark抓包的一个http报文:
Response Line:
第一行的内容叫作请求行,它表示了响应的http版本信息,以及状态码,和对应的状态信息。下面列举一些最常见的http状态码。对于其他的状态码,MDN有着十分详细的解释:
常用状态码:
- 200: ok,所发送的报文已经被正确的处理
- 401:Unauthorized,所发送的请求不能够访问目标资源
- 404:not found,找不到所要访问的资源
- 500:Internal Server Error,表示服务器内部遇到了一些错误
- 400:Bad Request,服务器无法处理请求,因为一些原因,比如说参数错误之类的
- 505:不支持该版本的http协议
Respoonse Headers:
后面的内容都叫做响应头,在上面的内容中,返回的有keep-alive表示服务器可以保持长久的链接,更加高效。last-modified是和缓存下相关的。当用户第一次请求某个URL对应的资源时,返回的报文中放着last-modified字段来表明上一次文件被修改或者被创建的时间。下次一次再去请求这个资源的时候,就发送一条带有if-modified-since,然后把之前得由返回报文中附带着的last-modified得到的时间放在报文中发出去,让服务器来判断是否要发送数据。如果没有更新的话,只要返回一条空的相应报文即可,状态码为304。这样就减少了网络中数据的传输。但是这仍然需要和目标服务器建立一次链接过程。
PS:既然说到了缓存,就再多讲一点,权当加深记忆吧
关于缓存的一些扯淡。虽然last-modified已经避免了重复数据的传输,但是还是由一条http报文的传输用于确认是否数据被更新过了。于是又引入了Expries字段,服务器向浏览器说明,再某时间点之前,我都不会修改数据,你直接从自己的缓存读吧。比如说上面的HTTP报文截图中,就说明了在2021.5.02之前都可以从缓存读。
但是呢,Expires有一个缺陷就是需要保证服务器和浏览器之间的时间是同步的。假设我们的浏览器时间比服务器慢了8个小时,如果服务器在8点的时候返回一条图片的报文,并且expires设置了早上9点,有效期为一个小时。然后浏览器此时的时间才0点,它收到这条报文以后,就会误认为有效期为9个小时。这样以来,到一个小时之后,还是从缓存中读取,此时服务器那边可能已经将图片更新了。显然是不好的。
因此,在HTTP1.1中又引入了cache-control来避免这个问题。cache-control通过设置max-age来设置缓存的有效时间。比如
cache-control: max-age=3600,表明缓存的有效时间是3600秒(一个小时)。所以就避免了expires的问题。在现实操作中,可以将expires和cache-control搭配使用。我想在之后对于代码的解释的时候再来解释一下这个问题。
https://www.mnot.net/cache_docs/#EXPIRES
Response Body:
在\r\n之后跟着的是响应体,主要是返回的数据,比如说html页面的内容。在本例当中,返回了69898字节的数据(看content-length字段 数据)。
代码的解释
代码的层次结构比较简单,在这里只是简要的介绍,每个类是干嘛的具体可以查看代码注释,如下:
- TinyWebserver.Server:用于启动server,使用线程池来并发地处理http请求
- TinyWebserver.servlet:这个包的内部是用于定义servlet接口的,并且任何http servlet请求都将先会被转发到
HttpServlet
类中的service()方法,再由它转发到对应servlet的doPost()和doGet()方法 - myWebapp:该路径下是所有用户自定义的servlet
对于http报文的读取:
因为http报文都是以\r\n来表明每一行的结束的,所以在最开始,我想以BufferedReader的readline()来读取。即如下:
BufferedReader reader = new BufferedReader(socket.getInputStream());
String line;
while((line = reader.readLine())!= null){
...
}
但是会陷入阻塞,代码无法进行下去。这是因为tcp链接从未断开,所以reader不会达到EOF,所以合适的方法应该是先读取一部分的报文,再去判断它是否有content-type字段,如果有接着再去读取剩下的内容。如下:
byte[] buf = new byte[BUFFER_SIZE];
int count = 0;
try {
count = input.read(buf);
if (count < 0) {
//当没有数据可读的时候,count会返回-1
System.out.println("Read HTTP request error");
return;
}
} catch (IOException e) {
response.sendError(HttpServletResponse.HTTP_SERVER_ERROR,"Server Error",e.getMessage());
}
判断哪里是Request Body:
如果报文附有Request Body,我们首先需要找到哪一个字节开始是request body.回归一下报文的结构,可以看到连续的\r\n\r\n就表示后面的数据都是request body,request body的长度定义在字段content-length
当中。所以getPostRequestBody()
函数中的:
//count:第一次读取的时候,所读取的字节数
while (bodyIndex < count) {
//request body和request headers之间的界限是\r\n\r\n
if (buf[bodyIndex] == '\r' && buf[++bodyIndex] == '\n'
&& buf[++bodyIndex] == '\r'&& buf[++bodyIndex] == '\n') {
break;
}
bodyIndex++;
}
就是用于寻找request body的索引地址,将下标放在bodyIndex中。接下来,我们要判断,bodyIndex和content-length是否超过了首次读取的字节数.否则的话,就说明第一次的读取就已经将表单数据都读进来了,不需要再继续读取了。所以直接将数据复制到bodyBuf
中即可,否则的话,继续读取(图片数据基本都需要这个),直到将content-length bytes数据都读取。
处理表单数据:
对于没有附带文件的post请求,结构非常简单。引用自MDN-post:
POST /test HTTP/1.1
Host: foo.example
Content-Type: application/x-www-form-urlencoded
Content-Length: 27 \r\n
\r\n
field1=value1&field2=value2
注意上面,我加上了两个连续的\r\n,在结合上面的对于request body的解释应该可以理解。对于这类表单数据的处理非常简单,只要以&分割,然后放入到map即可。
较为复杂的是,对于带有文件的表单。即multipart/form-data
,这种表单的结构如下:
POST /test HTTP/1.1
Host: foo.example
Content-Type: multipart/form-data;boundary="boundary"
--boundary //起始boundary
Content-Disposition: form-data; name="field1"
value1
--boundary
Content-Disposition: form-data; name="field2"; filename="example.txt"
Content-type: text/plain \r\n//这一行在mdn文档中是没有的,但是实际的报文中是有的
\r\n //以四个连续的\r\n\r\n来区分数据
value2
--boundary-- //结束boundary
可以看到,表单数据的每一项以boundary来分割。boundary在request报文中已经给出。对于每个起始的boundary,要在前面加上--,而对于结束的boundary,则在前后都加上--。
因为\n可能也是文件中的数据,比如说二进制文件中。所以不能用readline来读取文件中数据,所以我先首先使用getBoundaryIndexes()
来读取到每个boundary在报文中的索引位置。然后,因为文件数据和其他内容之间又是以\r\n\r\n来区分的,所以使用getPostEntryDataOffset()
来找到文件的起始索引位置。然后以下一个boundary的起始索引作为终点,这两者之间的就都是文件数据。最后,在使用saveTempFile将数据保存为临时文件。
还有一个问题就是如何判断表项是否为文件数据,String type = getPostContentType(reader.readLine());
来读取Content-type: text/plain
判断是否为文件数据,因为如果不是文件表项,这里读到的数据应该是空的。
响应request:
对于get请求来说,主要有响应文本数据和图片数据。对于文本数据来说,可以使用PrintWriter来操作,不过对于二进制数据来说只能以socket.getOutputStream()来操作了,因为字符流的操作对象都是字符(characters),所以这里分开处理。
因为我想在servlet中使用writer来向浏览器返回数据,如果想在servlet中写回数据,那么就无法知道写回的数据的content-length是多少。以我们要引入一个新的类,对于这些数据的写入都先是写入到ResponseWriter
的buffer。同时在ResponseWriter
中定义一个count来统计写入多少字节的数据。最后在HttpResponse
中调用assembleResponse()
函数来装配好响应的Http
报文头。
同时重写flush和write的方法,使得用户对于flush的调用是无效的。因为我们要禁止用户来flush,所以重写了flush方法,不过flush函数内部的实现是空的,用户调用flush()将不会产生任何影响。Server调用realFlush()
来将数据发送到浏览器。
servlet到URL的映射:
我将所有的servlet都放在myWebapp
目录下,每一个servlet都用@webServlet注解来修饰。在server开始的阶段扫描(使用scanServlet()
函数)该目录下所有的类,并且使用反射来获得serlvet到URL的映射。在这里,要将.java文件替换为他的类名,所以就使用了字符串拼接来实现这一点。最后将映射信息放到servletMap当中,以URL为key,以servlet作为value。
报文中的缓存:
注意,我没有对任何数据做缓存处理,这里只是对HTTP报文中和缓存相关字段的一些解释。
在assembleResponse()
对于静态资源(图片),返回的报文中设置了浏览器可以缓存它一天。代码如下:
.append(String.format("Expires: %s\r\n",tomorrow))
.append(String.format("Cache-control: max-age=%d\r\n",STATIC_EXPIRES))
所以对于某一图片的读取的时候,总是从缓存中读取。可以实验一下,当图片缓存过后,使用控制台工具(F12)查看文件的读取,应该可以看到对于图片的读取,首次是从硬盘读取(disk cache),接下来如果刷新,那么是从(memory cache)。如果此时,在服务器这端将原来的图片改为其他图片,保持文件名不变,重新刷新网页的时候,会发现图片没有改变。
下面是从硬盘缓存读取图片的截图:
parse()中的小问题
在服务器运行一段时间后,HttpParser
中的parse
函数中的read()
会返回-1,表明已经没有更多的数据需要读取了,我没有深究此时浏览器的行为是怎么样的,我暂且认为浏览器关闭了tcp链接。所以此时,会导致在Request
对象并没有被正确的初始化,所以ServerHandler
中的response.responseToClient()
会引发NullPointerException
,所以此时只要关闭tcp链接,并且直接结束当前线程就好。我一开始想用System.exit(1)
来退出,看了下文档,该语句会导致整个jvm退出.所以采用直接return的方式来退出线程。