跟我一起動手實現Tomcat(三):解析Request請求引數、請求頭、cookie

Geoffrey.Yip?發表於2019-03-04

前言

最近筆者讀了《深入剖析tomcat》這本書(原作:《how tomcat works》),發現該書簡單易讀,每個
章節循序漸進的講解了tomcat的原理,在接下來的章節中,tomcat都是基於上一章新增功能並完善,
到最後形成一個簡易版tomcat的完成品。所以有興趣的同學請按順序閱讀,本文為記錄第三章的知識點
以及原始碼實現(造輪子)。
複製程式碼

內容回顧

跟我一起動手實現Tomcat(二):實現簡單的Servlet容器

上一章我們實現了一個簡單的Servlet容器,能夠呼叫並執行使用者自定義實現Servlet介面的類。

本章內容

  • 模組模仿tomcat,實現Connector(聯結器)、Bootstrap(啟動器)和核心模組。
  • 能夠執行繼承HttpServlet類的自定義Servlet(上一章是實現了Servlet介面)
  • 能夠解析使用者請求引數(Parameters)/Cookie/請求頭(Header)

開始之前

  • 簡單介紹Connector(聯結器)

    對Tomcat比較熟悉的朋友對這個詞應該不陌生,後面的篇幅會繼續比較詳細介紹,在這裡不熟悉的朋友可以理解為:聯結器只是負責接收請求,然後將請求丟給Container(容器)去執行相應的請求。

  • javax.servlet.http.HttpServlet類

    上一章我們自定義的Servlet是實現了Servlet介面,例項化Servlet的時候我們是將解析的Request/Response(分別實現了ServletRequest/ServletResponse介面)傳入對應的service()方法中完成執行。

    那我們來回顧一下剛學Servlet開發的時候,大部分教程都按順序講解實現Servlet介面、繼承GenericServlet類、繼承HttpServlet類這三種方式去寫自己的Servlet,那麼後面推薦的仍然是最後一種,重寫其中的doGet()/doPost()方法即可,那麼我們來看看上一章我們的tomcat能不能支援繼承HttpServlet類的Servlet呢:

//HttpServlet原始碼片段
public abstract class HttpServlet extends GenericServlet {
...
public void service(ServletRequest req, ServletResponse res)
throws ServletException, IOException
{
HttpServletRequest request;
HttpServletResponse response;
//如果傳進來的request/response物件不是Http型別的則拋異常
if (!(req instanceof HttpServletRequest &&
res instanceof HttpServletResponse)) {
throw new ServletException("non-HTTP request or response");
}

request = (HttpServletRequest) req;
response = (HttpServletResponse) res;

service(request, response);
}
...
}
複製程式碼

如上所示原始碼,再來看看我們上一章的ServletProcess呼叫Servlet原始碼:

servlet.service(new RequestFacade(request), new ResponseFacade(response));
複製程式碼

很明顯上一章的request/response在HttpServlet時會丟擲異常,所以本章我們會將Request/Response以及它們的外觀類都實現HttpServletRequest/HttpServletResponse介面。

程式碼實現

在程式碼實現前我們看看整體模組以及流程執行圖(看不清可以點選放大):

1. Bootstrap模組

啟動模組目前我們沒有多大工作,只是啟動HttpConnector:

 public final class Bootstrap {
public static void main(String[] args){
new HttpConnector().start();
}
}
複製程式碼

2. HttpConnector模組(聯結器)

    聯結器模組和下面的核心模組的前身其實就是上一章的HttpServer類,我們把它按功能拆分成了
等待和建立連線(HttpConnector)/處理連線(HttpProcess)2個模組。
複製程式碼

聯結器功能是等待請求並將請求丟給相應執行器去執行:

public class HttpConnector implements Runnable {
public void start(){
new Thread(this).start();
}
@Override
public void run() {
ServerSocket serverSocket = new ServerSocket(8080, 1, InetAddress.getByName("127.0.0.1"));
while (true) {
Socket accept = serverSocket.accept();
HttpProcess process = new HttpProcess(this);
process.process(accept);
}
serverSocket.close();
}
}
複製程式碼

3. 核心模組(執行器)

上面也有說到,執行器也是上一章HttpServer類的前身,只不過這章我們修改瞭解析請求資訊的方式。
複製程式碼
  • 主要程式碼
