计算机网络-期末大作业

计算机网络-期末大作业

题目

1.1

在这个编程作业中,你将用Java语言开发一个简单的Web服务器,它仅能处理一个请求,具体而言,你的Web服务器将:

  1. 当一个客户(浏览器)联系时创建一个连接套接字;
  2. 从这个连接接收HTTP请求
  3. 解释该请求以确定所请求的特定文件
  4. 从服务器的文件系统获得请求的文件
  5. 创建一个由请求的文件组成的HTTP响应报文,报文前面有首部行
  6. 经TCP连接向请求的浏览器返回响应

具体需求

  • 请使用 ServerSocket 和 Socket 进行代码实现
  • 请使用多线程接管连接
  • 在浏览器中输入localhost:8081/index.html 能显示出自己的学号
  • 在浏览器中输入 localhost:8081 下其他无效路径显示404not found
  • 在浏览器中输入 localhost:8081/shutdown 能使服务器关闭
  • 使用postman再次进行测试,测试 get/post两种请求方法

1.2

在这个编程作业中,你将用java语言研发一个简单的Web代理服务器

  1. 当你的代理服务器从一个浏览器接收到对某个对象的HTTP请求,他生成对相同对象的一个新HTTP请求并向初始服务器发送
  2. 当该代理从初始服务器接收到具有该对象的HTTP响应时,它生成一个包括该对象的新HTTP 响应,并发送给该客户
  3. 这个代理将是多线程的,使其在同一时间能够处理多个请求

具体需求

  • 在题目1.1 的代码上进行修改,使用ServerSocket和 Socket 进行代码实现
  • 请使多线程接管连接(最好使用线程池)
  • 请分别使用浏览器和postman 进行代理的测试

功能和性能需求

  • 之后会给大家一个压测的client端进行测试,在保证功能完整的前提下测试每秒相应的请求数
  • 附加题(选做) : 分析现有的 能支持同时连接的最大数,修改代码使得服务器能同时支持一千个连接(需要使用NIO)

开发历程

准备工作

(反)序列化

要求我们开发一个Java后端,我这里使用了Maven,方便后期对项目进行管理和维护。因此,引入(反)序列化对项目的配置(环境变量、端口号等)进行管理是必不可少的。那么在这个项目中,我们用JSON 来存放关于项目的配置信息,并通过 Jackson 将JSON 中的键值对序列化后变成Java对象。

那么首先就要用maven引入 Jakson包,因此我们需要在 pom.xml中添加依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- Dependencies -->
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.9.9</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.9.9</version>
</dependency>
</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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.httpserver.util;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.*;

import java.io.IOException;

public class Json {

private static ObjectMapper myObjectMapper = defaultObjectMapper();
private static ObjectMapper defaultObjectMapper(){
ObjectMapper om = new ObjectMapper();
om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,false);
return om;
}
// 这个parse函数的作用是收到 是一个json格式String,得到的是一个JsonNode类型
public static JsonNode parse(String jsonSrc) throws IOException {
return myObjectMapper.readTree(jsonSrc);
}
// 这个fromJson接受两个参数:JsonNode和目标类,目的是将JsonNode转换成自定义的java类
public static <A> A fromJson(JsonNode node,Class<A> clazz) throws JsonProcessingException {
return myObjectMapper.treeToValue(node,clazz);
}
// toJson 和 fromJson的作用相反,是将自定义的java类转换成JsonNode
public static JsonNode toJson(Object obj){
return myObjectMapper.valueToTree(obj);
}
//将JsonNode 序列化成一个 JSON 字符串
public static String stringify(JsonNode node) throws JsonProcessingException {
return generateJson(node,false);
}
//如果打开SerializationFeature.INDENT_OUTPUT这个特性开关,能以多行缩进格式化的格式输出JSON
public static String generateJson (Object o,boolean pretty) throws JsonProcessingException {
ObjectWriter objectWriter = myObjectMapper.writer();
if (pretty){
objectWriter = objectWriter.with(SerializationFeature.INDENT_OUTPUT);
}
return objectWriter.writeValueAsString(o);
}
}

接下来我们就要写将JSON字符串转化成哪个Java类了。

我们创建一个 Configuration类,用来存放 json 中的键值对信息:

