HTTP协议,以及用Java程序在PC端无框架实现Tomcat基本功能

我是一个从汽车行业转行IT的项目经理,我是Edward,如想了解更多,请关注我的公众号【转行项目经理的逆袭之路】。今天跟大家聊聊HTTP协议以及用Java程序在PC端无框架实现Tomcat基础功能。

HTTP协议 超文本传输协议

由万维网制定(w3c)是浏览器与服务器通讯的应用层协议,规定了浏览器与服务器之间的交互规则以及交互数据的格式信息等。

HTTP协议对于客户端与服务端之间的交互规则有以下定义:
要求浏览器与服务端之间必须遵循一问一答的规则,即:浏览器与服务端建立TCP连接后需要先发送一个请求(问)然后服务端接收到请求并予以处理后再发送响应(答)。注意,服务端永远不会主动给浏览器发送信息。

HTTP要求浏览器与服务端的传输层协议必须是可靠的传输,因此是使用TCP协议作为传输层协议的。

HTTP协议对于浏览器与服务端之间交互的数据格式,内容也有一定的要求。
浏览器给服务端发送的内容称为请求Request
服务端给浏览器发送的内容称为响应Response

请求和响应中大部分内容都是文本信息(字符串),并且这些文本数据使用的字符集为:
ISO8859-1.这是一个欧洲的字符集,里面是不支持中文的!!!。而实际上请求和响应出现
的字符也就是英文,数字,符号。

char a 0000 1101; 只识别后八位。

请求Request
请求是浏览器发送给服务端的内容,HTTP协议中一个请求由三部分构成:
分别是:请求行,消息头,消息正文。消息正文部分可以没有。

1:请求行
请求行是一行字符串,以连续的两个字符(回车符和换行符)作为结束这一行的标志。
回车符:在ASC编码中2进制内容对应的整数是13.回车符通常用cr表示。 00001101
换行符:在ASC编码中2进制内容对应的整数是10.换行符通常用lf表示。 00001010
回车符和换行符实际上都是不可见字符。
Enter实际上结合了回车+换行

请求行分为三部分:
请求方式(SP)抽象路径(SP)协议版本(CRLF) 注:SP是空格 CRLF是回车换行符
GET /index.html HTTP/1.1

2:消息头
消息头是浏览器可以给服务端发送的一些附加信息,有的用来说明浏览器自身内容,有的
用来告知服务端交互细节,有的告知服务端消息正文详情等。

消息头由若干行组成,每行结束也是以CRLF标志。
每个消息头的格式为:消息头的名字(:SP)消息的值(CRLF)
消息头部分结束是以单独的(CRLF)标志。
例如:
Host: localhost:8088(CRLF)
Connection: keep-alive(CRLF)
Upgrade-Insecure-Requests: 1(CRLF)
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36(CRLF)
Sec-Fetch-User: ?1(CRLF)
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3;q=0.9(CRLF)
Sec-Fetch-Site: none(CRLF)
Sec-Fetch-Mode: navigate(CRLF)
Accept-Encoding: gzip, deflate, br(CRLF)
Accept-Language: zh-CN,zh;q=0.9(CRLF)(CRLF)

3:消息正文
消息正文是2进制数据,通常是用户上传的信息,比如:在页面输入的注册信息,上传的
附件等内容。

GET /index.html HTTP/1.1
Host: localhost:8088
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36
Sec-Fetch-User: ?1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9

HTTP响应Response
响应是服务端发送给客户端的内容。一个响应包含三部分:状态行,响应头,响应正文

1:状态行
状态行是一行字符串(CRLF结尾),并且状态行由三部分组成,格式为:
protocol(SP)statusCode(SP)statusReason(CRLF)
协议版本(SP)状态代码(SP)状态描述(CRLF)
例如:
HTTP/1.1 200 OK

状态代码是一个3位数字,分为5类:
1xx:保留
2xx:成功,表示处理成功,并正常响应
3xx:重定向,表示处理成功,但是需要浏览器进一步请求
4xx:客户端错误,表示客户端请求错误导致服务端无法处理
5xx:服务端错误,表示服务端处理请求过程出现了错误

