ElixirでBehaviourを使用してDIPを実現する基礎知識

November 07, 2020

こんばんは

Elixir は他の言語でいうところの interface と似たような機能として、behaviour という機能を持っています Typespecs and behaviours - elixir Getting Started ビヘイビア - Elixir School

Elixir は動的型付け言語ではありますが、以下のように型を扱うことも可能なわけです。 Typespecs - Elixir

この記事では Bahaviour はそもそも何が嬉しいのか、どういう目的で用いるのかと言ったことから、実際の実装のサンプルまでを紹介したいと思います。 (基礎知識なので、経験のある方は、「はいはい分かる分かる」という感じで読んで貰えればと思います)

Behaviour 何が嬉しいの?

Behaviour は結局何が嬉しいのでしょうか

前述の Elixir School の説明が一番詳しくてわかりやすいですね.

  • 実装しなければならない関数一式を定義すること
  • その関数一式が実際に実装されているかチェックすること

また、もう一つ大きなメリットがあります。

上位のモジュールが下位のモジュールに依存することを防ぐことができるということです。 いわゆる、依存性逆転の原則( The Dependency Inversion Principle)、SOLID 原則でいうところの D ですね。

これに関しては検索トップに出てくる以下の記事がわかりやすいです 依存関係逆転の原則の重要性について

上位のモジュールは下位のモジュールに依存してはならない。どちらのモジュールも「抽象」に依存すべきである 「抽象」は実装の詳細に依存してはならない。実装の詳細が「抽象」に依存すべきである

この中で出てきている「抽象」が Behaviour です。

下位のモジュールは Behaviour に沿って実装を行い、そして上位のモジュールは Behaviour が満たされていることを前提にして下位モジュールの使用を行います。

「下位のモジュールを直接使用してたら結局依存してるんじゃないか」となりますが、これから紹介する方法では config にて DI(依存性の注入)を行い、環境変数経由で上位モジュールから使用モジュールを参照することで、「Behaviour が満たされていることを前提に下位モジュールの使用を行う(= Behaviour に依存する)」と言ったことを実現できます。

その他、config/test.exsにて mock を代わりに DI することで test でのみ mock を利用できたりするメリットがあります。詳しい話は以下の 5 年前の José の記事に記載があります Mocks and explicit contracts

この記事にもありますが、こう言ったモックの使い方に便利なライブラリとしてというものがあります。mox に関しては次の記事で取り上げます。

dashbitco/mox - GitHub

Behaviour を定義する

今回は以下のように User に関する DB 操作を一つの Behaviour にまとめます

defmodule TsundokuBuster.Repository.UserBehaviour do
  alias TsundokuBuster.Schema.User

  @callback list_users() :: [%User{}]
  @callback get_user(id :: String.t()) :: {:ok, %User{}} | {:error, atom()}
  @callback create_user(attrs :: TsundokuBuster.Database.User.attrs()) ::
              {:ok, %User{}} | {:error, %Ecto.Changeset{}}
  @callback update_user(user :: %User{}, attrs :: TsundokuBuster.Database.User.attrs()) ::
              {:ok, %User{}} | {:error, %Ecto.Changeset{}}
  @callback delete_user(user :: %User{}) :: {:ok, %User{}} | {:error, %Ecto.Changeset{}}
  @callback change_user(user :: %User{}, attrs :: TsundokuBuster.Database.User.attrs()) ::
              %Ecto.Changeset{}
end

Behaviour では@callbackを使用して、関数名や関数の引数/型、返り値として想定される値/型を記述していきます

モジュールを Behaviour に沿って実装

@behaviour@implを使用して Behaviour に沿ったモジュールを実装します

実装内容はあまり関係ないので適当に読み飛ばしてください

