Go 1.22中路由 URL 路由参数

处理基于 HTTP 的 API 时,通常使用 URL 路由参数(也称为路由变量)传递数据。这些参数是 URL 路由段的一部分。它们通常用于识别 API 操作的资源。

在除了最简单的 Web 应用程序之外的所有 Web 应用程序中,API 都是使用路由来定义的。将请求映射到 HTTP 处理程序的模式。

作为路由的一部分,我们可能需要定义路由参数:
/products/{slug}
/users/{id}/profile
/{page}

在上面的路由中, 、{slug}和{id}被{page}命名为路由参数。
这些参数可以通过它们的名称在 HTTP 处理程序中检索和使用:

func handler(w http.ResponseWriter, r *http.Request) {
    //从这里 Get slug, id or page .
}

在 Go 版本 1.22 之前,标准库不支持上面看到的命名参数。这使得路由参数的使用变得有点痛苦。

Go 1.22 增强了路由模式,其中包括对命名路由参数的完全支持。
让我们看看如何使用它们。

定义路由
http.ServeMux 类型上有两个方法允许你使用这些路由模式定义路由:Handle 和 HandleFunc。

它们的区别仅在于接受的处理程序类型不同。一种方法接受 http.Handler,另一种方法接受具有以下签名的函数:
func(w http.ResponseWriter, r *http.Request)

在本文中,我们将始终使用 http.HandleFunc,因为它更简洁。

通配符
如果查看文档,没有提到 "路由参数",但有一个稍微宽泛的概念:通配符。

通配符允许你以多种不同方式定义 URL 路由的可变部分。那么通配符是如何定义的呢?

通配符必须是完整的路由段:前面必须有斜线,后面必须有斜线或字符串的结尾。

例如,以下三种路由模式都包含有效的通配符:

/{message}
/products/{slug}
/{id}/elements

请注意,通配符必须是完整的路由段。部分路由段无效:
/product_{id}
/articles/{slug}.html

获取值
可以使用 *http.Request 类型的 PathValue 方法获取通配符的具体值。

您只需将通配符名称传给该方法,它就会以字符串形式返回其值,如果没有值,则返回空字符串""。

您将在下面的示例中看到 PathValue 的实际应用。

基本示例
下面我们创建一个 /greetings/{greeting} 端点。HTTP 处理程序将获取通配符的值并将其打印到 stdout。

在示例中,我们发送了 6 个请求,如果一个请求失败,我们将打印包含网址和状态代码的错误信息。

package main

import (
    "fmt"
    
"net/http"
    
"net/http/httptest"
)

func main() {
    mux := &http.ServeMux{}

    
// 用 "问候 "通配符设置端点。
    mux.HandleFunc(
"/greetings/{greeting}", handler)

    urls := []string{
        
"/greetings/hello-world",
        
"/greetings/good-morning",
        
"/greetings/hello-world/extra",
        
"/greetings/",
        
"/greetings",
        
"/messages/hello-world",
    }

    for _, u := range urls {
        req := httptest.NewRequest(http.MethodGet, u, nil)
        rr := httptest.NewRecorder()

        mux.ServeHTTP(rr, req)

        resp := rr.Result()
        if resp.StatusCode != http.StatusOK {
            fmt.Printf(
"Request failed: %d %v\n", resp.StatusCode, u)
        }
    }
}

func handler(w http.ResponseWriter, r *http.Request) {
    
// 获取问候语通配符的值。
    g := r.PathValue(
"greeting")
    fmt.Printf(
"Greeting received: %v\n", g)
}

如果运行该示例,你会发现最后 4 个请求没有被路由到处理程序。

  • /greetings/hello-world/extra 不匹配,因为路由模式中没有额外的路由段。
  • /greetings 和 /greetings/ 因为缺少一个路由段。
  • /messages/hello-world,因为第一个路由段不匹配。

多个通配符
可以在一个模式中指定多个通配符。下面我们在 /chats/{id}/message/{index} 端点中使用了两个通配符。

package main

import (
    "fmt"
    
"net/http"
    
"net/http/httptest"
)