public class HttpProcess {
private HttpRequest request;
private HttpResponse response;

public void process(Socket socket) {
InputStream input = socket.getInputStream();
OutputStream output = socket.getOutputStream();
//初始化request以及response
request = new HttpRequest(input);
response = new HttpResponse(output, request);
//解析request請求行和請求頭
this.parseRequestLine(input);
this.parseHeaders(input);
//呼叫對應的處理器處理
if (request.getRequestURI().startsWith(SERVLET_URI_START_WITH)) {
new ServletProcess().process(request, response);
} else {
new StaticResourceProcess().process(request, response);
}
}
}
複製程式碼

看了上面的實現可能很多人對有些物件有點陌生,下面一一介紹:

 1. HttpRequest/HttpResponse變數就是上一章的Request/Response物件,因為實現了
HttpServletReuqest/HttpServletResponse也就順便改了個名,將會在下面介紹;
2. 每一個請求都對應了一個HttpProcess物件,所以這裡request/response是成員變數;
3. 解析請求行和解析請求頭的方法也在下面介紹。
複製程式碼

  • parseRequestLine、parseHeaders方法

    讓我們先看看一個原始的HTTP請求字串,看看如何去解析請求行和請求頭:

    GET /index.html?utm_source=aaa HTTP/1.1\r\n
    Host: www.baidu.com\r\n
    Connection: keep-alive\r\n
    Pragma: no-cache\r\n
    Cache-Control: no-cache\r\n
    Upgrade-Insecure-Requests: 1\r\n
    User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36\r\n
    Accept: text/html\r\n
    Accept-Encoding: gzip, deflate, sdch, br\r\n
    Accept-Language: zh-CN,zh;q=0.8\r\n
    Cookie: BAIDUID=462A9AC35EE6158AA7DFCD27AF:FG=1; BIDUPSID=462A9AC35EE6158AA7DF027AF; PSTM=1506310304; BD_CK_SAM=1; PSINO=7; BD_HOME=1; H_PS_PSSID=1459_24885_21115_25436; BD_UPN=12314353; sug=3; sugstore=0; ORIGIN=2; bdime=0\r\n
    複製程式碼

    大家可以發現,其實我們使用socket讀取HTTP請求時候,發現每一行都會有'\r\n'這個回車換行符,只不過在我們瀏覽器按F12時被瀏覽器自動解析成了換行而已,我們分析上面的這個請求資訊得出以下規律:

    - 每一行結尾字元都是\r\n
    - 請求行(第一行)的HTTP請求方法、URI、請求協議中間都有個空格
    - 第二行開始(請求頭)key和value的內容都是以':'和一個' '字元隔開
    - Cookie的鍵值對是以'='分割,以';'' '區分前後鍵值對
    複製程式碼

    接下來我們分別去解析以下ISO-8859-1編碼情況下上面字元對應的值,並建立一個常量類:

    public class HttpConstant {
    /* 回車 \r */
    public static final byte CARRIAGE_RETURN = 13;
    /* 換行 \n */
    public static final byte LINE_FEED = 10;
    /* 空格 */
    public static final byte SPACE = 32;
    /* 冒號 : */
    public static final byte COLON = 58;
    }
    複製程式碼
  • 1.parseRequestLine方法

    根據上面的思路,我們就可以輕鬆地解析請求行的資料:

    StringBuilder temp = new StringBuilder();
    int cache;
    while ((cache = requestStream.read()) != -1) {
    //讀取到第一個\r\n時則說明讀取請求行完畢
    if (HttpConstant.CARRIAGE_RETURN == cache && HttpConstant.LINE_FEED == requestStream.read()) {
    break;
    }
    temp.append((char)cache);
    }
    String[] requestLineArray = temp.toString().split(" ");
    複製程式碼

    最後分割空格使用陣列裝著請求行(如果你有更好的方案也可以在評論區說一說哈)


接下來判斷URI有沒有使用"?"傳遞引數,如果有就擷取並丟到HttpRequest的QueryString變數中,
最後擷取URI即可。

String uri  = requestLineArray[1];
int question = uri.indexOf("?");
if (question >= 0) {
request.setQueryString(uri.substring(question+1,uri.length()));
uri = uri.substring(0,question);
}
複製程式碼
判斷是不是從?傳遞jsessionid過來,如果是就賦值到request物件中
複製程式碼
String match = ";jsessionid=";
int semicolon = uri.indexOf(match);
if (semicolon >= 0) {
String rest = uri.substring(semicolon + match.length());
int semicolon2 = rest.indexOf(';');
if (semicolon2 >= 0) {
request.setRequestedSessionId(rest.substring(0, semicolon2));
rest = rest.substring(semicolon2);
} else {
request.setRequestedSessionId(rest);
rest = "";
}
request.setRequestedSessionURL(true);
uri = uri.substring(0, semicolon) + rest;
} else {
request.setRequestedSessionId(null);
request.setRequestedSessionURL(false);
}
複製程式碼