defmodule TsundokuBuster.Database.User do
  @moduledoc """
  manage users in Database
  """
  @behaviour TsundokuBuster.Repository.UserBehaviour

  import Ecto.Query, warn: false
  alias TsundokuBuster.Repo

  alias TsundokuBuster.Schema.User

  @type attrs :: %{
          optional(:name) => String.t(),
          optional(:twitter_id) => String.t(),
          optional(:oauth_token) => String.t(),
          optional(:oauth_token_secret) => String.t(),
          optional(:created_at) => DateTime.t(),
          optional(:updated_at) => DateTime.t()
        }

  @doc """
  Returns the list of users.

  ## Examples

      iex> list_users()
      [%User{}, ...]

  """
  @impl TsundokuBuster.Repository.UserBehaviour
  @spec list_users() :: [%User{}]
  def list_users do
    Repo.all(User)
  end

  @doc """
  Gets a single user.

  ## Examples

      iex> get_user(123)
      {:ok, %User{}}

      iex> get_user(456)
      {:error, :not_found}

  """
  @impl TsundokuBuster.Repository.UserBehaviour
  @spec get_user(id :: String.t()) :: {:ok, %User{}} | {:error, atom()}
  def get_user(id) do
    case Repo.get(User, id) do
      nil -> {:error, :not_found}
      user -> {:ok, user}
    end
  end

  @doc """
  Creates a user.

  ## Examples

      iex> create_user(%{field: value})
      {:ok, %User{}}

      iex> create_user(%{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  @impl TsundokuBuster.Repository.UserBehaviour
  @spec create_user(attrs :: TsundokuBuster.Database.User.attrs()) ::
          {:ok, %User{}} | {:error, %Ecto.Changeset{}}
  def create_user(attrs \\ %{}) do
    %User{}
    |> User.changeset(attrs)
    |> Repo.insert()
  end

  @doc """
  Updates a user.

  ## Examples

      iex> update_user(user, %{field: new_value})
      {:ok, %User{}}

      iex> update_user(user, %{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  @impl TsundokuBuster.Repository.UserBehaviour
  @spec update_user(user :: %User{}, attrs :: TsundokuBuster.Database.User.attrs()) ::
          {:ok, %User{}} | {:error, %Ecto.Changeset{}}
  def update_user(%User{} = user, attrs) do
    user
    |> User.changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Deletes a user.

  ## Examples

      iex> delete_user(user)
      {:ok, %User{}}

      iex> delete_user(user)
      {:error, %Ecto.Changeset{}}

  """
  @impl TsundokuBuster.Repository.UserBehaviour
  @spec delete_user(user :: %User{}) :: {:ok, %User{}} | {:error, %Ecto.Changeset{}}
  def delete_user(%User{} = user) do
    Repo.delete(user)
  end

  @doc """
  Returns an `%Ecto.Changeset{}` for tracking user changes.

  ## Examples

      iex> change_user(user)
      %Ecto.Changeset{data: %User{}}

  """
  @impl TsundokuBuster.Repository.UserBehaviour
  @spec change_user(user :: %User{}, attrs :: TsundokuBuster.Database.User.attrs()) ::
          %Ecto.Changeset{}
  def change_user(%User{} = user, attrs \\ %{}) do
    User.changeset(user, attrs)
  end
end

↑ のモジュールを使用するモジュールの実装

@user_repo@twitter_clientを使用して環境変数から使用するモジュールを決定しています @user_repoには先程のTsundokuBuster.Repository.UserBehaviourを満たすモジュールが DI されることを期待します (ちゃんと Behaviour を満たすモジュールが DI されてんの?っていうのはDialyzerを使用すれば担保できるのですが、これもまたいつか別記事で紹介します) ※DI: Dependency Injection(依存性の注入)

これも実装の細かい内容はあまり関係がないので適当に読み飛ばしてください

defmodule TsundokuBuster.Usecase.User do
  @user_repo Application.get_env(:tsundoku_buster, :user_repo)
  @twitter_client Application.get_env(:tsundoku_buster, :twitter_client)
  alias TsundokuBuster.Schema.User

  @spec get_authorize_url() :: {:ok, String.t()} | {:error, atom()}
  def get_authorize_url() do
    case @twitter_client.request_token() do
      {:ok, token} ->
        token
        |> Map.get(:oauth_token)
        |> @twitter_client.authorize_url()

      {:error, error} ->
        {:error, error}
    end
  end

  @spec create_user_from_twitter(String.t(), String.t()) :: {:ok, %User{}} | {:error, atom()}
  def create_user_from_twitter(oauth_verifier, oauth_token) do
    case @twitter_client.access_token(oauth_verifier, oauth_token) do
      {:ok, creds} ->
        case @twitter_client.user(creds.user_id) do
          {:ok, twitter_user} ->
            case @user_repo.create_user(%{
                   name: twitter_user.name,
                   twitter_id: twitter_user.screen_name,
                   oauth_token: creds.oauth_token,
                   oauth_token_secret: creds.oauth_token_secret,
                   created_at: Timex.now(),
                   updated_at: Timex.now()
                 }) do
              {:ok, user} -> {:ok, user}
              _ -> {:error, :cannot_store_user}
            end

          error ->
            error
        end

      error ->
        error
    end
  end

  @spec get_user(String.t()) :: {:ok, %User{}} | {:error, atom()}
  def get_user(id) do
    @user_repo.get_user(id)
  end

  @spec update_user(String.t()) :: {:ok, %User{}} | {:error, atom()}
  def update_user(id) do
    case @user_repo.get_user(id) do
      {:ok, user} ->
        case @twitter_client.user(user.twitter_id) do
          {:ok, twitter_user} ->
            case @user_repo.update_user(
                   user,
                   %{
                     name: twitter_user.name,
                     twitter_id: twitter_user.screen_name,
                     updated_at: Timex.now()
                   }
                 ) do
              {:ok, user} -> {:ok, user}
              _ -> {:error, :cannot_store_user}
            end

          error ->
            error
        end

      error ->
        error
    end
  end

  @spec delete_user(String.t()) :: {:ok, :no_content} | {:error, atom()}
  def delete_user(id) do
    case @user_repo.get_user(id) do
      {:ok, user} ->
        case @user_repo.delete_user(user) do
          {:ok, _} -> {:ok, :no_content}
          {:error, _} -> {:error, :cannot_delete_user}
        end

      error ->
        error
    end
  end
end

config にて DI する

config にて DI します

config :tsundoku_buster,
  twitter_client: ExTwitter,
  user_repo: TsundokuBuster.Database.User

これによって実行時に環境変数を通して先程のusecase/user.exにて Behaviour を満たすモジュールが使用されます

終わりに

今回は Elixir の Behaviour とその利用例を紹介しました。 境界を明確にすることで変更の範囲を抑えることができたりするなど、Behaviour のメリットには多くのものがあります。

この記事が誰かの参考になれば幸いです。

このエントリーをはてなブックマークに追加