因为当前有两个键值对: portwebroot ,因此我们要为这两个变量设置getter和setter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.httpserver.config;

public class Configuration {
private int port;
private String webroot;

public Configuration() {
}

public int getPort() {
return port;
}

public void setPort(int port) {
this.port = port;
}

public String getWebroot() {
return webroot;
}

public void setWebroot(String webroot) {
this.webroot = webroot;
}
}

但是单单有这个 Configuration 类,还是不够的,我们还需要一个经纪人,负责读取JSON文件,并将其注入Configuration对象中. 我们将其命名为 ConfigurationManager 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
package com.httpserver.config;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.httpserver.util.Json;

import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;

public class ConfigurationManager {

private static ConfigurationManager myConfigurationManager;
private static Configuration myCurrentConfiguration;
// Configuration的构造函数,这里将其私有化了
private ConfigurationManager(){}
// 暴露一个 getInstance()方法,返回一个实例
public static ConfigurationManager getInstance(){
if (myConfigurationManager ==null)
myConfigurationManager = new ConfigurationManager();
return myConfigurationManager;
}
/*
* Used to load a configuration file aby the path provided
* */
//这个方法是关键,也就是读取JSON文件,生成Configuration对象
public void loadConfigurationFile(String filePath){
FileReader fileReader = null;
// 创建一个 FileReader 并读取文件
try {
fileReader = new FileReader(filePath);
} catch (FileNotFoundException e) {
throw new HttpConfigurationException(e);
}
StringBuffer sb = new StringBuffer();
int i;
while(true){
try {
if (!((i=fileReader.read())!=-1)) break;
} catch (IOException e) {
throw new HttpConfigurationException(e);
}
sb.append((char)i);
}
// 创建一个JsonNode 用来存放 parse()返回的信息
JsonNode conf = null;
try {
conf = Json.parse(sb.toString());
} catch (IOException e) {
throw new HttpConfigurationException("Error parsing the configuration file",e);
}

try {
// 利用 fromJson 方法将JsonNode中的信息放入Configuration类中
myCurrentConfiguration = Json.fromJson(conf,Configuration.class);
} catch (JsonProcessingException e) {
throw new HttpConfigurationException("Error parsing the configuration file,internal",e);
}
}
/*
* Returns the current loaded Configuration
* */
// 将处理好的 Configuration 对象返回
public Configuration getCurrentConfiguration(){
if (myCurrentConfiguration == null){
throw new HttpConfigurationException("No Current Configuration Set.");
}return myCurrentConfiguration;
}
}

注意到我上面在处理异常的时候用到的是HttpConfigurationException 这是一个我自定义的异常类,其继承自IOException

测试

我现在在resource文件加下编写 http.json文件

1
2
3
4
{
"port": 8080,
"webroot": "/java"
}

然后创建 HttpServer类,作为启动器,并在 main方法中写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static void main(String[] args) throws IOException {
System.out.println("Server Starting...");
// 首先,利用经纪人将http.json注入内置的Configuration对象中
ConfigurationManager
.getInstance()
.loadConfigurationFile("src/main/resources/http.json");
// 然后利用getCurrentConfiguration() 来返回这个对象
Configuration conf = ConfigurationManager
.getInstance()
.getCurrentConfiguration();

System.out.println("Using Port: "+ conf.getPort());
System.out.println("Using WebRoot: "+ conf.getWebroot());

}catch(IOException e){
e.printStackTrace();
}

}

其中,JsonNode 打印结果如下:

{"port":8080,"webroot":"/java"}

整个打印结果如下:

日志插件

在后端测试中,在终端使用日志是十分重要的。虽然可以使用System.out 但是在多线程情况下,输出日志能让我们的程序显得更加严谨并让我们得到更多信息

首先还是在 pom.xml中添加依赖:

1
2
3
4
5
6
7
8
9
10
11
<!--LOGGING-->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.5.6</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
</dependency>

等待自动下载完成后,就可以对整个类使用Logger了:

1
private final static Logger LOGGER = (Logger) LoggerFactory.getLogger(HttpServer.class);

测试

对于一个项目来说,后期的测试是必不可少的因此我们使用测试工具 junit

首先在 pom.xml 中添加依赖

1
2
3
4
5
6
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.0.0-M5</version>
<scope>test</scope>
</dependency>

