#文檔,測試與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的應用的一部分,那麼考慮將其打碎並編寫更多增強的比集成測試輕量得多的單元測試就變得很是重要.
在下一章,咱們終於要經過添加一個桶路由機制來使得咱們的系統成爲分佈式的.咱們也將學習應用配置.