具体的数字在HTTP协议手册中有相关的定义,可参阅。
状态描述手册中根据不同的状态代码有参考值,也可以自行定义。通常使用参考值即可。

响应头:
响应头与请求中的消息头格式一致,表示的是服务端发送给客户端的附加信息。

响应正文:
2进制数据部分,包含的通常是客户端实际请求的资源内容。

响应的大致内容:
HTTP/1.1 200 OK(CRLF)
Content-Type: text/html(CRLF)
Content-Length: 2546(CRLF)(CRLF)
1011101010101010101…

这里的两个响应头:
Content-Type是用来告知浏览器响应正文中的内容是什么类型的数据(图片,页面等等)
不同的类型对应的值是不同的,比如:
文件类型 Content-Type对应的值
html text/html
css text/css
js application/javascript
png image/png
gif image/gif
jpg image/jpeg

Content-Length是用来告知浏览器响应正文的长度,单位是字节。

浏览器接收正文前会根据上述两个响应头来得知长度和类型从而读取出来做对应的处理以
显示给用户看。

无框架实现Tomcat基本功能

在这里插入图片描述
以上就是实现的具体流程图。
整体上是一个BS模型,也就是经历以下几个步骤:
1、客户端(浏览器)发送HTTP Request
2、服务器处理请求
3、服务器响应客户端发送Response
4、将客户端需要的信息返回给客户端

核心是:服务器的搭建、请求的解析以及响应的发送

package com.webserver.core;
/**
 * 
 * http://localhost:8088/index.html
 * 
 * WebServer主类
 * 
 * WebServer是一个Web容器
 * Web容器的主要工作:
 * 1.管理多个webapp(网络应用,每个应用包含网页,资源,java代码等,就是一个“网站”)
 * 2.负责与客户端(通常是浏览器)建立TCP连接,以及基于HTTP协议进行交互
 * 
 * 这个项目模拟的是TOMCAT的基础功能。
* @author EP
* @date 2020年4月1日  
* @version 1.0
 */

import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import com.webserver.http.HttpContext;

public class WebServer {
	private ServerSocket server;
	private ExecutorService threadPool;
	
	public WebServer() {
		try {
			System.out.println("正在启动服务端...");
			server = new ServerSocket(8088);
			threadPool = Executors.newFixedThreadPool(50);
			System.out.println("服务端启动完毕!");
		} catch (Exception e) {
			e.printStackTrace();
		}
		
	}
	
	public void start() {
		try {
			while (true) {
				System.out.println("等待客户端连接...");
				Socket socket = server.accept();
				System.out.println("一个客户端连接了");
				//启动一个线程用于处理该客户端交互
				ClientHandler handler = new ClientHandler(socket);
				threadPool.execute(handler);
				
			}
			
		} catch (Exception e) {
			e.printStackTrace();
		}
		
	}
	
	public static void main(String[] args) {
		WebServer server = new WebServer();
		server.start();
	}

}
package com.webserver.core;
/**
 * 该线程任务负责与指定客户端完成HTTP交互
 * 
* @author EP
* @date 2020年4月2日  
* @version 1.0
 */

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.util.HashMap;
import java.util.Map;

import com.webserver.http.EmptyRequestException;
import com.webserver.http.HttpContext;
import com.webserver.http.HttpRequest;
import com.webserver.http.HttpResponse;
import com.webserver.servlet.CreateQRServlet;
import com.webserver.servlet.HttpServlet;
import com.webserver.servlet.LoginServlet;
import com.webserver.servlet.RegServlet;
import com.webserver.servlet.UpdatePwdServlet;

