在分析 Tomcat 實現之前,首先看一下 Servlet 規范是如何規定容器怎么把請求映射到一個 servlet。本文首發於(微信公眾號:頓悟源碼)
1. 使用 URL 路徑
收到客戶端請求后,容器根據請求 URL 的上下文名稱匹配 Web 應用程序,然后根據去除上下文路徑和路徑參數的路徑,按以下規則順序匹配,並且只使用第一個匹配的 Servlet,后續不再嘗試匹配:
- 精確匹配,查找一個與請求路徑完全匹配的 Servlet
- 前綴路徑匹配,遞歸的嘗試匹配最長路徑前綴的 Servlet,通過使用 "/" 作為路徑分隔符,在路徑樹上一次一個目錄的匹配,選擇路徑最長的
- 擴展名匹配,如果 URL 最后一部分包含擴展名,如 .jsp,則嘗試匹配處理此擴展名請求的 Servlet
- 如果前三個規則沒有匹配成功,那么容器要為請求提供一個默認 Servlet
容器在匹配時區分大小寫。
2. 映射規范
在 web.xml 部署描述符中,使用以下語法定義映射:
- 以 '/' 字符開始、以 '/*' 后綴結尾的字符串使用路徑匹配
- 以 '*.' 為前綴的字符串使用擴展名匹配
- 空字符串("")是一個特殊的 URL 模式,其精確映射到應用的上下文根,即,http://host:port/
/ 請求形式。在這種情況下,路徑信息是 '/' 且 servlet 路徑和上下文路徑是空字符串("") - 只包含 '/' 的字符串表示應用的默認 Servlet,在這種情況下,servlet 路徑是請求 URL 減去上下文路徑且路徑信息是 null
- 所以其他字符串僅使用精確,完全匹配
3. 映射示例
假設有以下映射配置:
/foo/bar/* servlet1
/baz/* servlet2
/catalog servlet3
*.bop servlet4
那么以下請求路徑的匹配情況是:
/foo/bar/index.html servlet1
/foo/bar/index.bop servlet1
/baz servlet2
/baz/index.html servlet2
/catalog servlet3
/catalog/index.html default servlet
/catalog/racecar.bop servlet4
/index.bop servlet4
注意,在 /catalog/index.html 和 /catalog/racecar.bop 的情況下,不使用映射到 /catalog 的 servlet,是因為不是完全匹配。
4. 實現
實現請求映射的一般方法是,首先構建一個路由表,然后按照規范進行匹配,最后返回匹配結果。Tomcat 就是如此,與請求映射相關的類有三個,分別是:
- Mapper: 存儲請求路由表並執行匹配
- MapperListener: 查詢所有的 Host、Context、Wrapper 構建路由表
- MappingData: 請求映射結果
4.1 構建路由表
這里使用的源碼版本是 6.0.53,此版本 MapperListener 是通過 JMX 查詢 Host、Context、Wrapper,然后加入到 Mapper 的路由表中。而在高版本,如7和8中,則使用的是 containerEvent 和 lifecycleEvent 容器和生命周期事件進行構建。
Mapper 內部設計了路由表的組成結構,相關的類圖如下:
上圖包含了各類的核心成員變量和方法,也直觀的體現了類之間的關系。
Mapper 在構建路由時,addHost 和 addContext 比較簡單,都是對數組的操作,這里着重對 addWrapper 的源碼進行分析。
從類圖中可看出 Context 內部有四種 Wrapper,對應着處理不同映射規則的 Servlet,分別是:
- exactWrappers: 處理精確,完全匹配的 Wrapper 數組
- wildcardWrappers: 處理模糊匹配的 Wrapper 數組,即以 '/*' 結尾的路徑匹配
- extensionWrappers: 處理擴展名匹配的 Wrapper 數組,即以 '*.' 為前綴的路徑
- defaultWrapper: 默認 Servlet,即只包含 '/' 的路徑
addWrapper 就是以這種規則,根據請求 path 按條件將 Wrapper 插入對應的數組中,核心源碼如下:
protected void addWrapper(Context context, String path, Object wrapper,
boolean jspWildCard) {
synchronized (context) {
Wrapper newWrapper = new Wrapper();
newWrapper.object = wrapper; // StandardWrapper 對象
newWrapper.jspWildCard = jspWildCard; // 是否是 JspServlet
if (path.endsWith("/*")) {
// Wildcard wrapper 模糊匹配,最長前綴路徑匹配
// 存儲名稱時去除 /* 字符
newWrapper.name = path.substring(0, path.length() - 2);
... // 插入到 context 處理模糊匹配的 Wrapper 數組中
context.wildcardWrappers = newWrappers;
} else if (path.startsWith("*.")) {
// Extension wrapper 擴展名匹配
newWrapper.name = path.substring(2); // 存儲名稱時去除 *. 字符
... // 插入到 context 處理擴展名匹配的 Wrapper 數組中
context.extensionWrappers = newWrappers;
} else if (path.equals("/")) {
// Default wrapper 默認 Servlet
newWrapper.name = ""; // 名稱為空字符串
context.defaultWrapper = newWrapper;
} else {
// Exact wrapper 完全匹配
newWrapper.name = path;
... // 插入到 context 處理完全匹配的 Wrapper 數組中
context.exactWrappers = newWrappers;
}
}
}
上文的 Servlet 映射實例的配置,在內存中,存儲情況如下:
- exactWrappers[]: servlet3(/catalog)
- wildcardWrappers[]: servlet1(/foo/bar); servlet2(/baz)
- extensionWrappers[]: servlet4(bop)
4.2 執行映射
觸發映射請求的動作是 CoyoteAdapter 的 postParseRequest() 方法,最終由 Mapper 內部的 internalMap 和 internalMapWrapper 兩個方法完成。
internalMap 根據 name 字符串匹配 Host 和 Context,其中 Host 不區分大小寫,Context 區分。internalMapWrapper 實現的就是 Servlet 規范描述的 URL 匹配規則。
有一點需要注意,在遍歷數組查找 Host、Context、Wrapper 時,使用的是二分查找,比較的是字符串,在返回結果時,返回的是與參數盡可能接近或相等的元素下標,其中的一個 find 源碼如下:
private static final int find(MapElement[] map, String name) {
int a = 0;
int b = map.length - 1;
// 如果數組為空
if (b == -1) {
return -1;
} // 或者小於數組的第一個元素,那么返回 -1 表示沒找到
if (name.compareTo(map[0].name) < 0) {
return -1;
} // 或者大於數組的第一個元素,且數組長度為 1,返回下標 0
if (b == 0) {
return 0;
}
// 二分查找等於或長度最接近 name 的數組元素下標
int i = 0;
while (true) {
i = (b + a) / 2; // 中間元素下標
int result = name.compareTo(map[i].name);
if (result > 0) { // 大於 map[i]
a = i; // 從中間往后開始查找
} else if (result == 0) {
return i; // 等於,直接返回 i
} else { // 小於,從中間往前開始查找
b = i;
}
if ((b - a) == 1) {// 如果下次比較的元素就剩兩個
int result2 = name.compareTo(map[b].name);
if (result2 < 0) {
return a; // 小於返回下標 a
} else {
return b; // 大於等於返回下標 b
}
}
}
}
以上文映射實例的配置為例,分析 /foo/bar/index.html 映射 Servlet 的源碼實現,注意這里使用的路徑,要去除上下文路徑和路徑參數。
首先嘗試完全匹配:
// Rule 1 -- Exact Match
Wrapper[] exactWrappers = context.exactWrappers;
// 獲取處理完全匹配的 Wrapper 數組,這里是 [servlet3(/catalog)]
internalMapExactWrapper(exactWrappers, path, mappingData);
private final void internalMapExactWrapper(...) {
int pos = find(wrappers, path); // 查找 path 長度最相近或相等的 wrapper
if ((pos != -1) && (path.equals(wrappers[pos].name))) {
// 如果匹配成功,設置匹配數據,直接返回,后續不再匹配
mappingData.requestPath.setString(wrappers[pos].name);
mappingData.wrapperPath.setString(wrappers[pos].name);
mappingData.wrapper = wrappers[pos].object;
}
}
如果完全匹配失敗,然后嘗試最長路徑的模糊匹配,核心代碼如下:
// Rule 2 -- Prefix Match
boolean checkJspWelcomeFiles = false;
// 獲取處理路徑匹配的 Wrapper 數組,這里是 [servlet1(/foo/bar),servlet2(/baz)]
Wrapper[] wildcardWrappers = context.wildcardWrappers;
// 確保完全匹配失敗
if (mappingData.wrapper == null) {
internalMapWildcardWrapper(wildcardWrappers, path,...);
}
private final void internalMapWildcardWrapper(...) {
...
int pos = find(wrappers, path);
boolean found = false;
while (pos >= 0) {
// 如果以 path 以 /foo/bar 開頭
if (path.startsWith(wrappers[pos].name)) {
length = wrappers[pos].name.length();
if (path.getLength() == length) {
// 長度正好相等,則匹配成功
found = true;
break;
} else if (path.startsWithIgnoreCase("/", length)) {
// 或者跳過這個開頭並且以 "/" 開始,也匹配成功
found = true;
break;
}
}
}
// 這里的 path 是 /foo/bar/index.html,符合第二個 if
if (found) {
mappingData.wrapperPath.setString
mappingData.pathInfo.setChars
...
}
}
此時已經成功匹配到 Servlet,后續的匹配將不會不執行。簡單對后面的匹配進行分析,擴展名匹配比較簡單,首先會從 path 中找到擴展名的值,然后在 extensionWrappers 數組中查找即可;如果前面都沒匹配成功,那么就返回默認的 Wrapper
5. 小結
在返回的 MappingData 結果中,有幾個 path 需要注意一下,它們分別在以下位置:
|-- Context Path --|-- Servlet Path -|--Path Info--|
http://localhost:8080 /webapp /helloServlet /hello
|-------- Request URI ----------------------------|
看源碼時,發現 Tomcat 寫了大量的代碼,那是因為,它為了減少內存拷貝,設計了一個 CharChunk,在一個 char[] 數組視圖上,實現了類似 String 的一些比較方法。