然后只要对特定的类建立测试类即可

解决单个client 连接

现在我们来模拟单个链接时的情况,功能十分简陋,远不是最终成型的样子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//...
public class HttpServer {
public static void main(String[] args) throws IOException {
System.out.println("Server Starting...");

ConfigurationManager.getInstance().loadConfigurationFile("src/main/resources/http.json");
Configuration conf = ConfigurationManager.getInstance().getCurrentConfiguration();

System.out.println("Using Port: "+ conf.getPort());
System.out.println("Using WebRoot: "+ conf.getWebroot());
try{
// 创建一个 ServerSocket
ServerSocket serverSocket = new ServerSocket(conf.getPort());
// 当接入client之后,创建套接字
Socket socket = serverSocket.accept();
// 创建输入输出流
InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream();

String html = "<html><head><title>Hello,My name is Jason</title></head><body><h1>My ID is 10195501423</h1></body></html>";

final String CRLF = "\n\r";
// 编写返回信息
String response =
"HTTP/1.1 200 OK"+CRLF+
"Content-Length: "+html.getBytes(StandardCharsets.UTF_8).length+CRLF+
CRLF+
html+
CRLF+CRLF;
outputStream.write(response.getBytes(StandardCharsets.UTF_8));
inputStream.close();
outputStream.close();
socket.close();
serverSocket.close();
}catch(IOException e){
e.printStackTrace();
}

}
}

首先,在地址栏搜索 localhost:8080之后,会弹回一个html,如下图所示:

通过Wireshark抓包后,我们可以看出浏览器和我写的Java server中是存在tcp通讯的。当浏览器请求8080端口的时候,server就会发送一个tcp包

其中,响应报文如下,包括 HTTP版本,状态码以及数据内容,数据内容是一个html字符串。也就是我们 response的内容

1
2
3
4
5
6
7
8
9
Hypertext Transfer Protocol
HTTP/1.1 200 OK\n
\r
[HTTP response 1/1]
[Time since request: 0.040561000 seconds]
[Request in frame: 328]
[Request URI: http://localhost:8080/]
File Data: 132 bytes
Data (132 bytes)

解决多个client 连接

显然,刚才的实现方法是不理想的,因为只能连接一个client,且代码很乱。因此我们现在要优化刚才的代码,能让多个client连接. 这就需要用到多线程。

那么首先我们先用LOGGER来替换掉System.out

1
2
3
4
5
6
7
public class HttpServer {
private final static Logger LOGGER = (Logger) LoggerFactory.getLogger(HttpServer.class);
public static void main(String[] args) throws IOException {
// 直接用 LOGGER.info 就能输出
LOGGER.info("Server Starting...");
//...
}

然后我们架构一下接下来的操作:既然要多线程,那么我们必须保持socket 在一段时间内始终保持打开状态。然后对于每一个接入的client,可以新开一个线程。因此我们可以采用父子线程的方式来实现这个功能。父线程负责维持socket打开并接入client,子线程可以负责处理client的请求。

于是我们新建一个父线程类叫 ListenerThread.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

public class ListenerThread extends Thread {

private final static Logger LOGGER = (Logger) LoggerFactory.getLogger(HttpServer.class);

private int port;
private String webroot;
private ServerSocket serverSocket;
// 从 HttpServer 中获取port和 webroot
public ListenerThread(int port, String webroot) throws IOException {
this.port = port;
this.webroot = webroot;
this.serverSocket = new ServerSocket(this.port);
}

//这个线程是一直等待的
@Override
public void run() {
try{
// 当 ServerSocket没关且被绑定在端口上的时候,这个线程就开着
while(serverSocket.isBound()&&!serverSocket.isClosed()) {
Socket socket = serverSocket.accept();
// 每当接入一个client在日志中记入,并开启一个子线程
LOGGER.info("Connection accepted: "+socket.getInetAddress());
WorkerThread workerThread = new WorkerThread(socket);
workerThread.start();
}
}catch(IOException e){
LOGGER.error("Problem with setting socket...",e);
}finally{
if (serverSocket!=null){
try{
serverSocket.close();
}catch (IOException e){
}
}
}
}
}

然后创建一个子线程类: WorkerThread.java, 子线程做的事情就是处理client发来的http请求,大量代码是复制的。不过因为父进程创建子进程时需要将socket传入,所以要创建一个参数为socket的构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package com.httpserver.core;

import com.httpserver.HttpServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

public class WorkerThread extends Thread{

private final static Logger LOGGER = (Logger) LoggerFactory.getLogger(HttpServer.class);
private Socket socket;

public WorkerThread(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
InputStream inputStream = null;
OutputStream outputStream = null;

try {
inputStream = socket.getInputStream();
outputStream = socket.getOutputStream();
String html = "<html><head><title>Hello,My name is Jason</title></head><body><h1>My ID is 10195501423</h1></body></html>";

final String CRLF = "\n\r";
String response =
"HTTP/1.1 200 OK" + CRLF +
"Content-Length: " + html.getBytes(StandardCharsets.UTF_8).length + CRLF +
CRLF +
html +
CRLF + CRLF;

outputStream.write(response.getBytes(StandardCharsets.UTF_8));
LOGGER.info(" Connection Processing Finished");
}catch (IOException e){
LOGGER.error("Problem with communication",e) ;
}finally {
//最后,当线程结束时,需要关闭 inputeStream、outPutStream和socket
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {}
}
if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {}
}
if (socket != null) {
try {
outputStream.close();
} catch (IOException e) {}
}
}
}
}

