檢視原始碼 與處理程序相關的反模式

此文件概述與處理程序和基於處理程序的抽象相關的潛在反模式。

依處理程序組織程式碼

問題

此反模式是指不必要地依處理程序組織的程式碼。處理程序本身並非反模式,但只應使用來建模執行時期屬性(例如並行性、存取共用資源、錯誤隔離等)。當您使用處理程序進行程式碼組織時,它可能會在系統中造成瓶頸。

範例

此反模式的範例如下所示,是一個透過 GenServer 處理程序實作算術運算(例如 addsubtract)的模組。如果對此單一處理程序的呼叫次數增加,此程式碼組織可能會損害系統效能,因此成為瓶頸。

defmodule Calculator do
  @moduledoc """
  Calculator that performs basic arithmetic operations.

  This code is unnecessarily organized in a GenServer process.
  """

  use GenServer

  def add(a, b, pid) do
    GenServer.call(pid, {:add, a, b})
  end

  def subtract(a, b, pid) do
    GenServer.call(pid, {:subtract, a, b})
  end

  @impl GenServer
  def init(init_arg) do
    {:ok, init_arg}
  end

  @impl GenServer
  def handle_call({:add, a, b}, _from, state) do
    {:reply, a + b, state}
  end

  def handle_call({:subtract, a, b}, _from, state) do
    {:reply, a - b, state}
  end
