/exaop

A minimal elixir library for aspect-oriented programming.

Primary LanguageElixir

Exaop

Build Status

A minimal elixir library for aspect-oriented programming.

Installation

Add exaop to your list of dependencies in mix.exs:

def deps do
  [
    {:exaop, "~> 0.1"}
  ]
end

Usage

Unlike common AOP patterns, Exaop does not introduce any additional behavior to existing functions, as it may bring complexity and make the control flow obscured. Elixir developers prefer explicit over implicit, thus invoking the cross-cutting behavior by simply calling the plain old function generated by pointcut definitions is better than using some magic like module attributes and macros to decorate and weave a function.

Hello World

Use Exaop in a module, then define some pointcuts to separate the cross-cutting logic:

defmodule Foo do
  use Exaop

  check :validity
  set :compute
end

When you compile the file, the following warnings would occur:

warning: function check_validity/3 required by behaviour Foo.ExaopBehaviour is not implemented (in module Foo)
  foo.exs:1: Foo (module)

warning: function set_compute/3 required by behaviour Foo.ExaopBehaviour is not implemented (in module Foo)
  foo.exs:1: Foo (module)

It reminds you to implement the corresponding callbacks required by your pointcut definitions:

defmodule Foo do
  use Exaop

  check :validity
  set :compute

  @impl true
  def check_validity(%{b: b} = _params, _args, _acc) do
    if b == 0 do
      {:error, :divide_by_zero}
    else
      :ok
    end
  end

  @impl true
  def set_compute(%{a: a, b: b} = _params, _args, acc) do
    Map.put(acc, :result, a / b)
  end
end

A function __inject__/2 is generated in the above module Foo. When it is called, the callbacks are triggered in the order defined by your pointcut definitions.

Throughout the execution of the pointcut callbacks, an accumulator is passed and updated after running each callback. The execution process may be halted by a return value of a callback.

If the execution is not halted by any callback, the final accumulator value is returned by the __inject__/2 function. Otherwise, the return value of the callback that terminates the entire execution process is returned.

In the above example, the value of the accumulator is returned if the check_validity is passed:

iex> params = %{a: 1, b: 2}
iex> initial_acc = %{}
iex> Foo.__inject__(params, initial_acc)
%{result: 0.5}

The halted error is returned if the execution is aborted:

iex> params = %{a: 1, b: 0}
iex> initial_acc = %{}
iex> Foo.__inject__(params, initial_acc)
{:error, :divide_by_zero}

Pointcut definitions

check :validity
set :compute

We've already seen the pointcut definitions in the example before. check_validity/3 and set_compute/3 are the pointcut callback functions required by these definitions.

Additional arguments can be set:

check :validity, some_option: true
set :compute, {:a, :b}

Pointcut callbacks

Naming and arguments

All types of pointcut callbacks have the same function signature. Each callback function following the naming convention in the example, using an underscore to connect the pointcut type and the following atom as the callback function name.

Each callback has three arguments and each argument can be of any Elixir term.

The first argument of the callback function is passed from the first argument of the caller __inject__/2. The argument remains unchanged in each callback during the execution process.

The second argument of the callback function is passed from its pointcut definition, for example, set :compute, :my_arg passes :my_arg as the second argument of its callback function set_compute/3.

The third argument is the accumulator. It is initialized as the second argument of the caller __inject__/2. The value of accumulator is updated or remains the same after each callback execution, depending on the types and the return values of the callback functions.

Types and behaviours

Each kind of pointcut has different impacts on the execution process and the accumulator.

  • check
    • does not change the value of the accumulator.
    • the execution of the generated function is halted if its callback return value matches the pattern {:error, _}.
    • the execution continues if its callback returns :ok.
  • set
    • does not halt the execution process.
    • sets the accumulator to its callback return value.
  • preprocess
    • allows to change the value of the accumulator or halt the execution process.
    • the execution of the generated function is halted if its callback return value matches the pattern {:error, _}.
    • the accumulator is updated to the wrapped acc if its callback return value matches the pattern {:ok, acc}.