运行后如下图所示,我们开启多个tag,同时访问 localhost:8080 发现使用了这种方式以后,后端会自动为连进来的client创建不同的进程

解析请求

刚才我们所做的,是非常简单的功能,也就是收到请求,不管请求什么,都返回相同的东西。因此不论我请求 localhost:8080还是localhost:8080/index 只要是在这个端口发起的请求,都会收到 My ID is 1019550123的返回结果。因此现在我们需要解析client发来的请求。

要解析请求,首先我们要捕获 client发来的HTTP头部信息,我们可以这样来写:

1
2
3
4
int _byte;
while((_byte=inputStream.read())>=0){
System.out.print((char) _byte);
}

现在,我在浏览器中请求 localhost:8080/index, 结果如下:

在请求头中包含了这是一个 GET 请求,路由是 /index

整个HTTP请求的格式如下所示

1
2
3
4
HTTP-message = start-line						// 在请求时的格式是:method SP request-target SP HTTP-version CRLF
+(header-field CRLF) //可能有多个信息
CRLF
[ message-body ] //请求体

现在我们可以进行解析了。我们来介绍两种解析器: Lexer Parser 和 Lexerless Parser。 前者会把 socket发来的流先处理成token,再讲token转为我们想要的信息(请求方式、路由等)。后者则是直接转换。

这里我们使用后者,因为我们对时间的要求较高,需要对请求进行迅速解析。

搞清楚 HTTP 请求的格式之后,我们知道最重要的部分就是请求的方式以及请求的目标文件。这两者决定了Server怎么去响应请求。因此我们需要创建几个比较基础的类:HttpMethodHttpStatusCode是两个枚举类,分别用来放请求方法和状态码。HttpRequest 用来存放请求行的信息(请求方式、目标文件以及HTTP版本)

HttpMethod

这是一个枚举类,用来存放请求方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.http.request;

public enum HttpMethod {
GET,HEAD,POST,PUT,DELETE;
// MAX_LENGTH 代表最长的请求方式,如果超出这个值就说明是非法请求
public static final int MAX_LENGTH;

static {
int tempMaxLength = -1;
for(HttpMethod method:values()){
if(method.name().length()>tempMaxLength){
tempMaxLength = method.name().length();
}
}
MAX_LENGTH = tempMaxLength;
}
}

HttpStatusCode

这个类用来存放状态码,我这里列举了几个错误时发生的状态码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.http.request;

public enum HttpStatusCode {
Client_ERROR_400_BAD_REQUEST(400,"Bad request"),
Client_ERROR_401_METHOD_NOT_ALLOWED(401,"Method not allowed"),
Client_ERROR_414_BAD_REQUEST(414,"URL Too long"),
SERVER_ERROR_500_INTERNAL_SERVER_ERROR(500,"Internmal server error"),
SERVER_ERROR_501_NOT_IMPLEMENTED(501,"Internal server error"),
SERVER_ERROR_404_NOT_FOUND(404,"NOT FOUND"),
SERVER_ERROR_200_OK(200,"OK");


public final int STATUS_CODE;
public final String MESSAGE;

HttpStatusCode(int status_code, String message) {
STATUS_CODE = status_code;
MESSAGE = message;
}
}

