最佳實踐:路徑路由匹配規則的設計與實現

_哲思發表於2023-05-10

最佳實踐:路徑路由匹配規則的設計與實現

作者:哲思
時間:2023.5.9
郵箱:zhe__si@163.com
GitHub:zhe-si (哲思) (github.com)

前言

時間一晃研究生都過去大半年了,學了些東西,也做了些專案,藉著部落格總結一下。這次先聊一個簡單的話題開個頭。

開發中,常用形似 “a/b/c” 的描述方式來描述路徑、定位資源,有著層次化和可讀性高的特點,最經典的例子就是 URL(統一資源定位符),第二節會進行簡要介紹。

將資源都路徑化後,可以透過每一段路徑精確的匹配來唯一的確定一個資源。但有時候,需要對具有相關特徵的一組資源進行統一的描述或操作。比如,將所有獲得使用者資訊的請求都路由到一個指定的處理程式上,請求的 URL 中包含不同使用者 id 路徑分段指向不同使用者資訊資源。再比如,介面中導航欄包含圖片組(包含圖1、2、3)和文字組(包含文字1、2、3),在訪問圖片組下不同圖片時開啟圖片展示器而在訪問文字組的文字時開啟文字展示器。

基於上述場景的需求,需要一種簡單而通用的路徑路由匹配規則。最強大的方式是直接使用正規表示式來描述一組路徑,但在描述一些複雜的路徑場景時,正規表示式使用起來非常繁瑣和困難。比如,匹配這樣一組路徑 "x1/a/x2/a",x1 表示任意長的最短匹配路徑,x2表示任意長的最長匹配路徑,大家可以嘗試用正規表示式實現,並和本文設計的匹配規則的描述進行對比。

本文設計並實現了一種專用於路徑路由匹配的規則,以一種簡單而通用的方式描述一組路徑的特徵,來簡化這種場景路由描述難度,讓小白可以快速學習並上手。

什麼是URL?什麼是路徑?

首先,需要明確一下什麼是資源?什麼是路徑?

上面提到的 URL(統一資源定位符)是 URI(統一資源識別符號)的一種分類。

URI 的本質語義是標識一個資源,資源可以是一張圖片、一個文件、一個服務、一個使用者等具體或抽象的實體,官方(RFC2396)將其格式標準化為如下格式(就是 URL 的格式)

URI格式

該格式的大致含義是某人(user:pass)用某種方式(protocol)訪問某個主機埠(hostname:port)某個路徑(pathName)的資源,同時可用 search 對該資源做篩選、排序等操作、用 hash 訪問資源的片段(子資源)。

而標識一個資源,可以透過描述位置或名字的方式,所以 URI 包括 URL 和 URN(統一資源名稱)。

  • 描述位置:用資源所處的地址來描述該資源,該描述指定了在特定地址的資源而不特指某一個具體的資源,也就是說實際指向的資源可能會隨時間發生變化,資源的位置描述也會隨資源本身位置的變化而變化,如 URL。
  • 描述名字:用一個全域性唯一的識別符號持久的標記一個特定的資源,不會隨著時間或位置變化而改變指向的資源,如 URN(例:urn:oasis:names:specification:docbook:dtd:xml:4.1.2),常用於 Map、Redis 中 KEY 的定義等場景。

但不管是位置描述還是名字描述、不管具體的格式是什麼,都可以把它們抽象為一種“路徑”,只是路徑的描述的含義不同、分隔符不同。

比如,URL 中,最核心的部分就是hostname:port/path這一部分,如下圖藍色區域,

url與路徑

藍色區域已經完整描述了資源的位置,protocol 是補充描述了訪問資源的方式,username:password 是附帶的認證資訊,search 和 hash 則描述的對某一個資源的進一步處理。而hostname:port/path就是一個路徑,每個路徑分段描述的是某個層級的位置節點。

比如,URN 中,路徑的每個路徑分段則描述不同名稱空間及名稱空間下的名字。

路徑路由匹配規則的設計

