どうも、靖宗です。
くどいようですが、今回も前回の引き続きです。
今回はDoctests, patterns and withということで、Doctestsはドキュメント関連かな?とは思いますが他はしりません。
早速進めて行きます。
このチャプターではMix and OTP編の最初で言っていた
CREATE shopping OK PUT shopping milk 1 OK PUT shopping eggs 3 OK GET shopping milk 1 OK DELETE shopping eggs OK
というコマンドを実装していきます。
Doctests
たぶんドキュメントもしっかり書こうね!って事だと思います。(適当)
とりあえず実装を進めます。lib/kv_server/command.ex
を作成してdoctestをやってみます。
defmodule KVServer.Command do @doc ~S""" Parses the given `line` into a command. ## Examples iex> KVServer.Command.parse("CREATE shopping\r\n") {:ok, {:create, "shopping"}} """ def parse(_line) do :not_implemented end end
どうやらドキュメントの中にiex>
などとiexでの動作を想定したコマンドを記載しておけばテストしてくれる機能のようです。
改行などする場合はiex同様に...>
とするみたいです。
ちなみに@doc
の直後に~S
というsigilが来ているのでドキュメント内のエスケープシーケンスは勝手にエスケープされます。
テスト用のドキュメントなので、当然テストを作成していきます。
test/kv_server/command_test.exs
にテストをつくります。
defmodule KVServer.CommandTest do use ExUnit.Case, async: true doctest KVServer.Command end
mix test
でテストを行います。なんにも実装してないので失敗するはずです。
PS > mix test 11:22:16.070 [info] Accepting connections on port 4040 1) doctest KVServer.Command.parse/1 (1) (KVServer.CommandTest) test/kv_server/command_test.exs:3 Doctest failed code: KVServer.Command.parse("CREATE shopping\r\n") === {:ok, {:create, "shopping"}} left: :not_implemented right: {:ok, {:create, "shopping"}} stacktrace: lib/kv_server/command.ex:7: KVServer.Command (module) Finished in 0.04 seconds 1 doctest, 1 failure Randomized with seed 76000
おっけーですね。
ドキュメントに動作を書いてなおかつテストまで回せるのでこれは便利です。
それではテストも書けたことなので、テストに通るように実装していきます。
def parse(line) do case String.split(line) do ["CREATE", bucket] -> {:ok, {:create, bucket}} end end
String.split/1
はスペースで分割する関数のようです。区切り文字を自分で指定したい場合はString.split/3
のようですが、とりあえずここではサンプル通りにします。
CREATE shopping
の実装だけやりましたが、他の実装も@doc
に書きます。
@doc ~S""" Parses the given `line` into a command. ## Examples iex> KVServer.Command.parse "CREATE shopping\r\n" {:ok, {:create, "shopping"}} iex> KVServer.Command.parse "CREATE shopping \r\n" {:ok, {:create, "shopping"}} iex> KVServer.Command.parse "PUT shopping milk 1\r\n" {:ok, {:put, "shopping", "milk", "1"}} iex> KVServer.Command.parse "GET shopping milk\r\n" {:ok, {:get, "shopping", "milk"}} iex> KVServer.Command.parse "DELETE shopping eggs\r\n" {:ok, {:delete, "shopping", "eggs"}} Unknown commands or commands with the wrong number of arguments return an error: iex> KVServer.Command.parse "UNKNOWN shopping eggs\r\n" {:error, :unknown_command} iex> KVServer.Command.parse "GET shopping\r\n" {:error, :unknown_command} """
この挙動をparse/1
に実装します。
def parse(line) do case String.split(line) do ["CREATE", bucket] -> {:ok, {:create, bucket}} ["GET", bucket, key] -> {:ok, {:get, bucket, key}} ["PUT", bucket, key, value] -> {:ok, {:put, bucket, key, value}} ["DELETE", bucket, key] -> {:ok, {:delete, bucket, key}} _ -> {:error, :unknown_command} end end
Elixirならif/else
を使わずにシンプルに書けます。
ちなみにUnknown commands or commands ...
の箇所以降のテストですが、意味合いとしては1個のテストとして見なしても問題無さそうです。
ただし、doctestでは空改行が入っていれば別のテストとして認識されるそうです。
また、doctestでテストコードいらんやん!とも思いがちですが、doctestはドキュメントを最新に維持するために使われるもの(挙動が変わってたらドキュメントもかわるので)であって、テストに置き換わるものではないと注意書きがあります。
with
コマンドをパースするスクリプトが書けたので実際に処理を発行する箇所を書いていきます。
想定としてはKVServer.Command
モジュールの中に
defmodule KVServer.Command do @doc """ Runs the given command. """ def run(command) do {:ok, "OK\r\n"} end end
という関数があり、クライアントからソケットを受信→パース→コマンド実行、という流れになると思います。
とりあえず上のコードはlib/kv_server/command.ex
に追加しときます。
lib/kv_server/command.ex
にきちんとコードを実装していく前にlib/kv_server.ex
を修正します。
KVServer.Command
モジュールの実装に加えて、クライアントとの通信が切断されたときにクラッシュしてたread_line/1
周辺も修正します。
defp serve(socket) do msg = case read_line(socket) do {:ok, data} -> case KVServer.Command.parse(data) do {:ok, command} -> KVServer.Command.run(command) {:error, _} = err -> err end {:error, _} = err -> err end write_line(socket, msg) serve(socket) end defp read_line(socket) do # serve側でエラー判定とってるからこっちはシンプルになった :gen_tcp.recv(socket, 0) end defp write_line(socket, {:ok, text}) do :gen_tcp.send(socket, text) end defp write_line(socket, {:error, :unknown_command}) do # Known error; write to the client :gen_tcp.send(socket, "UNKNOWN COMMAND\r\n") end defp write_line(_socket, {:error, :closed}) do # The connection was closed, exit politely exit(:shutdown) end defp write_line(socket, {:error, error}) do # Unknown error; write to the client and exit :gen_tcp.send(socket, "ERROR\r\n") exit(error) end
write_line
が結構増えましたが、エラー処理なども含めてこれで実装できた筈です。mix run --no-halt
で実行してtelnetに色々打ち込んでみます。
PS> telnet 127.0.0.1 4040 CREATE shopping OK HELLO UNKNOWN COMMAND
CREATE shopping
はパースで想定されているものなので、とりあえず実装したOKが返ってきます。
想定された以外のコマンドは全てUNKNOWN COMMAND
になるはずです。
実装はこれで良いんですが、なにやらcaseがネストしてるのが「エレガントでない!」とお気に召さない模様。
その為にwith
構文があるそうです。
defp serve(socket) do msg = with {:ok, data} <- read_line(socket), {:ok, command} <- KVServer.Command.parse(data), do: KVServer.Command.run(command) write_line(socket, msg) serve(socket) end
スッキリ!書き方的にfor
に通ずるところがあるような気がします。
条件句を<-
を用いてパターンマッチで書いていくことで次へ次へと進めて行く構文のようです。
else
などの機能もあるようですが、詳しくはElixirのwith
のドキュメントを参照とのこと。
Running commands
それではrun
コマンドをlib/kv_server/command.ex
に実装していきます。
@doc """ Runs the given command. """ def run(command) def run({:create, bucket}) do KV.Registry.create(KV.Registry, bucket) {:ok, "OK\r\n"} end def run({:get, bucket, key}) do lookup(bucket, fn pid -> value = KV.Bucket.get(pid, key) {:ok, "#{value}\r\nOK\r\n"} end) end def run({:put, bucket, key, value}) do lookup(bucket, fn pid -> KV.Bucket.put(pid, key, value) {:ok, "OK\r\n"} end) end def run({:delete, bucket, key}) do lookup(bucket, fn pid -> KV.Bucket.delete(pid, key) {:ok, "OK\r\n"} end) end defp lookup(bucket, callback) do case KV.Registry.lookup(KV.Registry, bucket) do {:ok, pid} -> callback.(pid) :error -> {:error, :not_found} end end
:create
以外はプロセスIDを取ってくる処理が入るのでlookup/2
でラップしてます。
全ての関数に対して言えることですが、:kv
アプリケーションに依存しています。なのでmix.exs
に依存関係を記載する必要があったというわけです。
依存関係は既に編集済みなので、問題無いです。
また、def run(command)
が処理無しで書かれてます。これは関数のデフォルト引数のところで出てきましたが、デフォルト引数が指定されていません。
どうやらこれはドキュメント用に宣言だけ書かれているようです。
ここで、lookup/2
でラップした箇所ですが、エラーの場合{:error, :not_found}
が先ほどのwith
構文のところでmsgに返ってしまうので、write_line/2
で{:error, :not_found}
にマッチする関数をlib/kv_server.ex
へ追記します。
defp write_line(socket, {:error, :not_found}) do :gen_tcp.send(socket, "NOT FOUND\r\n") end
これで実装はほぼ完了したような気がします。
ですが、KVServer
のテストを全然書いてません。
KVServer
のテストですが、kv
アプリケーションによってグローバルに登録されたKV.Registry
(KV.Supervisor
でchirdlenに{KV.Registry, name: KV.Registry}
を渡したやつ)を使用しています。
グローバルに登録されたものを使用して平行してテストを行うと、案の定競合しちゃうんじゃないの?という心配があります。たぶんします。
そこで、「独立して単体テストできるように修正する」か、「統合テストを行う」かの2択が考えられます。
「独立して単体テストできるように修正する」場合であれば、run
の関数をグローバルのKV.Registry
を参照しないようにすればいけるはずです。
def run({:create, bucket}, pid) do KV.Registry.create(pid, bucket) {:ok, "OK\r\n"} end # ... create以外のほかのやつも ...
実装を変更すれば非同期的にテストが行えるので早いのでこれもアリかもしれません。
ただめんどくさいのか、単体テストはやったから統合テストやってみろよという思想なのか、サンプルでは統合テストで行くように書かれています。
どっちでも良いとは思いますがKV.Registry
の方がある程度完成しているなら統合テストでも良いんじゃないでしょうか?ただし、同期的に行う工夫は必要です。
また、今回であればパースの関数parse/1
なんかは間違えても統合テストに含めないようにしましょう。(単体テストできるのにテストが遅くなる)
それでは統合テストをtest/kv_server_test.exs
に書いていきます。
defmodule KVServerTest do use ExUnit.Case # 同期的にテストしたいのでいつもの async: trueは書かない setup do Application.stop(:kv) :ok = Application.start(:kv) end setup do opts = [:binary, packet: :line, active: false] {:ok, socket} = :gen_tcp.connect('localhost', 4040, opts) # 今までtelnetでやってたのを:gen_tcpで行う %{socket: socket} end test "server interaction", %{socket: socket} do assert send_and_recv(socket, "UNKNOWN shopping\r\n") == "UNKNOWN COMMAND\r\n" assert send_and_recv(socket, "GET shopping eggs\r\n") == "NOT FOUND\r\n" assert send_and_recv(socket, "CREATE shopping\r\n") == "OK\r\n" assert send_and_recv(socket, "PUT shopping eggs 3\r\n") == "OK\r\n" # GET returns two lines assert send_and_recv(socket, "GET shopping eggs\r\n") == "3\r\n" assert send_and_recv(socket, "") == "OK\r\n" assert send_and_recv(socket, "DELETE shopping eggs\r\n") == "OK\r\n" # GET returns two lines assert send_and_recv(socket, "GET shopping eggs\r\n") == "\r\n" assert send_and_recv(socket, "") == "OK\r\n" end defp send_and_recv(socket, command) do :ok = :gen_tcp.send(socket, command) {:ok, data} = :gen_tcp.recv(socket, 0, 1000) data end end
上記のテストで一通りのサーバーの動作はカバーできている筈です。
今までtelnetでTCP通信を試していましたが、クライアント側もテストコード上で:gen_tcp
で通信を開始しています。
この通信自体は特にテスト毎に終了する必要も無く、テストが終了したら勝手に終わるので特にcloseする必要もないそうです。
テスト前にApplication.stop(:kv)
している理由ですが、同期的にテストを行ってるとは言えテストの順番までは不明です。
なのでこのテストが動作する際にグローバルにある:kv
アプリケーションが初期状態であることを確実にするために一度停止して再起動しています。
これで状態が初期状態になっているはずということです。
それでは、テストしてみます。
PS > mix test 19:02:09.033 [info] Accepting connections on port 4040 ....... 19:02:09.125 [info] Application kv exited: :stopped 1) test server interaction (KVServerTest) test/kv_server_test.exs:16 Assertion with == failed code: assert send_and_recv(socket, "GET shopping eggs\r\n") == "NOT FOUND\r\n" left: "ERROR\r\n" right: "NOT FOUND\r\n" stacktrace: test/kv_server_test.exs:19: (test) Finished in 0.09 seconds 7 doctests, 1 test, 1 failure 19:02:09.210 [error] Task #PID<0.227.0> started from #PID<0.184.0> terminating ** (stop) :not_found (kv_server) lib/kv_server.ex:63: KVServer.write_line/2 (kv_server) lib/kv_server.ex:38: KVServer.serve/1 (elixir) lib/task/supervised.ex:90: Task.Supervised.invoke_mfa/2 (stdlib) proc_lib.erl:249: :proc_lib.init_p_do_apply/3 Function: #Function<0.58633890/0 in KVServer.loop_acceptor/1> Args: [] Randomized with seed 44000
ア゙!? あとで追加したNOT FOUNDのところがマッチせずERRORを返してます。
わたくしのタイポ&記載場所のミスでした。
defp write_line(socket, {:error, :not_found}) do # :not_foundがアトムになってなかった :gen_tcp.send(socket, "NOT FOUND\r\n") end defp write_line(socket, {:error, error}) do # Unknown error; write to the client and exit :gen_tcp.send(socket, "ERROR\r\n") exit(error) end
ERRORを返す関数もパターンマッチにハマるので、この関数より前に書いてないと先にERRORを返すパターンに処理が移行してしまいます。
なので、かくならdefp write_line(socket, {:error, error})
以前に。
もう一回。
PS > mix test Compiling 1 file (.ex) 19:07:40.834 [info] Accepting connections on port 4040 ....... 19:07:40.911 [info] Application kv exited: :stopped . Finished in 0.07 seconds 7 doctests, 1 test, 0 failures Randomized with seed 841000
いけました!
19:07:40.911 [info] Application kv exited: :stopped
という箇所で:kv
アプリケーションの再起動が行われています。
この表示は一応消せる(出しといた方が明確でいいのでは?)そうで、@moduletag :capture_log
という属性をuse ExUnit.Case
とsetup
の間に書くとおそらくKVServerTest
全体に影響を及ぼします。
defmodule KVServerTest do use ExUnit.Case @moduletag :capture_log setup do Application.stop(:kv) ...
ちゃんとテストが失敗したときは、表示しなかった出力(今回の19:07:40.911 [info] Application kv exited: :stopped
)をまとめて出してくれるそうです。
1) test server interaction (KVServerTest) test/kv_server_test.exs:17 ** (RuntimeError) oops stacktrace: test/kv_server_test.exs:29 The following output was logged: # ここ以下に表示される 13:44:10.035 [info] Application kv exited: :stopped
自分の環境では特に問題ありませんでしたが、環境次第ではTCP通信が遅くてうまくいかない場合があるそうです。
そういうときはsend_and_recv
関数の:gen_tcp.recv(socket, 0, 1000)
の箇所を:gen_tcp.recv(socket, 0)
と書いて、通信がタイムアウトしないようにすると良いかもしれません。
もう完璧やろ!とも思うんですがあと一章なにか残ってるみたいです。