一步一步實現Tomcat之一——實現一個簡單的Web服務器

前言

最近在讀《How Tomcat Works》,收穫頗豐,在編寫書中示例的過程當中也踩了很多坑。不知你有沒有體會,編程就一門是「不試不知道,一試嚇一跳」的實踐藝術。因此我將將本身的實踐過程記錄下來並附上本身的思想過程編撰成文,望能拋磚引玉,引發你們思考。
原書中主要內容是一步一步實現一個相似於Tomcat的Servlet容器。有點再造輪子的感受,我也會根據書中章節並按照本身理解分步成文。html

本文涉及內容

本文描述了一個簡單的Web服務器的實現,這個服務器能接收瀏覽器請求,訪問本地的靜態HTML文件,若是文件不存在返回404頁面。這個瀏覽器只是一個示例,重點讓你瞭解Http請求到響應過程的大體處理方法,對於細節沒有過多涉及。java

基礎知識

閱讀本文須要你先了解一下基礎知識:git

  1. Http協議。
  2. Socket網絡編程。

1. Http協議

「協議」廣義上說就是計算機相互交流的語言。Http協議就是網絡上千千萬萬瀏覽器和服務器交流的語言,瀏覽器經過Http協議向服務器發送請求,服務器經過一樣的協議回覆瀏覽器。github

clipboard.png

【圖一】web

Http協議處於TCP/IP協議棧的應用層,Http傳遞的內容是Http報文,報文就至關於語言中的「短語」和「句子」用來代表意圖。報文由一行行簡單的字符串組成,方便人們讀寫。chrome

報文包括三個部分:起始行(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爲起始行,其餘爲首部,沒有主體部分。tomcat

響應報文:

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接口供你們使用。

用代碼說話

基礎知識介紹的差很少了,若是你們感興趣能夠參考相應的書籍。接下來讓咱們用代碼說話。

1、 看似很簡單

若是是隻返回靜態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!」頁面的時候,你會等的花兒都謝了。

2、 問題在哪裏?

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!」,見證奇蹟。

3、 你覺得這樣就完了?

終於,人生中第一個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:協議》

相關文章
相關標籤/搜索