檢視原始碼 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 表示我們的指令不會區分空白字元。開頭和結尾的空白字元無關緊要,字詞之間連續的空白字元也不重要。讓我們新增一些新的 doctest 來測試這個行為以及其他指令

@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}

"""

有了 doctest,輪到您讓測試通過!準備好後,您可以將您的工作與我們下列的解答進行比較

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 個 doctest。這是因為 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}

沒有換行符號,如下所示,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 文件中閱讀更多關於 doctest 的資訊。

with

由於我們現在能夠解析指令,因此我們終於可以開始實作執行指令的邏輯。讓我們先為這個函式新增一個 stub 定義

defmodule KVServer.Command do
  @doc """
  Runs the given command.
  """
  def run(command) do
    {:ok, "OK\r\n"}
  end
end

在實作這個函式之前,讓我們變更我們的伺服器以開始使用我們新的 parse/1run/1 函式。請記住,當用戶端關閉 socket 時,我們的 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 呼叫。我們來用 with 重寫 serve/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 會擷取 <- 右側回傳的值,並將其與左側的模式進行匹配。如果值符合模式,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.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 表格和連結程序一樣,不需要關閉 socket。一旦測試程序結束,socket 便會自動關閉。

這次,由於我們的測試依賴於全域資料,我們沒有提供 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 報告一起列印。

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 應用程式的昂貴設定。

最後,由您和您的團隊決定應用程式的最佳測試策略。您需要平衡程式碼品質、信心和測試套件執行時間。例如,我們可以從僅使用整合測試來測試伺服器開始,但如果伺服器在未來版本中持續擴充,或它成為經常有臭蟲的應用程式的一部分,則考慮將其拆分並撰寫更多密集的單元測試(沒有整合測試的負擔)非常重要。

讓我們進入下一章。我們將透過新增一個儲存區路由機制,讓我們的系統最終分佈。我們將利用這個機會來提升我們的測試技巧。