1. 程式人生 > >1000行程式碼手寫Web伺服器(一)

1000行程式碼手寫Web伺服器(一)

1000行程式碼手寫Web伺服器(包括HTTP伺服器和Servlet容器)

具備的功能(均為簡化版的實現):

  • HTTP Protocol 實現了HTTP協議
  • Servlet
  • ServletContext
  • Request 封裝HTTP請求報文
  • Response 封裝HTTP響應報文
  • DispatcherServlet Servlet轉發
  • Static Resources & File Download 靜態資源的訪問
  • Error Notification 錯誤頁面顯示
  • Get & Post & Put & Delete 支援各種HTTP方法
  • web.xml parse 解析web.xml
  • Forward 轉發
  • Redirect 重定向
  • Simple TemplateEngine 簡單的模板引擎
  • session&cookie 會話管理

使用技術

基於Java BIO、多執行緒、Socket網路程式設計、XML解析、log4j/slf4j日誌
只引入了junit,lombok(簡化POJO開發),slf4j,log4j,dom4j(解析xml),mime-util(用於判斷檔案型別)依賴,與web相關內容全部自己完成。

參考資料

尚學堂《Java300集》中的195~207集,視訊資料放在網盤裡,大家可以直接下載,或者到尚學堂的官網上下載亦可。

我是在其基本功能的基礎上添加了一部分功能,並且儘量和標準的JavaEE API類似。當然我沒有讀過Tomcat的原始碼,可能真實實現有一些差距,而且為了儘量少地引入不必要的依賴,沒有使用Spring,面向介面程式設計做的還不夠,很多的地方是直接使用實現類的,這和JavaEE API差距是很大的。
我做的基本只是JavaEE API的模擬,也在專案裡寫了一個使用者的登入登出的Demo。在健壯性上可能有很多不足,畢竟只花了兩天的時間,大概只有1000行的程式碼量。

具體實現

Overview

這裡寫圖片描述
這個是專案的結構圖,一個標準的maven構建的JavaWeb工程。src/main/java下面放的是原始碼,src/main/resources下面放的是配置檔案,src/main/webapp是下面放的是web相關的靜態資源。

HTTPServer

最最重要的當然是伺服器主體,放在包的根目錄下。
主要是使用了:

  • 一個Main執行緒,在main方法中執行,如果在控制檯輸入EXIT,那麼伺服器退出
  • 一個Listener執行緒,監聽客戶端的連線事件,並將請求交給DispatcherServlet進行轉發。
  • 一個執行緒池,由DispatcherServlet維護,將訪問Servlet的請求作為任務提交到執行緒池中,每個請求都在一個執行緒中執行。
      while (!Thread.currentThread().isInterrupted()) {
               Socket client;
               try {
                   //TCP的短連線,請求處理完即關閉
                   client = server.accept();
                   log.info("client:{}", client);
                   dispatcherServlet.doDispatch(client);
               } catch (IOException e) {
                   e.printStackTrace();
               }
           }

