blog.petitviolet.net

Akka-HTTP - ルーティングDSLの基本となるPathMatcherのまとめ

2016-05-17

QiitaScalaAkkaAkka-HTTP

Path を構築するための DSL であるDirectivePathMathcerについて。 ドキュメントは以下。 The PathMatcher DSL — Akka Documentation

tl;dr

大体ここに書いてある。 The PathMatcher DSL — Akka Documentation

spray も参考になる。 Path Filters · spray/spray Wiki

PathMatcher

Path を作るための型といえるもの。 ドキュメントの Overview にあるようにPathMatcherの型は以下のようになっている。

trait PathMatcher[L: Tuple]
type PathMatcher0 = PathMatcher[Unit]
type PathMatcher1[T] = PathMatcher[Tuple1[T]]

リクエストがあった Path に対してマッチするかどうかの判定を行い、マッチする場合は pass して内側の処理に入り、マッチしなかった場合は reject して次の Path 判定にうつる。 PathMatcherNpathに与えるとDirectiveNが得られるようになっている。

文字列

Path を定義する基本となるはずのやつ。 文字列でルーティングを定義する。

val route = path("ping") {
  get {
    complete("pong")
  }
}

これにlocalhost:8080/pingに GET リクエストを送るとpongと返ってくる。 getを使用して GET であることを明示しているが、HTTP メソッドは指定しなければ GET となる

正規表現を使いたい場合

pathRegexオブジェクトを渡すだけでよい。

val route = path("[abc]+".r) { rgx: String =>
  get {
    complete(s"matched: $rgx")
  }
}

このrgxには正規表現のグループ数によってバインドされる文字列が変化し、 グループ数が 0 の場合は正規表現として与えた文字列が、1 の場合はそのグループとしてマッチした文字列が得られる。 注意点として、正規表現のグループ数が 1 より大きい場合は使用できず、実行時例外が throw されてしまう。 なおグループ数はこのように求められる。(akka/EnhancedRegex.scala)

"regex".r.pattern.matcher("").groupCount()

グループ数が 0 の場合

val route =
  path("hello_.+".r) { rgx: String =>
    get {
      complete(s"matched: $rgx")
    }
  }

正規表現にマッチした文字列全体が得られる。

$ curl 'http://localhost:8080/alice'
The requested resource could not be found.
$ curl 'http://localhost:8080/hello_alice'
matched: hello_alice%

グループ数が 1 の場合

abc(hoge|foo|bar)か、というちょっと変わった例。

val route =
  path("a|b|c(hoge|foo|bar)+".r) { rgx: String =>
    get {
      complete(s"matched: $rgx")
    }
  }

この場合、rgxにはグループにマッチした文字列が入る。 つまりcfooにリクエストを送るとfooが得られることとなる。

$ curl 'http://localhost:8080/cfoo'
matched: foo

グループにマッチする文字列が無い場合はnullが渡ってきてしまう点に注意

$ curl 'http://localhost:8080/a'
matched: null

Path に数値を入れたい場合

用意されているIntNumberHexLongNumberを使えば Path として与えた数字がオブジェクトとして得られる。

val routes = pathPrefix("ping") {
  path(IntNumber) { i =>
    get {
      complete(s"int: $i")
    }
  } ~
  path(LongNumber) { l =>
    get {
      complete(s"long: $l")
    }
  } ~
  path(DoubleNumber) { d =>
    get {
      complete(s"double: $d")
    }
  } ~
  path(HexIntNumber) { hi =>
    get {
      complete(s"hexInt: $hi")
    }
  } ~
  path(HexLongNumber) { hl =>
    get {
      complete(s"hexLong: $hl")
    }
  }
}
$ curl http://localhost:8080/ping/1
int: 1

$ curl http://localhost:8080/ping/10000000000000000
long: 10000000000000000

$ curl http://localhost:8080/ping/1.0
double: 1.0

$ curl http://localhost:8080/ping/fff
hexInt: 4095

$ curl http://localhost:8080/ping/ffffffffffff
hexLong: 281474976710655%

なお、path(HexXxxNumber) { ??? }path(XxxNumber) { ??? }より前に持ってくると HexXxxNumberでキャッチされてしまい、XxxNumberの path には入ってこないため、併用する場合は注意が必要。

Map で Path を作る

pathにはMap[String, T]を与えることが出来る。

val map: Map[String, Long] = Map(
  "one" -> 1L,
  "two" -> 2L,
  "three" -> 3L
)

val route =
  path(map) { l: Long =>
    complete(s"long: $l")
  }

深い Path を実装する

深い階層になった Path を表現するにはいくつか方法がある。 例としてこのような Path を考える。

GET /nice/user
GET /nice/user/greet
GET /nice/user/hello  # .../greetと同じ挙動とする
GET /nice/user/<name>

pathEnd, pathPrefix, pathSuffix を使う

さっそく、例として上げたルーティングを実装するとこのようになる。

val route =
  pathPrefix("nice") {  // /nice/...
    pathPrefix("user") {  // /nice/user/...
      pathEnd {  // nice/user
        complete("nice user!")
      } ~
        pathSuffix("greet" | "hello") {  // nice/user/.../greet
          complete(s"hello!")
        } ~
        pathPrefix(".+".r) { name =>  // /nice/user/<name>/...
          pathEndOrSingleSlash {  // /nice/user/<name> or /nice/user/<name>/
            complete(s"nice name => $name")
          }
        }
    }
  }

