elixir官方教程Mix与OTP(八) 文档,测试与with

#文档,测试与with服务器

  1. 文档测试
  2. with
  3. 运行命令

本章,咱们将实现可以解析咱们在第一章中描述的命令的代码:app

CREATE shopping
OK

PUT shopping milk 1
OK

PUT shopping eggs 3
OK

GET shopping milk
1
OK

DELETE shopping eggs
OK

解析完成后,咱们将更新咱们的服务器来调遣解析后的命令到kv应用中.socket

#文档测试(Doctests)async

在语言主页,咱们提到Elixir将文档当作语言中的一等公民.咱们已经在本教程中屡次探索了这个概念,经过mix help,或输入h Enum等其余模块在IEx控制台中.tcp

本节,咱们将使用文档测试来实现解析功能,它容许咱们从文档中直接编写测试.这帮助咱们给文档提供精确的代码样本.分布式

让咱们在lib/kv_server/command.ex中建立命令解析器,并以文档测试开头:函数

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>...>的下一行开始,并以新的行或新的iex>前缀做为结尾.oop

还要注意的是咱们使用@doc ~S"""来开始文档字符串.~S可以避免\r\n字符被转化成回车和换行,直到它们在测试中被执行.单元测试

要运行咱们的文档测试,咱们会在test/kv_server/command_test.exs中建立一个文件,并在测试中调用doctest KVServer.Command:学习

defmodule KVServer.CommandTest do
  use ExUnit.Case, async: true
  doctest KVServer.Command
end

运行这套测试,文档测试将会失败:

1) test doc at 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"}}
   lhs:  :not_implemented
   stacktrace:
     lib/kv_server/command.ex:11: KVServer.Command (module)

很好!

如今只须要让文档测试经过就好了.让咱们来实现parse/1函数:

def parse(line) do
  case String.split(line) do
    ["CREATE", bucket] -> {:ok, {:create, bucket}}
  end
end

咱们的实现是简单地用空格拆分命令行,而后匹配列表中的命令.使用String.split/1意味着咱们的命令将会是空格不敏感的,开头和结尾的空格是可有可无的,单词间连续的空格也是同样.让咱们添加一些新的文档测试,来测试其它命令:

@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}

"""

如今轮到你来让测试经过!你完成以后,能够对比一下咱们的解决方案:

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

注意咱们是如何优雅地解析命令的,不须要添加一大堆 的if/else从句来检查命令名和参数数量!

最后,你可能会发现每一个文档测试都被认为是不一样的测试,由于咱们这套测试最后报告了7个测试.这是由于ExUnit是这样辨认两个不一样测试的定义的:

iex> KVServer.Command.parse "UNKNOWN shopping eggs\r\n"
{:error, :unknown_command}

iex> KVServer.Command.parse "GET shopping\r\n"
{:error, :unknown_command}

中间没有隔一行的话,ExUnit就会将其编译为一个测试:

iex> KVServer.Command.parse "UNKNOWN shopping eggs\r\n"
{:error, :unknown_command}
iex> KVServer.Command.parse "GET shopping\r\n"
{:error, :unknown_command}

你能够阅读ExUnit.DocTest文档来获取更多关于文档测试的内容.

#with

如今咱们可以解析命令了,咱们终于能够开始实现运行命令的逻辑了.让咱们为这个函数添加一个存根定义:

defmodule KVServer.Command do
  @doc """
  Runs the given command.
  """
  def run(command) do
    {:ok, "OK\r\n"}
  end
end

在咱们实现这个函数以前,让咱们修改服务器,使其开始使用咱们新的parse/1run/1函数.记住,咱们的read_line/1函数会在客户端关闭套接字时崩溃,因此让咱们也抓住机会修复它.打开lib/kv_server.ex:

defp serve(socket) do
  socket
  |> read_line()
  |> write_line(socket)

  serve(socket)
end

defp read_line(socket) do
  {:ok, data} = :gen_tcp.recv(socket, 0)
  data
end

defp write_line(line, socket) do
  :gen_tcp.send(socket, line)
end

替换成:

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
  :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

启动咱们的服务器,如今咱们能够向它发送命令.如今咱们能够获得两个不一样的回复:当命令已知时回复"OK",不然回复"UNKNOWN COMMAND":

$ telnet 127.0.0.1 4040
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
CREATE shopping
OK
HELLO
UNKNOWN COMMAND

这意味着咱们的实现已经朝着正确的方向运行,可是这看起来不太优雅,对吗?

以前的实现使用了资源管线,使得逻辑很清晰.然而,如今咱们须要处理不一样的错误代码,咱们的服务器逻辑嵌套在了许多case调用中.

幸运的是,Elixir v1.2引入了一个叫作with的结构,它可以简化像上面那样的代码.让咱们用它来重写server/1函数吧:

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

好多了!明智的语法,with的理解和for很相似.with将会获取<-右边的返回值,并与左边进行模式匹配.若是匹配成功,with会进入下一个表达式.若是匹配失败,未匹配的值将会被返回.

