パスのマッチング
このレッスンでは、/users/{id} のような URL パターンが実際のパスとどう照合されているのかを掴み、その照合そのものを自分で書きます。
パス変数は、パターンの穴です
REST API では、対象を識別する値を URL の中に埋め込みます。
Java
@RestController
public class UserController {
@GetMapping("/users/{id}")
public User find(@PathVariable Long id) {
return userService.findById(id);
}
@GetMapping("/users/{userId}/posts/{postId}")
public Post findPost(@PathVariable Long userId, @PathVariable Long postId) {
return postService.find(userId, postId);
}
}{id} の部分がパス変数です。これは「ここには何か 1 つの値が入る」という穴で、@PathVariable を付けた引数がその穴の中身を受け取ります。中かっこの中の名前と引数名が一致していれば、対応付けは自動で行われます。名前が違う場合は @PathVariable("id") Long userId のように明示します。
Long と書けば、String から Long への変換も Spring がやってくれます。数字でない値が来た場合は変換に失敗し、コントローラのメソッドは呼ばれずに 400 Bad Request が返ります。
照合は「区切りごとの突き合わせ」です
パターンと実際のパスをどう突き合わせているのか、素朴に考えると次の手順になります。
- どちらもスラッシュで区切って断片の列にする
- 断片の数が違えば、その時点で不一致
- 前から順に 1 つずつ比べる
- パターン側の断片が中かっこで囲まれていれば、実際の側の断片は何でもよい。その値を変数として覚えておく
- 中かっこでなければ、文字列として完全に一致していなければ不一致
- 最後まで比べ終わったら一致
たとえばパターン /users/{id}/posts と実際のパス /users/12/posts を突き合わせると、断片はどちらも 3 つで、1 つめの users は文字列一致、2 つめは {id} なので 12 を覚えて通過、3 つめの posts は文字列一致となり、一致と判定されます。
一方で /users/12 を同じパターンに当てると、断片が 2 つしかないので 2 番目の手順で不一致になります。
| パターン | 実際のパス | 結果 |
|---|---|---|
| /users/{id} | /users/12 | 一致 id は 12 |
| /users/{id} | /users/12/posts | 不一致 (断片の数が違う) |
| /users/{id} | /orders/12 | 不一致 (users と orders) |
| /users/{userId}/posts/{postId} | /users/3/posts/9 | 一致 userId は 3、postId は 9 |
Spring が実際に使っている照合はもっと高機能で、* や ** のようなワイルドカード、拡張子の指定、複数のパターンが同時に当たったときにどちらを優先するかという順位付けまで持っています。しかし骨格はここに書いた手順そのものです。
演習でやること
パターンと実際のパスの 2 つの文字列を受け取り、照合して結果を返す関数を書きます。一致した場合は取り出した変数を id=12 のような形でつなぎ、変数が複数あればカンマで区切ります。一致しない場合は no-match を返します。
Spring では @GetMapping("/users/{id}") と @PathVariable を書くだけで済んでいる処理を、ここでは自分で書きます。書き終えたあとにもう一度アノテーションを見ると、あの 2 行がどれだけの仕事を隠しているのかが分かるはずです。
要件
- パターンと実際のパスをスラッシュで区切って断片に分け、空の断片は無視すること。断片の数が違えば "no-match" を返すこと
- パターン側の断片が中かっこで囲まれていればパス変数として値を取り出し、そうでなければ完全一致するか確かめること
- 取り出したパス変数を name=value の形でカンマ区切りにして返し、不一致なら "no-match" を返すこと
- パターンに変数が1つも無く、それでも一致した場合は match を返すこと
入出力例
matchPath("/users/{id}", "/users/12") → "id=12"
matchPath("/users/{id}/posts", "/users/12/posts") → "id=12"
matchPath("/users/{userId}/posts/{postId}", "/users/3/posts/9") → "userId=3,postId=9"
matchPath("/health", "/health") → "match"
matchPath("/users/{id}", "/users/12/posts") → "no-match"
matchPath("/users/{id}", "/orders/12") → "no-match"
matchPath("/users/{id}/posts", "/users/12/comments") → "no-match"