HttpRequest

这个类用来存放请求行的信息(请求方式、目标文件以及HTTP版本)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package com.http.request;

public class HttpRequest {

private HttpMethod method;
private String requestTarget;
private String httpVersion;


HttpRequest() {
}


public HttpMethod getMethod() {
return method;
}
//packege level
void setMethod(String methodName) throws HttpParsingException {
for(HttpMethod method:HttpMethod.values()){
if(methodName.equals(methodName)){
this.method = method;
return;
}
}

throw new HttpParsingException(
HttpStatusCode.SERVER_ERROR_501_NOT_IMPLEMENTED
);
}

public String getRequestTarget() {
return requestTarget;
}

public void setRequestTarget(String requestTarget) {
this.requestTarget = requestTarget;
}

public String getHttpVersion() {
return httpVersion;
}

public void setHttpVersion(String httpVersion) {
this.httpVersion = httpVersion;
}
}

HttpParser

这是解析器最重要的部分了,起作用相当一座桥梁,能够把 Byte转换为字符串。事实上需要对三个部分进行解析: 请求行、头部以及请求体。但是根据这个项目的要求,我们只要来解析请求行即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
//...

public class HttpParser {
private final static Logger LOGGER = LoggerFactory.getLogger(HttpParser.class);
//接下来对头部进行拆解,我们知道头部的格式是:
/* Method+SP+Target+SP+HttpVersion+CRLF */
//又这是通过字节流传送的,因此我们需要在一开始就查询空格换行的ASCII码
private static final int SP =0x20;
private static final int CR =0x0D;
private static final int LF =0x0A;

// 首先吗,从子线程传进来一个socket的输入流,然后在这个 parseHttpRequest方法中进行解析
public HttpRequest parseHttpRequest(InputStream inputStream) throws HttpParsingException {
// a bridge from Byte to String
InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.US_ASCII);

HttpRequest request = new HttpRequest();
//解析请求行
try {
parseRequestLine(reader,request);
} catch (IOException e) {
e.printStackTrace();
}
//解析头部信息
parseHeaders(reader,request);
//解析请求体
parseBody(reader,request);

return request;
}

private void parseRequestLine(InputStreamReader reader, HttpRequest request) throws IOException, HttpParsingException {
StringBuilder processingDataBuffer = new StringBuilder();

boolean methodParsed = false;
boolean requestTargetParsed = false;

int _byte;

while((_byte=reader.read())>=0){
//如果读到 CR 了,就说明头部行即将结束在往后读取一个信息
if(_byte==CR){
_byte = reader.read();
//如果CR后面是LF,说明头部行已结束,最后一个part是Http Version,我们设置一下
if(_byte == LF){
LOGGER.debug("Request Line VERSION to Process: {}", processingDataBuffer.toString());
request.setHttpVersion(processingDataBuffer.toString());
//因为读到最后一个部分了,但是前面的method和target却仍然未设置,说明出错了
if(!methodParsed || !requestTargetParsed){
throw new HttpParsingException(HttpStatusCode.Client_ERROR_414_BAD_REQUEST);
}
return;
}
}
// 如果读到空格,说明有一部分已经结束了
if(_byte == SP){
//method是第一部分,因此先判断method是否设置,若未设置则设置并把flag置为true
if(!methodParsed){
LOGGER.debug("Request Line to Process: {}", processingDataBuffer.toString());
request.setMethod(processingDataBuffer.toString());
methodParsed = true;
}else if (!requestTargetParsed){
// 然后判断 target是否已设置,若未设置则把target置为true
LOGGER.debug("Request Line to Process: {}", processingDataBuffer.toString());
request.setRequestTarget(processingDataBuffer.toString());
requestTargetParsed=true;
}else{
//如果读到最后发现请求行还是有空格,说明这是个错误的请求
throw new HttpParsingException(HttpStatusCode.Client_ERROR_414_BAD_REQUEST);
}
// 每一部分解析完成后,在Buffer中删除该信息
processingDataBuffer.delete(0,processingDataBuffer.length());
}else {
//既不是SP也不是CRLF,说明是我们要的信息,我们将其记录在一个Buffer当中取用
processingDataBuffer.append((char)_byte);
if(!methodParsed){
if(processingDataBuffer.length()>HttpMethod.MAX_LENGTH){
throw new HttpParsingException((HttpStatusCode.SERVER_ERROR_501_NOT_IMPLEMENTED));
}
}
}
}
}