瞭解了什麼是路徑,接下來給出路徑路由匹配規則的定義描述:

  • R 模式:正則模式,格式:R:正規表示式

    該模式下,完全採用正規表示式格式進行匹配。

  • 標準模式:標準路徑路由描述表示式,格式形如:/**/xxx/*/xxx,由多個路徑分段的分段列表組成,要求路徑分段列表全匹配

    具體語法:

    • 分隔方法

      預設使用 '/' 分隔符分隔多個路徑分段

      • 支援自定義路由分隔方法路徑分隔方法
    • 路徑分段

      • 每個具體的路徑分段預設採用完全字串匹配
      • r 模式:路徑分段正則模式,該路徑分段採用正規表示式進行匹配,格式:r:正規表示式
    • 萬用字元 '?'

      匹配任意一段或 0 段路徑分段,在滿足後續部分匹配的情況下優先不匹配

    • 萬用字元 '*'

      匹配任意一端路徑,不可匹配空或不匹配

    • 萬用字元 '**'

      匹配任意多段路徑分段,不保證儘可能滿足的最短匹配原則,即在滿足緊接的後續非萬用字元部分匹配的情況下儘可能少的匹配

    • 萬用字元 '***'

      匹配任意多段路徑分段,保證儘可能滿足的最長匹配原則,即在滿足後續匹配的情況下儘可能多的匹配

舉一些例子:

路由 匹配路徑 不匹配路徑
a/?/c a/b/c、a//c、a/c a/c/d
a/*/c a/b/c a/c
a/b/* a/b/c a/b
**/b/c a/b/c、b/c、a/a/b/b/c b/c/b/c
a/***/c/* a/c/c、a/c/b/c/d a/b/c
a/**/c/* a/c/c a/c/b/c/d

一組路徑 "x1/a/x2/a",x1 表示任意長的最短匹配路徑,x2表示任意長的最長匹配路徑,使用標準路徑路由描述表示式描述就是**/a/***/b

其中,最常用的萬用字元是 "**",透過不保證儘可能匹配的方式最短匹配,確保匹配的是我們直觀預期的路徑。比如如下目錄結構,

- common
	- A.java
	- B.java
	- a.conf
	- impl
		- common
			- Utils.java
		- AImpl.java
		- BImpl.java

我們希望匹配介面 A 和 B 的 java 檔案,而不匹配到 impl 裡的實現類,可以採用如下匹配方式:**/common/r:.*\.java

路徑路由匹配規則的實現

本文采用 kotlin 進行實現,重點位置已經進行註釋說明,原始碼可見倉庫

/**
 * **路由匹配**
 * - 若 "R:" 開頭,則為正則模式,採用正規表示式直接匹配
 * - 其他情況,採用標準路由模式[matchStdRoutePattern]進行匹配
 *
 * @author lq
 * @version 1.0
 */
fun matchRoutePattern(routePattern: String, path: String): Boolean {
    return if (routePattern.startsWith("R:")) {
        Regex(routePattern.substring(2)).matches(path)
    } else {
        matchStdRoutePattern(routePattern, path)
    }
}

/**
 * **路由模式**:路由的特定描述表示式,形如 / ** /xxx/ * /xxx/
 *
 * 語法:
 * - 以 '/'([PATH_DELIMITER]) 分隔的路徑表示式,要求全匹配路徑,首尾的分隔符可以省略
 * - 每一段路徑描述預設採用字串完全匹配方式,也可透過 "r:" 開頭標記該段採用正規表示式匹配
 * - 使用萬用字元 "?" 可以匹配任意一段或 0 段路徑,優先不匹配
 * - 使用萬用字元 "*" 可以匹配任意一段路徑
 * - 使用萬用字元 "**" 可以匹配任意多段路徑,最短匹配原則
 * - 使用萬用字元 "***" 可以匹配任意多段路徑,最長匹配原則
 */
fun matchStdRoutePattern(routePattern: String, path: String): Boolean {
    val routeSplit = splitRoute(routePattern)
    val pathSplit = splitPath(path)
    return matchRoutePatternSplit(routeSplit, 0, pathSplit, 0)
}

/**
 * 路由分隔方法
 */
val splitRoute: (String) -> List<String> = ::splitPathSimple
/**
 * 路徑分隔方法
 */
val splitPath: (String) -> List<String> = ::splitPathSimple

