2.1 使用多线程处理多用户请求
2.1.1 多线程Socket通信
在上一章的案例中,服务端显然只能处理一次浏览器请求,请求一次浏览器端就结束程序。如何解决这个问题呢?可以采用多线程Socket通信技术,解决多用户并发请求。
在多线程Socket通信中,服务端会启动一个主线程用于监听客户端的连接请求,并为每个客户端连接请求创建一个新的子线程进行处理。这样可以保证服务端能够同时处理多个客户端的请求,提高系统的并发性能和稳定性。
具体流程如下:
1. 服务端启动主线程监听客户端的连接请求;
2. 当有新的客户端连接请求时,主线程创建一个新的子线程来处理该客户端请求;
3. 子线程接收客户端的请求信息,并根据请求内容进行相应的业务处理;
4. 子线程将处理结果封装成响应报文发送给客户端;
5. 子线程关闭连接,结束线程。
这样,服务端就可以同时处理多个客户端请求,实现了高并发处理能力。同时,使用多线程编程也能提高代码的可维护性和可扩展性,减少代码耦合度。
2.1.2 使用多线程处理HTTP通信
首先定义ClientHandler,作为线程处理HTTP请求和发生HTTP响应:
public class ClientHandler implements Runnable {
private Socket socket;
public ClientHandler(Socket clientSocket){
socket = clientSocket;
}
@Override
public void run() {
try {
//从客户端Socket对象中获取输入流,读取HTTP请求报文(请求消息)。
InputStream in = socket.getInputStream();
StringBuilder builder= new StringBuilder();
// 前一个字符 当前字符
char previous = 0, current = 0;
int b;
while ((b=in.read())!=-1){
//将读取的字节存储到当前字符, 由于请求头采用了ISO8859-1编码,
// 所以可以讲字节直接转化为字符类型
current = (char) b;
//如果发现了 前一个字符是 \r 当前字符是 \n 就读取到了行末尾
if (previous == '\r' && current == '\n'){
//如果这一行是空行就结束处理了
if (builder.toString().isEmpty()){
break;
}
//输出这一行数据当前一行数据并且清空builder,为下次缓存数据做准备
System.out.println(builder);
builder.delete(0, builder.length());
}else if (current != '\r' && current != '\n'){
//当前的不是 \r \n 就是一行中的字符
builder.append(current);
}
//最后将当前的字符作为下次的前一个字符
previous = current;
}
OutputStream out = socket.getOutputStream();
//一个简单的网页内容
String html = "<html>\n" +
"<head>\n" +
"<title>Example</title>\n" +
"</head>\n" +
"<body>\n" +
"<p>Hello World!</p>\n" +
"</body>\n" +
"</html>";
byte[] body = html.getBytes(StandardCharsets.UTF_8);
out.write("HTTP/1.1 200 OK".getBytes(StandardCharsets.ISO_8859_1));
out.write('\r');
out.write('\n');
out.write("Content-Type: text/html; charset=utf-8".getBytes(StandardCharsets.ISO_8859_1));
out.write('\r');
out.write('\n');
out.write(("Content-Length: "+body.length).getBytes(StandardCharsets.ISO_8859_1));
out.write('\r');
out.write('\n');
out.write('\r'); //空行
out.write('\n');
out.write(body);
//关闭客户端连接
out.close();
in.close();
}catch (IOException e){
e.printStackTrace();
}finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
然后重构start()方法:
public class ServerBootApplication {
private ServerSocket serverSocket;
public void start(){
try {
//创建ServerSocket对象,并指定监听的端口号8088。
serverSocket = new ServerSocket(8088);
while (true) {
//使用accept()方法等待客户端的连接请求,并获取客户端的Socket对象
Socket clientSocket = serverSocket.accept();
ClientHandler clientHandler = new ClientHandler(clientSocket);
Thread thread = new Thread(clientHandler);
thread.start();
}
}catch (IOException e){
e.printStackTrace();
}
}
public static void main(String[] args) {
//创建ServerBoot对象
ServerBootApplication application = new ServerBootApplication();
//启动服务器
application.start();
}
}
该代码是一个典型的多线程Socket通信的服务器端代码结构。在主线程中,创建了一个ServerSocket对象,并指定要监听的端口号8088。接着,通过一个while循环不断地使用accept()方法等待客户端的连接请求,一旦接收到请求,就会创建一个新的ClientHandler对象,将客户端的Socket对象传递给它,然后将ClientHandler对象封装成一个新的线程并启动,用于处理客户端的请求。在这个过程中,主线程一直保持监听状态,等待下一个客户端连接。
为了处理多个客户端的并发请求,每个ClientHandler对象都运行在一个单独的线程中,这使得服务器可以同时处理多个客户端的请求,提高了系统的并发处理能力。
经过上述重构我们的WebServer就可以处理多用户的并发请求了。
2.1.3 关于favicon.ico
实现了多线程Web请求处理功能以后,控制台上出现了如下请求信息:
由图可以看出:显然请求了favicon.ico文件。
favicon.ico是一个网站上常见的文件,它是网站的图标文件,通常会显示在网站的标签页和书签上。当用户访问一个网站时,浏览器会自动请求这个文件,以便在标签页和书签上显示网站的图标。因此,在服务器的请求日志中,我们会看到很多关于 favicon.ico 的请求记录。这些请求记录是非常正常的现象,不必过于关注。
理解这些请求信息有助于我们对网站访问进行监控和分析。通常情况下,这些请求不会对服务器性能产生重大影响,因为 favicon.ico 文件通常是相对较小的图标文件,并且浏览器会进行缓存,减少了对服务器的重复请求。
因此,不必担心看到这些 favicon.ico 请求记录,除非你注意到在短时间内出现异常的请求量,这可能是有人在恶意攻击或者其他异常情况,需要进一步分析和处理。否则,这些请求是正常的,不需要特别处理。
2.2 解析请求行
2.2.1 解析请求行
HTTP请求行是HTTP请求报文的第一行,包括HTTP方法、请求URL和HTTP协议版本号。例如:
GET /index.html HTTP/1.1
其中,GET表示HTTP请求方法,/index.html表示请求的资源URL,HTTP/1.1表示使用的HTTP协议版本号。
解析HTTP请求行可以获取请求方法、请求URL和协议版本等信息,这些信息对于服务器来说非常重要,可以根据这些信息对请求进行处理和响应。例如,根据请求URL可以确定请求的资源类型和位置,从而进行处理和响应;根据请求方法可以确定请求的类型(如GET、POST、PUT、DELETE等),从而采取相应的处理方式。因此,解析HTTP请求行是Web服务器处理HTTP请求的重要步骤之一。
前述多线程的WebServer案例虽然能过处理用户请求,但是用户发起任何请求都会得到相同的响应结果,比如发送:http://localhost:8088/ 、http://localhost:8088/index.html和 http://localhost:8088/demo.html,都得到如下结果:
这个结果显然不理想:不可能任何请求都返回相同的响应结果。正确情况应该是:请求index.html就显示index.html文件的内容,请求demo.index文件就显示demo.html的内容。
2.2.2 显示正确的请求内容
如何解决这个问题呢?解决的办法就是将请求行进行解析,找出客户端发起的请求资源路径,根据请求资源的路径找到响应的资源,发送响应到客户端浏览器。这样就可以在用户请求不同资源时候,响应不同的结果:
2.2.3 读取请求行
请求行在请求消息的第一行,请求行以\r\n为结尾,可以使用算法在读取请求头之前读取第一行作为请求行,如下代码可以读取请求的第一行:
InputStream in = socket.getInputStream();
StringBuilder builder= new StringBuilder();
// 前一个字符 当前字符
char previous = 0, current = 0;
int b;
String requestLine = null;
//解析请求行
while ((b=in.read())!=-1){
current = (char) b;
if (previous == '\r' && current == '\n'){
requestLine = builder.toString();
builder.delete(0, builder.length());
break;
}else if (current != '\r' && current != '\n'){
builder.append(current);
}
previous = current;
}
System.out.println(requestLine);
显然这个算法和读取请求头的代码是重复的,所以可以尝试将代码重构抽取一个从输入流中读取一行的方法:
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();
}
这段代码的作用是从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转换成字符串并返回。
重构后的ClientHandler代码就清爽许多:
public class ClientHandler implements Runnable {
private Socket socket;
public ClientHandler(Socket clientSocket){
socket = clientSocket;
}
@Override
public void run() {
try {
//从客户端Socket对象中获取输入流,读取HTTP请求报文(请求消息)。
InputStream in = socket.getInputStream();
//读取请求行
String requestLine = readLine();
System.out.println(requestLine);
//读取请求头
String requestHeader;
//读取到空行就不在读取请求头了
while (!(requestHeader = readLine()).isEmpty()){
System.out.println(requestHeader);
}
OutputStream out = socket.getOutputStream();
//一个简单的网页内容
String html = "<html>\n" +
"<head>\n" +
"<title>Example</title>\n" +
"</head>\n" +
"<body>\n" +
"<p>Hello World!</p>\n" +
"</body>\n" +
"</html>";
byte[] body = html.getBytes(StandardCharsets.UTF_8);
out.write("HTTP/1.1 200 OK".getBytes(StandardCharsets.ISO_8859_1));
out.write('\r');
out.write('\n');
out.write("Content-Type: text/html; charset=utf-8".getBytes(StandardCharsets.ISO_8859_1));
out.write('\r');
out.write('\n');
out.write(("Content-Length: "+body.length).getBytes(StandardCharsets.ISO_8859_1));
out.write('\r');
out.write('\n');
out.write('\r'); //空行
out.write('\n');
out.write(body);
//关闭客户端连接
out.close();
in.close();
}catch (IOException e){
e.printStackTrace();
}finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
/**
* 这段代码的作用是从Socket的输入流中读取一行数据并返回。
* @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();
}
}
2.2.4 解析请求行
上述代码实现了,读取请求行,读取后需要从请求行中解析其中的每个部分,然后可以根据请求行找到相应的本地文件资源,发送响应给浏览器,显示不同的资源内容。
解析请求行:
String[] line = requestLine.split("\\s");
String method = line[0];
String uri = line[1];
String protocol = line[1];
System.out.println("method: "+method);
System.out.println("uri: " + uri);
System.out.println("protocol: " + protocol);
这段代码用于解析HTTP请求报文中的请求行。首先,将请求行按照空白字符进行分割,得到一个包含请求方法、URI和协议版本三个字段的字符串数组。接着,将这三个字段分别存储到对应的变量中,并打印出来以供调试或其他用途。
具体解释如下:
- 使用String类的split()方法按照空白字符(包括空格、制表符和换行符)对请求行进行分割,得到一个包含请求方法、URI和协议版本三个字段的字符串数组line
- 将line数组中的第一个元素存储到字符串变量method中,第二个元素存储到字符串变量uri中,第三个元素存储到字符串变量protocol中
- 最后,使用System.out.println()方法打印出method、uri和protocol的值,方便调试和查看解析结果
需要注意的是,该代码仅仅是对HTTP请求报文中的请求行进行了最基本的解析,仅适用于最简单的HTTP请求,对于复杂的HTTP请求报文,需要进行更加严谨和完整的解析。
重新启动后服务端后,用浏览器发起 http://localhost:8088/index.html 请求,控制台信息包含如下信息:
这个信息表示请求行解析成功。
2.3 响应静态资源
2.3.1 创建静态资源
前述项目完成了解析请求行,从请求行中得到了请求资源的路径URI,为了能响应客户端需求,返回对应的资源,所以要建立静态文件夹存储静态资源:
index.html 文件是一个html文件,格式类似于html,内容如下:
<!DOCTYPE html>
<html lang="cn">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>Hello World</h1>
</body>
</html>
demo.html 文件内容如下:
<!DOCTYPE html>
<html lang="cn">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>Demo Page</h1>
</body>
</html>
有了这了两个资源,就可以实现根据用户请求的uri,找到这两个资源,并且发送到响应到浏览器,实现根据用户请求响应不同文件的功能。
2.3.2 响应静态资源
重构服务端代码,根据URI在resources/static文件夹中找到静态资源,并且将静态资源响应给客户端,原理为:
2.3.3 响应静态资源
重构后的代码:
//发送响应
//根据找到静态资源
//类加载路径: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);
//读取文件的全部内容
byte[] bytes = new byte[(int)file.length()];
FileInputStream fin = new FileInputStream(file);
fin.read(bytes);
fin.close();
OutputStream out = socket.getOutputStream();
out.write("HTTP/1.1 200 OK".getBytes(StandardCharsets.ISO_8859_1));
out.write('\r');
out.write('\n');
out.write("Content-Type: text/html; charset=utf-8".getBytes(StandardCharsets.ISO_8859_1));
out.write('\r');
out.write('\n');
out.write(("Content-Length: "+bytes.length).getBytes(StandardCharsets.ISO_8859_1));
out.write('\r');
out.write('\n');
out.write('\r'); //空行
out.write('\n');
out.write(bytes);
//关闭客户端连接
out.close();
in.close();
这段代码是一个简单的HTTP服务器响应客户端请求的部分,主要功能是根据请求的URI定位服务器上的静态资源,并将其发送给客户端。
具体来说,代码首先根据当前类的类加载器获取类加载路径,然后根据该路径找到服务器上的静态资源所在目录。接着,根据URI定位静态资源文件,并读取该文件的全部内容到一个字节数组中。
然后,代码使用Java的Socket API将HTTP响应发送给客户端。响应包括HTTP响应头和响应体两部分。响应头中包含HTTP协议版本、状态码和响应内容类型等信息,而响应体则包含实际的静态资源内容。发送响应的代码使用OutputStream将响应数据写入到客户端的Socket连接中,并在最后关闭客户端连接。
完成的ClientHandler参考如下:
public class ClientHandler implements Runnable {
private Socket socket;
public ClientHandler(Socket clientSocket){
socket = clientSocket;
}
@Override
public void run() {
try {
//从客户端Socket对象中获取输入流,读取HTTP请求报文(请求消息)。
InputStream in = socket.getInputStream();
//读取请求行
String requestLine = readLine();
System.out.println(requestLine);
//解析请求行
String[] line = requestLine.split("\\s");
String method = line[0];
String uri = line[1];
String protocol = line[1];
System.out.println("method: "+method);
System.out.println("uri: " + uri);
System.out.println("protocol: " + protocol);
//读取请求头
String requestHeader;
//读取到空行就不在读取请求头了
while (!(requestHeader = readLine()).isEmpty()){
System.out.println(requestHeader);
}
//发送响应
//根据找到静态资源
//类加载路径: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);
//读取文件的全部内容
byte[] bytes = new byte[(int)file.length()];
FileInputStream fin = new FileInputStream(file);
fin.read(bytes);
fin.close();
OutputStream out = socket.getOutputStream();
out.write("HTTP/1.1 200 OK".getBytes(StandardCharsets.ISO_8859_1));
out.write('\r');
out.write('\n');
out.write("Content-Type: text/html; charset=utf-8".getBytes(StandardCharsets.ISO_8859_1));
out.write('\r');
out.write('\n');
out.write(("Content-Length: "+bytes.length).getBytes(StandardCharsets.ISO_8859_1));
out.write('\r');
out.write('\n');
out.write('\r'); //空行
out.write('\n');
out.write(bytes);
//关闭客户端连接
out.close();
in.close();
}catch (IOException | URISyntaxException e){
e.printStackTrace();
}finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
/**
* 这段代码的作用是从Socket的输入流中读取一行数据并返回。它通过InputStream获取Socket的输入流,
* 然后使用一个StringBuilder对象来存储读取的数据,最终返回读取的数据。
* @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();
}
}
2.3.4 HTTP协议补充
HTTP(Hypertext Transfer Protocol)是一种用于在计算机网络上传输超文本数据的协议。它是Web应用程序中最基本的通信协议,用于在客户端(例如浏览器)和服务器之间传输数据。
HTTP的主要特点包括:
- 状态无关:HTTP是一种无状态协议,即服务器不会记录客户端之前的请求信息。每个HTTP请求都是独立的,服务器不会保留客户端的状态信息,这样可以降低服务器的负担,也使得HTTP协议具有良好的扩展性。
- 请求-响应模型:HTTP是基于请求-响应模型的协议。客户端发送HTTP请求到服务器,然后服务器返回HTTP响应。请求包括请求方法(GET、POST等)、请求头、请求体等信息,响应包括状态码、响应头、响应体等信息。
- 可靠性:HTTP在传输过程中使用TCP协议作为传输层协议,因此具有可靠性。TCP协议会确保数据的正确传输和接收,如果数据丢失或损坏,TCP会自动重传。
- 简单灵活:HTTP协议采用文本形式传输数据,易于阅读和调试。同时,HTTP协议也非常灵活,可以传输不同类型的数据,支持多种编码和内容类型。
- 支持缓存:HTTP协议支持缓存机制,通过在响应中添加缓存相关的头信息,可以让浏览器在下次请求相同资源时直接从缓存中获取,提高性能和加载速度。
HTTP协议的工作方式是客户端向服务器发送请求,服务器根据请求进行处理并返回响应。客户端和服务器通过URL(统一资源定位符)来定位资源,URL由协议类型(例如http)、服务器地址和资源路径组成。
HTTP协议是Web开发中非常重要的基础,它使得浏览器能够请求并获取Web页面、图片、视频、文件等资源,并实现了Web应用程序的交互性。同时,HTTP也不断发展,出现了新的版本,例如HTTP/1.1和HTTP/2,以满足不断增长的Web应用需求。
2.3.5 幂等性
在计算机科学和网络编程中,幂等性(Idempotence)是指对同一个操作进行一次或多次的操作,产生的结果是相同的。换句话说,无论对一个操作进行多少次重复,其结果都是一致的。
幂等性在计算机系统设计和网络通信中具有重要意义,特别是在处理故障、网络延迟或重试等情况下。幂等性操作保证了系统对相同请求的重复处理不会导致副作用或错误结果。
举个简单的例子,假设有一个用于更新用户信息的API接口。如果这个API是幂等的,那么当多个请求同时更新同一个用户信息时,无论请求执行多少次,最终用户的信息都只会更新一次,而不会因为重复的请求导致用户信息被错误地更新多次。
在实际应用中,一些常见的幂等性操作包括:
- GET请求:GET请求是幂等的,因为对于同一个URL的GET请求,无论请求执行多少次,都只会返回相同的响应结果,不会对服务器产生任何副作用。
- PUT请求:PUT请求通常用于更新资源,在幂等性的设计下,对于同一个URL的PUT请求,重复执行对资源的更新操作将得到相同的结果。
- DELETE请求:DELETE请求通常用于删除资源,在幂等性的设计下,对于同一个URL的DELETE请求,重复执行对资源的删除操作将得到相同的结果。
幂等性的接口设计:在设计API接口时,如果接口的操作具有幂等性,可以增强系统的稳定性和可靠性。通过一些设计措施,比如生成唯一的请求标识,对于相同的请求标识只处理一次,就能实现幂等性。
总的来说,幂等性操作对于构建健壮和可靠的系统非常重要,可以避免重复操作导致的不一致性和错误结果。在网络通信中,幂等性的操作可以增加系统的容错性,确保请求的可靠传输和正确处理。