A minimal elixir library for aspect-oriented programming.
Add exaop
to your list of dependencies in mix.exs
:
def deps do
[
{:exaop, "~> 0.1"}
]
end
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.
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}
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}
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.
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.
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