View documentation of these macros for details.

A more in-depth example

Exaop is ready for production and makes complex application workflows simple and self-documenting. In practice, we combine it with some custom simple macros as a method to separate cross-cutting concerns and decouple business logic. Note that we do not recommend overusing it, it is only needed when the workflow gets complicated, and the pointcuts should be strictly restricted to the domain of cross-cutting logic, not the business logic body itself.

Here's a more complex example, a wallet balance transfer. The configuration loading, context setting and transfer validations are separated, but the main transfer logic remains untouched. The example also introduces an external callback, which is defined in a module other than its pointcut definition.

defmodule Wallet do
  @moduledoc false

  use Exaop
  alias Wallet.AML
  require Logger

  ## Definitions for cross-cutting concerns

  set :config, [:max_allowed_amount, :fee_rate]
  set :accounts

  check :amount, guard: :positive
  check :amount, guard: {:lt_or_eq, :max_allowed_amount}
  check :recipient, :not_equal_to_sender
  check AML

  set :fee
  check :balance

  @doc """
  A function injected by explicitly calling __inject__/2 generated by Exaop.
  """
  def transfer(%{from: _, to: _, amount: _} = info) do
    info
    |> __inject__(%{})
    |> handle_inject(info)
  end

  defp handle_inject({:error, _} = error, info) do
    Logger.error("transfer failed", error: error, info: info)
  end

  defp handle_inject(_acc, info) do
    # Put the actual transfer logic here:
    # Wallet.transfer!(acc, info)
    Logger.info("transfer validated and completed", info: info)
  end

  ## Setters required by the above concern definitions.

  @impl true
  def set_accounts(%{from: from, to: to}, _args, acc) do
    balances = %{"Alice" => 100, "Bob" => 30}

    acc
    |> Map.put(:sender_balance, balances[from])
    |> Map.put(:recipient_balance, balances[to])
  end

  @impl true
  def set_config(_params, keys, acc) do
    keys
    |> Enum.map(&{&1, Application.get_env(:my_app, &1, default_config(&1))})
    |> Enum.into(acc)
  end

  defp default_config(key) do
    Map.get(%{fee_rate: 0.01, max_allowed_amount: 1_000}, key)
  end

  @impl true
  def set_fee(%{amount: amount}, _args, %{fee_rate: fee_rate} = acc) do
    Map.put(acc, :fee, amount * fee_rate)
  end

  ## Checkers required by the above concern definitions.

  @impl true
  def check_amount(%{amount: amount}, args, acc) do
    args
    |> Keyword.fetch!(:guard)
    |> do_check_amount(amount, acc)
  end

  defp do_check_amount(:positive, amount, _acc) do
    if amount > 0 do
      :ok
    else
      {:error, :amount_not_positive}
    end
  end

  defp do_check_amount({:lt_or_eq, key}, amount, acc)
       when is_atom(key) do
    max = Map.fetch!(acc, key)

    if max && amount <= max do
      :ok
    else
      {:error, :amount_exceeded}
    end
  end

  @impl true
  def check_recipient(%{from: from, to: to}, :not_equal_to_sender, _acc) do
    if from == to do
      {:error, :invalid_recipient}
    else
      :ok
    end
  end

  @impl true
  def check_balance(%{amount: amount}, _args, %{fee: fee, sender_balance: balance}) do
    if balance >= amount + fee do
      :ok
    else
      {:error, :insufficient_balance}
    end
  end
end

defmodule Wallet.AML do
  @moduledoc """
  A module defining external Exaop callbacks.
  """

  @behaviour Exaop.Checker

  @aml_blacklist ~w(Trump)

  @impl true
  def check(%{from: from, to: to}, _args, _acc) do
    cond do
      from in @aml_blacklist ->
        {:error, {:aml_check_failed, from}}

      to in @aml_blacklist ->
        {:error, {:aml_check_failed, to}}

      true ->
        :ok
    end
  end
end

License

The MIT License