计算机网络-期末大作业
题目
1.1
在这个编程作业中,你将用Java语言开发一个简单的Web服务器,它仅能处理一个请求,具体而言,你的Web服务器将:
- 当一个客户(浏览器)联系时创建一个连接套接字;
- 从这个连接接收HTTP请求
- 解释该请求以确定所请求的特定文件
- 从服务器的文件系统获得请求的文件
- 创建一个由请求的文件组成的HTTP响应报文,报文前面有首部行
- 经TCP连接向请求的浏览器返回响应
具体需求
- 请使用 ServerSocket 和 Socket 进行代码实现
- 请使用多线程接管连接
- 在浏览器中输入
localhost:8081/index.html
能显示出自己的学号 - 在浏览器中输入
localhost:8081
下其他无效路径显示404not found - 在浏览器中输入
localhost:8081/shutdown
能使服务器关闭 - 使用postman再次进行测试,测试
get/post
两种请求方法
1.2
在这个编程作业中,你将用java语言研发一个简单的Web代理服务器
- 当你的代理服务器从一个浏览器接收到对某个对象的HTTP请求,他生成对相同对象的一个新HTTP请求并向初始服务器发送
- 当该代理从初始服务器接收到具有该对象的HTTP响应时,它生成一个包括该对象的新HTTP 响应,并发送给该客户
- 这个代理将是多线程的,使其在同一时间能够处理多个请求
具体需求
- 在题目1.1 的代码上进行修改,使用ServerSocket和 Socket 进行代码实现
- 请使多线程接管连接(最好使用线程池)
- 请分别使用浏览器和postman 进行代理的测试
功能和性能需求
- 之后会给大家一个压测的client端进行测试,在保证功能完整的前提下测试每秒相应的请求数
- 附加题(选做) : 分析现有的 能支持同时连接的最大数,修改代码使得服务器能同时支持一千个连接(需要使用NIO)
开发历程
准备工作
(反)序列化
要求我们开发一个Java后端,我这里使用了Maven,方便后期对项目进行管理和维护。因此,引入(反)序列化对项目的配置(环境变量、端口号等)进行管理是必不可少的。那么在这个项目中,我们用JSON 来存放关于项目的配置信息,并通过 Jackson 将JSON 中的键值对序列化后变成Java对象。
那么首先就要用maven引入 Jakson包,因此我们需要在 pom.xml
中添加依赖:
1 | <!-- Dependencies --> |
注意了,我们这里需要在idea的设置中对两个地方进行修改:
第一个在maven设置中三个都要勾选,可以自动下载依赖。
在第二个在maven>import 中,要勾选这两个Override,将maven库改为本地的仓库。 否则在引入Jackson是会出现找不到包的情况。
进入到正式的序列化
首先在项目文件夹中新建一个 util
文件夹来存放 Json
类,这个类会的作用能让 JSON中的键值对和java对象之间相互转换。
这里我要讲几个预备知识:
ObjectMapper
类是Jackson库的主要类,它提供一些功能将数据集或对象转换的实现。- 将 JSON 转换为Java 对象,称为反序列化。但是考虑到 JSON中的 key值和对象中的成员名可能不完全一致,因此需要在
configure()
中将DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES
设置为false。 - JsonNode类是Jackson库的一个类,该类可以很容易的操作Json格式的数据。如获取某个简单json串中某个key的值、获取某个层层嵌套的json串中某个key的值
1 | package com.httpserver.util; |
接下来我们就要写将JSON字符串转化成哪个Java类了。
我们创建一个 Configuration
类,用来存放 json 中的键值对信息:
因为当前有两个键值对: port
和 webroot
,因此我们要为这两个变量设置getter和setter
1 | package com.httpserver.config; |
但是单单有这个 Configuration
类,还是不够的,我们还需要一个经纪人,负责读取JSON文件,并将其注入Configuration
对象中. 我们将其命名为 ConfigurationManager
类:
1 | package com.httpserver.config; |
注意到我上面在处理异常的时候用到的是HttpConfigurationException
这是一个我自定义的异常类,其继承自IOException
测试
我现在在resource
文件加下编写 http.json
文件
1 | { |
然后创建 HttpServer类,作为启动器,并在 main方法中写:
1 | public static void main(String[] args) throws IOException { |
其中,JsonNode 打印结果如下:
{"port":8080,"webroot":"/java"}
整个打印结果如下:
日志插件
在后端测试中,在终端使用日志是十分重要的。虽然可以使用System.out
但是在多线程情况下,输出日志能让我们的程序显得更加严谨并让我们得到更多信息
首先还是在 pom.xml
中添加依赖:
1 | <!--LOGGING--> |
等待自动下载完成后,就可以对整个类使用Logger了:
1 | private final static Logger LOGGER = (Logger) LoggerFactory.getLogger(HttpServer.class); |
测试
对于一个项目来说,后期的测试是必不可少的因此我们使用测试工具 junit
首先在 pom.xml
中添加依赖
1 | <dependency> |
然后只要对特定的类建立测试类即可
解决单个client 连接
现在我们来模拟单个链接时的情况,功能十分简陋,远不是最终成型的样子。
1 | //... |
首先,在地址栏搜索 localhost:8080
之后,会弹回一个html,如下图所示:
通过Wireshark抓包后,我们可以看出浏览器和我写的Java server中是存在tcp通讯的。当浏览器请求8080端口的时候,server就会发送一个tcp包
其中,响应报文如下,包括 HTTP版本,状态码以及数据内容,数据内容是一个html字符串。也就是我们 response的内容
1 | Hypertext Transfer Protocol |
解决多个client 连接
显然,刚才的实现方法是不理想的,因为只能连接一个client,且代码很乱。因此我们现在要优化刚才的代码,能让多个client连接. 这就需要用到多线程。
那么首先我们先用LOGGER来替换掉System.out
1 | public class HttpServer { |
然后我们架构一下接下来的操作:既然要多线程,那么我们必须保持socket 在一段时间内始终保持打开状态。然后对于每一个接入的client,可以新开一个线程。因此我们可以采用父子线程的方式来实现这个功能。父线程负责维持socket打开并接入client,子线程可以负责处理client的请求。
于是我们新建一个父线程类叫 ListenerThread.java
1 |
|
然后创建一个子线程类: WorkerThread.java
, 子线程做的事情就是处理client发来的http请求,大量代码是复制的。不过因为父进程创建子进程时需要将socket传入,所以要创建一个参数为socket的构造函数
1 | package com.httpserver.core; |
运行后如下图所示,我们开启多个tag,同时访问 localhost:8080
发现使用了这种方式以后,后端会自动为连进来的client创建不同的进程
解析请求
刚才我们所做的,是非常简单的功能,也就是收到请求,不管请求什么,都返回相同的东西。因此不论我请求 localhost:8080
还是localhost:8080/index
只要是在这个端口发起的请求,都会收到 My ID is 1019550123的返回结果。因此现在我们需要解析client发来的请求。
要解析请求,首先我们要捕获 client发来的HTTP头部信息,我们可以这样来写:
1 | int _byte; |
现在,我在浏览器中请求 localhost:8080/index
, 结果如下:
在请求头中包含了这是一个 GET 请求,路由是 /index
整个HTTP请求的格式如下所示
1 | HTTP-message = start-line // 在请求时的格式是:method SP request-target SP HTTP-version CRLF |
现在我们可以进行解析了。我们来介绍两种解析器: Lexer Parser 和 Lexerless Parser。 前者会把 socket发来的流先处理成token,再讲token转为我们想要的信息(请求方式、路由等)。后者则是直接转换。
这里我们使用后者,因为我们对时间的要求较高,需要对请求进行迅速解析。
搞清楚 HTTP 请求的格式之后,我们知道最重要的部分就是请求的方式以及请求的目标文件。这两者决定了Server怎么去响应请求。因此我们需要创建几个比较基础的类:HttpMethod
和HttpStatusCode
是两个枚举类,分别用来放请求方法和状态码。HttpRequest
用来存放请求行的信息(请求方式、目标文件以及HTTP版本)
HttpMethod
这是一个枚举类,用来存放请求方式
1 | package com.http.request; |
HttpStatusCode
这个类用来存放状态码,我这里列举了几个错误时发生的状态码。
1 | package com.http.request; |
HttpRequest
这个类用来存放请求行的信息(请求方式、目标文件以及HTTP版本)
1 | package com.http.request; |
HttpParser
这是解析器最重要的部分了,起作用相当一座桥梁,能够把 Byte转换为字符串。事实上需要对三个部分进行解析: 请求行、头部以及请求体。但是根据这个项目的要求,我们只要来解析请求行即可。
1 | //... |
ResponseBuilder
解析完头部信息之后,我们就需要根据请求的结果编写响应报文了
1 | package com.http.response; |
子线程的部分修改
因为我们把响应报文的编写交给 ResponseBuilder了,因此我们这里需要调用ResponseBuilder并传入状态码和socket流
1 |
|
最终结果:输入localhost:8080/index.html
之后,显示如下
输入localhost:8080/222
(其他) ,显示如下
输入localhost:8080/shutdown
服务器关闭:
Postman 测试结果如下:
我们发现,postman测试的结果都通过了,且用GET和POST方法也没有出现错误。
性能测试
现在我们用 Jmeter 来测试一下这个 Java Server:
在1000线程并发时,发现这个server能毫无压力得处理。
最终,在测到4900个并发线程的时候,出现了请求失败的情况。对此我又多测试了几次,最终测得这个server的极限压力大概是5000个线程左右
我们现在尝试优化这个server,采用线程池的方法:
1 | ThreadPoolExecutor executor = new ThreadPoolExecutor(1000, 10000, 400, TimeUnit.MILLISECONDS, |
这几个参数分别是:
- corePoolSize:核心池的大小
- maximumPoolSize:线程池最大线程数,这个参数也是一个非常重要的参数,它表示在线程池中最多能创建多少个线程;
- keepAliveTime:表示线程没有任务执行时最多保持多久时间会终止。
- unit:参数keepAliveTime的时间单位
- workQueue:一个阻塞队列,用来存储等待执行的任务
如果当前线程池中的线程数目小于corePoolSize,则每来一个任务,就会创建一个线程去执行这个任务;
如果当前线程池中的线程数目>=corePoolSize,则每来一个任务,会尝试将其添加到任务缓存队列当中,若添加成功,则该任务会等待空闲线程将其取出去执行;若添加失败(一般来说是任务缓存队列已满),则会尝试创建新的线程去执行这个任务;
如果当前线程池中的线程数目达到maximumPoolSize,则会采取任务拒绝策略进行处理;
如果线程池中的线程数量大于 corePoolSize时,如果某线程空闲时间超过keepAliveTime,线程将被终止,直至线程池中的线程数目不大于corePoolSize;如果允许为核心池中的线程设置存活时间,那么核心池中的线程空闲时间超过keepAliveTime,线程也会被终止。
我给核心池开了1000个线程,(事实上根本用不了那么多),在这之后,我用8000个并发请求也能轻松处理。一个错误都没有
1.2 代理服务器的编写
“不正确”的代理服务器
代理服务器相当于一个中转站,需要解析请求报文和响应报文并生成新的报文。一开始我还以为是proxy和server都要自己写,结果改了半天最终成功了才发现其实只需要写proxy,要代理浏览器访问的页面。但是失误已经酿成了,因此我把这个失误的版本也贴上来。
流程如下
- 在8081端口, proxy作为server,收到了来自 client 的请求,开一个线程解析request、生成新的request。
- proxy作为client,利用socket.connect,向端口(8080)传输新的request
- 端口8080 收到以后,解析request,然后生成response。
- server 构造完后向端口8081传输response
- Proxy在端口8081收到response,然后生成新的response,并向浏览器发送。
整个流程都由一个线程完成。因此我们还是采用父子线程的架构模式。整个 workThread
如下图所示:
1 | //... |
最终结果如下图所示,用postman和浏览器分别测试:
“正经”代理服务器
1 | //... |
LineBuffer类
这个类设置了一个可以自动扩容的缓冲区,用来读client请求.
1 | package com.httpserver.util; |
ProxyWorkerThread类
这个类负责将客户端的请求转发到目标服务器
1 | public class ProxyWorkerThread extends Thread { |
在使用代理服务器上网之前,首先需要设置chrome的代理配置。将端口改为代理服务器监听的8080端口
结果如下图所示
如果访问https,也是同样的操作