這裡呼叫了一個校驗URI合法性的方法,如果URI不合法(例如包含'.//'之類跳轉目錄的危險字元)
則拋異常,否則就將上面解析到的內容丟到request中去。

String normalizedUri = this.normalize(uri);
if (normalizedUri == null) {
throw new ServletException("Invalid URI: " + uri + "'");
}
request.setRequestURI(normalizedUri);
request.setMethod(requestLineArray[0]);
request.setProtocol(requestLineArray[2]);
複製程式碼

就這樣,請求行的資訊就被我們讀取完畢,那我們再來看看讀取請求頭的程式碼:

  • parseHeaders方法

    這裡有個坑:Socket的read()方法讀取完畢時最後一個位元組不是-1,而是阻塞等待Socket客戶端傳送-1過來結束讀取,但是我們的Socket客戶端是瀏覽器,瀏覽器不會傳送-1以表示結束髮送,所以我們結合InputStream#available()方法(返回實際還可以讀取的位元組數)來判斷是否讀取完畢即可。

public void parseHeader() {
StringBuilder sb = new StringBuilder();
int cache;
while (input.available() > 0 && (cache = input.read()) > -1) {
sb.append((char)cache);
}
....看下文
}
複製程式碼

讀取完畢效果如圖:


如果是POST請求,那麼表單引數會在空行後面:

也很有規律,請求頭都用\r\n隔開,並且如果是POST請求提交表單,那麼表單引數會在一個空行後面(兩個\r\n)

//使用\r\n分割請求頭
Queue<String> headers = Stream.of(sb.toString().split("\r\n")).collect(toCollection(LinkedList::new));
while (!headers.isEmpty()) {
//獲取一個請求頭
String headerString = headers.poll();
//讀取到空行則說明請求頭已讀取完畢
if (StringUtil.isBlank(headerString)) {
break;
}
//分割請求頭的key和value
String[] headerKeyValue = headerString.split(": ");
request.addHeader(headerKeyValue[0], headerKeyValue[1]);
}
//如果在讀取到空行後還有資料,說明是POST請求的表單引數
if(!headers.isEmpty()){
request.setPostParams(headers.poll());
}
複製程式碼

大致流程:


最後我們對一些特殊的請求頭資訊設定到Request物件中(cookie、content-type、content-length);

String contentLength = request.getHeader("content-length");
if(contentLength!=null){
request.setContentLength(Integer.parseInt(contentLength));
}
request.setContentType(request.getHeader("content-type"));

Cookie[] cookies = parseCookieHeader( request.getHeader("cookie"));
Stream.of(cookies).forEach(cookie -> request.addCookie(cookie));
//如果sessionid不是從cookie中獲取的,則優先使用cookie中的sessionid
if (!request.isRequestedSessionIdFromCookie()) {
Stream.of(cookies)
.filter(cookie -> "jsessionid".equals(cookie.getName()))
.findFirst().
ifPresent(cookie -> {
//設定cookie的值
request.setRequestedSessionId(cookie.getValue());
request.setRequestedSessionCookie(true);
request.setRequestedSessionURL(false);
});
}
複製程式碼

讀取cookie的方法也很簡單:

private Cookie[] parseCookieHeader(String cookieListString) {
return Stream.of(cookieListString.split("; "))
.map(cookieStr -> {
String[] cookieArray = cookieStr.split("=");
return new Cookie(cookieArray[0], cookieArray[1]);
}).toArray(Cookie[]::new);
}
複製程式碼

不熟悉JDK8語法的小夥伴們可能看不太懂幹了什麼,沒關係來張圖解釋一下上面那段程式碼內容:


