正文
作为后端开发人员,在实际工作中,Web 服务器的使用频率极高,而在众多 Web 服务器中,Tomcat 作为不可或缺的重要框架,理应成为我们必须学习和掌握的重点。
Tomcat 本质上是一个 Web 框架,那么它的内部机制究竟是如何运作的呢?若不依赖 Tomcat,我们是否有能力自行构建一个 Web 服务器呢?
首先,Tomcat 的内部实现极为复杂,涵盖众多组件。我们将在后续章节中对这些细节展开深入探讨。 其次,本章将带领大家亲手构建一个 Web 服务器。
接下来,让我们一起动手,实现一个简易的 Web 服务器吧。
(【注】:参考自《How Tomcat Works》一书)
什么是 Http
HTTP 是一种协议,全称为超文本传输协议,它使得 Web 服务器与浏览器能够通过互联网传输与接收数据,属于一种请求/响应的通信机制。HTTP 协议的底层依赖于 TCP 协议进行数据传输。目前,HTTP 已经演进至 2.x 版本,历经从 0.9、1.0、1.1 到如今的 2.x,每次迭代都为协议增加了许多新功能。
在 HTTP 的通信模式中,始终由客户端发起请求,服务器接收到请求后处理相应的逻辑,并在处理完成后返回响应数据。客户端接收完数据后,请求流程结束。在此过程中,客户端和服务器均可以对已建立的连接进行中断操作,譬如通过浏览器的停止按钮来终止连接。
Http 请求
一个 HTTP 协议的请求由三部分组成:
请求行:包括请求方法、URI 和协议/版本,如
GET /index.html HTTP/1.1
。请求头部:包含各种元数据信息,如主机地址、用户代理、内容类型等,用于描述客户端和请求的相关信息。
请求主体:用于传输实际数据,通常在 POST 或 PUT 请求中包含,如表单数据或文件内容。
例如:
POST /api/gateway/test HTTP/1.1 Accept: application/json Accept-Encoding: gzip, deflate, br, zstd Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6 Authorization: Bearer eyJhbGiOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMywidXNlcl9uYW1lIjoicWluZ3l1Iiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTcyMzkyMzgyMywiYXV0aG9yaXRpZXMiOlsiNDQiLCIzOCJdLCJqdGkiOiIwMzBlMjJlOC0xYTk2LTRkOWQtOTY5ZC0zYzA4ZGNjOTVkNTQiLCJjbGllbnRfaWQiOiJxbXMtYWRtaW4iLCJ1c2VybmFtZSI6InFpbmd5dSJ9.EAlw27ZlHSULReScmD3Au740bNDc0zP8r4FfrDswUMLBheEzfEDp68skbhdqn3LWm3o6wpAcYq6lIOsZn2n6SLyPTh2MrhyiU4v6og6UasJ-DnajPyQ8f1RvM-YjLIlXira3KxSFR0QITsc7IH_XQJKJOI5ipYt3hwb44FITRqyAZk7usnTmWaTvuzTGKCkhO05Yi1b-U8N-6y22Gn6AkGBgABkiXceiq6Uv9ZXj7E2dPGBEpyASrr-Zop2wPCgpl8BxHp0adoBcEophMakEj7btRhXh7f4vXMxdnO6MqT3gZI94y8c-Hp44hZlhnkzs7EA2JyG8vf22TDDLiLTCxg Connection: keep-alive Content-Length: 64 Content-Type: application/json; charset=UTF-8 Cookie: JSESSIONID=8757AA1D1D00449F8B37FFFE3C50F00A Host: note.clubsea.cn Origin: https://note.clubsea.cn Referer: https://note.clubsea.cn/ Sec-Fetch-Dest: empty Sec-Fetch-Mode: cors Sec-Fetch-Site: same-origin User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0 access-control-allow-credentials: true lang: zh-cn sec-ch-ua: "Not)A;Brand";v="99", "Microsoft Edge";v="127", "Chromium";v="127" sec-ch-ua-mobile: ?0 sec-ch-ua-platform: "macOS"
数据的第一行包含请求方法、URI、协议和版本。在此例中,方法为 POST,URI 为/api/gateway/test
,协议为HTTP/1.1
,协议版本为 1.1。各部分通过空格进行分隔。
请求头部从第二行开始,采用英文冒号(:)分隔键和值。请求头部与主体内容之间通过一个空行隔开。在此例中,请求主体为表单数据。
http 协议-响应
类似于 HTTP 协议的请求,响应也由三部分构成:
响应行:包括协议、状态码和状态描述,如
HTTP/1.1 200 OK
。响应头部:包含各种元数据信息,如内容类型、服务器信息、日期等,用于描述服务器和响应的相关信息。
响应主体:传输实际数据的部分,例如网页内容或文件数据。
HTTP/1.1 200 OK Content-Type: application/json Transfer-Encoding: chunked Connection: keep-alive Server: nginx Date: Sat, 17 Aug 2024 15:44:03 GMT Access-Control-Allow-Origin: https://note.clubsea.cn Access-Control-Allow-Credentials: true Access-Control-Expose-Headers: * Access-Control-Max-Age: 18000L X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block Cache-Control: no-cache, no-store, max-age=0, must-revalidate Pragma: no-cache Expires: 0 X-Frame-Options: DENY Referrer-Policy: no-referrer Access-Control-Allow-Origin: * Access-Control-Allow-Credentials: true Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Headers: token,DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,XRequested-With Strict-Transport-Security: max-age=15768000
第一行 HTTP/1.1 200 OK
表示协议、状态码和状态描述。随后是响应头部部分。响应头部与主体内容之间由一个空行分隔。
什么是 Socket
Socket,即套接字,是网络连接中的一个端点(end point),它使得应用程序能够在网络上读取和写入数据。通过连接,不同计算机上的不同进程能够互相发送和接收数据。如果应用 A 希望向应用 B 发送数据,A 应用需要知道 B 应用的 IP 地址以及 B 应用开放的套接字端口。在 Java 中,java.net.Socket
类用来表示一个套接字。
java.net.Socket
最常用的构造方法为:public Socket(String host, int port);
,其中 host
表示主机名或 IP 地址,port
表示套接字端口。接下来,我们来看一个具体的例子:
import java.io.*; import java.net.Socket; public class SocketExample { public static void main(String[] args) { try { // 创建Socket连接到本地服务器,端口号为8080 Socket socket = new Socket("127.0.0.1", 8080); // 获取输出流以发送数据 OutputStream os = socket.getOutputStream(); PrintWriter out = new PrintWriter(new OutputStreamWriter(os), true); // 获取输入流以接收数据 BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); // 发送HTTP请求 out.println("GET /index.jsp HTTP/1.1"); out.println("Host: localhost:8080"); out.println("Connection: Close"); out.println(); // 结束请求头 // 读取并输出响应 StringBuilder response = new StringBuilder(); String line; while ((line = in.readLine()) != null) { response.append(line).append("\n"); } // 输出响应内容 System.out.println(response.toString()); // 关闭流和socket连接 in.close(); out.close(); socket.close(); } catch (IOException e) { e.printStackTrace(); } } }
这个示例代码做了以下几点:
连接到本地服务器的 8080 端口。
通过输出流发送 HTTP 请求。(通过
socket.getOutputStream()
方法可以发送数据)通过输入流读取服务器响应。(通过
socket.getInputStream()
方法可以读取数据。)关闭连接和流。
ServerSocket
Socket 表示一个客户端套接字,每次需要发送或接收数据时,都需要创建一个新的 Socket。相对而言,服务器端的应用程序需要考虑更多因素,因为服务器需要随时待命,无法预测何时会有客户端连接。为此,在 Java 中,我们使用 java.net.ServerSocket
来表示服务器端的套接字。
与 Socket 不同,ServerSocket 需要等待客户端的连接请求。一旦有客户端连接,ServerSocket 会创建一个新的 Socket 与客户端进行通信。
ServerSocket 提供了多种构造方法,我们可以举一个常用的例子。
import java.io.*; import java.net.*; public class ServerSocketExample { public static void main(String[] args) { try { // 创建ServerSocket对象,绑定到端口8080,连接请求队列长度为1,仅绑定到指定的本地IP地址 InetAddress bindAddress = InetAddress.getByName("127.0.0.1"); ServerSocket serverSocket = new ServerSocket(8080, 1, bindAddress); System.out.println("Server is listening on port 8080, bound to " + bindAddress); // 等待客户端连接 Socket clientSocket = serverSocket.accept(); System.out.println("Client connected!"); // 获取输入流以接收客户端数据 BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); // 获取输出流以发送数据到客户端 PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true); // 读取客户端发送的请求 String inputLine; while ((inputLine = in.readLine()) != null) { System.out.println("Received: " + inputLine); if (inputLine.isEmpty()) { break; // 请求头结束,退出循环 } } // 发送HTTP响应到客户端 out.println("HTTP/1.1 200 OK"); out.println("Content-Type: text/plain"); out.println("Connection: close"); out.println(); // 结束响应头 out.println("Hello, client!"); // 响应体内容 // 关闭流和socket连接 in.close(); out.close(); clientSocket.close(); serverSocket.close(); } catch (IOException e) { e.printStackTrace(); } } }
这个示例代码完成了以下步骤:
创建
ServerSocket
实例:8080
是服务器监听的端口。1
是连接请求队列的长度,即最大等待连接数。InetAddress.getByName("127.0.0.1")
指定了绑定的本地 IP 地址,确保服务器只接受来自本地的连接。等待客户端连接:
serverSocket.accept()
方法阻塞,直到有客户端连接进来。处理客户端连接:
读取客户端请求并打印。
发送一个简单的 HTTP 响应回客户端。
清理资源:
关闭流和套接字以释放资源。
HttpServer
我们来看一个具体的例子:
HttpServer
表示一个服务器端的入口,它提供了一个 main
方法,并在 8080 端口上持续监听,直到有客户端建立连接。当客户端连接到服务器时,服务器通过生成一个 Socket 来处理该连接。
import java.io.*; import java.net.*; public class HttpServer { /** * WEB_ROOT 是存放 HTML 和其他文件的目录。 * 对于这个包,WEB_ROOT 是工作目录下的 "webroot" 目录。 * 工作目录是从运行 `java` 命令时的文件系统位置。 */ public static final String WEB_ROOT = System.getProperty("user.dir") + File.separator + "webroot"; // 关闭命令的标识 private static final String SHUTDOWN_COMMAND = "/SHUTDOWN"; // 标记是否接收到关闭命令 private boolean shutdown = false; public static void main(String[] args) { // 创建 HttpServer 实例并启动等待请求 HttpServer server = new HttpServer(); server.await(); } /** * 等待客户端连接并处理请求 */ public void await() { ServerSocket serverSocket = null; int port = 8080; // 服务器监听的端口号 try { // 创建 ServerSocket 绑定到指定的端口和 IP 地址 serverSocket = new ServerSocket(port, 1, InetAddress.getByName("127.0.0.1")); } catch (IOException e) { e.printStackTrace(); System.exit(1); // 如果创建 ServerSocket 失败,则退出程序 } // 循环等待并处理请求 while (!shutdown) { Socket socket = null; InputStream input = null; OutputStream output = null; try { // 等待客户端连接 socket = serverSocket.accept(); // 获取客户端请求的输入流和响应的输出流 input = socket.getInputStream(); output = socket.getOutputStream(); // 创建 Request 对象并解析请求 Request request = new Request(input); request.parse(); // 创建 Response 对象并设置请求 Response response = new Response(output); response.setRequest(request); // 发送静态资源响应 response.sendStaticResource(); // 关闭与客户端的连接 socket.close(); // 检查请求的 URI 是否为关闭命令 shutdown = request.getUri().equals(SHUTDOWN_COMMAND); } catch (Exception e) { e.printStackTrace(); // 处理异常并继续等待下一个请求 continue; } } // 关闭服务器套接字 try { serverSocket.close(); } catch (IOException e) { e.printStackTrace(); } } }
Request 对象
Request
对象主要完成以下几项工作:
解析请求数据:处理客户端发送的所有请求数据。
解析 URI:从请求数据的第一行中提取和解析 URI。
import java.io.*; public class Request { // 输入流,用于读取客户端发送的请求数据 private InputStream input; // 存储请求的 URI(统一资源标识符) private String uri; /** * 构造函数,初始化 Request 对象 * @param input 输入流,用于读取客户端请求数据 */ public Request(InputStream input) { this.input = input; } /** * 解析客户端请求 */ public void parse() { // 创建一个 StringBuffer 用于存储从输入流读取的请求数据 StringBuffer request = new StringBuffer(2048); int i; byte[] buffer = new byte[2048]; // 缓冲区大小为2048字节 try { // 从输入流读取数据到缓冲区 i = input.read(buffer); } catch (IOException e) { e.printStackTrace(); // 处理读取错误 i = -1; // 读取失败 } // 将缓冲区中的字节转换为字符,并追加到 request 中 for (int j = 0; j < i; j++) { request.append((char) buffer[j]); } // 输出请求内容到控制台 System.out.print(request.toString()); // 从请求内容中解析 URI uri = parseUri(request.toString()); } /** * 从请求字符串中提取 URI * @param requestString 请求的字符串 * @return 提取的 URI */ private String parseUri(String requestString) { int index1, index2; // 查找第一个空格的位置,标记请求方法的结束 index1 = requestString.indexOf(' '); if (index1 != -1) { // 查找第二个空格的位置,标记请求 URI 的结束 index2 = requestString.indexOf(' ', index1 + 1); if (index2 > index1) { // 提取 URI 部分 return requestString.substring(index1 + 1, index2); } } // 如果未找到有效的 URI,返回 null return null; } /** * 获取解析出的 URI * @return 请求的 URI */ public String getUri() { return uri; } }
Response 对象
Response
主要负责向客户端发送文件内容(如果请求的 URI 指向的文件存在)。
import java.io.*; public class Response { // 缓冲区的大小,用于读取文件内容 private static final int BUFFER_SIZE = 1024; // 请求对象 Request request; // 输出流,用于将响应数据写入客户端 OutputStream output; /** * 构造函数,初始化 Response 对象 * @param output 输出流,用于发送响应数据到客户端 */ public Response(OutputStream output) { this.output = output; } /** * 设置请求对象 * @param request 请求对象 */ public void setRequest(Request request) { this.request = request; } /** * 发送静态资源(如 HTML 文件)的响应 * @throws IOException 如果发生 I/O 错误 */ public void sendStaticResource() throws IOException { byte[] bytes = new byte[BUFFER_SIZE]; // 创建缓冲区 FileInputStream fis = null; // 文件输入流 try { // 获取请求 URI 对应的文件 File file = new File(HttpServer.WEB_ROOT, request.getUri()); if (file.exists()) { // 如果文件存在,读取文件内容并发送到客户端 fis = new FileInputStream(file); int ch = fis.read(bytes, 0, BUFFER_SIZE); // 读取文件内容到缓冲区 while (ch != -1) { output.write(bytes, 0, ch); // 写入输出流 ch = fis.read(bytes, 0, BUFFER_SIZE); // 继续读取文件内容 } } else { // 如果文件不存在,发送404错误响应 String errorMessage = "HTTP/1.1 404 File Not Found\r\n" + "Content-Type: text/html\r\n" + "Content-Length: 23\r\n" + "\r\n" + "<h1>File Not Found</h1>"; output.write(errorMessage.getBytes()); // 发送错误响应 } } catch (Exception e) { // 捕获并打印异常 System.out.println(e.toString()); } finally { // 确保文件输入流被关闭 if (fis != null) { fis.close(); } } } }
总结
通过上述例子,我们惊喜地发现,在 Java 中实现一个 Web 服务器其实简单明了,代码也非常清晰!