public class ClientHandler implements Runnable{
	private Socket socket;
	public ClientHandler(Socket socket) {
		this.socket = socket;
	}
	public void run() {
		try {
			/*
			 * HTTP协议要求客户端与服务端的交互采取一问一答,因此
			 * ClientHandler处理本次请求时,完成三步:
			 * 1:解析请求
			 * 2:处理请求
			 * 3:响应客户端
			 */
			
			//1解析请求
			HttpRequest request =new HttpRequest(socket);
			//创建响应对象
			HttpResponse response = new HttpResponse(socket);
			
			//2处理请求
			//通过request获取请求的资源的抽象路径
			String path = request.getRequestURI();
			System.out.println("ClientHandler:抽象路径为:"+path);
			
			
			//判断本次请求是否为请求业务
			HttpServlet servlet = ServerContext.getServlet(path);
			if (servlet!=null) {
				servlet.service(request, response);
			}else {
				//相对于webapps目录寻找对应的资源
				File file = new File("./webapps"+path);
				if (file.exists()) {
					System.out.println("资源已找到");		
					
					//将请求的资源设置到response中
					response.setEntity(file);
					
					
					//Connection:Close是告知浏览器响应发送完毕后就与客户端断开TCP连接
					response.putHeader("Connection", "close");
					
					
					
					
				}else {
					System.out.println("资源不存在");
					
					File notFoundPage= new File("./webapps/root/404.html");
					
					response.setEntity(notFoundPage);
					//设置状态代码和描述为404相关
					response.setStatusCode(404);
					response.setStatusReason("NotFound");
					//设置响应头
					
					response.putHeader("Connection", "close");
					
				}
				
			}
			
			//3响应客户端
			
			response.flush();
			System.out.println("ClientHandler:响应发送完毕!");
			
			
			
			
		}catch (EmptyRequestException e) {
			//出现空请求异常,什么都不做,直接走下面finally断开连接即可
		}catch (Exception e) {
			e.printStackTrace();
		}finally {
			//与客户端断开连接的操作
			try {
				socket.close();
			} catch (IOException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}
	}

}

以上两段代码是服务端线程池的搭建以及业务处理线程的具体分配。下面是请求的解析和响应的回复。

package com.webserver.http;

import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;
import java.net.URLDecoder;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

/**
 * 请求对象,该类的每一个实例用于表示客户端发送过来的一个HTTP请求
 * 每个请求由三部分构成:
 * 1:请求行
 * 2:消息头
 * 3:消息正文
 * 
* @author EP
* @date 2020年4月2日  
* @version 1.0
 */

public class HttpRequest {

	/*
	 * 请求行的相关信息
	 */
	//请求方式
	private String method;
	//抽象路径
	private String uri;
	//协议版本
	private String protocol;
	
	//抽象路径中的请求部分,即:uri中”?“左侧内容
	private String requestURI;
	//抽象路径中的参数部分,即:uri中”?“右侧内容
	private String queryString;
	
	//保存用户提交上来的每一个参数
	private Map<String, String>parameters = new HashMap<String, String>();
	
	
	/*
	 * 消息头相关信息
	 */
	Map<String, String>headers = new HashMap<String, String>();
	
	
	
	/*
	 * 消息正文相关信息
	 */
	
	/*
	 * 与连接相关的属性
	 */
	private Socket socket;
	private InputStream in;
	/**
	 * HttpRequest的构造方法用于实例化HttpRequest对象,实例化的过程就是解析
	 * 请求的过程,需要传入Socket,以便通过获取的输入流读取客户端发送过来的请求
	 * 内容进行解析并实例化对象。
	 * @param socket
	 * @throws EmptyRequestException 
	 */
	