private void parseHeaders(InputStreamReader inputStream,HttpRequest request){}

private void parseBody(InputStreamReader inputStream,HttpRequest request){}
}

ResponseBuilder

解析完头部信息之后,我们就需要根据请求的结果编写响应报文了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.http.response;

import com.http.request.HttpStatusCode;

public class ResponseBuilder {
private static final String CRLF = "\n\r";
private static String response;


public static String build(String html,HttpStatusCode statusCode) {
response =
"HTTP/1.1 "+statusCode.STATUS_CODE+" "+statusCode.MESSAGE+ CRLF +
"Content-Length: " + html.getBytes().length + CRLF +
CRLF +
html +
CRLF + CRLF;
return response;

}

}

子线程的部分修改

因为我们把响应报文的编写交给 ResponseBuilder了,因此我们这里需要调用ResponseBuilder并传入状态码和socket流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Override
public void run() {
//.....
//如果请求的是正确的内容,那么我们就返回状态码200
if(clientRequest.getRequestTarget().equals("/index.html")){
String html = "<html><head><title>Hello,My name is Jason</title></head><body><h1>My ID is 10195501423</h1></body></html>";
String response = ResponseBuilder.build(html,HttpStatusCode.SERVER_ERROR_200_OK);
outputStream.write(response.getBytes(StandardCharsets.UTF_8));

}else if(clientRequest.getRequestTarget().equals("/shutdown")){
System.out.println("收到客户端的请求,关闭服务器");
System.exit(0);
} else {
//如果请求的是不存在的文件,那么我们就需要将状态码设置为404
String html = loadHtmlFile("/Users/jasonxu/IdeaProjects/finalproject/src/main/Web/404.html");
String response = ResponseBuilder.build(html,HttpStatusCode.SERVER_ERROR_404_NOT_FOUND);
outputStream.write(response.getBytes(StandardCharsets.UTF_8));
}

LOGGER.info(" Connection Processing Finished");
}catch (IOException | HttpParsingException e){
LOGGER.error("Problem with communication",e) ;
}finally {
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {}
}
if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {}
}
if (socket != null) {
try {
outputStream.close();
} catch (IOException e) {}
}
}
}

最终结果:输入localhost:8080/index.html 之后,显示如下

输入localhost:8080/222(其他) ,显示如下

输入localhost:8080/shutdown 服务器关闭:

Postman 测试结果如下:

我们发现,postman测试的结果都通过了,且用GET和POST方法也没有出现错误。

性能测试

现在我们用 Jmeter 来测试一下这个 Java Server:

在1000线程并发时,发现这个server能毫无压力得处理。

最终,在测到4900个并发线程的时候,出现了请求失败的情况。对此我又多测试了几次,最终测得这个server的极限压力大概是5000个线程左右

我们现在尝试优化这个server,采用线程池的方法:

1
2
ThreadPoolExecutor executor = new ThreadPoolExecutor(1000, 10000, 400, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<Runnable>(2000));

这几个参数分别是:

  • corePoolSize:核心池的大小
  • maximumPoolSize:线程池最大线程数,这个参数也是一个非常重要的参数,它表示在线程池中最多能创建多少个线程;
  • keepAliveTime:表示线程没有任务执行时最多保持多久时间会终止。
  • unit:参数keepAliveTime的时间单位
  • workQueue:一个阻塞队列,用来存储等待执行的任务

如果当前线程池中的线程数目小于corePoolSize,则每来一个任务,就会创建一个线程去执行这个任务;

如果当前线程池中的线程数目>=corePoolSize,则每来一个任务,会尝试将其添加到任务缓存队列当中,若添加成功,则该任务会等待空闲线程将其取出去执行;若添加失败(一般来说是任务缓存队列已满),则会尝试创建新的线程去执行这个任务;

如果当前线程池中的线程数目达到maximumPoolSize,则会采取任务拒绝策略进行处理;