func main() {
    mux := &http.ServeMux{}

    
// set up the endpoint with a "time" and "greeting" wildcard.
    mux.HandleFunc(
"/chats/{id}/message/{index}", handler)

    urls := []string{
        
"/chats/102/message/31",
        
"/chats/103/message/1",
        
"/chats/104/message/4/extra",
        
"/chats/105/",
        
"/chats/105",
        
"/chats/",
        
"/chats",
        
"/messages/hello-world",
    }

    for _, u := range urls {
        req := httptest.NewRequest(http.MethodGet, u, nil)
        rr := httptest.NewRecorder()

        mux.ServeHTTP(rr, req)

        resp := rr.Result()
        if resp.StatusCode != http.StatusOK {
            fmt.Printf(
"Request failed: %d %v\n", resp.StatusCode, u)
        }
    }
}

func handler(w http.ResponseWriter, r *http.Request) {
    
// get the value for the id and index wildcards.
    id := r.PathValue(
"id")
    index := r.PathValue(
"index")
    fmt.Printf(
"ID and Index received: %v %v\n", id, index)
}

与前面的示例一样,每个通配符段都必须有一个值。

匹配剩余部分
模式中的最后一个通配符可以选择匹配所有剩余路由段,方法是让其名称以...结尾。

在下面的示例中,我们使用这种模式将 "步骤 "传递给 /tree/ 端点。

package main

import (
    "fmt"
    
"net/http"
    
"net/http/httptest"
)

func main() {
    mux := &http.ServeMux{}

    
// set up the endpoint with a "steps" wildcard.
    mux.HandleFunc(
"/tree/{steps...}", handler)

    urls := []string{
        
"/tree/1",
        
"/tree/1/2",
        
"/tree/1/2/test",
        
"/tree/",
        
"/tree",
        
"/none",
    }

    for _, u := range urls {
        req := httptest.NewRequest(http.MethodGet, u, nil)
        rr := httptest.NewRecorder()

        mux.ServeHTTP(rr, req)

        resp := rr.Result()
        if resp.StatusCode != http.StatusOK {
            fmt.Printf(
"Request failed: %d %v\n", resp.StatusCode, u)
        }
    }
}

func handler(w http.ResponseWriter, r *http.Request) {
    
// get the value for the steps wildcard.
    g := r.PathValue(
"steps")
    fmt.Printf(
"Steps received: %v\n", g)
}

不出所料,前 3 个请求会被路由到处理程序,并将所有剩余步骤作为值。

与前面的例子不同的是,/tree/ 也与 "步骤 "为空的模式匹配。空余数 "也算余数。

/tree 和 /none 仍然不匹配路由模式。

  • 但请注意,对 /tree 的请求现在会导致 301 重定向,而不是 404 未找到错误。
  • 这是因为 http.ServeMux 的尾部斜线重定向行为。这与我们讨论的通配符无关。

带尾斜线的模式
如果路由模式以尾部斜线结尾,则会产生 "匿名""... "通配符。

这意味着你无法为这个通配符取值,但它仍会匹配路由,就像最后一个路由段是 "剩余匹配段 "一样。

如果我们将此应用到前面的树形示例中,就会得到下面的 /tree/ 端点:

package main

import (
    "fmt"
    
"net/http"
    
"net/http/httptest"
)

func main() {
    mux := &http.ServeMux{}

    
// set up the endpoint with a trailing slash:
    mux.HandleFunc(
"/tree/", handler)

    urls := []string{
        
"/tree/1",
        
"/tree/1/2",
        
"/tree/1/2/test",
        
"/tree/",
        
"/tree",
        
"/none",
    }

    for _, u := range urls {
        req := httptest.NewRequest(http.MethodGet, u, nil)
        rr := httptest.NewRecorder()

        mux.ServeHTTP(rr, req)

        resp := rr.Result()
        if resp.StatusCode != http.StatusOK {
            fmt.Printf(
"Request failed: %d %v\n", resp.StatusCode, u)
        }
    }
}

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Printf(
"URL Path received: %s\n", r.URL.Path)
}

请注意,我们无法使用 r.PathValue 来检索步骤,因此我们使用 r.URL.Path 来代替。

不过,匹配规则与之前的示例完全相同。

匹配 URL 结尾
要匹配 URL 的结尾,可以使用特殊通配符 {$}。

这在不希望尾部斜线导致匿名"... "通配符,而只匹配尾部斜线时非常有用。

例如,如果将我们的树形端点修改为使用 /tree/{$},那么现在它将只匹配 /tree/ 请求:

package main

