项目作者: nobrick

项目描述 :
A minimal elixir library for aspect-oriented programming.
高级语言: Elixir
项目地址: git://github.com/nobrick/exaop.git
创建时间: 2020-08-04T16:30:34Z
项目社区:https://github.com/nobrick/exaop

开源协议:

下载


Exaop

Build Status

A minimal elixir library for aspect-oriented programming.

Installation

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

  1. def deps do
  2. [
  3. {:exaop, "~> 0.1"}
  4. ]
  5. 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:

  1. defmodule Foo do
  2. use Exaop
  3. check :validity
  4. set :compute
  5. end

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

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

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

  1. defmodule Foo do
  2. use Exaop
  3. check :validity
  4. set :compute
  5. @impl true
  6. def check_validity(%{b: b} = _params, _args, _acc) do
  7. if b == 0 do
  8. {:error, :divide_by_zero}
  9. else
  10. :ok
  11. end
  12. end
  13. @impl true
  14. def set_compute(%{a: a, b: b} = _params, _args, acc) do
  15. Map.put(acc, :result, a / b)
  16. end
  17. 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:

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

The halted error is returned if the execution is aborted:

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

Pointcut definitions

  1. check :validity
  2. 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:

  1. check :validity, some_option: true
  2. 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.

  1. defmodule Wallet do
  2. @moduledoc false
  3. use Exaop
  4. alias Wallet.AML
  5. require Logger
  6. ## Definitions for cross-cutting concerns
  7. set :config, [:max_allowed_amount, :fee_rate]
  8. set :accounts
  9. check :amount, guard: :positive
  10. check :amount, guard: {:lt_or_eq, :max_allowed_amount}
  11. check :recipient, :not_equal_to_sender
  12. check AML
  13. set :fee
  14. check :balance
  15. @doc """
  16. A function injected by explicitly calling __inject__/2 generated by Exaop.
  17. """
  18. def transfer(%{from: _, to: _, amount: _} = info) do
  19. info
  20. |> __inject__(%{})
  21. |> handle_inject(info)
  22. end
  23. defp handle_inject({:error, _} = error, info) do
  24. Logger.error("transfer failed", error: error, info: info)
  25. end
  26. defp handle_inject(_acc, info) do
  27. # Put the actual transfer logic here:
  28. # Wallet.transfer!(acc, info)
  29. Logger.info("transfer validated and completed", info: info)
  30. end
  31. ## Setters required by the above concern definitions.
  32. @impl true
  33. def set_accounts(%{from: from, to: to}, _args, acc) do
  34. balances = %{"Alice" => 100, "Bob" => 30}
  35. acc
  36. |> Map.put(:sender_balance, balances[from])
  37. |> Map.put(:recipient_balance, balances[to])
  38. end
  39. @impl true
  40. def set_config(_params, keys, acc) do
  41. keys
  42. |> Enum.map(&{&1, Application.get_env(:my_app, &1, default_config(&1))})
  43. |> Enum.into(acc)
  44. end
  45. defp default_config(key) do
  46. Map.get(%{fee_rate: 0.01, max_allowed_amount: 1_000}, key)
  47. end
  48. @impl true
  49. def set_fee(%{amount: amount}, _args, %{fee_rate: fee_rate} = acc) do
  50. Map.put(acc, :fee, amount * fee_rate)
  51. end
  52. ## Checkers required by the above concern definitions.
  53. @impl true
  54. def check_amount(%{amount: amount}, args, acc) do
  55. args
  56. |> Keyword.fetch!(:guard)
  57. |> do_check_amount(amount, acc)
  58. end
  59. defp do_check_amount(:positive, amount, _acc) do
  60. if amount > 0 do
  61. :ok
  62. else
  63. {:error, :amount_not_positive}
  64. end
  65. end
  66. defp do_check_amount({:lt_or_eq, key}, amount, acc)
  67. when is_atom(key) do
  68. max = Map.fetch!(acc, key)
  69. if max && amount <= max do
  70. :ok
  71. else
  72. {:error, :amount_exceeded}
  73. end
  74. end
  75. @impl true
  76. def check_recipient(%{from: from, to: to}, :not_equal_to_sender, _acc) do
  77. if from == to do
  78. {:error, :invalid_recipient}
  79. else
  80. :ok
  81. end
  82. end
  83. @impl true
  84. def check_balance(%{amount: amount}, _args, %{fee: fee, sender_balance: balance}) do
  85. if balance >= amount + fee do
  86. :ok
  87. else
  88. {:error, :insufficient_balance}
  89. end
  90. end
  91. end
  92. defmodule Wallet.AML do
  93. @moduledoc """
  94. A module defining external Exaop callbacks.
  95. """
  96. @behaviour Exaop.Checker
  97. @aml_blacklist ~w(Trump)
  98. @impl true
  99. def check(%{from: from, to: to}, _args, _acc) do
  100. cond do
  101. from in @aml_blacklist ->
  102. {:error, {:aml_check_failed, from}}
  103. to in @aml_blacklist ->
  104. {:error, {:aml_check_failed, to}}
  105. true ->
  106. :ok
  107. end
  108. end
  109. end

License

The MIT License