#文档,测试与with服务器
本章,咱们将实现可以解析咱们在第一章中描述的命令的代码: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/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
的结构,它可以简化像上面那样的代码.让咱们用它来重写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的应用的一部分,那么考虑将其打碎并编写更多增强的比集成测试轻量得多的单元测试就变得很是重要.
在下一章,咱们终于要经过添加一个桶路由机制来使得咱们的系统成为分布式的.咱们也将学习应用配置.