檢視原始碼 使用代理進行簡單的狀態管理
在本指南中,我們將學習如何在多個實體之間保留和共用狀態。如果您有先前的程式設計經驗,您可能會想到全域共用變數,但我們將在此處學習的模型相當不同。後續章節將概括在此處介紹的概念。
如果您跳過入門指南,或是在很久以前閱讀過,請務必重新閱讀程序章節。我們將以此為起點。
(可變)狀態的問題
Elixir 是一種不變語言,其中預設不共用任何內容。如果我們想要共用資訊,可以從多個地方讀取和修改,我們在 Elixir 中有兩個主要選項
- 使用程序和訊息傳遞
- ETS(Erlang Term Storage)
我們在入門指南中介紹了程序。ETS(Erlang Term Storage)是一個新主題,我們將在後續章節中探討。然而,當談到程序時,我們很少自己動手,而是使用 Elixir 和 OTP 中提供的抽象
Agent
— 狀態周圍的簡單封裝器。GenServer
— 封裝狀態的「通用伺服器」(程序),提供同步和非同步呼叫,支援程式碼重新載入,以及更多功能。Task
— 非同步計算單元,允許產生程序,並有可能在稍後時間擷取其結果。
我們將在本指南中探討這些抽象中的大多數。請記住,它們都是使用 VM 提供的基本功能在程序之上實作的,例如 send/2
、receive/1
、spawn/1
和 Process.link/1
。
在這裡,我們將使用代理,並建立一個名為 KV.Bucket
的模組,負責儲存我們的鍵值輸入,讓其他程序可以讀取和修改它們。
代理程式 101
Agent
是狀態的簡單包裝器。如果你只希望從處理程序中保留狀態,代理程式是絕佳選擇。讓我們在專案中使用下列指令啟動 iex
會話
$ iex -S mix
並使用代理程式玩一下
iex> {:ok, agent} = Agent.start_link(fn -> [] end)
{:ok, #PID<0.57.0>}
iex> Agent.update(agent, fn list -> ["eggs" | list] end)
:ok
iex> Agent.get(agent, fn list -> list end)
["eggs"]
iex> Agent.stop(agent)
:ok
我們啟動了一個代理程式,其初始狀態為空清單。我們更新了代理程式的狀態,將我們的項目新增到清單的開頭。 Agent.update/3
的第二個引數是函式,它將代理程式的目前狀態作為輸入,並傳回其所需的狀態。最後,我們擷取了整個清單。 Agent.get/3
的第二個引數是函式,它將狀態作為輸入,並傳回 Agent.get/3
本身將傳回的值。一旦我們完成代理程式,我們可以呼叫 Agent.stop/3
來終止代理程式處理程序。
Agent.update/3
函式接受的第二個引數是任何接收一個引數並傳回值的函式
iex> {:ok, agent} = Agent.start_link(fn -> [] end)
{:ok, #PID<0.338.0>}
iex> Agent.update(agent, fn _list -> 123 end)
:ok
iex> Agent.update(agent, fn content -> %{a: content} end)
:ok
iex> Agent.update(agent, fn content -> [12 | [content]] end)
:ok
iex> Agent.update(agent, fn list -> [:nop | list] end)
:ok
iex> Agent.get(agent, fn content -> content end)
[:nop, 12, %{a: 123}]
如你所見,我們可以隨意修改代理程式狀態。因此,我們很可能不希望在程式碼中的許多不同位置存取代理程式 API。相反地,我們希望將所有與代理程式相關的功能封裝在單一模組中,我們將其稱為 KV.Bucket
。在我們實作之前,讓我們撰寫一些測試,這些測試將概述我們的模組公開的 API。
在 test/kv/bucket_test.exs
中建立一個檔案(請記住 .exs
副檔名),內容如下
defmodule KV.BucketTest do
use ExUnit.Case, async: true
test "stores values by key" do
{:ok, bucket} = KV.Bucket.start_link([])
assert KV.Bucket.get(bucket, "milk") == nil
KV.Bucket.put(bucket, "milk", 3)
assert KV.Bucket.get(bucket, "milk") == 3
end
end
use ExUnit.Case
負責設定我們的模組進行測試,並匯入許多與測試相關的功能,例如 test/2
巨集。
我們的第一次測試透過呼叫 start_link/1
並傳遞選項的空清單,來啟動新的 KV.Bucket
。然後我們對它執行一些 get/2
和 put/3
作業,並斷言結果。
另請注意傳遞給 ExUnit.Case
的 async: true
選項。此選項透過使用我們機器中的多個核心,讓測試案例與其他 :async
測試案例並行執行。這對於加速我們的測試套件非常有用。但是,:async
僅在測試案例不依賴或變更任何全域值時才應設定。例如,如果測試需要寫入檔案系統或存取資料庫,請保持同步(略過 :async
選項)以避免測試之間的競爭條件。
無論是否非同步,我們的測試顯然會失敗,因為在受測模組中尚未實作任何功能
** (UndefinedFunctionError) function KV.Bucket.start_link/1 is undefined (module KV.Bucket is not available)
為了修正失敗的測試,讓我們在 lib/kv/bucket.ex
中建立一個檔案,其內容如下。在查看以下實作之前,歡迎嘗試使用代理程式實作 KV.Bucket
模組。
defmodule KV.Bucket do
use Agent
@doc """
Starts a new bucket.
"""
def start_link(_opts) do
Agent.start_link(fn -> %{} end)
end
@doc """
Gets a value from the `bucket` by `key`.
"""
def get(bucket, key) do
Agent.get(bucket, &Map.get(&1, key))
end
@doc """
Puts the `value` for the given `key` in the `bucket`.
"""
def put(bucket, key, value) do
Agent.update(bucket, &Map.put(&1, key, value))
end
end
實作的第一步是呼叫 use Agent
。本指南中我們將學習的大部分功能,例如 GenServer
和 Supervisor
,都遵循此模式。對所有這些功能而言,呼叫 use
會產生一個具有預設組態的 child_spec/1
函式,這在第 4 章中開始監督程序時會很方便。
然後,我們定義一個 start_link/1
函式,它將有效啟動代理程式。定義一個始終接受選項清單的 start_link/1
函式是一種慣例。我們目前不打算使用任何選項,但我們可能會在稍後使用。然後,我們繼續呼叫 Agent.start_link/1
,它會接收一個傳回代理程式初始狀態的匿名函式。
我們在代理程式內部保留一個映射來儲存我們的金鑰和值。在映射上取得和放置值是透過代理程式 API 和擷取運算子 &
來完成的,這在 入門指南 中介紹過。當呼叫 Agent.get/2
和 Agent.update/2
時,代理程式會透過 &1
參數將其狀態傳遞給匿名函式。
現在 KV.Bucket
模組已經定義完成,我們的測試應該會通過!你可以透過執行以下指令自行嘗試: mix test
。
使用 ExUnit 回呼函式進行測試設定
在繼續進行並新增更多功能到 KV.Bucket
之前,讓我們來討論 ExUnit 回呼。正如你所預期的,所有 KV.Bucket
測試都需要一個 bucket 代理執行中。幸運的是,ExUnit 支援回呼,讓我們可以跳過這些重複性的工作。
讓我們改寫測試案例來使用回呼
defmodule KV.BucketTest do
use ExUnit.Case, async: true
setup do
{:ok, bucket} = KV.Bucket.start_link([])
%{bucket: bucket}
end
test "stores values by key", %{bucket: bucket} do
assert KV.Bucket.get(bucket, "milk") == nil
KV.Bucket.put(bucket, "milk", 3)
assert KV.Bucket.get(bucket, "milk") == 3
end
end
我們首先使用 setup/1
巨集定義一個設定回呼。 setup/1
巨集定義一個回呼,在每個測試之前執行,在與測試本身相同的程序中。
請注意,我們需要一個機制來傳遞 bucket
PID 從回呼到測試。我們使用 *測試內容* 來執行此操作。當我們從回呼傳回 %{bucket: bucket}
時,ExUnit 會將這個映射合併到測試內容中。由於測試內容本身是一個映射,我們可以從中模式比對 bucket,在測試中提供對 bucket 的存取
test "stores values by key", %{bucket: bucket} do
# `bucket` is now the bucket from the setup block
end
你可以在 ExUnit.Case
模組文件 中閱讀更多關於 ExUnit 案例,以及在 ExUnit.Callbacks
中閱讀更多關於回呼。
其他代理動作
除了取得值和更新代理狀態之外,代理允許我們透過 Agent.get_and_update/2
在一個函式呼叫中取得值和更新代理狀態。讓我們實作一個 KV.Bucket.delete/2
函式,從 bucket 中刪除一個金鑰,傳回其目前值
@doc """
Deletes `key` from `bucket`.
Returns the current value of `key`, if `key` exists.
"""
def delete(bucket, key) do
Agent.get_and_update(bucket, &Map.pop(&1, key))
end
現在輪到你為上述功能撰寫測試了!此外,務必探索 Agent
模組的文件 以進一步了解它們。
代理中的客戶端/伺服器
在我們進入下一章之前,讓我們討論代理中的客戶端/伺服器二分法。讓我們擴充我們剛實作的 delete/2
函式
def delete(bucket, key) do
Agent.get_and_update(bucket, fn dict ->
Map.pop(dict, key)
end)
end
我們傳遞給代理的函式內的所有內容都在代理程序中發生。在這個案例中,由於代理程序是接收和回應我們訊息的程序,因此我們說代理程序是伺服器。函式外的所有內容都在客戶端中發生。
這個區別很重要。如果有昂貴的動作需要執行,你必須考慮在客戶端或伺服器上執行這些動作會比較好。例如
def delete(bucket, key) do
Process.sleep(1000) # puts client to sleep
Agent.get_and_update(bucket, fn dict ->
Process.sleep(1000) # puts server to sleep
Map.pop(dict, key)
end)
end
當伺服器執行長時間的動作時,所有其他對該特定伺服器的請求都將等待動作完成,這可能會導致某些用戶端逾時。
在下一章中,我們將探討 GenServer,其中用戶端和伺服器之間的分離更加明顯。