换句话说,咱们将case/2中的每一个表达式转化成了with中的步骤.只要任何一步中返回值不能匹配{:ok, x},with就会跳出,并返回未匹配的值.

你可在咱们的文档中获取更多关于with的信息.

#运行命令

最后一步是实现KVServer.Command.run/1,来使:kv应用运行解析后的命令.它的实现以下所示:

@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

这个实现很简单:咱们只须要派遣到KV.Registry服务器,它是咱们在:kv应用启动时注册的.由于咱们的:kv_server依赖于:kv应用,因此彻底能够依赖它所提供的服务器/服务.

注意到咱们也定义了一个名为lookup/2的私有函数来完成一个经常使用功能:搜索桶,若是存在就返回它的pid,不然返回{:error, :not_found}.

此外,因为咱们如今返回的是{:error, :not_found},咱们应该修改KV.Server中的write_line/2函数使之也能来打印这个错误:

defp write_line(socket, {:error, :not_found}) do
  :gen_tcp.send(socket, "NOT FOUND\r\n")
end

咱们的服务器功能基本完成了!咱们只须要添加测试.这一次,咱们把测试留到最后,由于有一些重要的决定要作.

KVServer.Command.run/1的实现是直接发送命令到由:kv应用注册的KV.Registry服务器.这意味着这个服务器是全局的,若是咱们有两个测试同时发送信息给它,咱们的测试将会相互冲突(极可能失败).咱们须要决定是使用相互独立且能同步运行的单元测试,仍是运行在全局状态顶部的集成测试,可是每次测试就要调用应用的全栈.

目前咱们只写过单元测试,并且是直接测试单个模块.然而,为了使KVServer.Command.run/1能像一个单元同样被测试,咱们须要改变它的实现,再也不直接发送命令到KV.Registry进程,而是传送一个做为参数的服务器.这意味着咱们须要改变run的签名到def run(command, pid),以及对:create命令的实现:

def run({:create, bucket}, pid) do
  KV.Registry.create(pid, bucket)
  {:ok, "OK\r\n"}
end

当对KVServer.Command进行测试时,咱们须要启动一个KV.Registry的实例,相似于咱们在apps/kv/test/kv/registry_test.exs中作的那样,并将其做为一个参数传送给run/2.

这已经成为咱们一直在测试中使用的方法,它的优势是:

\1. 咱们的实现不会与任何特定的服务器名耦合 \2. 咱们能够保持同步运行测试,由于这里没有共用状态

然而,它的缺点是咱们的API为了容纳全部的外部参数而变得很是大.

替代方案是编写集成测试,它依赖于全局服务器名来使用整个堆栈,从TCP服务器到桶.集成测试的缺点是它们会比单元测试慢得多,所以它们必须节制地使用.例如,咱们不该该使用集成测试在咱们的命令解析实现中来测试一个边界状况.

如今咱们将编写一个集成测试.集成测试会使用一个TCP客户端来发送命令到咱们的服务器,并断言咱们将获得预期的回复.

让咱们在test/kv_server_test.exs中实现以下所示的集成测试:

defmodule KVServerTest do
  use ExUnit.Case

  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)
    {:ok, 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

咱们的集成测试检查了全部的服务器接口,包括未知命令和未找到错误.由于是在处理ETS表格和连接进程,因此没必要关闭套接字.一旦测试进程退出,套接字会自动关闭.

这一次,由于咱们的测试依赖于全局数据,因此咱们没有将async: true传送给use ExUnit.Case.并且,为了保证咱们的测试始终在一个干净的状态,在每一个测试以前咱们中止再启动了:kv应用.事实上,中止:kv应用会在终端打印一个警告:

18:12:10.698 [info] Application kv exited: :stopped

为了不在测试过程当中打印日志,ExUnit提供了一个叫作:capture_log的干净特性.经过在每次测试前设置@tag :capture_log,或者为整个测试设置@moduletag :capture_log,在测试运行时,ExUnit会自动捕获日志中的任何东西.若是测试失败,捕获的日志会被打印在ExUnit报告旁边.

启动以前,添加以下调用:

@moduletag :capture_log

当测试崩溃时,你会看到以下报告:

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

从这个简单的集成测试中,咱们能够知道为何集成测试可能很慢.不止由于这种测试不能同步运行,还由于要求中止再启动:kv应用这种昂贵的启动配置.

最后,应当由你和你的团队来找到适用于你的应用的最好的测试策略.你须要平衡代码质量,信心,和测试套件的运行时.例如,最开始咱们可能只用集成测试来测试服务器,可是若是服务器在以后的发布中持续成长,或者它成为了一个频繁发生bug的应用的一部分,那么考虑将其打碎并编写更多增强的比集成测试轻量得多的单元测试就变得很是重要.

在下一章,咱们终于要经过添加一个桶路由机制来使得咱们的系统成为分布式的.咱们也将学习应用配置.

相关文章
相关标签/搜索