一步一步实现Tomcat之一——实现一个简单的Web服务器

前言

最近在读《How Tomcat Works》,收获颇丰,在编写书中示例的过程中也踩了不少坑。不知你有没有体会,编程就一门是“不试不知道,一试吓一跳”的实践艺术。所以我将将自己的实践过程记录下来并附上自己的思想过程编撰成文,望能抛砖引玉,引起大家思考。
原书中主要内容是一步一步实现一个类似于Tomcat的Servlet容器。有点再造轮子的感觉,我也会根据书中章节并按照自己理解分步成文。

本文涉及内容

本文描述了一个简单的Web服务器的实现,这个服务器能接收浏览器请求,访问本地的静态HTML文件,如果文件不存在返回404页面。这个浏览器只是一个示例,重点让你了解Http请求到响应过程的大致处理方法,对于细节没有过多涉及。

基础知识

阅读本文需要你先了解一下基础知识:

  1. Http协议。
  2. Socket网络编程。

1. Http协议

“协议”广义上说就是计算机相互交流的语言。Http协议就是网络上千千万万浏览器和服务器交流的语言,浏览器通过Http协议向服务器发送请求,服务器通过同样的协议回复浏览器。

clipboard.png

【图一】

Http协议处于TCP/IP协议栈的应用层,Http传递的内容是Http报文,报文就相当于语言中的“短语”和“句子”用来表明意图。报文由一行行简单的字符串组成,方便人们读写。

报文包括三个部分:起始行(star line)、首部(heads)、主体(body)
报文分为两类:请求报文(request message)、响应报文(response message)

报文实例:

请求报文:

GET / HTTP/1.1
Host: www.baidu.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:61.0) Gecko/20100101 Firefox/61.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Cookie: BAIDUID=DF436E68F85BD96DE35AEA9DC97FB19D:FG=1; BIDUPSID=DF436E68F85BD96DE35AEA9DC97FB19D; PSTM=1535160357; BD_UPN=1352; delPer=0; BD_HOME=0; H_PS_PSSID=1442_21097_26350
Connection: keep-alive

GET / HTTP/1.1为起始行,其他为首部,没有主体部分。

响应报文:

HTTP/1.1 200 OK
Bdpagetype: 1
Bdqid: 0xc317983b0005c39e
Cache-Control: private
Connection: Keep-Alive
Content-Encoding: gzip
Content-Type: text/html
Cxy_all: baidu+3d05fe4a15be8fad069c0f37a523ec5e
Date: Sun, 26 Aug 2018 06:39:25 GMT
Expires: Sun, 26 Aug 2018 06:39:09 GMT
Server: BWS/1.1
Set-Cookie: delPer=0; expires=Tue, 18-Aug-2048 06:39:09 GMT
Set-Cookie: BDSVRTM=0; path=/
Set-Cookie: BD_HOME=0; path=/
Set-Cookie: H_PS_PSSID=1442_21097_26350; path=/; domain=.baidu.com
Strict-Transport-Security: max-age=172800
Vary: Accept-Encoding
X-Ua-Compatible: IE=Edge,chrome=1
Transfer-Encoding: chunked

<!DOCTYPE html>
<!--STATUS OK-->
<html>
<head>
    <meta http-equiv="content-type" content="text/html;charset=utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=Edge">
    <meta content="always" name="referrer">
    <meta name="theme-color" content="#2932e1">
    <link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
    <title>百度一下,你就知道</title>
</head>
<body>
太多了,省略...

</body>
</html>

HTTP/1.1 200 OK为起始行,Bdpagetype: 1Transfer-Encoding: chunked为首部,其余的为主体。

通过观察请求和返回报文我们发现两个关键点:

  1. 报文起始行和首部由行分割的ASCII文本,Http协议规定每一行由回车符(ASCII码13)和换行符(ASCII码10)表示结束。
  2. 一个空白行将实体和首部区分开来,返回报文的主体的就是HTML语言,浏览器就是通过返回的主体内容渲染HTML语言展示请求内容的,当然除了HTML语言之外,主体还可以返回其他字符和二进制内容。

2. Socket网络编程

Http协议不仅规定了传输的内容,还规定了用什么来传输,一门语言不能光有文字和语法,还要有传播通道,例如空气就是声音的传输通道。

Http协议将传输的工作交由TCP协议负责,TCP协议位于TCP/IP协议栈的传输层,是很多上层应用协议的传输方式。

TCP协议是面向连接的、保障型传输协议,一旦建立起TCP连接,客户端和服务器端之间的报文交换就不会丢失、不会被破坏也不会在接收时错序。

TCP协议一般由操作系统底层实现,在Java中抽象为Socket接口供大家使用。

用代码说话

基础知识介绍的差不多了,如果大家感兴趣可以参考相应的书籍。接下来让我们用代码说话。

一、 看似很简单