在DispatcherServlet中(放在/servlet/base包下),doDispatch方法對請求進行分別處理。

  1. 首先解析HTTP請求,將其封裝到Request中。
  2. 如果是靜態資源,交給靜態資源處理器返回。
  3. 如果是動態資源,交由某個Servlet執行。
        try {
            //解析請求
            request = new Request(client.getInputStream());
            response = new Response(client.getOutputStream());
            request.setServletContext(servletContext);
            //如果是靜態資源,那麼直接返回
            if (request.getMethod() == RequestMethod.GET && (request.getUrl().contains(".") || request.getUrl().equals("/"))) {
                log.info("靜態資源:{}", request.getUrl());
                //首頁
                if (request.getUrl().equals("/")) {
                    resourceHandler.handle("/index.html", response, client);
                } else {
                    //其他靜態資源
                    //與html有關的全部放在views裡
                    if (request.getUrl().endsWith(".html")) {
                        resourceHandler.handle("/views" + request.getUrl(), response, client);
                    } else {
                        //其他靜態資源放在static裡
                        resourceHandler.handle("/static" + request.getUrl(), response, client);
                    }
                }
            } else {
                //處理動態資源,交由某個Servlet執行
                //Servlet是單例多執行緒
                //Servlet在RequestHandler中執行
                pool.execute(new RequestHandler(client, request, response, servletContext.dispatch(request.getUrl()), exceptionHandler));
            }

每個請求都被封裝到一個RequestHandler中,它實現了Runnable介面,持有request&response。具體轉發過程見後面的ServletContext部分。
在其run方法中,呼叫Servlet的service方法,執行正式的業務程式碼。

        try {
            if (servlet == null) {
                throw new ServletNotFoundException(HTTPStatus.NOT_FOUND);
            }
            //為了讓request能找得到response,以設定cookie
            request.setRequestHandler(this);
            servlet.service(request, response);
            response.write();
        } catch (ServletException e) {
            exceptionHandler.handle(e, response, client);
        } catch (Exception e) {
           //其他未知異常
            exceptionHandler.handle(new ServerErrorException(HTTPStatus.INTERNAL_SERVER_ERROR), response, client);
        } finally {
            try {
                client.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

Request

HTTP請求封裝過程:
首先是讀取socket的inputstream,將請求報文全部讀進來,然後進行URL解碼,並按CRLF(\r\n)進行切割。
切割後,解析請求頭&請求體。

this.attributes = new HashMap<>();
        log.info("開始讀取Request");
        BufferedInputStream bin = new BufferedInputStream(in);
        byte[] buf = null;
        try {
            buf = new byte[bin.available()];
            int len = bin.read(buf);
            if (len <= 0) {
                throw new RequestInvalidException(HTTPStatus.BAD_REQUEST);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

        String[] lines = null;
        try {
            //支援中文,對中文進行URL解碼
            lines = URLDecoder.decode(new String(buf, CharsetProperties.UTF_8_CHARSET), CharsetProperties.UTF_8).split(CharConstant.CRLF);
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        log.info("Request讀取完畢");
        log.info("{}", Arrays.toString(lines));
        try {
            parseHeaders(lines);
            if (headers.containsKey("Content-Length") && !headers.get("Content-Length").get(0).equals("0")) {
                parseBody(lines[lines.length - 1]);
            }
        } catch (Throwable e) {
            e.printStackTrace();
            throw new RequestParseException(HTTPStatus.BAD_REQUEST);
        }

請求頭和請求體的解析過程暫略,完全可以按照請求報文結構進行編碼。

Response

HTTP響應的封裝過程:
主要是header(…)和body(…)方法,header是封裝響應頭,有一些響應頭是共有的,直接寫死了在程式碼裡了。另外還提供addHeader和addCookie方法進行擴充套件。最後在write方法中將響應報文寫回到outputstream。
構建響應體,一種是呼叫body(byte[]),適合靜態資源;另一種是多次呼叫print/println,類似於JavaEE API的方式,可以多次呼叫。

    public void write() {
        //預設返回OK
        if(this.headerAppender.toString().length() == 0){
            header(HTTPStatus.OK);
        }

        //如果是多次使用print或println構建的響應體,而非一次性傳入
        if(body == null){
            log.info("多次使用print或println構建的響應體");
            body(bodyAppender.toString().getBytes(CharsetProperties.UTF_8_CHARSET));
        }

        byte[] header = this.headerAppender.toString().getBytes(UTF_8_CHARSET);

        //生成響應報文
        byte[] response = new byte[header.length + body.length];
        System.arraycopy(header, 0, response, 0, header.length);
        System.arraycopy(body, 0, response, header.length, body.length);
        try {
            os.write(response);
            os.flush();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                os.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

Servlet

設定了一個根Servlet:HTTPServlet,所有Servlet均繼承於此,根據需求覆蓋doGet/doPost/doPut/doDelete方法。也可以直接覆蓋service方法,自定義實現。

    public void service(Request request, Response response) throws ServletException, IOException {
        if (request.getMethod() == RequestMethod.GET) {
            doGet(request, response);
        } else if (request.getMethod() == RequestMethod.POST) {
            doPost(request, response);
        } else if (request.getMethod() == RequestMethod.PUT) {
            doPut(request, response);
        } else if (request.getMethod() == RequestMethod.DELETE) {
            doDelete(request, response);
        }
    }

Exception

設定一個根異常:ServletException(放在/exception/base),所有與web有關的異常均繼承於此,並繫結一個對應的HTTP狀態碼。
RequestHandler在執行Servlet時,如果丟擲了異常,那麼會交給ExceptionHandler(放在/exception/handler)進行處理,它會將對應的錯誤頁面寫入到輸出流。
注意一個異常RequestInvalidException,有時候會出現讀取請求報文內容為空的現象,直接拋棄報文即可,在實際訪問時沒有看出有什麼影響。

    public void handle(ServletException e, Response response, Socket client) {
        try {
            if (e instanceof RequestInvalidException) {
                log.info("請求無法讀取,丟棄");
            } else {
                log.info("丟擲異常:{}", e.getClass().getName());
                e.printStackTrace();
                response
                        .header(e.getStatus())
                        .body(
                                IOUtil.getBytesFromFile(
                                        String.format(ERROR_PAGE, String.valueOf(e.getStatus().getCode())))
                        )
                        .write();
                log.info("錯誤訊息已寫入輸出流");
            }
        } catch (IOException e1) {
            e1.printStackTrace();
        } finally {
            try {
                client.close();
            } catch (IOException e1) {
                e1.printStackTrace();
            }
        }
    }

Resource

對於所有的靜態資源,交由ResourceHandler處理。
它會取出對應的靜態資源並寫入輸出流,如果檔案未找到,那麼會將請求再次交給ExceptionHandler處理。

    public void handle(String url, Response response, Socket client) {
        try {
            if (ResourceHandler.class.getResource(url) == null) {
                log.info("找不到該資源:{}",url);
                throw new ResourceNotFoundException();
            }
            response.header(HTTPStatus.OK, MimeTypeUtil.getTypes(url)).body(IOUtil.getBytesFromFile(url)).write();
            log.info("{}已寫入輸出流", url);
        } catch (IOException e) {
            e.printStackTrace();
            exceptionHandler.handle(new RequestParseException(), response, client);
        } catch (ServletException e) {
            exceptionHandler.handle(e, response, client);
        } finally {
            try {
                client.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

ServletContext&web.xml

伺服器啟動時會解析web.xml,模仿了JavaWeb開發中web.xml的寫法,使用和標籤,以實現URL和Servlet的對映。我們使用了dom4j這個庫來解析xml檔案(視訊中使用的是JavaAPi實現的,但我感覺SAX方式解析比較麻煩,而DOM方式編碼簡潔很多)。
WebApplication這個類的static程式碼塊會在專案啟動時執行,建立了一個ServletContext(放在/servlet/context),構造時會讀取web.xml檔案並將資料封裝到Map中。
this.servlet = new HashMap<>();
this.mapping = new HashMap<>();
this.attributes = new ConcurrentHashMap<>();
this.sessions = new ConcurrentHashMap<>();
Document doc = XMLUtil.getDocument(ServletContext.class.getResource(“/WEB-INF/web.xml”).getFile());
Element root = doc.getRootElement();
List servlets = root.elements(“servlet”);
for (Element servlet : servlets) {
String key = servlet.element(“servlet-name”).getText();
String value = servlet.element(“servlet-class”).getText();
HTTPServlet httpServlet = null;
try {
httpServlet = (HTTPServlet) Class.forName(value).newInstance();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
this.servlet.put(key, httpServlet);
}

    List<Element> mappings = root.elements("servlet-mapping");
    for (Element mapping : mappings) {
        String key = mapping.element("url-pattern").getText();
        String value = mapping.element("servlet-name").getText();
        this.mapping.put(key, value);
    }

這裡使用了dom4j的API讀取web.xml,並使用反射來建立Servlet例項。
注意attributes和session可能會產生併發修改,所以要使用ConcurrentHashMap,基於CAS實現併發修改的執行緒安全。

    //由URL得到對應的Servlet類
    public HTTPServlet dispatch(String url) {
        return servlet.get(mapping.get(url));
    }

這段程式碼實現了由URL得到Servlet例項的邏輯。
此外ServletContext還負責維護域物件和session。稍後再解釋Session。

Template

這裡說的模板引擎是很簡單的字串替換,比如requestScope.username/{sessionScope.username} / applicationScope.username仿JSP{requestScope.user.username}),如果要實現的話,應該需要使用反射。
遇到這些${}佔位符時,會在forward時自動從request/session/servletContext中尋找是否有對應的key,並進行字串替換。

    public static String resolve(String content, Request request) throws TemplateResolveException {
        Matcher matcher = regex.matcher(content);
        StringBuffer sb = new StringBuffer();
        while (matcher.find()) {
            log.info("{}", matcher.group(1));
            String placeHolder = matcher.group(1);
            if (placeHolder.indexOf('.') == -1) {
                throw new TemplateResolveException();
            }
            ModelScope scope = ModelScope
                    .valueOf(
                            placeHolder.substring(0, placeHolder.indexOf('.'))
                                    .replace("Scope", "")
                                    .toUpperCase());
            String key = placeHolder.substring(placeHolder.indexOf('.') + 1);
            if (scope == null) {
                throw new TemplateResolveException();
            }
            Object value = null;
            switch (scope) {
                case REQUEST:
                    value = request.getAttribute(key);
                    break;
                case SESSION:
                    value = request.getSession().getAttribute(key);
                    break;
                case APPLICATION:
                    value = request.getServletContext().getAttribute(key);
                    break;
                default:
                    break;
            }
            log.info("value:{}",value);
            if (value == null) {
                matcher.appendReplacement(sb, "");
            } else {
                //把group(1)得到的資料,替換為value
                matcher.appendReplacement(sb, value.toString());
            }
        }
        return sb.toString();
    }

這裡使用了一個正則表示式,分組並進行替換。如果找不到的話,替換為空串(替換成null就非常尷尬了…)。

Forward&Redirect

這裡順便複習一下JavaWeb中老生常談轉發&重定向。轉發是伺服器內部發生的行為,對瀏覽器完全透明,一般是Servlet從Service層獲取資料後,將資料儲存到request/session/application域中,然後forward到某個頁面,模板引擎將域中的資料填充到頁面中,然後返回給瀏覽器;而redirect是客戶端sensitive的,瀏覽器中的位址列的URL會發生改變,通常是用於頁面跳轉。
我也是儘量模仿JavaEE API的轉發和重定向,API非常類似。

Forward

ApplicationRequestDispatcher(/request/dispatch/impl)中包含了轉發的邏輯。

    @Override
    public void forward(Request request, Response response) throws ServletException, IOException {
        if (ResourceHandler.class.getResource(url) == null) {
            throw new ResourceNotFoundException();
        }
        String body = TemplateResolver.resolve(new String(IOUtil.getBytesFromFile(url), CharsetProperties.UTF_8_CHARSET),request);
        response.header(HTTPStatus.OK, MimeTypeUtil.getTypes(url)).body(body.getBytes(CharsetProperties.UTF_8_CHARSET));
    }

forward是基於模板引擎的,會將頁面中的佔位符替換為域中的資料。

Redirect

重定向的程式碼在Response中。

    public void sendRedirect(String url){
        log.info("重定向至{}",url);
        addHeader(new Header("Location",url));
        header(HTTPStatus.MOVED_TEMPORARILY);
        body(bodyAppender.toString().getBytes(CharsetProperties.UTF_8_CHARSET));
    }

是在響應頭中加入一個Header,key是Location,value是地址。
注意!
雖然forward和redirect都是轉向一個頁面,但是頁面的路徑不一樣,前者是相對於伺服器的相對路徑,而後者是相對於瀏覽器的絕對路徑,需要包含Scheme,ServerName,Port。
這裡祭出一張非常好的解釋路徑的圖,來自傳智播客的30天精通JavaWeb課程。
這裡寫圖片描述

Session&Cookie

這又是老生常談的話題,聽課的時候感覺聽懂了,實現的時候發現還有一些細節不太清楚。Session是基於Cookie的,Cookie是瀏覽器提供的。一言以蔽之,Session是伺服器建立,伺服器儲存,Cookie是伺服器建立,客戶端儲存。第一次訪問時的response會帶上一個名為JSESSIONID的Cookie,每個JSESSIONID對應著一個session。以後瀏覽器的每次訪問該網站的請求都會帶上這個JSESSIONID,伺服器通過這個Id來唯一標識一次會話,取出對應的session域,實現會話的維持。
關於Session&Cookie的實現,涉及Request和ServletContext類。
當用戶(業務程式設計師)要求使用session時,如果當前請求已經有對應的session,那麼直接返回;否則會建立一個session,並在響應頭中加入一個Set-Cookie,值是JSESSIONID(通常是一個隨機不重複的字串,我這裡使用的是UUID)。
程式碼如下:

  • Request:
    public HTTPSession getSession() {
        if (session != null) {
            return session;
        }
        for (Cookie cookie : cookies) {
            if (cookie.getKey().equals("JSESSIONID")) {
                log.info("servletContext:{}",servletContext);
                HTTPSession currentSession = servletContext.getSession(cookie.getValue());
                if (currentSession != null) {
                    this.session = currentSession;
                    return session;
                }
            }
        }
        session = servletContext.createSession(requestHandler.getResponse());
        return session;
    }
  • ServletContext:
    public HTTPSession getSession(String JSESSIONID) {
        return sessions.get(JSESSIONID);
    }

    public HTTPSession createSession(Response response){
        HTTPSession session = new HTTPSession(UUIDUtil.uuid());
        sessions.put(session.getId(),session);
        response.addCookie(new Cookie("JSESSIONID",session.getId()));
        return session;
    }

域物件

眾所周知,JavaWeb中有三大域物件:Request,Session,Application(或許還有pageContext,這裡暫且不算)。我在Request、Session和ServletContext中都設定了一個名為attributes的Map,用於儲存請求處理過程中的資料。
大概結構都是這樣子:

    public void setAttribute(String key, Object value) {
        attributes.put(key, value);
    }

    public Object getAttribute(String key) {
        return attributes.get(key);
    }

未來希望新增/改進的地方:

  • NIO實現多路複用
  • 手寫WebSocket伺服器,實現HTTP長連線
  • Filter
  • Listener
  • 手寫Spring的IOC容器以及AOP,更多的面向介面程式設計

總結

這可能是第一次用Java造輪子,以後可能會更多地沉浸在造輪子的快樂中(怕不是個傻子)…比如Spring和SpringMVC。
最近心態比較浮躁,可能是因為面試屢屢受挫吧,暑期這麼久又沒有專案能做。瞭解了一下大公司的面試,往往會考察專案情況。如果沒有機會做真實專案的話,自己造輪子也是有一定價值的,如果能真的會被人使用的話,也算是對開源事業做了點貢獻。
Github地址:

如果有人喜歡,想自己也花點時間手寫一個的話(只有1000行),歡迎fork和star。
共勉。