前言
最近筆者讀了《深入剖析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