小白也能看懂!手写一个 Tomcat 服务器(一)

806 阅读7分钟

本篇文章主要介绍 Java Web 服务器是怎么运行的,以及如何手写一个简易版的 tomcat 服务器。

Web 服务器也称为超文本传输协议(HTTP)服务器,因为它使用 HTTP 与其客户端进行通信。常用的 Web 服务器有:Apache 服务器、Nginx 服务器、Tomcat 服务器等等。

对于笔者来说,最常使用的就是 tomcat 服务器了。

平常我们使用 tomcat 服务器都是新建一个 Web 项目,写好代码,在 IDEA 中配置好 tomcat 的路径然后点击 IDEA 的绿色启动按钮 Run,就启动了 tomcat 服务器,我们也就能通过浏览器去访问我们的项目了。不过却很少去深究里面的原理,今天我们就来盘一盘 Web 服务器的基本原理。

Http 请求流程

说到服务器,肯定要说到我们客户端(浏览器)是怎么和服务器交互的,通过HTTP 请求。Http 请求的处理流程如下:

http请求.png

先来说一下 HTTP 请求,我们如果要手写一个服务器,那肯定要先清楚服务器这边接受到的请求是什么样的,才好进行解析。

HTTP 请求

  • 一个 Http 请求包含以下三个部分:
    • 请求办法 + uri + 协议/版本
    • 请求头
    • 实体 示例如下:
GET http://localhost:8080/hello-servlet HTTP/1.1
Host: localhost:8080
Connection: keep-alive
sec-ch-ua: "Chromium";v="94", "Google Chrome";v="94", ";Not A Brand";v="99"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 

myname=pengnan&age=18

上面第一行就是请求办法 + uri + 使用的协议,中间的就是请求头,空行后面是实体。注意请求头和实体时间一定是有空行(CRLF)分隔的。

  • 与 HTTP 请求类似,HTTP 响应也包括三个部分:
    • 协议 + 状态码 + 描述
    • 响应头
    • 响应实体段 示例如下:
HTTP/1.1 200 OK
Set-Cookie: JSESSIONID=1174D54DBE49E3E6E157B2351ABB1201; Path=/; HttpOnly
Content-Type: text/html;charset=ISO-8859-1
Content-Length: 53
Date: Sat, 16 Apr 2022 15:24:21 GMT

<html><body>
<h1>Hello World!</h1>
</body></html>

上述响应头和响应实体正文之间也有一个空行(CRLF)分隔。

Socket 类

俗话说的好,网络编程一切皆是 Socket。那 Socket 的重要性不言而喻。实现从浏览器到服务器的通信实际上就是 Socket 通信。Socket 也叫套接字,是网络连接的端点。那么相应的,我们只要在网络的这一边有个端点,在网络的另一边有个端点,就能实现网络的连接。

套接字由 java.net.Socket 标志,Socket 类可以分为服务端和客户端,服务端使用的类主要是 ServerSocket 类,客户端使用的类主要是 Socket 类。

为了更深刻的理解 HTTP 请求,也就是我们浏览器是怎么发送请求到服务器的(比如百度、微博之类)。我们可以先手写一个客户端,我们自己通过这个客户端来实现请求服务器(百度或微博),而不使用浏览器去请求。

既然是客户端,那我们就要使用 Socket 类,Socket 类有众多构造办法,常用的是这个

publict Socekt (java.lang.String host, int port)

其中参数 host 是远程主机的名称或 IP 地址,port 的相应的端口号。比如我们想连接百度,我们的 host 可以写 baidu.com, 端口可以写 443.

那我们是怎么发送出一个 HTTP 请求的呢(假设本次请求的是本地的 8080 端口),看下面代码:

Socket socket = new Socket("127.0.0.1", 8080);
OutputStream outputStream = socket.getOutputStream();
PrintWriter out = new PrintWriter(outputStream, true);
// 给指定 ip 地址发送一个 http 请求
out.println("GET /index.jsp HTTP/1.1"); // 请求办法 uri 协议
out.println("Host: localhost:8080"); // 请求头
out.println("Connection: keep-alive");
out.println(); // 空行

那上面的代码就代表我们发送了一个 HTTP 请求了,如果要接受服务器返回来的响应,可以接着写:

BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));

通过这个 in 来读取里面的内容进行解析。

这里说明一下流的使用,很简单的,outputStream 流就是可以往里面写东西的,因为要输出去嘛,output 出去,所以 outputStream 都是和 write 绑定使用的。而 inputSream 是输入流,输入进来的,我们要从里面读取东西出来,所以 inputStream 是和 read 绑定使用的。只要记住这两个关系,使用的时候就不会混。

简易版 Tomcat

ok,铺垫了这么多,终于可以进入正题,这次的简易版的服务器只有三个类:

  • HttpServer
  • Request
  • Response