到這裡,HttpProcess處理請求的邏輯就搞定啦,(是不是覺得程式碼有點多),細心的客官們一定發現了,request怎麼可以設定那麼多屬性呢?上一章的request好像沒有那麼多功能吧?是的,我們這一章也對request/response做了手腳,請看下文分析:

  • HttpRequest(上一章的Request物件)

    沒錯,在文章的開頭我們已經說了要把Request升級一下,那麼怎麼升級呢?也就是實現HttpServletRequest介面啦:

    public class HttpRequest implements HttpServletRequest {
    private String contentType;
    private int contentLength;
    private InputStream input;
    private String method;
    private String protocol;
    private String queryString;
    private String postParams;
    private String requestURI;
    private boolean requestedSessionCookie;
    private String requestedSessionId;
    private boolean requestedSessionURL;
    protected ArrayList<Cookie> cookies = new ArrayList<>();
    protected HashMap<String, ArrayList<String>> headers = new HashMap<>();
    protected ParameterMap parameters;
    ...
    }
    複製程式碼

    哈哈沒有看錯,多了一堆引數,但是細心的客官們應該可以看到,這些引數都是非常眼熟,而且上面已經對大部分引數設值過了,眼生的可能就是下面的那個ParameterMap,那麼等下我們慢慢分析:(那些get、set方法就不分析了)

    請求頭(header)操作:

    public void addHeader(String name, String value) {
    name = name.toLowerCase();
    //如果key對應的value不存在則new一個ArrayList
    ArrayList<String> values = headers.computeIfAbsent(name, k -> new ArrayList<>());
    values.add(value);
    }
    public ArrayList getHeaders(String name) {
    name = name.toLowerCase();
    return headers.get(name);
    }
    public String getHeader(String name) {
    name = name.toLowerCase();
    ArrayList<String> values = headers.get(name);
    if (values != null) {
    return values.get(0);
    } else {
    return null;
    }
    }
    public ArrayList getHeaderNames() {
    return new ArrayList(headers.keySet());
    }
    複製程式碼

    大家可以看到請求頭是是個Map,key是請求頭的名字,value則是請求頭的內容陣列(一個請求頭可以有多個內容),所以也就是對這個Map做操作而已~

    Cookie操作:

     public Cookie[] getCookies() {
    return cookies.toArray(new Cookie[cookies.size()]);
    }
    public void addCookie(Cookie cookie) {
    cookies.add(cookie);
    }
    複製程式碼

    好像也沒什麼好說的,對List\做常規操作。

    Parameters操作:

    這是我們最常用的一個操作啦,那麼ParameterMap是個什麼東西呢,我們先來看看:

    public final class ParameterMap extends HashMap<String,String[]> {
    private boolean locked = false;
    public boolean isLocked() {
    return locked;
    }
    public void setLocked(boolean locked) {
    this.locked = locked;
    }
    public String[] put(String key, String[] value) {
    if (locked) {
    throw new IllegalStateException("error");
    }
    return (super.put(key, value));
    }
    ...
    }
    複製程式碼

    好吧其實它就是在HashMap基礎上加了一個locked物件(如果已經解析引數完畢了則將這個物件設定為true禁止更改),key是引數名,value是引數值陣列(可有多個)
    例如:127.0.0.1:8080/servlet/QueryServlet?name=geoffrey&name=yip

    那麼我們來看看對parameter這個map的操作有:

    public String getParameter(String name) {
    parseParameters();
    String[] values = parameters.get(name);
    return Optional.ofNullable(values).map(arr -> arr[0]).orElse(null);
    }
    public Map getParameterMap() {
    parseParameters();
    return this.parameters;
    }
    public ArrayList<String> getParameterNames() {
    parseParameters();
    return new ArrayList<>(parameters.keySet());
    }
    public String[] getParameterValues(String name) {
    parseParameters();
    return parameters.get(name);
    }
    複製程式碼

    程式碼都很簡單,但是這個parseParameters()是什麼呢,對,它是去解析請求的引數了(懶載入),因為我們不知道使用者使用Servlet會不會用到請求引數這個功能,而且解析它的開銷比解析其他資料大,所以我們會在使用者真正使用引數的時候才會去解析,提高整體的響應速度,大概的程式碼如下:

    protected void parseParameters() {
    if (parsed) {
    //已經解析過則停止解析
    return;
    }
    ParameterMap results = parameters;
    if (results == null) {
    results = new ParameterMap();
    }
    results.setLocked(false);
    String encoding = getCharacterEncoding();
    if (encoding == null) {
    encoding = StringUtil.ISO_8859_1;
    }
    // 解析URI攜帶的請求引數
    String queryString = getQueryString();
    this.parseParameters(results, queryString, encoding);
    // 初始化Content-Type的值
    String contentType = getContentType();
    if (contentType == null) {
    contentType = "";
    }
    int semicolon = contentType.indexOf(';');
    if (semicolon >= 0) {
    contentType = contentType.substring(0, semicolon).trim();
    } else {
    contentType = contentType.trim();
    }
    //解析POST請求的表單引數
    if (HTTPMethodEnum.POST.name().equals(getMethod()) && getContentLength() > 0
    && "application/x-www-form-urlencoded".equals(contentType)) {
    this.parseParameters(results, getPostParams(), encoding);
    }
    //解析完畢就鎖定
    results.setLocked(true);
    parsed = true;
    parameters = results;
    }
    /**
    * 解析請求引數
    * @param map Request物件中的引數map
    * @param params 解析前的引數
    * @param encoding 編碼
    */

    public void parseParameters(ParameterMap map, String params, String encoding) {
    String[] paramArray = params.split("&");
    Stream.of(paramArray).forEach(param -> {
    String[] splitParam = param.split("=");
    String name = splitParam[0];
    String value = splitParam[1];
    //此處是將key和value使用URLDecode解碼並新增進map中
    putMapEntry(map, urlDecode(name, encoding), urlDecode(value, encoding));
    });
    }
    複製程式碼

    大概內容就是根據之前HttpProcess解析請求行的queryString引數以及如果是POST請求的表單資料放入ParameterMap中,並且鎖定Map。

  • HttpResponse(上一章的Response物件)

    HttpResponse物件也跟隨者實現了HttpServletResponse介面,但是本章沒有實現具體的內容,所以此處略過。

    public class HttpResponse implements HttpServletResponse {
    ...
    }
    複製程式碼
  • ServletProcess

