🚀

【Elixir】Commandexを使ってコマンドパターンをゆるく実現する

2022/01/28に公開

YoutubeにElixir Confの動画が上がっていて面白そうなのをちょこちょこと見ているのですが、次の動画で紹介されているCommandexというライブラリが良さげでしたので、紹介してみたいと思います。

YouTubeのvideoIDが不正ですhttps://youtu.be/g6rPd1-DdCs?t=812

https://github.com/codedge-llc/commandex

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つだけと、いくつかのマクロを提供するのみの非常にコンパクトなライブラリとなっています。

https://ja.wikipedia.org/wiki/Command_パターン

コード例を見てみる

READMEのコード例が分かりやすいです。コードをパッと見ただけで

  • コマンドのインプットに emailpassword があって、
  • パスワードのハッシュ化、ユーザーレコードの作成、ウェルカムメールの送信が順に行われて、
  • データとしてハッシュ化されたパスワードとユーザーレコードが取得できる

というのがコードの詳細を読まずともなんとなく把握できます。

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がパッと分かるような作りになっていて、複雑なビジネスロジックを実装する際には使えるかもしれません。

関連してそうなので参考までに追記しておきます。

https://github.com/Zorbash/opus

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