/**
 * 簡單解析路徑為路徑分段列表
 */
private fun splitPathSimple(routePattern: String): List<String> {
    val pathDelimiter = '/'
    return routePattern.trim(pathDelimiter).split(pathDelimiter).filter { p -> p.isNotEmpty() }
}

private fun matchRoutePatternSplit(routeSplit: List<String>, ri: Int, pathSplit: List<String>, pi: Int): Boolean {
    if (ri >= routeSplit.size) {
        return pi >= pathSplit.size
    }
    if (pi >= pathSplit.size) {
        for (i in ri until routeSplit.size) {
            if (routeSplit[i] !in listOf("?", "**", "***")) return false
        }
        return true
    }
    when (routeSplit[ri].trim()) {
        "?" -> {
            if (matchRoutePatternSplit(routeSplit, ri + 1, pathSplit, pi)) return true
            return matchRoutePatternSplit(routeSplit, ri + 1, pathSplit, pi + 1)
        }
        "*" -> {
            return matchRoutePatternSplit(routeSplit, ri + 1, pathSplit, pi + 1)
        }
        "**" -> {
            for (i in 0 until pathSplit.size - pi) {
                val isShortMatch = matchRoutePatternShort(routeSplit, ri + 1, pathSplit, pi + i, false)
                if (isShortMatch.first) return true
                if (isShortMatch.second) return false
            }
            return matchRoutePatternSplit(routeSplit, ri + 1, pathSplit, pi + pathSplit.size - pi)
        }
        "***" -> {
            for (i in pathSplit.size - pi downTo 1) {
                if (matchRoutePatternSplit(routeSplit, pi + 1, pathSplit, pi + i)) return true
            }
            return matchRoutePatternSplit(routeSplit, ri + 1, pathSplit, pi)
        }
        else -> {
            if (!checkRouteSegPattern(routeSplit[ri], pathSplit[pi])) return false
            return matchRoutePatternSplit(routeSplit, ri + 1, pathSplit, pi + 1)
        }
    }
}

/**
 * 最短原則匹配,返回 (是否匹配, 是否已經最短匹配)
 */
private fun matchRoutePatternShort(routeSplit: List<String>, ri: Int, pathSplit: List<String>, pi: Int, isShortMatch: Boolean): Pair<Boolean, Boolean> {
    if (ri >= routeSplit.size) {
        return (pi >= pathSplit.size) to isShortMatch
    }
    if (pi >= pathSplit.size) {
        for (i in ri until routeSplit.size) {
            if (routeSplit[i] !in listOf("?", "**", "***")) return false to isShortMatch
        }
        return true to isShortMatch
    }
    when (routeSplit[ri].trim()) {
        "?" -> {
            val isMatch = matchRoutePatternShort(routeSplit, ri + 1, pathSplit, pi, isShortMatch)
            if (isMatch.first) return isMatch
            return matchRoutePatternShort(routeSplit, ri + 1, pathSplit, pi + 1, isShortMatch)
        }
        "*" -> {
            return matchRoutePatternShort(routeSplit, ri + 1, pathSplit, pi + 1, isShortMatch)
        }
        "**" -> {
            return matchRoutePatternSplit(routeSplit, ri, pathSplit, pi) to isShortMatch
        }
        "***" -> {
            return matchRoutePatternSplit(routeSplit, ri, pathSplit, pi) to isShortMatch
        }
        else -> {
            return if (checkRouteSegPattern(routeSplit[ri], pathSplit[pi])) {
                matchRoutePatternShort(routeSplit, ri + 1, pathSplit, pi + 1, true)
            } else {
                false to isShortMatch
            }
        }
    }
}

/**
 * 路徑分段匹配檢查
 */
private fun checkRouteSegPattern(routeSeg: String, pathSeg: String): Boolean {
    if (routeSeg.startsWith("r:")) {
        if (Regex(routeSeg.substring(2)).matches(pathSeg)) {
            return true
        }
    } else if (routeSeg == pathSeg) return true
    return false
}

後記

本次分享了在專案中的一個細節設計,後續會繼續分享在工作、學習和生活中的點點滴滴,也歡迎大家在評論區共同討論或與我郵件溝通。

相關文章