	public HttpRequest(Socket socket) throws EmptyRequestException {
		this.socket = socket;
		try {
			this.in = socket.getInputStream();
			/*
			 * 解析分为三步
			 * 1.解析请求行
			 * 2.解析消息头
			 * 3.解析消息正文
			 */
			
			parseRequestLine();
			parseHeader();
			parseContent();
		}	catch (EmptyRequestException e) {
			throw e;
		} 	catch (IOException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		System.out.println("HttpRequest:开始解析请求...");
		
		System.out.println("HttpRequest:解析请求完毕!");
		
	}
	/*
	 *解析请求行 
	 */
	private void parseRequestLine() throws EmptyRequestException {
		System.out.println("HttpRequest:开始解析请求行...");
		try {
			String line = readline();
			//判断本次请求是否为空请求
			if ("".equals(line)) {
				//抛出空请求异常
				throw new EmptyRequestException();
			}
			System.out.println("请求行:"+line);
			
			/*
			 * 拆分请求行,将三部分内容分别赋值
			 */
			String[]data=line.split("\\s");
			method =data[0];
			uri =data[1];
			/*
			 * 该方法是将uri中"%XX"这样的内容按照指定的字符集还原为对应文字
			 */
			uri = URLDecoder.decode(uri, "GBK");
			protocol =data[2];
			
			System.out.println("method:"+method);
			System.out.println("uri:"+uri);
			System.out.println("protocol:"+protocol);
			
			//进一步解析uri
			parseURI();
			
		}catch (EmptyRequestException e) {   //单独处理空请求异常
			throw e;
		
		}catch (Exception e) {
			e.printStackTrace();
		}
		
		System.out.println("HttpRequest:请求行解析完毕!");
		
	}
	/**
	 * 进一步解析uri
	 */
	private void parseURI() {
		System.out.println("HttpRequest:进一步解析uri...");
		try {
			/*
			 * 由于uri有两种情况:1含有参数,2不含有参数。因此,我们要对uri进行
			 * 一次解析。
			 * 如果不含有参数(uri中不含有"?"),那么直接将uri赋值给requestURI
			 * 其他两个参数无需处理(queryString和parameters)
			 * 
			 * 如果含有参数(uri中含有"?"),则需要进行拆分工作
			 * 1:将uri中“?”左侧内容赋值给requestURI
			 * 2:将uri中“?”右侧内容赋值给queryString
			 * 3:再将queryString中的参数内容按照“&”拆分出每一个参数,之后每个
			 * 参数再按照“=”拆分为参数名与参数值,并将参数名作为key,参数值作为value
			 * 保存到parameters中。
			 */
			if (uri.indexOf("?")!=-1) {  //也可以用String的contains方法,用遍历太啰嗦
				//按照?拆分出请求部分和参数部分
				String []data = uri.split("\\?");
				requestURI = data[0];
				if (data.length>1) {   //可能?后面什么都没有,连标签name都没有
					queryString = data[1];
					//进一步拆分每一个参数
					data=queryString.split("&");
					for (String para : data) {   
					//注意NewFor或者迭代器无法增删改原数组或list,不过提取数据都一样
						String[]arr = para.split("=");
						if (arr.length>1) {
							parameters.put(arr[0], arr[1]);
						}else {
							parameters.put(arr[0], null);
						}
					}
				}
			}else {
				requestURI = uri;
			}
//			char[]arr = uri.toCharArray();   //我的方法略复杂...
//			boolean flag = true;
//			for (int i = 0; i < arr.length; i++) {
//				if ('?'==arr[i]) {
//					flag=false;
//					for (int j = 0; j < i; j++) {
//						requestURI+=arr[j];
//					}
//					for (int j = i+1; j < arr.length; j++) {
//						queryString+=arr[j];
//					}
//					String[]data=queryString.split("&");
//					for (int j = 0; j < data.length; j++) {
//						String[]entry = data[j].split("=");
//						String key= entry[0];
//						String value= entry[1];
//						parameters.put(key, value);
//					}					
//					break;
//				}
//			}
//			if (flag) {
//				requestURI=uri;
//			}
			
			
			
			System.out.println("requestURI:"+requestURI);
			System.out.println("queryString:"+queryString);
			System.out.println("parameters:"+parameters);
		} catch (Exception e) {
			e.printStackTrace();
		}
		System.out.println("HttpRequest:解析uri完毕!");
	}
	
	/*
	 * 解析消息头
	 */
	private void parseHeader() {
		System.out.println("HttpRequest:开始解析消息头...");
		try {
			/*
			 * 循环读取每一行字符串,如果单独读取了CRLF就停止(调用readLine()
			 * 返回值为空字符串时"",就说明单独读取了CRLF)。
			 * 然后将每行字符串按照“: ”(冒号空格)拆分为两部分,分别表示的
			 * 是消息头的名字和对应的值。并将名字作为key,值作为value保存到
			 * headers中完成解析消息头工作。
			 */
			while (true) {   
				String line = readline();
				if ("".equals(line)) {    //字符串判断一定要用equals!!!
					break;
				}
				System.out.println("消息头:"+line);
				String[]data = line.split(": ");
				headers.put(data[0], data[1]);
			}	
			System.out.println("header:"+headers);
			
		} catch (Exception e) {
			e.printStackTrace();
		}
		
		System.out.println("HttpRequest:消息头解析完毕!");

	}
	/*
	 * 解析消息正文
	 */
	private void parseContent() {
		System.out.println("HttpRequest:开始解析消息正文...");
		
		System.out.println("HttpRequest:消息正文解析完毕!");

	}
	
	private String readline() throws IOException {
		//c1存放上次读取到的字符,c2存放本次读取到的字符
		int c1=0, c2=0;
		StringBuilder builder = new StringBuilder();
		while ((c2=in.read())!=-1) {
			//是否连续读取到了回车和换行
			if (c1==13&&c2==10) {      // CR回车 13  LF换行 10
				break;
			}
			builder.append((char)c2);
			c1=c2;    //将c2赋值给c1,再循环
			
		}
		String line = builder.toString().trim();   //把先前读到的CR去掉
		return line;

	}
	public String getMethod() {
		return method;
	}
	public String getUri() {
		return uri;
	}
	public String getProtocol() {
		return protocol;
	}
	
	public String getHeader(String name) {
		return headers.get(name);	
	}
	public String getRequestURI() {
		return requestURI;
	}
	public String getQueryString() {
		return queryString;
	}
	/**
	 * 根据给定的参数名获取对应的参数值
	 * @param name
	 * @return
	 */
	public String getParameter(String name) {
		return parameters.get(name);
	}
	
	
	
	

	
	
	
	
	
	
	
	
	
	
	
}
package com.webserver.http;
/**
 * 响应对象,该类的每个实例用于表示发送给客户端的一个响应内容。
 * 每个响应包含三部分:
 * 状态行,响应头,响应正文
* @author EP
* @date 2020年4月7日  
* @version 1.0
 */

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.Map.Entry;

public class HttpResponse {
	//状态行相关信息
	private int statusCode =200; //状态代码,默认值200
	private String statusReason = "OK"; //状态描述
	
	
	//响应头相关信息
	private Map<String, String> headers = new HashMap<String, String>();
	
	
	//响应正文相关信息
	private File entity;
	private byte[]data;
	