end
iex> {:ok, pid} = GenServer.start_link(Calculator, :init)
{:ok, #PID<0.132.0>}
iex> Calculator.add(1, 5, pid)
6
iex> Calculator.subtract(2, 3, pid)
-1

重構

在 Elixir 中,如下所示,程式碼組織只能透過模組和函式進行。只要有可能,函式庫不應對其使用者施加特定行為(例如並行化)。最好將此行為決策委派給客戶端的開發人員,從而增加函式庫程式碼再利用的可能性。

defmodule Calculator do
  def add(a, b) do
    a + b
  end

  def subtract(a, b) do
    a - b
  end
end
iex> Calculator.add(1, 5)
6
iex> Calculator.subtract(2, 3)
-1

分散的處理程序介面

問題

在 Elixir 中,使用 AgentGenServer 或任何其他處理程序抽象本身並非反模式。但是,當與處理程序直接互動的責任散佈在整個系統中時,可能會產生問題。此不良做法會增加維護程式碼的難度,並使程式碼更容易出錯。

範例

下列程式碼旨在說明此反模式。與 Agent 直接互動的責任散佈在四個不同的模組(ABCD)中。

defmodule A do
  def update(process) do
    # Some other code...
    Agent.update(process, fn _list -> 123 end)
  end
end
defmodule B do
  def update(process) do
    # Some other code...
    Agent.update(process, fn content -> %{a: content} end)
  end
end
defmodule C do
  def update(process) do
    # Some other code...
    Agent.update(process, fn content -> [:atom_value | content] end)
  end
end
defmodule D do
  def get(process) do
    # Some other code...
    Agent.get(process, fn content -> content end)
  end
end

此責任的散佈可能會產生重複的程式碼,並使維護程式碼更加困難。此外,由於無法控制共用資料的格式,因此可能會共用複雜的複合資料。自由使用任何資料格式很危險,可能會導致開發人員引入錯誤。

# start an agent with initial state of an empty list
iex> {:ok, agent} = Agent.start_link(fn -> [] end)
{:ok, #PID<0.135.0>}

# many data formats (for example, List, Map, Integer, Atom) are
# combined through direct access spread across the entire system
iex> A.update(agent)
iex> B.update(agent)
iex> C.update(agent)

# state of shared information
iex> D.get(agent)
[:atom_value, %{a: 123}]

對於 GenServer 和其他行為,當將呼叫分散到 GenServer.call/3GenServer.cast/2 中,而不是將與 GenServer 的所有互動封裝在單一位置時,這種反模式將會顯現。

重構

與其在程式碼中的許多地方散佈對程序抽象(例如 Agent)的直接存取,不如透過將與程序互動的責任集中在單一模組中來重構此程式碼。此重構透過移除重複的程式碼來改善可維護性;它也允許您限制共用資料的可接受格式,進而降低錯誤發生率。如下所示,模組 Foo.Bucket 正在集中與 Agent 互動的責任。程式碼中任何其他需要存取共用資料的地方現在都必須將此動作委派給 Foo.Bucket。此外,Foo.Bucket 現在只允許資料以 Map 格式共用。

defmodule Foo.Bucket do
  use Agent

  def start_link(_opts) do
    Agent.start_link(fn -> %{} end)
  end

  def get(bucket, key) do
    Agent.get(bucket, &Map.get(&1, key))
  end

  def put(bucket, key, value) do
    Agent.update(bucket, &Map.put(&1, key, value))
  end
end

以下是將存取共用資料(由 Agent 提供)委派給 Foo.Bucket 的範例。

# start an agent through `Foo.Bucket`
iex> {:ok, bucket} = Foo.Bucket.start_link(%{})
{:ok, #PID<0.114.0>}

# add shared values to the keys `milk` and `beer`
iex> Foo.Bucket.put(bucket, "milk", 3)
iex> Foo.Bucket.put(bucket, "beer", 7)

# access shared data of specific keys
iex> Foo.Bucket.get(bucket, "beer")
7
iex> Foo.Bucket.get(bucket, "milk")
3

其他說明

此反模式以前稱為 Agent 迷戀

傳送不必要的資料

問題

如果訊息夠大,傳送訊息給程序可能會是一項昂貴的操作。這是因為該訊息將被完整複製到接收程序中,這可能會消耗大量 CPU 和記憶體。這是由於 Erlang 的「不共用任何東西」架構,其中每個程序都有自己的記憶體,這簡化並加速了垃圾回收。

在使用 send/2GenServer.call/3GenServer.start_link/3 中的初始資料時,這一點更加明顯。值得注意的是,在使用 spawn/1Task.async/1Task.async_stream/3 等時也會發生這種情況。這裡比較微妙,因為傳遞給這些函式的匿名函式會擷取它引用的變數,而所有擷取的變數都將被複製。這樣做可能會意外地將比實際需要的更多資料傳送到程序中。

範例

想像一下,您要實作一些針對應用程式提出要求的 IP 位址的簡單報告。您希望非同步執行此操作,而不阻塞處理,因此您決定使用 spawn/1。將整個連線傳遞過去似乎是個好主意,因為我們可能稍後需要更多資料。然而,傳遞連線會複製許多不必要的資料,例如請求主體、參數等。

# log_request_ip send the ip to some external service
spawn(fn -> log_request_ip(conn) end)

存取相關部分時也會發生此問題

spawn(fn -> log_request_ip(conn.remote_ip) end)

這仍然會複製所有 conn,因為 conn 變數會在產生的函式內擷取。接著函式會萃取 remote_ip 欄位,但僅在複製完所有 conn 之後。

send/2GenServer API 也依賴訊息傳遞。在以下範例中,conn 會再次複製到底層 GenServer

GenServer.cast(pid, {:report_ip_address, conn})

重構

此反模式有許多潛在補救措施

  • 將傳送的資料限制在絕對必要的最小值,而不是傳送整個結構。例如,如果您只需要幾個欄位,請不要傳送整個 conn 結構。

  • 如果唯一需要資料的程序就是您要傳送到的程序,請考慮讓程序擷取該資料,而不是傳遞資料。

  • 某些抽象化,例如 :persistent_term,允許您在程序之間共用資料,只要此類資料變更不頻繁即可。

在我們的案例中,限制輸入資料是一種合理的策略。如果我們現在唯一需要的是 IP 位址,那麼我們就只處理它,並確保我們只將 IP 位址傳遞到封閉中,如下所示

ip_address = conn.remote_ip
spawn(fn -> log_request_ip(ip_address) end)

或在 GenServer 案例中

GenServer.cast(pid, {:report_ip_address, conn.remote_ip})

非監督程序

問題

在 Elixir 中,在監督樹之外建立程序本身並非反模式。但是,當您在監督樹之外產生許多長期執行的程序時,這可能會讓這些程序的能見度和監控變得困難,使開發人員無法完全控制其應用程式。

範例

以下程式碼範例旨在說明一個負責透過 GenServer 程序(在監督樹之外)維護數值 Counter 的函式庫。多個計數器可以由客戶端同時建立(每個計數器一個程序),這使得這些非監督程序難以管理。這可能會導致系統初始化、重新啟動和關閉的問題。

defmodule Counter do
  @moduledoc """
  Global counter implemented through a GenServer process.
  """

  use GenServer

  @doc "Starts a counter process."
  def start_link(opts \\ []) do
    initial_value = Keyword.get(opts, :initial_value, 0)
    name = Keyword.get(opts, :name, __MODULE__)
    GenServer.start(__MODULE__, initial_value, name: name)
  end

  @doc "Gets the current value of the given counter."
  def get(pid_name \\ __MODULE__) do
    GenServer.call(pid_name, :get)
  end

  @doc "Bumps the value of the given counter."
  def bump(pid_name \\ __MODULE__, value) do
    GenServer.call(pid_name, {:bump, value})
  end

  @impl true
  def init(counter) do
    {:ok, counter}
  end

  @impl true
  def handle_call(:get, _from, counter) do
    {:reply, counter, counter}
  end

  def handle_call({:bump, value}, _from, counter) do
    {:reply, counter, counter + value}
  end
end
iex> Counter.start_link()
{:ok, #PID<0.115.0>}
iex> Counter.get()
0
iex> Counter.start_link(initial_value: 15, name: :other_counter)
{:ok, #PID<0.120.0>}
iex> Counter.get(:other_counter)
15
iex> Counter.bump(:other_counter, -3)
12
iex> Counter.bump(Counter, 7)
7

重構

為了確保程式庫的用戶能完全控制其系統,無論使用多少程序和每個程序的生命週期為何,所有程序都必須在監督樹中啟動。如下所示,此程式碼使用 Supervisor 作為監督樹。當此 Elixir 應用程式啟動時,兩個不同的計數器 (Counter:other_counter) 也會作為名為 App.SupervisorSupervisor 的子程序啟動。一個初始化為 0,另一個初始化為 15。透過此監督樹,可以管理所有子程序的生命週期(停止或重新啟動每個程序),進而提高整個應用程式的可見性。

defmodule SupervisedProcess.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children = [
      # With the default values for counter and name
      Counter,
      # With custom values for counter, name, and a custom ID
      Supervisor.child_spec(
        {Counter, name: :other_counter, initial_value: 15},
        id: :other_counter
      )
    ]

    Supervisor.start_link(children, strategy: :one_for_one, name: App.Supervisor)
  end
end
iex> Supervisor.count_children(App.Supervisor)
%{active: 2, specs: 2, supervisors: 0, workers: 2}
iex> Counter.get(Counter)
0
iex> Counter.get(:other_counter)
15
iex> Counter.bump(Counter, 7)
7
iex> Supervisor.terminate_child(App.Supervisor, Counter)
iex> Supervisor.count_children(App.Supervisor) # Only one active child
%{active: 1, specs: 2, supervisors: 0, workers: 2}
iex> Counter.get(Counter) # The process was terminated
** (EXIT) no process: the process is not alive...
iex> Supervisor.restart_child(App.Supervisor, Counter)
iex> Counter.get(Counter) # After the restart, this process can be used again
0