先说一下整体的思路

  1. 既然是要搭建服务器,我们就理所当然的要使用 ServerSocket 类来创建一个套接字。
  2. HttpServer 的作用就是创建一个 ServerSocket,然后等待连接。
  3. 有连接进来后我们就要解析进来的流,解析成 Request 对象,方便我们使用。
  4. 本次简易版为了快速展示整个流程,我们只处理静态资源(后面会有改进)。
  5. 我们根据请求的路径去获取静态资源文件,读取文件内容然后使用 Response 类输出到浏览器回去。

主体代码如下,我们贴一点解释一点,慢慢来

  • 首先是 HttpServer 类,他的作用就几个:
    • 创建 ServerSocket
    • 使用 Request 解析
    • 使用 Response 响应
public class HttpServer {

    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 server = new HttpServer();
        server.await();
    }

    public void await() {
         xxx
    }
}

这里说一下 System.getProperty("user.dir")的作用,就是获取当前项目所在的根路径,WEB_ROOT就是我们存放静态资源的目录。上述的 SHUTDOWN_COMMAND 表示如果请求路径是 /SHUTDOWN 就会停止服务器。

这里我们简单看下整个目录结构:

image.png

await() 办法是这个类的灵魂:

public void await() {
    ServerSocket serverSocket = null;
    int port = 8080;
    try {
        serverSocket = new ServerSocket(port, 1, InetAddress.getByName("127.0.0.1"));
    } catch (IOException e) {
        e.printStackTrace();
        System.exit(1);
    }

    // 等待连接进来 
    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();

            // Close the socket
            socket.close();

            // 检查是否是停止命令
            shutdown = request.getUri().equals(SHUTDOWN_COMMAND);
        } catch (Exception e) {
            e.printStackTrace();
            continue;
        }
    }
}

这个 await() 办法别看代码少,其实这就是一个 web 服务器的本质了,创建 ServerSocket,等待连接,处理连接,返回响应。

接着看 Request 类和 Response 类:

public class Request {

    private InputStream input;
    private String uri;

    public Request(InputStream input) {
        this.input = input;
    }

    public void parse() {
        // 从 socket 流中读取数据 这个 parse 办法就是读取流,流里面其实就是 http 请求(见上面的 http请求示例)
        StringBuffer request = new StringBuffer(2048);
        int i;
        byte[] buffer = new byte[2048]; // 创建一个字节输入,相当与容器
        try {
            i = input.read(buffer); // 把流中的内容读取到容器中
        } catch (IOException e) {
            e.printStackTrace();
            i = -1;
        }
        for (int j = 0; j < i; j++) { // i 是读取的长度,将流中的内容写到 request 字符串中
            request.append((char) buffer[j]);
        }
        System.out.print(request.toString());
        uri = parseUri(request.toString());
    }

    // 根据 http 请求解析出请求路径
    // 其实就是 http 请求第一行,两个空格之间:GET index.html HTTP/1.1
    private String parseUri(String requestString) {
        int index1, index2;
        index1 = requestString.indexOf(' ');
        if (index1 != -1) {
            index2 = requestString.indexOf(' ', index1 + 1);
            if (index2 > index1)
                return requestString.substring(index1 + 1, index2);
        }
        return null;
    }

    public String getUri() {
        return uri;
    }

}

Request 类这里的作用主要的读取 HTTP 请求中的路径,这时候整个 request 实例会被传给 response。

public class Response {

    private static final int BUFFER_SIZE = 1024;
    Request request;
    OutputStream output;

    public Response(OutputStream output) {
        this.output = output;
    }

    public void setRequest(Request request) {
        this.request = request;
    }

    public void sendStaticResource() throws IOException {
        byte[] bytes = new byte[BUFFER_SIZE];
        FileInputStream fis = null;
        try {
            // 自己写一个成功的响应头
            String successHeader = "HTTP/1.1 200 OK\r\n" +
                    "Content-Type: text/html\r\n" +
                    "\r\n";
            output.write(successHeader.getBytes()); // 注意 Response 类中的 output 是 HttpServer 类中传进来的
            // 再读取 webroot 目录下的静态文件的内容,输出到 output
            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 {
                // file not found
                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();
        }
    }
}

Response 类主要是根据路径去找文件,如果文件不存在,则返回错误消息到浏览器,存在则返回文件内容到浏览器。

写完之后我们看下 index.html 的内容:

<html>
<head>
<title>Welcome to BrainySoftware</title>
</head>
<body>
<img src="./images/logo.gif">
<br>
Welcome to My first Server.
</body>
</html>

启动一下 HttpServer 的 main 办法,访问一下 http://localhost:8080/index.html 如下:

image.png

至此,一个简易版的 Tomcat 服务器就算手写完毕。之后还会对这个手写的服务器进行进一步的完善,不出意外的话这应该会写成一个系列,这是第一篇。

代码我放在 gitee 上面,需要自取:gitee.com/the_meaning…

本篇文章思路来自《深入剖析 Tomcat》这本书,这是第一章的内容,看完之后觉得有点意思。我看的是实体书,如果也想要看同学可以评论区 call 我一下,我去找找电子书资源放上来。

写完太晚啦,晚安吧。