ServletProcess具體只需要將request和response的外觀類跟著升級實現對應的介面即可:

public void process(HttpRequest request, HttpResponse response) throws IOException {
...
servlet.service(new HttpRequestFacade(request), new HttpResponseFacade(response));
...
}
public class HttpRequestFacade implements HttpServletRequest {
private HttpRequest request;
...
}

public class HttpResponseFacade implements HttpServletResponse {
private HttpResponse response;
...
}
複製程式碼

實驗

我們先編寫一個Servlet:

/**
* 測試註冊Servlet
*/

public class RegisterServlet extends HttpServlet {
@Override
public void doGet(HttpServletRequest req, HttpServletResponse resp) {
//列印表單引數
String name = req.getParameter("name");
String password = req.getParameter("password");
if (StringUtil.isBlank(name) || StringUtil.isBlank(password)) {
try {
resp.getWriter().println("賬號/密碼不能為空!");
} finally {
return;
}
}
//列印請求行
System.out.println("Parse user register method:" + req.getMethod());
//列印Cookie
System.out.println("Parse user register cookies:");
Optional.ofNullable(req.getCookies())
.ifPresent(cookies ->
Stream.of(cookies)
.forEach(cookie ->System.out.println(cookie.getName() + ":" + cookie.getValue()
)));
//列印請求頭
System.out.println("Parse http headers:");
Enumeration<String> headerNames = req.getHeaderNames();
while (headerNames.hasMoreElements()) {
String headerName = headerNames.nextElement();
System.out.println(headerName + ":" + req.getHeader(headerName));
}
System.out.println("Parse User register name :" + name);
System.out.println("Parse User register password :" + password);
try {
resp.getWriter().println("註冊成功!");
} finally {
return;
}
}
@Override
public void doPost(HttpServletRequest req, HttpServletResponse resp) {
this.doGet(req, resp);
}
}
複製程式碼

編寫一個HTML:

<html>
<head>
<title>註冊</title>
</head>
<body>
<form method="post" action="/servlet/RegisterServlet">
賬號:<input type="text" name="name"><br>
密碼:<input type="password" name="password"><br>
<input type="submit" value="提交">
</form>
</body>
</html>
複製程式碼

開啟瀏覽器測試:


控制檯輸出:

到這裡,我們們的Tomcat 3.0 web伺服器就已經開發完成啦(滑稽臉),已經可以實現簡單的自定義Servlet呼叫,以及請求行/請求頭/請求引數/cookie等資訊的解析,待完善的地方還有很多:

 - HTTPProcess一次性只能處理一個請求,其他請求只能堵塞,不具備伺服器使用性。
- 每一次請求就new一次Servlet,Servlet應該在初始化專案時就應該初始化,是單例的。
- 並未遵循Servlet規範實現相應的生命週期,例如init()/destory()方法我們均未呼叫。
- HttpServletRequest/HttpServletResponse介面的大部分方法我們仍未實現
- 架構/包結構和tomcat相差太多
- 其他未實現的功能
複製程式碼

在下一章節我們會把我們的聯結器實現Catalina的Connector介面,並且使得我們可以同時處理多個請求,最終我們的聯結器實現的功能以及結構和Tomcat4版本的預設聯結器基本一致。

PS:本章原始碼已上傳github: SimpleTomcat

相關文章