技術メモ

プログラミングとか電子工作とか

Elixir入門(Mix and OTP編 第9章 Doctests, patterns and with)

f:id:ysmn_deus:20190122112104p:plain

どうも、靖宗です。
くどいようですが、今回も前回の引き続きです。
今回は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のドキュメントを参照とのこと。

hexdocs.pm

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.RegistryKV.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

上記のテストで一通りのサーバーの動作はカバーできている筈です。
今までtelnetTCP通信を試していましたが、クライアント側もテストコード上で: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.Casesetupの間に書くとおそらく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)と書いて、通信がタイムアウトしないようにすると良いかもしれません。

もう完璧やろ!とも思うんですがあと一章なにか残ってるみたいです。