	//和连接相关的信息
	private Socket socket;
	private OutputStream out;
	
	public HttpResponse(Socket socket) {
		// TODO Auto-generated constructor stub
		try {
			this.socket = socket;
			this.out = socket.getOutputStream();
			
		} catch (Exception e) {
			// TODO: handle exception
			e.printStackTrace();
		}
	}
	/*
	 *将当前响应对象的内容以标准的HTTP响应格式发送给客户端 
	 */
	public void flush() {
		try {
			
			/*
			 * 顺序发送:
			 * 1:发送状态行
			 * 2:发送响应头
			 * 3:发送响应正文
			 */
			//1
			sendStatusLine();
			//2
			sendHeaders();
			//3
			sendContent();
			System.out.println("HttpResponse:发送响应完毕!");
			
		} catch (Exception e) {
			// TODO: handle exception
			e.printStackTrace();
		}
		
	}
	/*
	 * 发送状态行
	 */
	private void sendStatusLine() {
		try {
			System.out.println("HttpResponse:开始发送状态行...");
			//发送状态行
			String line = "HTTP/1.1"+" "+statusCode+" "+statusReason;
			System.out.println("状态行:"+line);
			byte[]data =line.getBytes("ISO8859-1");
			out.write(data);
			//发送回车符
			out.write(13);//written CR
			//发送换行符
			out.write(10);//written LF
			System.out.println("HttpResponse:状态行发送完毕!");
			
			
		} catch (Exception e) {
			// TODO: handle exception
			e.printStackTrace();
		}
	}
	/*
	 * 发送响应头
	 */
	private void sendHeaders() {
		try {
			System.out.println("HttpResponse:开始发送响应头...");
			//发送响应头
			//遍历headers,将所有的响应顺序发送
			Set<Entry<String, String>>entrySet = headers.entrySet();
			for (Entry<String, String> e : entrySet) {
				String key = e.getKey();
				String value = e.getValue();
				String line = key + ": "+value;
				System.out.println("响应头:"+line);
				out.write(line.getBytes("ISO8859-1"));
				out.write(13);
				out.write(10);
				
			}

			
			
			//单独发送CRLF表示响应头发送完毕
			out.write(13);
			out.write(10);
			System.out.println("HttpResponse:响应头发送完毕!");
			
		} catch (Exception e) {
			// TODO: handle exception
			e.printStackTrace();
		}
		
	}
	/*
	 * 发送响应正文
	 */
	private void sendContent() {
		if (data!=null) {
			try {
				out.write(data);
			} catch (IOException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}else if (entity!=null) {
			try (
					FileInputStream fis = new FileInputStream(entity);
					
					){
				System.out.println("HttpResponse:开始发送响应正文...");
				//发送响应正文(用户实际请求的资源文件的所有数据)
				byte[]buf = new byte[1024*10];
				int len=0;
				while ((len=fis.read(buf))!=-1) {
					out.write(buf, 0, len);
					
				}
				System.out.println("HttpResponse:响应正文发送完毕!");
			} catch (Exception e) {
				// TODO: handle exception
				e.printStackTrace();
			}
			
		}
		
	}
	
	public File getEntity() {
		return entity;
	}
	/**
	 * 设置响应正文的实体文件
	 * 设置的同时会根据该文件添加对应的响应头Content-Type和Content-Length
	 * @param entity
	 */
	public void setEntity(File entity) {
		this.entity = entity;
		//获取正文文件的后缀名
		String fileName = entity.getName();
		//split是对应regex,这个是对应ch,不一样
		String ext = fileName.substring(fileName.lastIndexOf(".")+1); 
		//根据后缀获取Content-Type的值
		String type = HttpContext.getMimeType(ext);
		
		//设置响应头
		putHeader("Content-Type", type);
		putHeader("Content-Length", entity.length()+"");
	}
	public int getStatusCode() {
		return statusCode;
	}
	public void setStatusCode(int statusCode) {
		this.statusCode = statusCode;
	}
	public String getStatusReason() {
		return statusReason;
	}
	public void setStatusReason(String statusReason) {
		this.statusReason = statusReason;
	}
	/**
	 * 添加一个响应头
	 * @param name 响应头的名字
	 * @param value 响应头的值
	 */
	public void putHeader(String name,String value) {
		this.headers.put(name, value);
	}
	public byte[] getData() {
		return data;
	}
	/**
	 * 将给定的字节数组内容作为响应正文内容
	 * @param data
	 */
	public void setData(byte[] data) {
		this.data = data;
		this.putHeader("Content-Length", data.length+"");
		//由于没有办法根据字节数组分析出内容的类型,因此Content-Type不设置
	}
	
	
	
	
	
	
}

有了这些基本框架,业务功能如注册、登录、修改密码、制作二维码、验证码以及动态返回用户信息清单等都可以通过后台逻辑的完善来实现,功能路径配置、端口服务器配置、以及Content类型配置都可以通过XML文件的读取来完成,一个基础功能的TOMCAT就搭建好了。

原创文章 46 获赞 7 访问量 2072

猜你喜欢

转载自blog.csdn.net/EdwardWH/article/details/105614923
今日推荐