該簡易的J2EE WEB容器缺失很多功能,卻可以提供給大家學習HTTP容器大致流程。
注:容器功能很少,只供學習。
1. 支持靜態內容與Servlet,不支持JSP
2. 僅支持304/404
3. 該設計參考Jetty容器
GIT地址:https://git.oschina.net/redcode/jerry.git
一、HTTP請求處理流程:
HTTP包的解析直接使用Socket讀取InputStream,再根據HTTP協議讀取HTTP請求頭於數據體,HTTP GET請求頭類似如下:
GET / HTTP/1.1
Accept: */*
Accept-Language: zh-CN
User-Agent:
Accept-Encoding: gzip, deflate
Host: www.baidu.com
Connection: Keep-Alive
1. 如GET / HTTP/1.1代表是GET 請求,請求路徑為/,協議版本為HTTP 1.1,中間使用空格分隔,請求頭每個屬性一行,使用\n換行(WINDOWS為\r\n)。
當解析Socket的InputStream的時候首先讀取第一行,代碼類似如下:
BufferedReader br = new BufferedReader( new InputStreamReader(socket.getInputStream()) ); String reqCmd = br.readLine(); if(reqCmd == null){ return null; //數據包不正常,忽略 } String[] cmds = reqCmd.split("\\s");
2. POST 請求包類似如下:
POST /login HTTP/1.1 Accept: */* User-Agent: Host: Pragma: no-cache Cookie: Content-Length: 25 count=1&viewid=lNe3tRpyVj
請求頭后換行,再封裝POST請求數據:count=1&viewid0=lNe3tRpyVj
解析POST請求包時,讀取請求頭后再讀取數據,存入Map中。檢查請求類型如下:
//Request method check if(!HttpMethod.isAccept(cmds[0])) { return null; }
接受的請求類型枚舉:
public enum HttpMethod { GET, POST; public static boolean isAccept(String method) { for(HttpMethod m : HttpMethod.values()) { if(m.name().equals(method)) { return true; } } return false; } public static HttpMethod getMethod(String method){ for(HttpMethod m : HttpMethod.values()) { if(m.name().equals(method)) { return m; } } return null; } }
POST 請求需讀取 Content-Length 屬性,即需要知道POST包中的參數包大小,當TCP包被拆分通過幾條鏈路到達目的地時,根據包長度使得服務端能合理的等待數據到來。
//Read headers String line; int contentLength = 0; HashMap<String,String> headers = new HashMap<String, String>(); while( (line = br.readLine()) != null && !line.equals("") ) { int idx = line.indexOf(": "); if(idx == -1) { continue; } if(HttpHeaders.CONTENT_LENGTH.equals(line)) { contentLength = Integer.parseInt(line.substring(idx+2).trim()); } headers.put(line.substring(0, idx), line.substring(idx+2)); }
二、總體設計說明:
1. 從Main函數開始說明應該的設計方法,有些機制可用於其他軟件的設計。
部署結構如下:
%HOME%/lib/* ----依賴包
%HOME%/conf/* -----配置文件夾
%HOME%/startup.sh ---啟動SHELL
%HOME%/logs/* ----日志文件夾
%HOME%/webapps/* ----頁面部署路徑
這個設計方法很類似於TOMCAT。ECLIPSE包結構截圖如下:
工程啟動類 org.mike.jerry.launcher.Main
lib類加載器 org.mike.jerry.launcher.ClassPath
服務加載類 org.mike.jerry.launcher.Bootstrap,該類中讀取配置並啟動服務端口監聽。
配置文件conf/config.properties 默認配置80端口,啟動后使用 http://127.0.0.1即可訪問。
2. 請求接受與線程池
真正處理請求即為org.mike.jerry.server.SocketConnector ,啟動與接受請求:
protected ServerSocket newServerSocket(String host, int port,int backlog) throws IOException{ ServerSocket ss= host==null? new ServerSocket(port,backlog): new ServerSocket(port,backlog,InetAddress.getByName(host)); return ss; } public void accept() throws IOException { log.info("Server started ..."); while(started){ Socket socket = serverSocket.accept(); ConnectorEndPoint connector = new ConnectorEndPoint(socket); connector.dispatch(); } }
每次請求開啟一個ConnectorEndPoint線程處理,該線程從線程池中獲取(org.mike.jerry.server.util.thread.ThreadPool),處理如下:
/* Request Handler */ protected class ConnectorEndPoint extends SocketEndPoint implements Runnable { public ConnectorEndPoint(Socket socket) throws IOException { super(socket); socket.setSoTimeout(7000); } public void dispatch() { threadPool.dispatch(this); } @Override public void run() { ...... } }
3. HTTP包解析器
HTTP包解析類由org.mike.jerry.http.HttpRequestDecoder工作,HTTP請求處理都位於org.mike.jerry.http包中。
請求解析工作有幾點:
1. 讀取請求頭,區分GET POST,獲取請求頭屬性,GET讀取URL中的符號“?”並解析參數,POST需要根據Content-Length再讀取請求體中的請求參數。
把解析完成的數據存入Request中,根據Servlet設計規范,Request中需要存儲請求體放入ServletInputStream in中,以供容器使用者在Servlet中能讀取到InputStream.
2. 請求讀取完畢后 把Resuqet交與 ResourceHandler 處理,讀取所需要請求的資源。
4. 讀取資源
資源的讀取中,默認請求為/的會固定讀取/index.html文件,該屬性本應該在web.xml中配置,不過為了學習簡易,硬編碼於此。
1. 首先檢查這路徑是否在Servlet中有匹配的,如果沒有,則進行下一步。
2. 從webapps文件夾中讀取請求的文件,如果不存在,則返回404,如果存在,則進行下一步。
3. 讀取請求中的ETag碼,這個標志類似於MD5、SHA1等文件摘要,用於標志文件是否改變,如果未改變,則返回304,節省服務器資源(CPU、磁盤與網絡等)
,只是MD5與SHA1計算文件摘要需要的CPU周期較長,固計算方法修改如下:
public String getWeakETag() { try{ StringBuilder b = new StringBuilder(32); b.append("M/\""); int length=uri.length(); long lhash=0; for (int i=0; i<length;i++) lhash= 31*lhash + uri.charAt(i); B64Code.encode(file.lastModified()^lhash, b); B64Code.encode(length^lhash, b); b.append('"'); return b.toString(); } catch (IOException e) { throw new RuntimeException(e); } }
5. 如果文件發生改變,則重新讀取文件字節流,放入響應包Response中。
5. 響應HTTP包封裝
5.1 響應頭輸出: 首先獲取socket輸出流,再寫出頭信息,127.0.0.1抓包工具可使用rawcap,得到pcap包后使用wireshark查看,格式類似於:
HTTP/1.1 200 OK
ETag: M/"AJMRnIhabgYAJMQ2H/NnL0"
Date: Wed, 5 Nov 2014 09:58:17 GMT
Content-Length: 1102
Last-Modified: Wed, 2 Jul 2014 23:01:08 GMT
Connection: Keep-Alive
Content-Type: text/html
Server: M
Cache-Control: private
相應代碼如:
OutputStream out = socket.getOutputStream(); //config status message String respStat = HttpStatus.getMessage(response.getStatus()); StringBuilder headers = new StringBuilder(); headers.append(response.getHttpVersion() + " " + response.getStatus() + " " + respStat + StringUtil.CRLF); //write headers for(Map.Entry<String, String> header : response.getHeaders().entrySet()){ headers.append(header.getKey() + ": " + header.getValue() + StringUtil.CRLF); } headers.append(StringUtil.CRLF);//響應頭寫入完畢必須空一行,這也是協議規定,以區分響應體 out.write(headers.toString().getBytes())
寫入響應頭后再寫入響應體,也就是請求的資源內容。