3.1 重构原生Web服务框架
3.1.1 分析原生Web服务框架
在服务端代码的 ClientHandler 中,请求解析、处理请求、返回响应的代码混杂在一起,这样的设计会导致代码难以维护和理解。为了提高代码的可读性、可维护性和可扩展性,我们需要对这些代码进行重构,并按照功能抽取对应的类,从而使后续的开发和维护更加方便。
分析目前的代码:
重构后软件的整体结构如下图:
重构后的结构主要包含以下几个主要组件:
-
ClientHandler(Web处理线程):该组件是处理客户端请求的主要线程。它接收客户端发送的HTTP请求,并将请求交给HttpServletRequest进行解析,然后将解析得到的请求信息传递给DispatcherServlet进行核心请求处理。最后,将处理得到的响应信息传递给HttpServletResponse进行缓存和发送给客户端。
-
HttpServletRequest(请求解析和封装):负责解析和封装客户端发送的HTTP请求的信息,包括请求方法、URL、请求头和请求体等。它将原始的HTTP请求转换为一个请求对象,以方便后续处理逻辑使用。
-
HttpServletResponse(响应缓存和处理):负责缓存和处理向客户端发送的响应信息。它将处理得到的响应内容暂存起来,并在合适的时机发送给客户端。
-
DispatcherServlet(请求分发器):封装了核心请求处理逻辑。当ClientHandler接收到客户端请求后,将请求信息传递给DispatcherServlet。它根据请求的URL和方法,决定调用哪个业务处理模块来处理请求,最终得到处理结果。
重构后的结构将不同功能的代码分离到独立的组件中,增强了代码的可读性和可维护性。HttpServletRequest负责解析和封装请求信息,HttpServletResponse负责缓存和处理响应信息,而DispatcherServlet作为请求分发器,负责将请求分发给相应的业务处理模块进行处理。这样的设计使得代码逻辑更加清晰,方便后续的开发和维护。
3.1.2 重构请求
为了提高代码的模块化和清晰性,我们将请求部分的代码抽取到一个新的类HttpServletRequest中,该类封装了HTTP请求的解析逻辑,并提供了访问解析结果的方法。
- 定义类HttpServletRequest,封装HTTP请求的逻辑。该类包括了以下成员变量:
- Socket socket: 保存客户端和服务器之间的网络连接
- String method: 保存HTTP请求的方法,如GET、POST等
- String uri: 保存HTTP请求的URI,即请求的资源路径
- String protocol: 保存HTTP请求使用的协议,如HTTP/1.1、HTTP/2.0等
- HashMap<String, String> headers: 保存HTTP请求头的所有内容,以键值对的形式存储
- 定义属性访问方法
- public String getMethod():返回HTTP请求的方法
- public String getUri():返回HTTP请求的URI
- public String getProtocol():返回HTTP请求使用的协议
-
定义方法private void parseRequestLine(),用于解析HTTP请求的请求行,包括请求方法、URI和协议版本号,将解析结果输出到控制台,便于后续调试。
-
定义方法解析请求头以及获取请求头:
- private void parseHeaders():解析HTTP请求头部的所有内容,以键值对的形式存储。
- public String getHeader(String name):根据请求头的名称返回请求头的值
- 定义构造函数public HttpServletRequest(Socket socket):接受一个Socket对象作为参数,通过解析Socket中的输入流,初始化该类的成员变量。并定义方法public String readLine(),用于从Socket的输入流中读取一行数据并返回。
HttpServletRequest类的完整代码示意如下:
java
/*封装HTTP请求逻辑 */
public class HttpServletRequest {
private Socket socket;
private String method;
private String uri;
private String protocol;
private HashMap<String, String> headers = new HashMap<>();
public HttpServletRequest(Socket socket) throws IOException {
this.socket = socket;
//解析请求行
parseRequestLine();
//解析请求头
parseHeaders();
}
/**
* 解析请求行方法
* @throws IOException 网络出现异常
*/
private void parseRequestLine() throws IOException{
String requestLine = readLine();
String[] parts = requestLine.split("\\s");
method = parts[0];
uri = parts[1];
protocol = parts[2];
System.out.println("解析请求行:"+requestLine);
System.out.println("method:"+method);
System.out.println("uri:"+uri);
System.out.println("protocol:"+protocol);
}
/**
* 解析请求头方法,将解析结构缓存到一个HashMap中
* @throws IOException 网络出现错误
*/
private void parseHeaders() throws IOException {
while (true) {
String line = readLine();
//解析到空行结束
if (line.isEmpty()) {
break;
}
System.out.println("解析请求头:" + line);
String[] parts = line.split(":\\s");
headers.put(parts[0], parts[1]);
}
System.out.println("所有请求头:" + headers);
}
/**
* 这段代码的作用是从Socket的输入流中读取一行数据并返回。它通过InputStream获取Socket的输入流,
* 然后使用一个StringBuilder对象来存储读取的数据,最终返回读取的数据。
* 具体实现逻辑如下:
* 1. 创建一个InputStream对象in,并将其设置为socket的输入流。
* 2. 创建一个StringBuilder对象builder,用于存储读取的数据。
* 3. 定义两个字符变量previous和current,用于记录前一个字符和当前字符。
* 4. 定义一个int类型变量b,用于记录从输入流中读取的字节。
* 5. 使用while循环从输入流中读取字节,直到读取完一行数据。
* 6. 将读取到的字节转换成字符类型,并赋值给变量current。
* 7. 判断当前字符是否为行结束符("\r\n"),如果是则退出循环,否则将当前字符添加到builder中。
* 8. 将当前字符赋值给previous,以备下次循环使用。
* 9. 循环结束后,将builder转换成字符串并返回。
* @return 从Socket的输入流中读取一行数据并返回
* @throws IOException 出现网络IO错误
*/
public String readLine() throws IOException{
InputStream in = socket.getInputStream();
StringBuilder builder= new StringBuilder();
// 前一个字符 当前字符
char previous = 0, current = 0;
int b;
//解析请求行
while ((b=in.read())!=-1){
current = (char) b;
if (previous == '\r' && current == '\n'){
//遇到行结束就结束读取
break;
}else if (current != '\r' && current != '\n'){
builder.append(current);
}
previous = current;
}
return builder.toString();
}
/**
* 获取当前的请求方式
* @return 请求方式
*/
public String getMethod() {
return method;
}
/**
* 获取当前请求的 uri
* @return 请求资源路径
*/
public String getUri() {
return uri;
}
/**
* 返回当前请求的 协议
* @return 返回请求协议
*/
public String getProtocol() {
return protocol;
}
/**
* 查询一个请求头
* @param name 请求头名字
* @return 请求头的值
*/
public String getHeader(String name) {
return headers.get(name);
}
}
- 重构ClientHandler类,将解析请求部分替换为HttpServletRequest:
java
//1. 解析请求
HttpServletRequest request = new HttpServletRequest(socket);
String uri = request.getUri();
通过重构后,现在ClientHandler类中的请求部分代码得到了简化,提高了代码的可读性和可维护性。同时,HttpServletRequest类封装了HTTP请求解析的逻辑,使得ClientHandler更专注于业务处理部分,使整体结构更清晰。这样的重构有助于提高代码的模块化和可维护性,方便后续的开发和维护。
3.1.3 重构响应
在进行请求部分的重构后,现在继续对响应逻辑进行重构,将响应代码抽取到HttpServletResponse类中,以优化ClientHandler。
- 定义了一个名为HttpServletResponse的类,封装HTTP响应的逻辑。包含:
- socket:一个socket实例变量,用于表示客户端连接的套接字
- statusCode:表示HTTP状态码,默认值为200
- statusReason:表示HTTP状态描述,默认值为"OK"
- contentFile:表示响应正文对应的实体文件
在构造函数中,将客户端的套接字作为参数,将其赋给socket实例变量。
-
添加方法setContentFile、setStatusCode和setStatusReason用于设置响应正文文件、状态码和状态描述,分别将它们赋给成员变量。
-
抽取println方法用于将一行数据发送到网络流中,首先通过socket的getOutputStream方法获取输出流,然后将数据转换为ISO_8859_1编码的字节数组,并发送回车符和换行符。
-
抽取send方法用于将HTTP响应发送给客户端:
- 它首先根据状态码和状态描述拼接一个状态行,并发送给客户端
- 然后发送响应头,包括Content-Type和Content-Length
- 最后发送一个空行表示响应头已经发送完成
- 通过FileInputStream读取contentFile中的数据,并通过OutputStream发送给客户端
HttpServletResponse类的完整代码示意如下:
java
/*封装HTTP响应逻辑 */
public class HttpServletResponse {
private Socket socket;
//状态行相关信息
private int statusCode = 200; //状态代码
private String statusReason = "OK"; //状态描述
//响应头相关信息
//响应正文相关信息
private File contentFile; //响应正文对应的实体文件
public HttpServletResponse(Socket socket){
this.socket = socket;
}
public void send() throws IOException {
String statusLine = "HTTP/1.1 " + statusCode + " " + statusReason;
//发送状态行
println(statusLine);
System.out.println("发送状态行: "+statusLine);
//发送响应头
println("Content-Type: text/html; charset=utf-8");
println("Content-Length: " + contentFile.length());
System.out.println("发送响应头: " + "Content-Length: " + contentFile.length());
//发送空行
println("");
//将文件内容发送到浏览器
FileInputStream in = new FileInputStream(contentFile);
OutputStream out = socket.getOutputStream();
byte[] buf = new byte[8*1024];
int n;
while ((n=in.read(buf))!=-1){
out.write(buf, 0, n);
}
}
public void setContentFile(File contentFile){
this.contentFile = contentFile;
}
public void setStatusCode(int statusCode) {
this.statusCode = statusCode;
}
public void setStatusReason(String statusReason){
this.statusReason = statusReason;
}
/**
* 发送一行到网络流
* @param line 一行
* @throws IOException 网络故障
*/
private void println(String line) throws IOException {
OutputStream out = socket.getOutputStream();
byte[] data = line.getBytes(StandardCharsets.ISO_8859_1);
out.write(data);
out.write('\r');//发送回车符
out.write('\n');//发送换行符
}
}
5.重构ClientHandler,使用HttpServletResponse类替换响应过程:
java
//1. 解析请求
HttpServletRequest request = new HttpServletRequest(socket);
HttpServletResponse response = new HttpServletResponse(socket);
String uri = request.getUri();
//2. 发送响应
//根据找到静态资源
//类加载路径:target/classes
File root = new File(
ClientHandler.class.getClassLoader().getResource(".").toURI()
);
//定位target/classes/static目录(SpringBoot中存放所有静态资源的目录)
File staticDir = new File(root,"static");
//定位target/classes/static目录中的文件
File file = new File(staticDir,uri);
response.setContentFile(file);
response.send();
- 重构后ClientHandler的代码变得非常简洁,但是测试时候控制台出现了异常信息:
这个显然是浏览器在请求favicon.ico文件,然而我们的服务器端没有对应的资源造成的问题。解决方案就是按照通行的惯例,在没有找到相应资源时候,给浏览器响应一个错误码404,错误原因是"Not Found"。
通过重构,现在ClientHandler类中的响应部分代码也得到了简化,提高了代码的可读性和可维护性。HttpServletResponse类封装了HTTP响应的逻辑,使得ClientHandler更专注于业务处理部分。同时,为了更好地处理未找到资源的情况,我们返回了404错误页面,提高了用户体验。
这样的重构有助于进一步优化代码结构,提高代码的模块化和可维护性,使整体逻辑更加清晰。
3.1.4 HTTP响应状态码
RFC2616是HTTP/1.1协议的规范,其中定义了HTTP协议中的状态码。以下是RFC2616中定义的HTTP状态码及其含义。
1xx(信息性状态码):表示接收的请求正在处理。
- 100 Continue:服务器已接收请求头部,并且客户端应继续发送请求的主体部分
- 101 Switching Protocols:服务器已经理解了客户端的请求,并将通过升级协议来完成这个请求
2xx(成功状态码):表示请求已成功被服务器接收、理解、并接受。
- 200 OK:请求已成功,请求所希望的响应头或数据体将随此响应返回
- 201 Created:请求已经被实现,而且有一个新的资源已经依据请求的需要而建立
- 202 Accepted:服务器已接受请求,但尚未处理
- 204 No Content:服务器成功处理了请求,但没有返回任何内容
3xx(重定向状态码):表示需要客户端执行进一步的操作才能完成请求。
- 301 Moved Permanently:请求的资源已被永久移动到新URI,将来的引用应使用新URI
- 302 Found:请求的资源临时从不同的URI响应请求,将来的引用仍然应该使用原来的URI
- 303 See Other:响应可以被找到在另一个URI,应使用GET方法来检索此资源
- 304 Not Modified:请求的资源未被修改,客户端可以使用缓存的版本
4xx(客户端错误状态码):表示客户端在请求的过程中出错。
- 400 Bad Request:服务器无法理解请求的格式,客户端不应该重复发送这个请求
- 401 Unauthorized:请求需要用户验证,无法通过验证
- 403 Forbidden:服务器已经理解请求,但是拒绝执行它
- 404 Not Found:服务器无法找到请求的资源
5xx(服务器错误状态码):表示服务器在处理请求的过程中出错。
- 500 Internal Server Error:服务器遇到了一个意外的情况,无法完成请求
- 501 Not Implemented:服务器不支持客户端请求的功能
- 502 Bad Gateway:服务器作为网关或代理,从上游服务器收到了无效的响应
- 503 Service Unavailable:服务器当前无法处理请求,可能是因为维护或过载
以上是RFC2616中定义的HTTP状态码及其含义,可以帮助开发者更好地理解HTTP协议中的错误码信息。
3.1.5 处理404错误
首先在 resources/static 文件夹中创建一个 404 错误的html文件 "404.html",该文件的HTML代码如下所示:
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>404</title>
</head>
<body>
<p>404 文件没有找到!</p>
</body>
</html>
然后重构ClientHandler,处理404错误:先检查文件是否存在,如果文件存在,就发送文件;否则设置状态码"404",状态原因为"Not Found",并且设置发送404.html文件。代码如下所示:
java
//1. 解析请求
HttpServletRequest request = new HttpServletRequest(socket);
HttpServletResponse response = new HttpServletResponse(socket);
String uri = request.getUri();
//2. 发送响应
//根据找到静态资源
//类加载路径:target/classes
File root = new File(
ClientHandler.class.getClassLoader().getResource(".").toURI()
);
//定位target/classes/static目录(SpringBoot中存放所有静态资源的目录)
File staticDir = new File(root,"static");
//定位target/classes/static目录中的文件
File file = new File(staticDir,uri);
//检查文件是否存在
if (file.isFile()){
//正常发送资源
response.setContentFile(file);
}else {
//处理404错误
response.setStatusCode(404);
response.setStatusReason("Not Found");
File file404 = new File(staticDir, "404.html");
response.setContentFile(file404);;
}
//3. 发送响应
response.send();
重构后进行测试:请求一个不存在的资源,比如:http://localhost:8088/hi.html 得到如下结果:
3.1.6 重构处理请求过程
在对ClientHandler的请求和响应逻辑进行重构后,现在可以进一步重构ClientHandler的请求处理过程。将请求处理逻辑抽取到一个新的类DispatcherServlet中,该类作为请求处理器,包含了处理HTTP请求的逻辑。
- 抽取请求处理逻辑到一个新的类DispatcherServlet:一个请求处理器,包含了处理HTTP请求的逻辑。具体功能如下:
- 根据请求中的URI定位到对应的静态资源文件,如果该文件存在则将其发送给浏览器
- 如果请求的资源不存在,则设置HTTP响应的状态码为404,状态描述为"Not Found",并将静态资源文件404.html发送给浏览器
该类的静态初始化块中,通过类加载器获取到当前类所在的classpath目录,然后找到其中的static目录作为静态资源文件的根目录。包含属性:
- root :代表当前classpath的根目录,是资源查找起始位置
- staticDir :静态资源的位置,静态网页和图片都存储在这个位置
- 在service方法中,通过HttpServletRequest的getUri方法获取到请求的URI,然后在静态资源文件根目录下查找相应的文件,如果存在则将其发送给浏览器,如果不存在则发送静态资源文件404.html。
DispatcherServlet类的完整代码示意如下:
java
/*封装请求处理逻辑 */
public class DispatcherServlet {
private static File root;
private static File staticDir;
static {
try {
//根据找到静态资源
//类加载路径:target/classes
root = new File(
ClientHandler.class.getClassLoader().getResource(".").toURI()
);
//定位target/classes/static目录(SpringBoot中存放所有静态资源的目录)
staticDir = new File(root,"static");
} catch (URISyntaxException e) {
e.printStackTrace();
}
}
public void service(HttpServletRequest request, HttpServletResponse response){
String uri = request.getUri();
//定位target/classes/static目录中的文件
File file = new File(staticDir,uri);
//检查文件是否存在
if (file.isFile()){
//正常发送资源
response.setContentFile(file);
}else {
//处理404错误
response.setStatusCode(404);
response.setStatusReason("Not Found");
File file404 = new File(staticDir, "404.html");
response.setContentFile(file404);;
}
}
}
- 重构ClientHandler
重构后的请求处理线程ClientHandler就非常清爽:
java
public class ClientHandler implements Runnable {
private Socket socket;
public ClientHandler(Socket clientSocket){
socket = clientSocket;
}
@Override
public void run() {
try {
//1. 解析请求
HttpServletRequest request = new HttpServletRequest(socket);
HttpServletResponse response = new HttpServletResponse(socket);
String uri = request.getUri();
//2. 处理请求
DispatcherServlet servlet = new DispatcherServlet();
servlet.service(request, response);
//3. 发送响应
response.send();
}catch (IOException e){
e.printStackTrace();
}finally {
//断开连接
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
3.1.7 请求前的空行问题
在进行大量的测试时候,有可能出现解析请求时候出现了空行情况,收到空请求行后进行请求行解析就会出现异常:
其原因是HTTP协议中,允许客户端浏览器在HTTP请求前发送空行,也就是一个空行符(CRLF)的作用是分隔请求头和请求体,它表示请求头的结束。在请求头结束之后,如果请求中包含请求体,请求体将会跟在空行之后。由于存在空请求体的请求,所以存在请求行之间有空行的意外。
在 HTTP/1.1 规范中,如果服务器在开始读取一个消息时收到一个 CRLF,则应该忽略它,以确保服务器在遇到任何异常情况时都能正常工作。(可以参考:RFC2616 4.1 Message Types)
3.1.8 检查请求行
为解决请求前的空行问题,需要在解析请求行的时候,忽略空行。然后再利用正则表达式检查请求行是否是合乎HTTP协议的标准,进一步增强程序的可靠性。
可以使用AI工具帮助生成正则表达式。
一个检查请求行正确的正则表达式如下:
java
^(GET|POST|PUT|DELETE|HEAD|OPTIONS) ([^?#\s]+)(\?[^#\s]*)? (HTTP\/1\.0|HTTP\/1\.1)$
这个正则表达式匹配了HTTP请求行的四个部分:请求方法、请求URL、请求参数、HTTP协议版本。
- ^:表示字符串的开始
- (GET|POST|PUT|DELETE|HEAD|OPTIONS):匹配HTTP请求的方法,这里使用了分组和|操作符表示多个可能的方法
- ([^?#\s]+):匹配请求URI,使用了非贪婪的正则表达式表示法,不包含URI中可能存在的参数和锚点
- (\?[^#\s]*)?:匹配请求URI中的参数,使用了可选分组,匹配以?开头的参数部分,可以不出现
- (HTTP\/1\.0|HTTP\/1\.1):匹配HTTP协议的版本号,同样使用了分组和|操作符
其中,\是转义字符,用于匹配特殊字符。正则表达式中的 . 和 | 都是特殊字符,需要用\进行转义。
先添加错误请求的自定义异常 BadRequestException:
java
/* 错误请求格式异常 */
public class BadRequestException extends Exception{
public BadRequestException() {
}
public BadRequestException(String message) {
super(message);
}
public BadRequestException(String message, Throwable cause) {
super(message, cause);
}
public BadRequestException(Throwable cause) {
super(cause);
}
public BadRequestException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}
然后重构请求行解析方法:
java
/**
* 解析请求行方法
* @throws IOException 网络出现异常
* @throws BadRequestException 请求行格式错误
*/
private void parseRequestLine() throws IOException, BadRequestException {
String requestLine = readLine();
//根据HTTP协议描述,requestLine 有可能是空行!
int n = 0;
while (requestLine.isEmpty()){
//跳过
requestLine = readLine();
if (n++ == 5){
throw new BadRequestException("过多的空请求行!");
}
}
String regex = "^(GET|POST|PUT|DELETE|HEAD|OPTIONS) ([^?#\\s]+)(\\?[^#\\s]*)? (HTTP\\/1\\.0|HTTP\\/1\\.1)$";
if (! requestLine.matches(regex)){
throw new BadRequestException("错误的请求行格式");
}
String[] parts = requestLine.split("\\s");
method = parts[0];
uri = parts[1];
protocol = parts[2];
System.out.println("解析请求行:"+requestLine);
System.out.println("method:"+method);
System.out.println("uri:"+uri);
System.out.println("protocol:"+protocol);
}
通过重构,现在ClientHandler类中的请求处理过程变得非常清晰简洁。我们将请求处理逻辑抽取到了DispatcherServlet类中,使得ClientHandler更专注于处理连接和调用请求处理器的功能。这样的设计提高了代码的模块化和可维护性,使整体结构更清晰,更易于后续的开发和维护。
3.2 单例模式
3.2.1 设计模式与单例模式
设计模式是针对面向对象编程中常见的问题和场景,提出的一套经过反复实践验证的解决方案的方法论,它描述了一组经过测试和证明的解决方案,可以用来解决面向对象编程中的各种问题。
单例模式是一种常用的设计模式,它保证一个类只有一个实例,并提供一个全局访问点来访问这个唯一的实例。在单例模式中,通常将该类的构造函数私有化,防止外部直接创建实例,而通过一个静态方法或者变量来获取唯一的实例。
单例模式可以避免在系统中出现多个相同的对象,减小系统开销,并且方便对这个唯一实例进行统一的管理和控制。在需要频繁创建和销毁对象的场景下,采用单例模式可以提高系统的性能和可维护性。
在实际开发中,单例模式的应用非常广泛,例如,线程池、数据库连接池、日志系统等等都可以采用单例模式来保证全局唯一性和统一管理。但是,在使用单例模式时也需要注意一些问题,例如线程安全性、延迟加载等等。
3.2.2 使用单例模式重构请求处理DispatcherServlet
使用单例模式重构请求处理DispatcherServlet可以优化资源的创建和提高软件效能。在Java中,创建对象的过程涉及一定的内存和时间开销,如果可以减少对象的创建次数,可以提升程序性能。在这里,我们可以使用饿汉单例模式来确保DispatcherServlet在整个应用程序中只有一个实例。
首先,我们需要将DispatcherServlet类设计为单例模式。饿汉单例模式的实现比较简单,可以在类加载时就创建唯一的实例对象,保证了线程安全性。
接下来,我们需要在ClientHandler类中使用DispatcherServlet的单例实例。
通过以上重构,我们将DispatcherServlet类设计为了饿汉单例模式,确保整个应用程序中只有一个DispatcherServlet实例。同时,在ClientHandler类中使用DispatcherServlet.getInstance()来获取该单例实例。
以下是将DispatcherServlet重构为饿汉单例模式的代码:
java
public class DispatcherServlet {
// 1. 将构造方法私有化,防止外部通过new创建实例
private DispatcherServlet(){
}
//2. 定义一个静态变量来保存实例,并进行初始化
private static DispatcherServlet instance = new DispatcherServlet();
// 3. 提供一个公有的静态方法来获取实例
public static DispatcherServlet getInstance() {
return instance;
}
// 略去 请求处理代码 ...
}
在上面的代码中,我们将DispatcherServlet的构造函数设置为私有,这样外部就无法通过new DispatcherServlet()来实例化对象。同时,我们在类加载时就创建了一个唯一的DispatcherServlet实例,并通过静态方法getInstance()来获取该实例。
接下来,我们需要在ClientHandler类中使用DispatcherServlet的单例实例:
java
DispatcherServlet servlet = DispatcherServlet.getInstance();
通过以上重构,我们将DispatcherServlet类设计为了饿汉单例模式,确保整个应用程序中只有一个DispatcherServlet实例。