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方法對請求進行分別處理。
- 首先解析HTTP請求,將其封裝到Request中。
- 如果是靜態資源,交給靜態資源處理器返回。
- 如果是動態資源,交由某個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
這裡說的模板引擎是很簡單的字串替換,比如{sessionScope.username} / {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。
共勉。