如果是只返回静态Html,应该很简单吧。简简单单想了一下流程,初始化服务器——等待连接——解析请求——返回数据——关闭连接,搞定,大功告成。

1. 建个服务器骨架吧

/**
 * 简单的Web服务器
 */
public class HttpServer {
    //定义一个资源存放路径,用来存放静态资源,
    public static final File WEB_ROOT = new File("d:\\webRoot");

    public static void main(String[] args) {
        //创建服务器对象
        HttpServer httpServer=new HttpServer();
        //等待客户端请求
        httpServer.await();
    }
    public void await() {
        try (ServerSocket serverSocket = new ServerSocket(8080)) {
            //创建socket嵌套字,监听8080端口。
             serverProcess(serverSocket);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    private void serverProcess(ServerSocket serverSocket) {
        while (true) {
            //循环等待客户端请求。
            try (Socket socket = serverSocket.accept()) {
                InputStream input = socket.getInputStream();
                OutputStream output = socket.getOutputStream();
                //未完待续。。。

            } catch (IOException e) {
                e.printStackTrace();
            } catch (Exception e){
                e.printStackTrace();
            }
        }
    }
}

非常简单的Socket服务器骨架就这样建好了,我们就可以接受客户端请求了,这里需要注意的是每一个通过serverSocket.accept()从客户端获取socket处理完后都会被close

2. 抽象一下“请求”和“响应”

有了服务器,接下来我们需要接收请求、处理请求、将处理结果返回给客户端。根据领域驱动原则,我们将名词抽象为类,动词抽象为类的行为也就是方法。

Request类

/**
 * 表示一次客户端请求
 */
public class Request {
    private InputStream input;
    private String uri;

    public Request(InputStream input) {
        this.input = input;
    }
    /**
     * 解析请求
     */
    public void parse() {
        //待实现
    }
    /**
     * 解析URL
     * @param requestString
     * @return
     */
    private String parseUri(String requestString) {
        //待实现
        return null;
    }

    public String getUri() {
        return uri;
    }
}

Response类:

/**
 * 表示返回值
 */
public class Response {
    private OutputStream output;
    public Response1(OutputStream output) {
        this.output = output;
    }
    /**
     * 发送静态页面的相应报文
     * @throws IOException
     */
    public void sendStaticResource() throws IOException {
        //待实现。
    }
}

3. 实现Request和Response中的方法。

类和方法已经定义的差不多了,现在我们来实现。

Request类

/**
 * 表示请求值
 */
public class Request {

    private InputStream input;
    private String uri;

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

    public void parse() {
        StringBuffer request = new StringBuffer(2048);
        int i;
        byte[] buffer = new byte[2048];
        try {
            while((i = input.read(buffer))!=-1){
                for (int j=0; j<i; j++) {
                    request.append((char) buffer[j]);
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        System.out.println(request.toString());
        uri = parseUri(request.toString());
    }

    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;
    }
}

Response类:

/**
 * 表示返回值
 */
public class Response {
    private static final int BUFFER_SIZE = 1024;
    private Request request;
    private 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];
        //读取访问地址请求的文件
        File file = new File(HttpServer.WEB_ROOT, request.getUri());
        try (FileInputStream fis = new FileInputStream(file)){
            if (file.exists()) {
                //如果文件存在
                //添加相应头。
                StringBuilder heads=new StringBuilder("HTTP/1.1 200 OK\r\n");
                heads.append("Content-Type: text/html\r\n");
                //头部
                StringBuilder body=new StringBuilder();
                //读取相应主体
                int len ;
                while ((len=fis.read(bytes, 0, BUFFER_SIZE)) != -1) {
                    body.append(new String(bytes,0,len));
                }
                //添加Content-Length
                heads.append(String.format("Content-Length: %d\n",body.toString().getBytes().length));
                heads.append("\r\n");
                output.write(heads.toString().getBytes());
                output.write(body.toString().getBytes());
            } else {
                response404(output);
            }
        }catch (FileNotFoundException e){
            response404(output);
        }
    }

    private void response404(OutputStream output) throws IOException {
        StringBuilder response=new StringBuilder();
        response.append("HTTP/1.1 404 File Not Found\r\n");
        response.append("Content-Type: text/html\r\n");
        response.append("Content-Length: 23\r\n");
        response.append("\r\n");
        response.append("<h1>File Not Found</h1>");
        output.write(response.toString().getBytes());
    }

注:原书代码没有返回响应头部,测试发现浏览器不能识别这样的响应报文。

4. 补全服务器方法。

public class HttpServer {
    //定义一个资源存放路径,用来存放静态资源,
    static final File WEB_ROOT = new File("d:\\webRoot");

    public static void main(String[] args) {
        HttpServer httpServer=new HttpServer();
        httpServer.await();
    }

    public void await() {
        try (ServerSocket serverSocket = new ServerSocket(8080)) {
           serverProcess(serverSocket);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private void serverProcess(ServerSocket serverSocket) {
        while (true) {
            try (Socket socket = serverSocket.accept()) {
                System.out.println(socket.hashCode());
                InputStream input = socket.getInputStream();
                OutputStream output = socket.getOutputStream();
                Request request = new Request(input);
                request.parse();
                Response response = new Response(output);
                response.setRequest(request);
                response.sendStaticResource();
            } catch (IOException e) {
                e.printStackTrace();
            } catch (Exception e){
                e.printStackTrace();
            }
        }
    }
}

5. 见证奇迹的时候到了,运行一下。

首先在D:/webRoot文件夹建立index.html文件
写入:

<h1>hello world!</h1>

启动HttpService,在浏览器输入http://localhost:8080/index.html,但你心心念的等待熟悉的“hello world!”页面的时候,你会等的花儿都谢了。

二、 问题在哪里?

1. 调试吧,少年

页面并没有显示,问题出在哪里?进入debug调试模式,发现方法阻塞在while((i = input.read(buffer))!=-1)语句上,以往我们读取输入流的方法都这样写也没有问题,为什么到了Socket就阻塞了呢?原因其实很简单,客户打开了一个socket的输出流向服务器发送消息,服务器端通过socket的输入流读取消息,但是服务器并不知道客户端消息的结尾,只要socket不关闭,服务器一旦读取了所有可用内容,read方法就要一直阻塞等待新的可用内容(超期时间之后也能返回),而此时的客户端也一直在等待服务器的返回,相互等待,死锁了。看来本地文件流和网络流处理方式不同。

clipboard.png

【图二】

翻看书中示例代码是这样写的:

public void parse() {
        StringBuilder request = new StringBuilder(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++) {
            request.append((char) buffer[j]);
        }
        System.out.println(request.toString());
        uri = parseUri(request.toString());
    }

书中一次性读取了2048长度的字节数组,无论请求内容是否结束都不会再去读第二遍,避免读取时遇到不可用情况造成的阻塞。
但是这依然有两个问题:

  1. 如果字符请求内容大于2048长度字节数组的内容,请求内容读取不全。
  2. 如果浏览器创建一个socket但是并不写入任何内容,服务器首次read的时候仍会被阻塞,不读取不知道有没有内容,一旦发现没有可用内容就被阻塞了。(测试中Chrome就会发送空socket)

问题2还好,有可能浏览器通过发送空socket维持长连接,需要根据http协议决定如何关闭socket。但是对于问题1就比较严重了,虽然我们的示例代码只需要读取起始行从中取出URL地址访问本地静态资源,但是一个web服务器服务读取所有请求内容确实有点说不过去了。这个问题后续还需要解决。

2. 再试试,有没有奇迹出现

替换上面的代码,再次重复刚刚的流程,好了,浏览器终于出现“hello world!”,见证奇迹。

三、 你以为这样就完了?

终于,人生中第一个web服务器就这样诞生了!当我难掩激动的用各个浏览器测试的时候,又发现的一个问题,一旦我用Chrome访问一次,再用其他浏览器访问就会卡死。哎,好吧,没完了。

1. 继续debug

经过debug发现,Chrome每次发送一次socket并收到服务器相应之后,都会发送一个新的空socket,socket没有写入任何内容,此时服务器就会阻塞在对这个空socket的读取中。直到浏览器再次向服务器发送请求,才会向这个空socket写入内容,服务器阻塞才会结束,然后继续重复以上的处理过程,只要Chrome浏览器发送一次请求,服务器就会阻塞与空socket的读取,无法为其他浏览器服务。

【图三】

2. 饭要一口一口吃

除了上面提到的两个问题还有其他问题,比如socket关闭时机问题,响应主体文字编码问(现在都是英文还好,中文就会出现乱码)等等。毕竟http协议也是比较复杂的,有很多规则需要实现。但是本文的内容就先到这了,我们实现了完成一个简单服务器的目标。

后记

本文到此结束了,参照《How Tomcat Works》第一章内容,加上自己的理解和实践,原书中没有涉及我调试中抛出的两个问题,关于这两个问题我会在以后的文章中解决。其实读书的的时候觉得很简单,也没有想到真正写代码的时候出现这些问题,所以希望大家读书过程中多实践,可以加深理解。作为专栏的第一篇文章,写的格外用心,但是也难免出现纰漏,望大家指摘。

源码

文中源码地址:https://github.com/TmTse/tiny...

参考

《深入剖析Tomcat》
《Http权威指南》
《TCP/IP详解卷1:协议》

阅读 1.9k

推荐阅读
重复造轮子系列
用户专栏

主要关注一下成熟开源框架原理解析及简单实现,加深对框架的理解。

3 人关注
3 篇文章
专栏主页