Tomcat 路由請求的實現 Mapper

在分析 Tomcat 實現之前,首先看一下 Servlet 規範是如何規定容器怎麼把請求映射到一個 servlet。本文首發於(微信公衆號:頓悟源碼

1. 使用 URL 路徑

收到客戶端請求後,容器根據請求 URL 的上下文名稱匹配 Web 應用程序,然後根據去除上下文路徑和路徑參數的路徑,按以下規則順序匹配,並且只使用第一個匹配的 Servlet,後續不再嘗試匹配:

  1. 精確匹配,查找一個與請求路徑完全匹配的 Servlet
  2. 前綴路徑匹配,遞歸的嘗試匹配最長路徑前綴的 Servlet,通過使用 "/" 作爲路徑分隔符,在路徑樹上一次一個目錄的匹配,選擇路徑最長的
  3. 擴展名匹配,如果 URL 最後一部分包含擴展名,如 .jsp,則嘗試匹配處理此擴展名請求的 Servlet
  4. 如果前三個規則沒有匹配成功,那麼容器要爲請求提供一個默認 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 路由表

上圖包含了各類的核心成員變量和方法,也直觀的體現了類之間的關係。

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 的一些比較方法。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章