import (
    "fmt"
    
"net/http"
    
"net/http/httptest"
)

func main() {
    mux := &http.ServeMux{}

    
// set up the endpoint with the match end wildcard:
    mux.HandleFunc(
"/tree/{$}", handler)

    urls := []string{
        
"/tree/",
        
"/tree",
        
"/tree/1",
        
"/none",
    }

    for _, u := range urls {
        req := httptest.NewRequest(http.MethodGet, u, nil)
        rr := httptest.NewRecorder()

        mux.ServeHTTP(rr, req)

        resp := rr.Result()
        if resp.StatusCode != http.StatusOK {
            fmt.Printf(
"Request failed: %d %v\n", resp.StatusCode, u)
        }
    }
}

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Printf(
"URL Path received: %s\n", r.URL.Path)
}

另一种有用的情况是处理 "主页 "请求。

/ 模式匹配所有 URL 的请求,但 /{$} 模式只匹配 / 的请求。

设置路由值
在测试或中间件中,可能需要在请求中设置路由值。

这可以使用 *http.Request 上的 SetPathValue 方法来实现。该方法接受键/值对,随后对 PathValue 的调用将返回该键的设置值。

请看下面的示例。

package main

import (
    "fmt"
    
"net/http"
    
"net/http/httptest"
)

func main() {
    req := httptest.NewRequest(http.MethodGet,
"/", nil)
    rr := httptest.NewRecorder()

    
// 在将请求传递给处理程序之前设置路由值。
    req.SetPathValue(
"greeting", "hello world")

    handler(rr, req)
}

func handler(w http.ResponseWriter, r *http.Request) {
    g := r.PathValue(
"greeting")
    fmt.Printf(
"Received greeting: %v\n", g)
}

路由匹配和优先级
一个请求可能会有多个路由匹配。

例如,以下两条路由:
/products/{id}
/products/my-custom-product

当接收到 URL /products/my-custom-product 的请求时,两种路由都有可能与之匹配。

那么实际匹配的是哪一个呢?

在本例中,是最后一个路由,即 /products/我的定制产品。因为它更具体。它比第一条路由匹配的请求更少。

请注意,顺序并不重要,即使 /products/{id} 定义在先,也不会被匹配。

下面的示例演示了这一点:

package main

import (
    "fmt"
    
"net/http"
    
"net/http/httptest"
)

func main() {
    mux := &http.ServeMux{}

    
// set up two endpoints
    mux.HandleFunc(
"/products/{id}", idHandler)
    mux.HandleFunc(
"/products/my-custom-product", customHandler)

    urls := []string{
        
"/products/test",
        
"/products/my-custom-product",
    }

    for _, u := range urls {
        req := httptest.NewRequest(http.MethodGet, u, nil)
        rr := httptest.NewRecorder()

        mux.ServeHTTP(rr, req)

        resp := rr.Result()
        if resp.StatusCode != http.StatusOK {
            fmt.Printf(
"Request failed: %d %v\n", resp.StatusCode, u)
        }
    }
}

func idHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Printf(
"%s -> idHandler\n", r.URL.Path)
}

func customHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Printf(
"%s -> customHandler\n", r.URL.Path)
}

冲突
注册具有相同特异性且匹配相同请求的路由会导致冲突。在注册此类路由时,Handler 和 HandleFunc 方法会出现恐慌。

要触发上例中的panic恐慌,可将 customHandler 的注册方式从"... "改为"...":

// ...
mux.HandleFunc(
"/products/my-custom-product", customHandler)
// ...

改为:

mux.HandleFunc("/products/{name}", customHandler)

如果运行程序,就会出现panic恐慌:
panic: pattern "/products/{name}" ... conflicts with pattern "/products/{id}" ...: /products/{name} matches the same requests as /products/{id}

总结
本文讨论了如何使用 Go 1.22 中引入的通配符路由模式实现 URL 路由参数。

主要启示如下

  • 通配符可用于在路由中创建一个或多个路由参数。
  • 使用 PathValue 方法获取路由值。
  • 使用余数匹配通配符匹配尾部路由段。
  • 尾部斜线可作为余数匹配通配符。
  • 使用 {$} 通配符可禁用此行为。
  • 使用 SetPathValue 在请求上设置路由值。
  • 路由根据特定性进行匹配。
  • 注册路由可能会引起恐慌。