スポンサーリンク

2016年9月26日

alchemist report 003

Goal

  • Plug+Cowboyのサンプルを作成する
  • Phoenixフレームワーク v0.1.1のPath部分を実装する

Dev-Environment

  • OS: Windows8.1
  • Erlang: OTP19
  • Elixir: v1.3.0
    • Plug: v1.1
    • Cowboy: v1.0

Prerequisite 1: Erlang、Elixirはインストール済みであること

Prerequisite 2: 前回の記事

Context

Caution: 知っている人にとってはなんてことない記事のため、有識者の方はブラウザバック推奨!

Phoenixフレームワーク(v0.1.1)のもどきを実装してみようとする
(現在だとコードが違いすぎるので)誰の役にも立たないこの内容…とうとう三週間目に突入。
今回は、Phoenix.Router.Pathの部分を実装してみようと思います。
パスの解析や変換などを行っている部分になりますね。
あくまで仕組みを知りたいだけであって詳細まで捉える気がないので、あまり大きな期待はしないでください。
ここから下は、”alias Phoenix.Router.Path”で進めていきます。(長いので打つの面倒…)

Pathモジュールを実装する

さてさて作るのはいいですが、どんな機能を作ればいいんでしょうか。
Pathにある機能を把握するため分解してみます。(今日はここまでできればいい方じゃないかな…)

パスの変換(?)

まずは、Router.perform_dispatch/2でパスを変換(?)している部分からやっていきましょう。

Example:

def split(path) do
  String.split(path, "/")
end

def join([]) do
  ""
end
def join(split_path) do
  Elixir.Path.join(split_path)
end

def split_from_conn(conn) do
  conn.path_info |> join |> split
end
例として実際にPlug.Connに入ってきている値を元に進めていきたいと思います。
例えば、”http://localhost:4000/example/show“ というURLが叩かれたとする。
上記のURLが叩かれたときのPlug.Connの内容は以下の通り。

Example:

iex> %Plug.Conn{adapter: {Plug.Adapters.Cowboy.Conn, :...}, assigns: %{},
 before_send: [], body_params: %Plug.Conn.Unfetched{aspect: :body_params},
 cookies: %Plug.Conn.Unfetched{aspect: :cookies}, halted: false,
 host: "localhost", method: "GET", owner: #PID<0.323.0>,
 params: %Plug.Conn.Unfetched{aspect: :params}, path_info: ["example", "show"],
 peer: {{127, 0, 0, 1}, 64027}, port: 4000, private: %{},
 query_params: %Plug.Conn.Unfetched{aspect: :query_params}, query_string: "",
 remote_ip: {127, 0, 0, 1}, req_cookies: %Plug.Conn.Unfetched{aspect: :cookies},
 req_headers: [{"host", "localhost:4000"}, {"connection", "keep-alive"},
  {"upgrade-insecure-requests", "1"},
  {"user-agent",
   "Mozilla/5.0 (Windows NT 6.3; WOW64) ..."},
  {"accept",
   "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"},
  {"accept-encoding", "gzip, deflate, sdch"},
  {"accept-language", "ja,en-US;q=0.8,en;q=0.6"}],
 request_path: "/example/show", resp_body: nil, resp_cookies: %{},
 resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}],
 scheme: :http, script_name: [], secret_key_base: nil, state: :unset,
 status: nil}
Router.perform_dispatch/2では、Plug.Connのpath_infoの値([“example”, “show”])を変換しています。
そのはずなのですが…ちょっと実行結果を見てみましょう。

Example:

iex> path_info = ["example", "show"]
["example", "show"]
iex> join_path = Path.join(path_info)
"example/show"
iex> String.split(join_path, "/")
["example", "show"]
結局、元の値に戻ってるんですよね。
現状だと意図が理解できていないのですが…
とりあえず、パスをPhoenixフレームワークで使う形に変換しようとしていると推察しています。
まぁ、分からないものは仕方ないので気を取り直して次へ。

