Doctests, patterns and with

本章是Mix和OTP指南的一部分,它取决于本指南的前几章。有关更多信息,请阅读简介指南或查看边栏中的章节索引。

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

CREATE shopping
OK

PUT shopping milk 1
OK

PUT shopping eggs 3
OK

GET shopping milk
1
OK

DELETE shopping eggs
OK

解析完成后,我们将更新我们的服务器以将解析的命令分发给:kv我们之前构建的应用程序。

文档测试

在语言主页上,我们提到Elixir使文档成为语言中的一流公民。我们在本指南中多次探讨了这个概念,无论是通过mix help打字h Enum还是通过在IEx控制台中键入或另一个模块。

在本节中,我们将使用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

Doctests由四个空格缩进指定,后跟iex>文档字符串中的提示。如果一个命令跨越多行,则可以使用...>,如在IEx中。预期的结果应该从下一行开始,iex>或者...>行(s),并以换行符或新的iex>前缀结束。

另外请注意,我们使用的是开始文档字符串@doc ~S"""。在~S防止\r\n从被转换为一个回车和换行,直到它们在试验评价字符。

为了运行我们的文档测试,我们将在测试用例中创建一个文件test/kv_server/command_test.exsdoctest KVServer.Command在其中调用:

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

运行测试套件,doctest应该失败:

  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:7: KVServer.Command (module)

优秀!

现在让我们进行doctest测试。让我们来实现这个parse/1功能:

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

我们的实现将行分割为空白,然后将该命令与列表进行匹配。使用String.split/1意味着我们的命令将是空白不敏感的。前后的空格不会有影响,单词之间的连续空格也无关紧要。让我们添加一些新的doctests来测试这个行为以及其他命令:

@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检查命令名称和参数数量的子句!

最后,您可能已经观察到,在我们的测试案例中,每个doctest被认为是一个不同的测试,因为我们的测试套件现在总共报告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}

您可以在文档中阅读更多关于doctests 的信息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构造,它允许你像上面那样简化代码,用一串case匹配的子句替换嵌套的调用。让我们重写serve/1要使用的函数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

好多了!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},所以我们应该修改write_line/2函数KVServer来打印这样的错误:

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

我们的服务器功能几乎完成。只有测试失踪。这一次,我们已经离开了最后的测试,因为有一些重要的考虑要做。

KVServer.Command.run/1的实现是直接将命令发送到名为KV.Registry:kv应用程序注册的服务器。这意味着这个服务器是全局的,如果我们有两个测试同时发送消息给它,我们的测试将会相互冲突(可能会失败)。我们需要在单独测试之间做出决定,这些测试可以异步运行,也可以编写在全局状态之上运行的集成测试,但是要运行我们的应用程序的完整堆栈,因为它是要在生产环境中执行的。

到目前为止,我们只编写单元测试,通常直接测试单个模块。然而,为了使KVServer.Command.run/1可测试作为一个单元,我们需要改变它的实现,不要直接发送命令给KV.Registry进程,而是传递一个服务器作为参数。例如,我们需要更改run签名def run(command, pid),然后相应地更改所有子句:

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

# ... other run clauses ...

请随时继续并做上面的改变并写出一些单元测试。这个想法是,你的测试将启动一个实例KV.Registry并将其作为参数传递给它,run/2而不是依赖于全局KV.Registry。由于没有共享状态,这具有保持我们的测试异步的优势。

但我们也尝试一些不同的东西。让我们编写依赖全局服务器名称的集成测试,以从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)
    %{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: trueuse ExUnit.Case。此外,为了保证我们的测试始终处于清洁状态,我们:kv在每次测试前停止并启动应用程序。事实上,停止:kv应用程序甚至会在终端上打印警告:

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

为了避免在测试过程中打印日志消息,ExUnit提供了一个简洁的功能:capture_log。通过@tag :capture_log在每个测试或@moduletag :capture_log整个测试用例之前设置,ExUnit将自动捕获测试运行时记录的任何内容。如果我们的测试失败,捕获的日志将与ExUnit报告一起打印。

use ExUnit.Case和设置之间,添加以下调用:

@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应用程序的设置。

在一天结束时,由您和您的团队来为您的应用程序找出最佳的测试策略。您需要平衡代码质量,信心和测试套件运行时间。例如,我们可能只会使用集成测试来测试服务器,但如果服务器在未来版本中继续增长,或者它成为应用程序的一部分并出现频繁的错误,则考虑将其分开并编写更密集的应用程序非常重要没有集成测试权重的单元测试。

在下一章中,我们将最终通过添加桶路由机制来使我们的系统分布。我们还将了解应用程序配置。