查看源代码 Doctests、模式和 with

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

CREATE shopping
OK

PUT shopping milk 1
OK

PUT shopping eggs 3
OK

GET shopping milk
1
OK

DELETE shopping eggs
OK

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

Doctests

在语言主页上,我们提到 Elixir 将文档视为语言中的一等公民。我们在本指南中多次探索了这个概念,无论是通过 mix help 还是在 IEx 控制台中输入 h Enum 或其他模块。

在本节中,我们将实现解析功能,对其进行文档化并确保我们的文档通过 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>...> 行后的下一行开始,并以换行符或新的 iex> 前缀终止。

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

要运行我们的 doctests,我们将在 test/kv_server/command_test.exs 中创建一个文件,并在测试用例中调用 doctest KVServer.Command

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

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

  1) doctest KVServer.Command.parse/1 (1) (KVServer.CommandTest)
     test/kv_server/command_test.exs:3
     Doctest failed
     doctest:
       iex> KVServer.Command.parse("CREATE shopping\r\n")
       {:ok, {:create, "shopping"}}
     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)

太棒了!

现在让我们让 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}

"""

有了 doctests,就轮到你让测试通过了!准备好了,你可以将你的工作与我们下面的解决方案进行比较。

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 个 doctests。这是因为 ExUnit 认为以下定义了两个不同的 doctests。

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。

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

顾名思义,doctest 首先是文档,其次是测试。他们的目标不是替代测试,而是提供最新的文档。你可以在 ExUnit.DocTest 文档中了解更多关于 doctests 的信息。

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/1 的更多信息。

运行命令

最后一步是实现 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 应用程序启动期间注册的 KV.Registry 服务器。由于我们的 :kv_server 依赖于 :kv 应用程序,因此依赖于它提供的服务是完全可以的。

你可能已经注意到,我们有一个没有函数体的函数头 def run(command)。在 模块和函数 一章中,我们了解到,无函数体的函数可以用来声明多子句函数的默认参数。这是另一个我们使用无函数体的函数来记录参数是什么的用例。

请注意,我们还定义了一个名为 lookup/2 的私有函数,以帮助查找存储桶并返回其 pid(如果存在),否则返回 {:error, :not_found}

顺便说一下,由于我们现在返回 {:error, :not_found},因此我们应该修改 KVServer 中的 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),然后相应地更改所有子句。

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 表和链接进程一样,没有必要关闭套接字。一旦测试进程退出,套接字就会自动关闭。

这一次,由于我们的测试依赖于全局数据,因此我们没有为 use ExUnit.Case 提供 async: true。此外,为了确保我们的测试始终处于干净状态,我们会在每次测试之前停止并启动 :kv 应用程序。事实上,停止 :kv 应用程序甚至会在终端上打印警告。

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

为了避免在测试期间打印日志消息,ExUnit 提供了一个名为 :capture_log 的整洁功能。通过在每次测试之前设置 @tag :capture_log 或为整个测试模块设置 @moduletag :capture_log,ExUnit 会自动捕获测试运行期间记录的所有内容。如果测试失败,捕获的日志将与 ExUnit 报告一起打印。

use ExUnit.Casesetup 之间,添加以下调用。

@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 [notice] Application kv exited: :stopped

通过这个简单的集成测试,我们开始明白为什么集成测试可能很慢。不仅此测试不能异步运行,而且它还需要停止和启动 :kv 应用程序的昂贵设置。

最终,选择最适合您的应用程序的测试策略取决于您和您的团队。您需要权衡代码质量、信心和测试套件运行时间。例如,我们可能一开始只使用集成测试来测试服务器,但如果服务器在将来的版本中继续增长,或者它成为一个经常出现 bug 的应用程序的一部分,那么考虑将其拆分并编写更深入的单元测试非常重要,这些单元测试不会像集成测试那样繁重。

让我们进入下一章。我们最终将通过添加一个桶路由机制来使我们的系统分布式。我们将借此机会改进我们的测试技能。