var!/2へquote

与えられた文字列、アトムをvar!/2を使う形へ変換しています。
(var!/2の参考:Elixirの”var!”マクロによるnilコンテキスト引数の考察)

Example:

def var_ast(var_name) when is_binary(var_name) do
  var_ast(:erlang.binary_to_atom(var_name, :utf8))
end
def var_ast(var_name) do
  quote do: var!(unquote(var_name))
end
実際に実行結果を見てみれば分かります。

Example:

iex> Example.var_ast("hoge")
{:var!, [context: Example, import: Kernel], [:hoge]}
iex> Example.var_ast(:hoge)
{:var!, [context: Example, import: Kernel], [:hoge]}

iex> Example.var_ast("hoge") |> Macro.to_string
"var!(:hoge)"
iex> Example.var_ast(:home) |> Macro.to_string
"var!(:home)"
この部分だけ見てもさっぱりだと思います。
Mapperから使っている部分まで見ていけば分かるようになるのですが、それはそのときに説明します。
とりあえず、quoteしてvar!に変換しているのね!ってところで機能の把握としてはOKです。

先頭のスラッシュを追加/削除

コードを見れば分かりますね。
パスの最初のスラッシュをバイナリのマッチングを使って確保したり消したりしています。

Example:

def ensure_leading_slash(path = <<"/" <> _rest>>) do
  path
end
def ensure_leading_slash(path) do
  "/" <> path
end

def ensure_no_leading_slash(<<"/" <> rest>>) do
  rest
end
def ensure_no_leading_slash(path) do
  path
end
実行結果があればもっと分かりやすいですね。

Example:

iex> Example.ensure_leading_slash("example/show")
"/example/show"
iex> Example.ensure_leading_slash("/example/show")
"/example/show"

iex> Example.ensure_no_leading_slash("example/show")
"example/show"
iex> Example.ensure_no_leading_slash("/example/show")
"example/show"

パスからパラメータの名前を取り出す

パスに含まれるパラメータ名一覧を取得するための関数になりますね。

Example:

def param_names(path) do
  Regex.scan(~r/[\:\*]{1}\w+/, path)
  |> List.flatten
  |> Enum.map(&String.strip(&1, ?:))
  |> Enum.map(&String.strip(&1, ?*))
end
Regex.scan/3は、マッチしたすべてを集めるために、
List.flatten/1はマッチした一覧をフラットに変換し、
Phoenixフレームワークのパスで下記のような書き方を見たことがあると思います。

Example:

get "/user/:id", UserController, :show
この:idの部分のみを取り出すため行っています。
実際に実行結果をみてみましょう。

Example:

iex> match_list = Regex.scan(~r/[\:\*]{1}\w+/, "user/:user_id/comment/:id")
[[":user_id"], [":id"]]
iex> match_flatten_list = List.flatten(match_list)
[":user_id", ":id"]
iex> Enum.map(match_flatten_list, &String.strip(&1, ?:))
["user_id", "id"]
# *を削除している方は割愛

iex> Example.param_names("user/:user_id/comments/:id")
["user_id", "id"]
iex> Example.param_names("example/show")
[]

パスのパラメータ名を値に置き換える

パス中に含まれているパラメータ名を値に置き換える関数になりますね。

Example:

