很多 HTTP 客户端 bug 都来自手写 URL。用户输入里有空格、斜杠、中文、&、?,一旦直接字符串拼接,就可能生成错误 URL,甚至改变参数含义。Go 标准库的 net/url 可以安全地处理路径和查询参数。
本文用调用外部搜索接口的例子,讲 url.URL、url.Values、PathEscape 和常见误区。
不推荐手写拼接
endpoint := "https://api.example.com/search?q=" + keyword + "&page=" + page
如果 keyword 是 go & rust,生成的 URL 会把 & rust 当成另一个参数。正确做法是让标准库编码。
使用 url.Values
func SearchURL(base string, keyword string, page int) (string, error) {
u, err := url.Parse(base)
if err != nil {
return "", err
}
q := u.Query()
q.Set("q", keyword)
q.Set("page", strconv.Itoa(page))
u.RawQuery = q.Encode()
return u.String(), nil
}
调用:
u, err := SearchURL("https://api.example.com/search", "go & rust", 2)
生成的 query 会正确转义。url.Values 还支持多值参数:
q.Add("tag", "go")
q.Add("tag", "web")
编码后会出现多个 tag。
路径参数要 PathEscape
如果用户 ID 是路径的一部分:
path := "/users/" + userID
当 userID 包含 / 时,路径层级就变了。应该:
path := "/users/" + url.PathEscape(userID)
查询参数用 url.Values,路径片段用 url.PathEscape。不要混用。QueryEscape 和 PathEscape 面对空格等字符的编码细节不同,语义也不同。
Base URL 和路径拼接
可以用 ResolveReference,但要理解斜杠语义:
base, _ := url.Parse("https://api.example.com/v1/")
ref, _ := url.Parse("users")
fmt.Println(base.ResolveReference(ref).String())
输出:
https://api.example.com/v1/users
如果 base 没有末尾斜杠:
url.Parse("https://api.example.com/v1")
v1 会被当成文件名,解析相对路径时可能被替换。实际项目里,简单而清楚的方式是封装一个 join 函数,或者让配置里的 base URL 明确带版本根路径。
校验外部回调 URL
如果用户提交回调地址,不要只看字符串是否以 http 开头:
func ValidateWebhookURL(raw string) (*url.URL, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, err
}
if u.Scheme != "https" {
return nil, errors.New("webhook must use https")
}
if u.Host == "" {
return nil, errors.New("missing host")
}
return u, nil
}
是否允许内网地址、localhost、IP 地址,要看安全策略。公开平台通常要防 SSRF,不能让用户随便填内网地址。入门阶段至少要理解:URL 解析只是第一步,业务校验仍然需要。
测试 URL 构造
func TestSearchURL(t *testing.T) {
got, err := SearchURL("https://api.example.com/search", "go & rust", 2)
if err != nil {
t.Fatal(err)
}
u, err := url.Parse(got)
if err != nil {
t.Fatal(err)
}
if u.Query().Get("q") != "go & rust" {
t.Fatalf("url = %s", got)
}
}
测试时不要硬比较完整 query 字符串顺序。url.Values.Encode() 会排序,但更稳的是 parse 回来检查语义。
追加路径时避免双斜杠
很多客户端会配置 base URL:
baseURL := "https://api.example.com/v1/"
业务代码再拼路径:
endpoint := strings.TrimRight(baseURL, "/") + "/users/" + url.PathEscape(id)
这种写法虽然朴素,但对固定 API 客户端很实用。比到处手写 baseURL + "/users" 更稳。可以封装到 client 方法里,让所有接口走同一套路径拼接逻辑。
func (c *Client) endpoint(parts ...string) string {
escaped := make([]string, 0, len(parts))
for _, part := range parts {
escaped = append(escaped, url.PathEscape(part))
}
return strings.TrimRight(c.BaseURL, "/") + "/" + strings.Join(escaped, "/")
}
如果某个 part 本身包含多个路径层级,就不要用这个函数。API 设计里最好区分“路径模板”和“路径参数”。
不要记录完整敏感 URL
URL 查询参数里可能包含 token、邮箱、手机号。日志里记录外部调用时,最好只记录 scheme、host 和 path:
func safeURLForLog(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return "<invalid>"
}
return u.Scheme + "://" + u.Host + u.Path
}
排查接口问题通常不需要完整 query。需要时可以记录经过白名单筛选的参数,比如 page、limit,不要把所有 query 原样写进日志。
编码不是校验
url.Values 能正确编码参数,但它不会判断参数是否业务合法。比如 page 仍然要大于 0,redirect URL 仍然要检查域名白名单。编码解决的是格式问题,校验解决的是规则问题。两者都要有。
处理回跳地址
登录后回跳是 URL 校验的典型场景。不要允许任意外部地址:
func safeReturnPath(raw string) string {
if raw == "" {
return "/"
}
u, err := url.Parse(raw)
if err != nil {
return "/"
}
if u.IsAbs() || !strings.HasPrefix(u.Path, "/") {
return "/"
}
return u.RequestURI()
}
这段代码只允许站内路径,避免用户被重定向到恶意站点。很多安全问题不是编码错误,而是把“外部 URL”和“站内路径”混在一起。函数名里写 Path,返回值也只允许 path,会让边界更清楚。
URL 测试要覆盖特殊字符
测试不要只用 abc。至少覆盖空格、中文、斜杠和 &:
cases := []string{"go web", "中文", "a/b", "a&b"}
这些值能快速暴露手写拼接的问题。URL 相关代码越是看起来简单,越应该用特殊字符测试。
小结
Go 里构造 URL 时,不要手写字符串拼接。查询参数用 url.Values,路径片段用 url.PathEscape,完整 URL 用 url.Parse 解析和校验。外部输入的 URL 还要做业务安全检查。
URL 是 HTTP 调用的入口,小错误会变成难查的接口问题。把编码交给标准库,代码更稳,也更容易测试。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。