查看源代码 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/1
和 run/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.Case
和 setup
之间,添加以下调用。
@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 的应用程序的一部分,那么考虑将其拆分并编写更深入的单元测试非常重要,这些单元测试不会像集成测试那样繁重。
让我们进入下一章。我们最终将通过添加一个桶路由机制来使我们的系统分布式。我们将借此机会改进我们的测试技能。