defp replace_param_names_with_values(param_names, param_values, path) do
  Enum.reduce param_names, path, fn param_name, path_acc ->
    value = param_values[binary_to_atom(param_name)] |> to_string
    String.replace(path_acc, ~r/[\:\*]{1}#{param_name}/, value)
  end
end
実行結果を確認してみましょう。

Example:

iex> param_names = ["user_id", "id"]
["user_id", "id"]
iex> param_values = [user_id: 1, id: 2]
[user_id: 1, id: 2]
iex> path = "user/:user_id/comments/:id"
"user/:user_id/comments/:id"

iex> param_values[:erlang.binary_to_atom("user_id", :utf8)]
1
iex> param_values[:erlang.binary_to_atom("user_id", :utf8)] |> to_string
"1"

iex> String.replace("user/:user_id/comments/:id", ~r/[\:\*]{1}#{"user_id"}/, "1")
"user/1/comments/:id"

iex> Enum.reduce param_names, path, fn(param_name, path_acc) ->
...>   value = param_values[:erlang.binary_to_atom(param_name, :utf8)] |> to_string
...>   String.replace(path_acc, ~r/[\:\*]{1}#{param_name}/, value)
...> end
{"user_id", "user/:user_id/comments/:id"} # 開始の値
{"id", "user/1/comments/:id"}             # 二回目開始の値
"user/1/comments/2"                       # 最終結果

iex> Example.replace_param_names_with_values(
...>   ["user_id", "id"], [user_id: 1, id: 2], "user/:user_id/comments/:id")
"user/1/comments/2"
実装自体はできているのですが、記事にするのが結構時間かかってしまい、今回はここまでになります。
残念ながら、主要な機能まで記事にできませんでした。すいません…
次回は、Pathの主要な部分に手をつけていきます。
それにしても、ドキュメントがちゃんとあると助かりますね(大体使い方書いてある)

追記: 2016/09/26

パスのビルド

やっと主要な部分に手を付けられました。
パスをビルドする機能です。

Example:

def build(path, []) do
  ensure_leading_slash(path)
end
def build(path, param_values) do
  path
  |> param_names
  |> replace_param_names_with_values(param_values, path)
  |> ensure_leading_slash
end
例えば、下記のようなルーティング上の定義があったとします。

Example:

get "/example/show/:id", UserController, :show
そして、”http://localhost:4000/example/show/1“ というURLが叩かれたとします。
:idの部分を1に置換するには解析して変換する必要があります。
それを行ってくれるものです。
といっても、今までの関数の機能を組み合わせただけなので大したことはないと思いますが…
一応、実行結果を確認します。

Example:

iex> param_values = [user_id: 1, id: 2]
[user_id: 1, id: 2]
iex> path = "user/:user_id/comments/:id"
"user/:user_id/comments/:id"
iex> Example.build(path, param_values)
"/user/1/comments/2"
後は主要な機能となるもう一つ部分とその周りをやれば終わりです。

パスのパラメータをASTに変換

パスの中のパラメータとしてマッチする部分をASTに変換する機能になります。

Example:

def matched_param_ast_bindings(path) do
  path
  |> split
  |> Enum.map(fn
    <<":" <> param>> -> var_ast(param)
    <<"*" <> param>> -> quote do: Phoenix.Router.Path.join(unquote(var_ast(param)))
    _part -> nil
  end)
  |> Enum.filter(&is_tuple(&1))
end
ここでやっとこさ、var_ast/1、join/1、split/1が出てきました。
順番で説明していくと、パスをsplit/1し、そのリストをEnum.map/2を使って各要素を回しています。
各要素でバイナリのマッチングに一致する部分はvar_ast/1を使ってASTに変換していますね。
最後に、不要な値をフィルタリングして中身がAST(タプル)のリストを取得しています。

Note: なお、”_path -> nil”部分ですが、元のソースだとnilの部分には何もありません。処置としてnilを入れています。(何もないんだから多分nilでしょ!)

実行結果を見ていけば分かりやすいと思います。

Example:

iex> path = "user/:user_id/comments/:id"
"user/:user_id/comments/:id"
iex> split_path = path |> Example.split
["user", ":user_id", "comments", ":id"]

iex> ast_param = Enum.map(split_path, fn
...>   <<":" <> param>> -> Example.var_ast(param)
...>   <<"*" <> param>> -> quote do: Example.join(unquote(Example.var_ast(param)))
...>   _part -> nil
...> end)
[nil, {:var!, [context: Example, import: Kernel], [:user_id]}, nil,
 {:var!, [context: Example, import: Kernel], [:id]}]

 iex> Enum.filter(ast_param, &is_tuple(&1))
[{:var!, [context: RouterPath, import: Kernel], [:user_id]},
 {:var!, [context: RouterPath, import: Kernel], [:id]}]
どうでもいいことですが、メソッドチェーンでつながれている途中の値って分解とか解析しないと分かり辛いですよね。
それに各関数について知っていないと分からない。(これは勉強不足だと思いますが…)
あぁ、メソッドチェーンもパターンマッチも好きな機能です。別に他意はありません。
さてさて、ある程度見えてきたと思いますが、
var!/2をやっているということは、マクロの外に影響させたい訳で…あとは分かりますね!(分かりません…)
ごめんなさい、ちょっと今日はここまで!

追記:2016/10/12

ASTにバインドしたパラメータ名とパラメータ名自体をタプルリストにする

ようやっと続きが投稿できます。お待たせしてしまってすいません・・・
さて、次は下記の関数を確認してみようと思います。
前回、確認したmatched_param_ast_bindings/1が出てきています。

Example:

def params_with_ast_bindings(path) do             
  Enum.zip(param_names(path), matched_param_ast_bindings(path))
end
あまり時間が多く割けないので駆け足ですが、早速実行を見てみたいと思います。
まずは、Enum.zip/2の動作を確認します。

Example:

iex> Enum.zip([1, 2, 3], [:a, :b, :c])
[{1, :a}, {2, :b}, {3, :c}]
こんな感じに二つのEnumerableを対応する要素同士でタプルに変換し、1つのリストにしてくれます。
関数の動きの方を見てみましょう。

Example:

iex> path = "users/:user_id/comments/:id"
"users/:user_id/comments/:id"
iex> ast_param =  Example.matched_param_ast_bindings("users/:user_id/comments/:id")
[{:var!, [context: RouterPath, import: Kernel], [:user_id]},
 {:var!, [context: RouterPath, import: Kernel], [:id]}]
iex> param_names = Example.param_names(path)
["user_id", "id"]
iex> Enum.zip(param_names, ast_param)
[{"user_id", {:var!, [context: RouterPath, import: Kernel], [:user_id]}},
 {"id", {:var!, [context: RouterPath, import: Kernel], [:id]}}]
パラメータ名とvar_ast/1で変換したパラメータ名のASTを対にしています。
さてさて、これがどこで使われるのでしょうか???
あと二つの関数を確認したら、Pathの関数をすべて確認したことになります。
もう一踏ん張りがんばりましょう!

パスをASTに変換する

最後の二つはまとめて確認していきます。
(matched_arg_list_with_ast_bindings/1とpart_to_ast_binding/2)

Example:

def matched_arg_list_with_ast_bindings(path) do
    path
    |> ensure_no_leading_slash
    |> split
    |> Enum.chunk(2, 1, [nil])
    |> Enum.map(fn [part, next] -> part_to_ast_binding(part, next) end)
    |> Enum.filter(fn part -> part end)
  end
  defp part_to_ast_binding(<<"*" <> _splat_name>>, nil) do
    nil
  end
  defp part_to_ast_binding(<<":" <> param_name>>, <<"*" <> splat_name>>) do
    {:|, [], [var_ast(param_name), var_ast(splat_name)]}
  end
  defp part_to_ast_binding(<<":" <> param_name>>, _next) do
    var_ast(param_name)
  end
  defp part_to_ast_binding(part, <<"*" <> splat_name>>) do
    {:|, [], [part, var_ast(splat_name)]}
  end
  defp part_to_ast_binding(part, _next) do
    part
  end
例によって少しずつ確認していきましょう。
まずは、matched_arg_list_with_ast_bindings/1を途中まで。

Example:

iex> path = "/users/:user_id/comments/:id"  
"/users/:user_id/comments/:id"
iex> path = Example.ensure_no_leading_slash(path)
"users/:user_id/comments/:id"
iex> split_path = Example.split(path)
["users", ":user_id", "comments", ":id"]
iex> chunk = Enum.chunk(split_path, 2, 1, [nil])
[["users", ":user_id"], [":user_id", "comments"], ["comments", ":id"],
 [":id", nil]]
このあと、Enum.map/2で要素を一つ一つpart_to_ast_binding/2で処理していますね。
Enum.map/2の中で行われていることを一つだけ細かく確認してみます。

Example:

iex> [<<":" <> param_name>>, next] = [":user_id", "comments"]
[":user_id", "comments"]
iex> param_name
"user_id"
iex> next
"comments"
iex> Example.var_ast(param_name)
{:var!, [context: RouterPath, import: Kernel], [:user_id]}
iex> Example.var_ast(param_name) |> Macro.to_string
"var!(:user_id)"

## 一応これも
iex> {:|, [], ["users", Example.var_ast("user_id")]}
{:|, [], ["users", {:var!, [context: Example, import: Kernel], [:user_id]}]}
iex> {:|, [], ["users", Example.var_ast("user_id")]} |> Macro.to_string
"\"users\" | var!(:user_id)"
いまさらですが、”*”の部分はパス中のワイルドカードですね。
(参考http://stackoverflow.com/questions/32189311/catch-all-wildcard-route-in-elixirs-phoenix/32191152)
さて、Enum.map/2の処理に戻りましょう。

Example:

iex> chunk
[["users", ":user_id"], [":user_id", "comments"], ["comments", ":id"],
 [":id", nil]]
iex> part_ast_list = Enum.map(chunk, fn([part, next]) -> Example.part_to_ast_binding(part, next) end)
["users", {:var!, [context: Example, import: Kernel], [:user_id]},
 "comments", {:var!, [context: Example, import: Kernel], [:id]}]

Example:

defp part_to_ast_binding(<<"*" <> _splat_name>>, nil) do
  nil
end
defp part_to_ast_binding(<<":" <> param_name>>, <<"*" <> splat_name>>) do
  {:|, [], [var_ast(param_name), var_ast(splat_name)]}
end
defp part_to_ast_binding(<<":" <> param_name>>, _next) do
  var_ast(param_name)
end
defp part_to_ast_binding(part, <<"*" <> splat_name>>) do
  {:|, [], [part, var_ast(splat_name)]}
end
defp part_to_ast_binding(part, _next) do
  part
end
それぞれの対応した処理が行われたリストが戻ってきました。
そして、このリストフィルターをかけています。
iex> Enum.filter(part_ast_list, fn(part) -> part end)
["users", {:var!, [context: Example, import: Kernel], [:user_id]},
 "comments", {:var!, [context: Example, import: Kernel], [:id]}]
iex> Enum.filter(part_ast_list, fn(part) -> part end) |> Macro.to_string
"[\"users\", var!(:user_id), \"comments\", var!(:id)]"
このフィルターはどれほどの意味があるのだろうか?
とりあえず、nilかfalseじゃなければフィルターされない・・・はず。

Note:

iex> Enum.filter([1, true, 2, false, 3, nil], fn(part) -> part end)
[1, true, 2, 3]
これでだいたい動作は見ました。
関数として組み込んで実行してみます。

Example:

iex> path = "users/:user_id/comments/:id"
"users/:user_id/comments/:id"
iex> Example.matched_arg_list_with_ast_bindings(path)
["users", {:var!, [context: Example, import: Kernel], [:user_id]},
 "comments", {:var!, [context: Example, import: Kernel], [:id]}]
問題なく同じ動作をします(当然)

終わりに

各動作がわかりましたが、じゃあこれどこで使われているのさ?って話ですね。
前に少しだけ作った(写経した)Mapperモジュールで使います。
次回は、そこら辺の組み込みやマクロ展開の流れについてやっていきましょう。

Bibliography

人気の投稿