如果线程池中的线程数量大于 corePoolSize时,如果某线程空闲时间超过keepAliveTime,线程将被终止,直至线程池中的线程数目不大于corePoolSize;如果允许为核心池中的线程设置存活时间,那么核心池中的线程空闲时间超过keepAliveTime,线程也会被终止。

我给核心池开了1000个线程,(事实上根本用不了那么多),在这之后,我用8000个并发请求也能轻松处理。一个错误都没有

1.2 代理服务器的编写

“不正确”的代理服务器

代理服务器相当于一个中转站,需要解析请求报文和响应报文并生成新的报文。一开始我还以为是proxy和server都要自己写,结果改了半天最终成功了才发现其实只需要写proxy,要代理浏览器访问的页面。但是失误已经酿成了,因此我把这个失误的版本也贴上来。

流程如下

  1. 在8081端口, proxy作为server,收到了来自 client 的请求,开一个线程解析request、生成新的request。
  2. proxy作为client,利用socket.connect,向端口(8080)传输新的request
  3. 端口8080 收到以后,解析request,然后生成response。
  4. server 构造完后向端口8081传输response
  5. Proxy在端口8081收到response,然后生成新的response,并向浏览器发送。

整个流程都由一个线程完成。因此我们还是采用父子线程的架构模式。整个 workThread如下图所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
//...
public class WorkerThread extends Thread{
private final static Logger LOGGER = (Logger) LoggerFactory.getLogger(ProxyServer.class);
private Socket socket;
RequestParser myRequestParser = new RequestParser();
ResponseParser myResponseParser = new ResponseParser();

Socket socketToServer = new Socket();

public WorkerThread(Socket socket) {
this.socket = socket;
}


@Override
public void run() {
final String CRLF = "\n\r";

InputStream inputStream = null;
OutputStream outputStream = null;

OutputStream outputStreamToServer = null;
InputStream inputStreamFromServer = null;

int _byte;

try {
// 利用connect方法,可以实现两个端口之间的流传递
socketToServer.connect(new InetSocketAddress(Inet4Address.getLocalHost(),
8080), 50);
// inputStream和 outputStream 是8081端口的输入输出流
inputStream = socket.getInputStream();
outputStream = socket.getOutputStream();
// outputStreamToServer和 inputStreamFromServer 是对于8080端口的输入输出流
outputStreamToServer = socketToServer.getOutputStream();
inputStreamFromServer = socketToServer.getInputStream();


/*******************解析Request from Client*****************/
/*
1.首先8081端口收到client发来的request,交给myRequestParser去解析,信息存到httpRequest中
2.利用RequestBuilder 来构造转发的请求。
3.利用outputStreamToServer,将构造的请求转发给server
*/
HttpRequest httpRequest = myRequestParser.parseHttpRequest(inputStream);

System.out.println(httpRequest.getMethod());
System.out.println(httpRequest.getRequestTarget());

String request = RequestBuilder.build(httpRequest.getMethod(),httpRequest.getRequestTarget(),httpRequest.getHttpVersion());
outputStreamToServer.write(request.getBytes(StandardCharsets.UTF_8));
/****************解析Response from Server*****************/
/*
1. 收到来自server的response之后,交给myResponseParser去解析,并存入httpResponse中
2. 利用httpResponse,构造新的response
3. 将response转发给client
*/
HttpResponse httpResponse = myResponseParser.parseHttpResponse(inputStreamFromServer);

System.out.println(httpResponse.getStatusCode());
System.out.println(httpResponse.getStatusMessage());

String response = ResponseBuilder.build(httpResponse);
outputStream.write(response.getBytes(StandardCharsets.UTF_8));

/*******************Finish*************************/
LOGGER.info(" Connection Processing Finished");
}catch (IOException | HttpParsingException e){
LOGGER.error("Problem with communication",e) ;
}finally {
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {}
}
if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {}
}
if (socket != null) {
try {
outputStream.close();
} catch (IOException e) {}
}
}
}
}

最终结果如下图所示,用postman和浏览器分别测试:

“正经”代理服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
//...

