【Elixir】Commandexを使ってコマンドパターンをゆるく実現する
YoutubeにElixir Confの動画が上がっていて面白そうなのをちょこちょこと見ているのですが、次の動画で紹介されているCommandexというライブラリが良さげでしたので、紹介してみたいと思います。
YouTubeのvideoIDが不正です
Commandex
Commandex structs are a loose implementation of the command pattern, making it easy to wrap parameters, data, and errors into a well-defined struct.
Commandexとはコマンドパターンというデザインパターンをゆるく実装したものであり、入力されるパタメーターとそれを加工したデータ、成功/失敗のステータスの管理を簡単にしてくれるライブラリとのことです。実装自体は lib/
以下に commandex.ex
ファイルが1つだけと、いくつかのマクロを提供するのみの非常にコンパクトなライブラリとなっています。
コード例を見てみる
READMEのコード例が分かりやすいです。コードをパッと見ただけで
- コマンドのインプットに
email
とpassword
があって、 - パスワードのハッシュ化、ユーザーレコードの作成、ウェルカムメールの送信が順に行われて、
- データとしてハッシュ化されたパスワードとユーザーレコードが取得できる
というのがコードの詳細を読まずともなんとなく把握できます。
defmodule RegisterUser do
import Commandex
command do
param :email
param :password
data :password_hash
data :user
pipeline :hash_password
pipeline :create_user
pipeline :send_welcome_email
end
def hash_password(command, %{password: nil} = _params, _data) do
command
|> put_error(:password, :not_given)
|> halt()
end
def hash_password(command, %{password: password} = _params, _data) do
put_data(command, :password_hash, Base.encode64(password))
end
def create_user(command, %{email: email} = _params, %{password_hash: phash} = _data) do
%User{}
|> User.changeset(%{email: email, password_hash: phash})
|> Repo.insert()
|> case do
{:ok, user} -> put_data(command, :user, user)
{:error, changeset} -> command |> put_error(:repo, changeset) |> halt()
end
end
def send_welcome_email(command, _params, %{user: user}) do
Mailer.send_welcome_email(user)
command
end
end
具体的な制御としては、
-
halt/1
を実行するとsuccessがfalseになり、かつそこでpipelineの処理がstopする -
put_error/3
でエラー内容をセットする
という具合でpipelineの制御を行います。シンプルで分かりやすいです。
実行するときは new/1
のあと run/1
を呼び出すか、直接 run/1
を呼び出すかの2択です。
%{email: "example@example.com", password: "asdf1234"}
|> RegisterUser.new()
|> RegisterUser.run()
|> case do
%{success: true, data: %{user: user}} ->
# Success! We've got a user now
%{success: false, errors: %{password: :not_given}} ->
# Respond with a 400 or something
%{success: false, errors: _errors} ->
# I'm a lazy programmer that writes catch-all error handling
end
RegisterUser.run(%{email: "example@example.com", password: "asdf1234"})
通常だとこういうのは with
を使って次のように書くことが多いんじゃないかと思います。
def run(params) do
with {:ok, %{password_hash: password_hash}} <- hash_password(params),
{:ok, user} <- create_user(params, password_hash),
:ok <- Mailer.send_welcome_email(user) do
:ok
else
{:error, %{password_hash: nil}} ->
do_something()
{:error, %Ecto.Changeset{}} ->
do_something()
end
end
Commandexを使ったコードだと
- param: 処理に必要な入力
- data: 成果物
- pipeline: 成果物を作るための一連のプロセス
というのが分かりやすく、plugっぽさもあって読みやすくて個人的には好きです。
ただ、そのライブラリの存在や仕様を知らないと読めないコードにはなるため、Ectoなどデファクトスタンダードなもの以外でのmacroの導入は一度検討が必要なところだとは思います。
まとめ
簡単でしたが、良さげなライブラリだったので紹介の記事を書きました。
冒頭に紹介した動画でもPhoenix Contextの置き換えに使ったとのことです。Contextが肥大化・複雑化してきた時には、1つ1つの update_xxxx
のような関数をモジュールに切り出してCommandexを使った実装に置き換えるなんてのもアリな気がします。
また、バッチ処理やデータ移行のツールなど、ある程度まとまった単位の処理を複数ステップで行うような実装を書くときは読みやすくて良さそうです。
2022/01/29 追記
さらに色々と調べていると、よりビジネスロジックの実装に特化したマクロを提供するOpusというライブラリも見つけました。これもマクロを使ってよりpipelineがパッと分かるような作りになっていて、複雑なビジネスロジックを実装する際には使えるかもしれません。
関連してそうなので参考までに追記しておきます。
defmodule ArithmeticPipeline do
use Opus.Pipeline
step :add_one, with: &(&1 + 1)
check :even?, with: &(rem(&1, 2) == 0), error_message: :expected_an_even
tee :publish_number, if: &Publisher.publishable?/1, raise: [ExternalError]
step :double, if: :lucky_number?
step :divide, unless: :lucky_number?
step :randomize, with: &(&1 * :rand.uniform)
link JSONPipeline
def double(n), do: n * 2
def divide(n), do: n / 2
def lucky_number?(n) when n in 42..1337, do: true
def lucky_number?(_), do: false
end
ArithmeticPipeline.call(41)
# {:ok, 84.13436750126804}
Discussion