檢視原始碼 與設計相關的反模式
此文件概述與您的模組、函數及其在程式碼庫中扮演的角色相關的潛在反模式。
替代回傳類型
問題
此反模式是指接收選項(通常作為關鍵字清單參數)的函數,會大幅變更其回傳類型。由於選項是可選的,有時會動態設定,如果它們也變更回傳類型,就可能難以了解函數實際回傳什麼。
範例
此反模式的範例如下所示,當函數有許多替代回傳類型,視接收的選項而定。
defmodule AlternativeInteger do
@spec parse(String.t(), keyword()) :: integer() | {integer(), String.t()} | :error
def parse(string, options \\ []) when is_list(options) do
if Keyword.get(options, :discard_rest, false) do
Integer.parse(string)
else
case Integer.parse(string) do
{int, _rest} -> int
:error -> :error
end
end
end
end
iex> AlternativeInteger.parse("13")
13
iex> AlternativeInteger.parse("13", discard_rest: true)
13
iex> AlternativeInteger.parse("13", discard_rest: false)
{13, ""}
重構
若要重構此反模式,如下所示,請針對每個回傳類型新增特定函數(例如,parse_discard_rest/1
),不再委派給作為引數傳遞的選項。
defmodule AlternativeInteger do
@spec parse(String.t()) :: {integer(), String.t()} | :error
def parse(string) do
Integer.parse(string)
end
@spec parse_discard_rest(String.t()) :: integer() | :error
def parse_discard_rest(string) do
case Integer.parse(string) do
{int, _rest} -> int
:error -> :error
end
end
end
iex> AlternativeInteger.parse("13")
{13, ""}
iex> AlternativeInteger.parse_discard_rest("13")
13
布林迷思
問題
此反模式發生在使用布林值而非原子來編碼資訊時。布林值本身的使用並非反模式,但每當使用多個布林值且狀態重疊時,用原子(或複合資料類型,例如組元)取代布林值可能會產生更清楚的程式碼。
這是基本迷思的特殊情況,特別針對布林值。
範例
此反模式的範例是接收兩個或更多選項的函數,例如 editor: true
和 admin: true
,以重疊的方式設定其行為。在以下程式碼中,如果設定 :admin
,:editor
選項就不會產生作用,表示 :admin
選項的優先順序高於 :editor
,而且它們最終是相關的。
defmodule MyApp do
def process(invoice, options \\ []) do
cond do
options[:admin] -> # Is an admin
options[:editor] -> # Is an editor
true -> # Is none
end
end
end
重構
與其使用多個選項,以上程式碼可以重構成接收單一選項,稱為 :role
,可以是 :admin
、:editor
或 :default
defmodule MyApp do
def process(invoice, options \\ []) do
case Keyword.get(options, :role, :default) do
:admin -> # Is an admin
:editor -> # Is an editor
:default -> # Is none
end
end
end
這種反模式也可能發生在我們自己的資料結構中。例如,我們可以定義一個具有兩個布林欄位的 User
結構,:editor
和 :admin
,而一個名為 :role
的單一欄位可能是首選。
最後,值得注意的是,即使我們只有一個布林參數/選項,使用原子也可能是首選。例如,考慮一個可以設定為已核准/未核准的發票。一種選擇是提供一個預期布林值的函式
MyApp.update(invoice, approved: true)
但是,使用原子可能更易於閱讀,並使將來更容易新增其他狀態(例如待處理)
MyApp.update(invoice, status: :approved)
請記住,布林在內部表示為原子。因此,一種方法不會比另一種方法有性能損失。
控制流程的例外
問題
此反模式是指使用 Exception
作為控制流程的程式碼。例外處理本身並不代表反模式,但開發人員必須優先使用 case
和模式比對來變更其程式碼流程,而不是 try/rescue
。反過來,函式庫作者應提供開發人員 API 來處理錯誤,而不依賴例外處理。當開發人員無法自由決定錯誤是否例外時,這被視為反模式。
範例
此反模式的一個範例,如下所示,是使用 try/rescue
來處理檔案操作
defmodule MyModule do
def print_file(file) do
try do
IO.puts(File.read!(file))
rescue
e -> IO.puts(:stderr, Exception.message(e))
end
end
end
iex> MyModule.print_file("valid_file")
This is a valid file!
:ok
iex> MyModule.print_file("invalid_file")
could not read file "invalid_file": no such file or directory
:ok
重構
要重構此反模式,如下所示,請使用 File.read/1
,它會傳回元組,而不是在無法讀取檔案時引發例外
defmodule MyModule do
def print_file(file) do
case File.read(file) do
{:ok, binary} -> IO.puts(binary)
{:error, reason} -> IO.puts(:stderr, "could not read file #{file}: #{reason}")
end
end
end
這僅是因為 File
模組提供 API 來讀取具有元組作為結果的檔案 (File.read/1
),以及引發例外的版本 (File.read!/1
)。驚嘆號 (感嘆號) 實際上是 Elixir 命名慣例 的一部分。
鼓勵函式庫作者遵循相同的做法。實際上,驚嘆號變體是在非引發版本的程式碼之上實作的。例如,File.read!/1
實作如下
def read!(path) do
case read(path) do
{:ok, binary} ->
binary
{:error, reason} ->
raise File.Error, reason: reason, action: "read file", path: IO.chardata_to_string(path)
end
end
社群遵循的常見做法是讓非引發版本傳回 {:ok, result}
或 {:error, Exception.t}
。例如,HTTP 客戶端在成功案例中可能會傳回 {:ok, %HTTP.Response{}}
,而在失敗時傳回 {:error, %HTTP.Error{}}
,其中 HTTP.Error
是 實作為例外。這讓任何人都可以透過呼叫 Kernel.raise/1
來引發例外,非常方便。
其他說明
此反模式以前稱為 用於控制流程的例外。
原始迷戀
問題
此反模式發生在 Elixir 基本類型(例如,integer、float 和 string)過度用於承載結構化資訊,而非建立特定複合資料類型(例如,tuples、maps 和 structs)來更佳地表示網域。
範例
此反模式的一個範例是使用單一 string 來表示 Address
。 Address
是比單純基本(又稱原始)值更複雜的結構。
defmodule MyApp do
def extract_postal_code(address) when is_binary(address) do
# Extract postal code with address...
end
def fill_in_country(address) when is_binary(address) do
# Fill in missing country...
end
end
雖然你可能會從資料庫、網路要求或第三方收到 address
作為字串,但如果你發現自己頻繁地從字串中操作或擷取資訊,這是一個很好的指標,表示你應該將 address 轉換為結構化資料
此反模式的另一個範例是使用浮點數來建模金錢和貨幣,而 應該優先使用更豐富的資料結構。
重構
解決此反模式的可能方法是使用 maps 或 structs 來建模我們的 address。以下範例建立一個 Address
struct,透過複合類型來更佳地表示此網域。此外,我們引入一個 parse/1
函數,將字串轉換為 Address
,這將簡化剩餘函數的邏輯。透過此修改,我們可以在需要時個別擷取此複合類型的每個欄位。
defmodule Address do
defstruct [:street, :city, :state, :postal_code, :country]
end
defmodule MyApp do
def parse(address) when is_binary(address) do
# Returns %Address{}
end
def extract_postal_code(%Address{} = address) do
# Extract postal code with address...
end
def fill_in_country(%Address{} = address) do
# Fill in missing country...
end
end
不相關的多子句函數
問題
使用多子句函數是 Elixir 的強大功能。然而,有些開發人員可能會濫用此功能來群組不相關的功能,這是一種反模式。
範例
此多子句函數使用方式的一個常見範例發生在開發人員將不相關的商業邏輯混合到同一個函數定義中,使得每個子句的行為與其他子句完全不同。此類函數通常具有過於廣泛的規格,使得其他開發人員難以理解和維護它們。
有些開發人員可能會使用文件機制,例如 @doc
註解,來彌補程式碼可讀性差,然而文件本身可能會充滿條件式,用於描述函數對每個不同引數組合的行為。這是子句最終不相關的一個良好指標。
@doc """
Updates a struct.
If given a product, it will...
If given an animal, it will...
"""
def update(%Product{count: count, material: material}) do
# ...
end
def update(%Animal{count: count, skin: skin}) do
# ...
end
如果更新動物與更新產品完全不同,且需要不同的規則組,那麼將這些規則拆分到不同的函數,甚至不同的模組中可能是值得的。
重構
如下所示,解決此反模式的可能方法是將混合在單一不相關多子句函數中的商業規則拆分到簡單函數中。每個函數可以有特定名稱和 @doc
,用於描述其行為和接收的參數。雖然此重構聽起來很簡單,但它可能會影響函數的呼叫者,所以要小心!
@doc """
Updates a product.
It will...
"""
def update_product(%Product{count: count, material: material}) do
# ...
end
@doc """
Updates an animal.
It will...
"""
def update_animal(%Animal{count: count, skin: skin}) do
# ...
end
這些函數仍可以使用多個子句來實作,只要子句群組相關功能即可。例如,update_product
實際上可以如下實作
def update_product(%Product{count: 0}) do
# ...
end
def update_product(%Product{material: material})
when material in ["metal", "glass"] do
# ...
end
def update_product(%Product{material: material})
when material not in ["metal", "glass"] do
# ...
end
您可以在 Elixir 本身中看到這種模式的實際應用。+/2
運算子可以將 Integer
和 Float
相加,但不能將 String
相加,後者必須使用 <>/2
運算子。在這種情況下,在同一個運算中處理整數和浮點數是合理的,但字串與它們無關,因此值得使用自己的函式。
您還可以在 Elixir 中找到一些函式的範例,這些函式可搭配任何結構使用,這看起來似乎是這種反模式的範例,例如 struct/2
iex> struct(URI.parse("/foo/bar"), path: "/bar/baz")
%URI{
scheme: nil,
userinfo: nil,
host: nil,
port: nil,
path: "/bar/baz",
query: nil,
fragment: nil
}
這兩者的不同之處在於 struct/2
函式對任何給定的結構都表現得完全相同,因此無需考慮函式如何處理不同的輸入。如果函式對所有輸入的行為都清晰且一致,則不會出現反模式。
將應用程式組態用於函式庫
問題
應用程式環境可用於參數化 Elixir 系統中可使用的全域變數。這種機制非常有用,因此本身不視為反模式。但是,函式庫作者應避免使用應用程式環境來組態其函式庫。原因在於應用程式環境是全域狀態,因此應用程式環境中每個金鑰只能有一個值。這使得依賴於同一個函式庫的多個應用程式無法以不同的方式組態函式庫的同一個面向。
範例
DashSplitter
模組表示一個函式庫,它透過全域應用程式環境來組態其函式的行為。這些組態集中在 config/config.exs 檔案中,如下所示
import Config
config :app_config,
parts: 3
import_config "#{config_env()}.exs"
DashSplitter
函式庫實作的函式之一是 split/1
。此函式的目的是將透過參數接收的字串分割成特定數量的部分。在 split/1
中用作分隔符號的字元永遠是 "-"
,而字串分割成的部分數量則由應用程式環境全域定義。此值由 split/1
函式透過呼叫 Application.fetch_env!/2
來擷取,如下所示
defmodule DashSplitter do
def split(string) when is_binary(string) do
parts = Application.fetch_env!(:app_config, :parts) # <= retrieve parameterized value
String.split(string, "-", parts: parts) # <= parts: 3
end
end
由於 DashSplitter
函式庫使用這個參數化值,所有依賴它的應用程式只能使用 split/1
函式,且字串分離產生的區塊數目行為相同。目前,這個值等於 3,如下所示範範例中所示
iex> DashSplitter.split("Lucas-Francisco-Vegi")
["Lucas", "Francisco", "Vegi"]
iex> DashSplitter.split("Lucas-Francisco-da-Matta-Vegi")
["Lucas", "Francisco", "da-Matta-Vegi"]
重構
若要移除這個反模式,應使用傳遞給函式的參數來執行這種類型的組態。下方顯示的程式碼透過接受 關鍵字清單 作為新的選用參數,來執行 split/1
函式的重構。有了這個新的參數,就能在呼叫函式時修改函式的預設行為,允許多種不同的方式在同一個應用程式中使用 split/2
defmodule DashSplitter do
def split(string, opts \\ []) when is_binary(string) and is_list(opts) do
parts = Keyword.get(opts, :parts, 2) # <= default config of parts == 2
String.split(string, "-", parts: parts)
end
end
iex> DashSplitter.split("Lucas-Francisco-da-Matta-Vegi", [parts: 5])
["Lucas", "Francisco", "da", "Matta", "Vegi"]
iex> DashSplitter.split("Lucas-Francisco-da-Matta-Vegi") #<= default config is used!
["Lucas", "Francisco-da-Matta-Vegi"]
當然,並非函式庫對應用程式環境的所有使用都是不正確的。一個範例是使用組態來替換函式庫的一個元件(或依賴項),以另一個必須有完全相同行為的元件。考慮一個需要剖析 CSV 檔案的函式庫。函式庫作者可能會選擇一個套件作為預設剖析器,但允許其使用者透過應用程式環境換成不同的實作。最後,選擇不同的 CSV 剖析器不應變更結果,函式庫作者甚至可以透過 定義行為 來強制執行此動作,並具備他們預期的確切語意。
其他說明:監督樹
在實務上,函式庫可能需要關鍵字清單以外的其他組態。例如,如果函式庫需要啟動一個監督樹,該函式庫的使用者要如何自訂其監督樹?由於監督樹本身是全域的(因為它屬於函式庫),函式庫作者可能會想再次使用應用程式組態。
一個解決方案是讓函式庫提供自己的子規格,而不是自己啟動監督樹。這允許使用者在自己的監督樹下啟動所有必要的程序,並可能在初始化期間傳遞自訂組態選項。
你可以在 Nx 和 DNS Cluster 等專案中看到這個模式的實作。這些函式庫要求你在自己的監督樹下列出程序
children = [
{DNSCluster, query: "my.subdomain"}
]
在這種情況下,如果 DNSCluster
的使用者需要針對每個環境組態 DNSCluster,他們可以從應用程式環境中讀取,而函式庫不會強迫他們這麼做
children = [
{DNSCluster, query: Application.get_env(:my_app, :dns_cluster_query) || :ignore}
]
有些函式庫,例如 Ecto,允許你傳遞應用程式名稱作為選項(稱為 :otp_app
或類似名稱),然後自動從你的應用程式讀取環境。雖然這解決了應用程式環境為全域的問題,因為它們從每個個別應用程式讀取,但與上述範例相比,它會產生一些間接成本,在上述範例中,使用者會在需要時明確從自己的程式碼中讀取其應用程式環境。
其他說明:編譯時期組態
類似的討論涉及編譯時間配置。如果函式庫作者需要在編譯時間提供一些配置,該怎麼辦?
同樣地,您可能不想強迫函式庫的使用者提供編譯時間配置,而是允許函式庫的使用者自行產生程式碼。這是 Ecto 等函式庫採取的方法
defmodule MyApp.Repo do
use Ecto.Repo, adapter: Ecto.Adapters.Postgres
end
Ecto 允許其使用者定義任意數量的儲存庫,而不是強迫開發人員共用單一儲存庫。由於 :adapter
配置在編譯時間是必要的,因此它是 use Ecto.Repo
上的必要值。如果開發人員想要針對每個環境配置適配器,那麼這是他們的選擇
defmodule MyApp.Repo do
use Ecto.Repo, adapter: Application.compile_env(:my_app, :repo_adapter)
end
另一方面,程式碼產生有其反模式,必須仔細考慮。也就是說:儘管不鼓勵將應用程式環境用於函式庫,特別是編譯時間配置,但在某些情況下,它們可能是最佳選擇。例如,假設函式庫需要解析 CSV 或 JSON 檔案,以根據資料檔案產生程式碼。在這種情況下,最好提供合理的預設值,並透過應用程式環境自訂它們,而不是要求函式庫的每個使用者產生完全相同的程式碼。
其他說明:Mix 任務
對於 Mix 任務和相關工具,可能需要提供每個專案的配置。例如,假設您有一個 :linter
專案,它支援設定輸出檔案和詳細程度。您可以選擇透過應用程式環境配置它
config :linter,
output_file: "/path/to/output.json",
verbosity: 3
不過,Mix
允許任務透過 Mix.Project.config/0
讀取每個專案的配置。在這種情況下,您可以在 mix.exs
檔案中直接配置 :linter
def project do
[
app: :my_app,
version: "1.0.0",
linter: [
output_file: "/path/to/output.json",
verbosity: 3
],
...
]
end
此外,如果 Mix 任務可用,您也可以將這些選項接受為命令列引數(請參閱 OptionParser
)
mix linter --output-file /path/to/output.json --verbosity 3