はじめに
OSSのシナリオテストツール runn を使っていて、小さな問題に遭遇しました。
その問題を調べて修正する過程で、path.Join() と url.JoinPath()という似た名前だけど用途が全く違うものの存在を知ったので、備忘録として残します。
遭遇した問題: trailing slashが消える
問題の内容
runnでHTTPリクエストのテストを書いているとき、こんな現象に遭遇しました。
# テストシナリオ
runners:
api:
endpoint: https://api.example.com
steps:
- api:
/users/: # 末尾のスラッシュを指定
post:
body:
username: taro
期待: POST /users/ にリクエスト
実際: POST /users にリクエスト(末尾のスラッシュが消える)
trailing slashの有無を厳密に区別するAPI(スラッシュなしだと404を返すなど)をrunnでテストしようとしたときに、末尾のスラッシュが消えてしまい困っていました。
そこで、原因を調べることにしました。
原因を調べる
ソースコードを読む
実はGo言語自体書いた経験が少なくて、結構手探りでしたが、URLを結合している部分を探しました。
func mergeURL(u *url.URL, p string) (*url.URL, error) {
// ...省略...
m.Path = path.Join(m.Path, a.Path) // ← ここでパスを結合
// ...省略...
}
引用元: https://github.com/k1LoW/runn/blob/da184c3daacab33b881b806becb86f0e1395ef8c/http.go#L595
path.Join() という関数が使われている。
path.Join() を調べる
公式ドキュメントを読むと、以下のように結果が正規化されると書いてあります。
The result is Cleaned.
引用元: https://pkg.go.dev/path#Join
実際のソースコードを見ると、Join() は内部で Clean() を呼び出しています。
// path/path.go より一部抜粋
func Join(elem ...string) string {
// ...省略...
return Clean(strings.Join(elem, string(Separator)))
}
引用元: https://github.com/golang/go/blob/go1.22.0/src/path/path.go#L172
さらに path.Clean() のドキュメントには、以下のようなことが書かれていて、ルート以外は末尾のスラッシュが削除される仕様でした。
The returned path ends in a slash only if it is the root "/".
引用元: https://pkg.go.dev/path#Clean
実際に試してみると:
fmt.Println(path.Join("/api", "/users/")) // → /api/users (スラッシュ消える)
これが原因だとわかりました。
自分なりの解決策
最初は preserveTrailingSlash オプションを追加することを提案したのですが、メンテナーの方から「デフォルトの動作を変更すべき」とフィードバックをいただいたので、修正を試みることにしました。
まず、こう考えました。
「スラッシュが消えるなら、消える前に覚えておいて、後から手動で追加すればいい?」
// 自分で考えた解決策
hasTrailing := strings.HasSuffix(a.Path, "/")
m.Path = path.Join(m.Path, a.Path)
if hasTrailing && !strings.HasSuffix(m.Path, "/") {
m.Path += "/" // 手動でスラッシュを追加
}
これでテストも通ったので、PRを作成しました。
メンテナーの方からのフィードバック
すると、メンテナーの方からこんなコメントが:
I recommend using the following function:
https://pkg.go.dev/net/url#JoinPath
「え、そんな関数あるの!?」
url.JoinPath() という関数の存在を知りませんでした...
url.JoinPath() を知る
調べてみた
https://pkg.go.dev/net/url#URL.JoinPath
func (u *URL) JoinPath(elem ...string) *URL
JoinPath returns a new URL with the provided path elements joined...
URL用のパス結合メソッド?
Go 1.19で追加されたメソッドらしいです。
実際に動かしてみた
u, _ := url.Parse("http://example.com/api")
// ルートパス "/" を結合
m1 := u.JoinPath("/")
fmt.Println(m1.Path) // → /api/ (スラッシュが追加される!)
// 通常のパス
m2 := u.JoinPath("/users")
fmt.Println(m2.Path) // → /api/users
URL用のメソッドなので、URLとして適切に処理してくれる
手動でスラッシュを追加する必要がなくなりました。
path.Join() と url.JoinPath() の違い
この経験を通じて学んだ違いをまとめます。
1. 使い方の違い
| 関数 | 説明 | パッケージ |
|---|---|---|
path.Join() |
スラッシュ区切りのパス結合(関数) | path |
url.JoinPath() |
URL のパス結合(URL のメソッド) | net/url |
2. trailing slashの扱いが違う
path.Join() の場合:
fmt.Println(path.Join("/api", "/users/")) // → /api/users (常に削除)
path.Join() は結果を Clean() で正規化するため、trailing slash が削除されます。
url.JoinPath() の場合:
u, _ := url.Parse("http://example.com/users")
m := u.JoinPath("/")
fmt.Println(m.Path) // → /users/ (trailing slashが付く)
URL 専用のメソッドなので、path.Join() とは異なる動作をします。
3. 返り値の型が違う
// path.Join() → string
s := path.Join("/a", "/b")
// url.JoinPath() → *url.URL
u, _ := url.Parse("http://example.com")
newURL := u.JoinPath("/a", "/b")
修正後のコード
メンテナーの方のアドバイスで、こうシンプルになりました。
// Before: 手動でtrailing slashを処理
hasTrailing := strings.HasSuffix(a.Path, "/")
m.Path = path.Join(m.Path, a.Path)
if hasTrailing && !strings.HasSuffix(m.Path, "/") {
m.Path += "/"
}
// After: url.JoinPath() を使う
m := u.JoinPath(a.Path)
コードがシンプルになり、より適切な関数/メソッドを使えるようになりました。
まとめ
小さな問題でしたが、Goを書いた経験が少なかったので、調べる過程でいろいろ勉強になりました。
特に、似た名前だけど動作が違うものがあることを知りました。
-
path.Join()→ trailing slash を削除する -
url.JoinPath()→ URL 専用のメソッド
URL を扱う場合は url.JoinPath() を使うほうが良さそうですね。
参考
Go公式ドキュメント
ソースコード
関連Issue/PR