.../greet.../helloが同じ挙動で、それを表現するには|を使用すれば良い。 この route に対するリクエストとレスポンスは以下のようになる。

$ curl 'localhost:8080/nice/user'
nice user!
$ curl 'localhost:8080/nice/user/'
The requested resource could not be found.
$ curl 'localhost:8080/nice/user/greet'
hello!
$ curl 'localhost:8080/nice/user/hello'
hello!
$ curl 'localhost:8080/nice/user/greet/'
nice name => greet
$ curl 'localhost:8080/nice/user/alice'
nice name => alice
$ curl 'localhost:8080/nice/user/alice/'
nice name => alice

それぞれの違いを簡単に書くと以下。(ほぼ英単語そのままの意味)

  • pathEnd, pathEndOrSingleSlash

    • Path の終わり
    • 後ろに/がつくかどうかの違い
  • pathPrefix

    • Path の先頭に来るもの
    • PathPrefixを組み合わせると深い Path を作ることが出来る
  • pathSuffix

    • Path の終端に来るもの

とくにpathSuffixは終端にあればマッチして処理されてしまうため、思ってない動きをすることもある。 例えば以下のように、適当な Path へのリクエストを正常に処理してしまう。 (もちろん、 ~ path("greet")とすれば問題ないが、あくまでpathSuffixを使った例として。)

$ curl 'localhost:8080/nice/user/alice/greet'
hello!
$ curl 'localhost:8080/nice/user/foo/bar/baz/greet'
hello!

path のみで実装する

例のルーティングを Path を結合する~と Slash 区切りにする/を組み合わせるとpathのみを使って実現できる。

val route =
  path("nice" / "user") {
    complete("nice user!")
  } ~
    path("nice" / "user" / ("greet" | "hello")) {
      complete("hello!")
    } ~
    path("nice" / "user" / ".+".r ~ Slash.?) { name =>
      complete(s"nice name => $name")
    }

pathのみでの実装は Play Flamework の routes のようにルーティングが明示的なので人間にはわかりやすいが、コードとして重複が多くメンテナンス性がやや下がってしまうのが難点。 /nice/user/greetを明示しているので、先述のPathSuffixを使用した実装とは/nice/user/.../greetの挙動は変わっている。

$ curl 'localhost:8080/nice/user/alice/greet'
The requested resource could not be found.%

また、path("nice" / "user")のようなものであればseparateOnSlashesでも表現できる。

path(separateOnSlashes("nice/user")) {
} ~
  path(separateOnSlashes("nice/user") / ("greet" | "hello")) {
    ...
  }

折衷案

pathPrefixpathを組み合わせた一般的な(?)実装が以下。

val route =
  pathPrefix("nice" / "user") {
    pathEnd {
      complete("nice user!")
    } ~
      path("greet" | "hello") {
        complete("hello!")
      } ~
      path(".+".r ~ Slash.?) { name =>
        complete(s"nice name => $name")
      }
  }

末尾に/がつくかどうかは~ Slash.?pathEndOrSingleSlashのどちらでも表現できそうだが、 pathEndOrSingleSlashはその名の通りpathEndであるため、その後に Path がまだ続く場合は~ Slash.?を使う必要が生じる。

複数の PathMatcher1 を組み合わせる

IntNumberや正規表現などのPathMatcher1を複数組み合わせることが可能。

path("hi" / IntNumber / ".+".r) { (n: Int, str: String) =>
  complete(s"n: $n, str: $str")
}

PathMatcher1を 2 つ組み合わせるとDirective[Tuple2[A, B]]となってTuple2を受け取ることが出来るようになる。 それぞれ型を書くと以下のようになる。(なお IntelliJ だとうまく型解決出来ないっぽくてエラーが出てしまう)

val pathMatcher: PathMatcher[Tuple2[Int, String]] = "hi" / IntNumber / ".+".r
val directive: Directive[Tuple2[Int, String]] = path(pathMatcher)

Segment

PathMatchers.Segmentは他でマッチしなかった Path を拾うためのもの、というイメージ。 関連するのはSegment, Segment.repeat, Segments, Rest, RestPathあたり。

  • Segment

    • ".+".rと同じように使える
    • 型はPathMatcher1[String]
  • Segment.repeat

    • Segmentを回数指定(min と max)で繰り返す
    • 型はPathMatcher1[List[String]]
  • Segments

    • Segment.repeatを最大 128 回繰り返す
    • min=0, max=128 なSegment.repeat
  • Rest

    • マッチしていない Path の残りを表す
    • 型はPathMatcher1[String]で、残りの Path を文字列として得られる
  • RestPath

    • Restと同じで、型がPathmatcher1[Path]となっている
    • 残りの Path に何かしら操作したい場合はこちらを使うべき

使用例としてはこんなところ

val route =
  path("foo" / Segment / "yes") { (x: String) =>
    complete(s"foo segment: $x")
  } ~
    path("bar" / Segment / RestPath) { (x: String, y: Path) =>
      complete(s"bar segment: $x, $y")
    } ~
    path("baz" / Segments) { (x: List[String]) =>
      complete(s"baz segment: $x")
    }

この route に対するリクエストとレスポンスはこうなる。

$ curl 'localhost:8080/foo/hello/yes'
foo segment: hello
$ curl 'localhost:8080/bar/a/b/c'
bar segment: a, b/c
$ curl 'localhost:8080/baz/up/down/left/right'
baz segment: List(up, down, left, right)

from: https://qiita.com/petitviolet/items/b74b89820917df7834ba