public class WorkerThread extends Thread{
private Socket socket;
private final static Logger LOGGER = (Logger) LoggerFactory.getLogger(ProxyServer.class);
public WorkerThread(Socket socket) {
this.socket = socket;
}

@Override
public void run() {
OutputStream outputStreamToClient = null;
InputStream inputStreamFromClient = null;

Socket proxySocket = null;

InputStream inputFromServer = null;
OutputStream outputToServer = null;
try {
inputStreamFromClient = socket.getInputStream();
outputStreamToClient = socket.getOutputStream();
String line;
String host = "";
LineBuffer lineBuffer = new LineBuffer(1024);
StringBuilder headStr = new StringBuilder();
//读取HTTP请求头,并拿到HOST请求头和method
while ( (line = lineBuffer.readLine(inputStreamFromClient))!=null) {
headStr.append(line).append("\r\n");
if (line.length() == 0) {
break;
} else {
String[] temp = line.split(" ");
if (temp[0].contains("Host")) {
host = temp[1];
}
}
}
// 请求方式
String type = headStr.substring(0, headStr.indexOf(" "));
LOGGER.debug("type: "+type);

//根据host头解析出目标服务器的host和port
String[] hostTemp = host.split(":");
host = hostTemp[0];
LOGGER.debug("host: "+host);
int port = 80;

if (hostTemp.length > 1) {
port = Integer.parseInt(hostTemp[1]);
LOGGER.debug("port: "+port);
}
//连接到目标服务器
proxySocket = new Socket(host, port);
inputFromServer = proxySocket.getInputStream();
outputToServer = proxySocket.getOutputStream();

//根据HTTP method来判断是https还是http请求,https
if ("CONNECT".equalsIgnoreCase(type)) {
//如果是https,type是CONNECT,如果是http则type是GET、POST之类的
//https需要建立隧道,以便能和client之间进行二进制数据的收发。
outputStreamToClient.write("HTTP/1.1 200 Connection Established\r\n\r\n".getBytes());
outputStreamToClient.flush();
} else {
//http直接将请求头转发
outputToServer.write(headStr.toString().getBytes());
}
//新开线程转发客户端请求至目标服务器,这边使用线程的原因是需要异步操作
new ProxyWorkerThread(inputStreamFromClient, outputToServer).start();
//转发目标服务器响应至客户端
while (true) {
outputStreamToClient.write(inputFromServer.read());
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (inputFromServer != null) {
try {
outputToServer.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (outputToServer != null) {
try {
outputToServer.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (proxySocket != null) {
try {
proxySocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (inputStreamFromClient != null) {
try {
inputStreamFromClient.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (outputStreamToClient != null) {
try {
outputStreamToClient.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (socket != null) {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}

}
}

LineBuffer类

这个类设置了一个可以自动扩容的缓冲区,用来读client请求.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package com.httpserver.util;

import java.io.IOException;
import java.io.InputStream;

public class LineBuffer {
private int size;

private static final int SP =' ';
private static final int CR ='\r';
private static final int LF ='\n';

public LineBuffer(int size) {
this.size = size;
}

public String readLine(InputStream input) throws IOException {
int flag = 0;
int index = 0;
byte[] bts = new byte[this.size];
int b;
//因为在请求头中,每一行的结尾都是 "\r\n" 所以当一行结束时,就返回这一行字符串
while(flag!=2&&(b= input.read())!=-1){
bts[index++] = (byte) b;
if(b == CR && flag%2==0){
flag++;
}else if(b== LF && flag%2==1){
flag++;
if(flag==2)
return new String(bts,0,index-2);
}else
flag = 0;
if(index==bts.length){
//满了就扩容
byte[] newBts = new byte[bts.length*2];
System.arraycopy(bts,0,newBts,0,bts.length);
bts = newBts;
}
}
return null;
}
}

ProxyWorkerThread类

这个类负责将客户端的请求转发到目标服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ProxyWorkerThread  extends Thread {
private InputStream input;
private OutputStream output;

public ProxyWorkerThread(InputStream inputStreamFromClient, OutputStream outputToServer) {
this.input = inputStreamFromClient;
this.output = outputToServer;
}


@Override
public void run() {
try {
while (true) {
output.write(input.read());
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

在使用代理服务器上网之前,首先需要设置chrome的代理配置。将端口改为代理服务器监听的8080端口

结果如下图所示

如果访问https,也是同样的操作

-------------本文结束,感谢您的阅读-------------