I don't maintain this project anymore. In hindsight, I don't think it was a good idea in the first place. I haven't been using ExActor myself for years, and I recommend sticking with regular GenServer instead :-)
Simplifies implementation of GenServer
based processes in Elixir.
ExActor
helps removing the boilerplate that typically occurs when using GenServer
behaviour. In particular, ExActor
can be useful in following situations:
start
function just packs all arguments into a tuple which it forwards toinit/1
viaGenServer.start
.- Calls and casts interface functions just forward all arguments to the server process via
GenServer.call
andGenServer.cast
. - Process is registered and all interface functions rely on this property.
- Some
handle_*
functions don't need the state. - All handlers need to specify timeout or hibernate.
- More liberal grouping of handler functions (you don't need to group calls and casts separately)
For other cases, you may need to use plain GenServer
functions (which can be used together with ExActor
macros). ExActor
is not meant to fully replace GenServer
. It just tries to reduce boilerplate in most common cases.
If you're new to Elixir, Erlang, and OTP, and are not familiar on how GenServer
works, I strongly suggest you learn about it first. It's really not that hard, and you can use Elixir docs as the starting point. It's also worth going through Mix/OTP getting started guide.
Once you're familiar with GenServer
, you can consider using ExActor
to reduce the boilerplate.
Online documentation is available here.
The stable package is available on hex.
Be sure to include a dependency in your mix.exs
:
deps: [{:exactor, "~> 2.2.4", warn_missing: false}, ...]
ExActor
is a compile-time dependency only. No need to add it into the list of dependent applications. All code transformations are performed at compile time. If you're using exrm to build OTP releases, you may need to supply the warn_missing: false
option to prevent warnings about a missing application dependency.
defmodule Calculator do
use ExActor.GenServer
defstart start_link, do: initial_state(0)
defcast inc(x), state: state, do: new_state(state + x)
defcast dec(x), state: state, do: new_state(state - x)
defcall get, state: state, do: reply(state)
defcast stop, do: stop_server(:normal)
end
This module be used in a typical fashion:
{:ok, calculator} = Calculator.start_link
Calculator.inc(calculator, 10)
Calculator.dec(calculator, 3)
Calculator.get(calculator)
Calculator.stop(calculator)
The module definition above is translated at compile-time into something like:
defmodule Calculator do
use GenServer
def start_link, do: GenServer.start_link(__MODULE__, nil)
def stop(pid), do: GenServer.cast(pid, :stop)
def inc(pid, x), do: GenServer.cast(pid, {:inc, x})
def dec(pid, x), do: GenServer.cast(pid, {:dec, x})
def get(pid), do: GenServer.call(pid, :get)
def init(_), do: {:ok, 0}
def handle_cast({:inc, x}, state), do: {:noreply, state + x}
def handle_cast({:dec, x}, state), do: {:noreply, state - x}
def handle_cast(:stop, state), do: {:stop, :normal, state}
def handle_call(:get, _, state), do: {:reply, state, state}
end
A bit more complex and feature rich example is presented here.
To use ExActor
macros, you must choose a predefine module and use
it into your own module. A predefine is an ExActor
module that provides some default implementations for GenServer
callbacks.
Following predefines are currently provided:
ExActor.GenServer
- AllGenServer
callbacks are provided byGenServer
from Elixir standard library.ExActor.Strict
- AllGenServer
callbacks are provided. The default implementations for all exceptcode_change
andterminate
will cause the server to be stopped.ExActor.Tolerant
- AllGenServer
callbacks are provided. The default implementations ignore all messages without stopping the server.ExActor.Empty
- No default implementation forGenServer
callbacks are provided.
It is up to you to decide which predefine you want to use. See online docs for detailed description. You can also build your own predefine. Refer to the source code of the existing ones as a template.
defmodule Calculator do
use ExActor.GenServer, export: :calculator
# you can also use via, and global
# use ExActor.GenServer, export: {:global, :calculator}
# use ExActor.GenServer, export: {:via, :gproc, :calculator}
...
end
# all functions defined via defcall and defcast will take
# advantage of the export option
Calculator.start
Calculator.inc(5)
Calculator.get
defstart start_link, do: initial_state(arg)
defcall foo, do: set_and_reply(new_state, response)
defcast bar, do: new_state(new_state)
defhandleinfo :stop, do: stop(normal)
defhandleinfo _, do: noreply
See here for detailed list.
defstart start_link(x, y, z) do
# Generates start_link function and `init/1` clause. The code runs in init/1 function.
initial_state(x + y + z)
end
By default, corresponding GenServer
function is deduced from the function name, so you can use either start_link
or start
. If you want a custom function name, you need to provide explicit :link
option:
defstart my_start(...), link: true do
...
end
defmodule Calculator do
use ExActor.GenServer
# gen_server_opts: :runtime will add additional argument to the start
# function. This argument will be passed as options to the `GenServer` start
# function.
defstart start_link(x), gen_server_opts: :runtime, do: ...
end
# You can pass `name: :foo` due to `gen_server_opts: :runtime` option in the starter
Calculator.start_link(x, name: :foo)
# Or in the supervisor specification:
Supervisor.start_link(
[
worker(Calculator, [x, [name: :foo]]),
# ...
]
)
defmodule Database do
use ExActor.GenServer, export: :database
defabcast store(key, value), do: ...
defmulticall get(key), do: ...
end
# called on all nodes
Database.store(key, value)
Database.get(key)
# called on specified nodes
Database.store(some_nodes, key, value)
Database.get(some_nodes, key)
There are private versions available in form of defstartp
, defcallp
, defcastp
, defmulticallp
, and defabcastp
. The only difference here is that interface functions are defined with defp
. This can help you when you need to include some custom logic before or after the operation. See here for an example.
defstart start_link(1), do:
defstart start_link(2), do:
defstart start_link(x), when: x < 5, do:
defcall a(1), do: ...
defcall a(2), do: ...
defcall a(x), state: 1, do: ...
defcall a(x), when: x > 1, do: ...
defcall a(_), do: ...
defhandleinfo :msg, state: {...}, when: ..., do: ...
All matches take place on both interface and handler functions.
Default arguments are also supported:
defcall inc(x \\ 1), ...
In this case, we'll end up with two inc
interface functions, and a single handle_call
function that matches on {:inc, x}
.
Can be useful do handle messages:
defhandleinfo :some_message, do:
defhandleinfo :another_message, state: ..., do:
Or to pattern match on the state:
# Body-less clause defines only the interface function
defcast inc
# Handle clauses pattern match on the state
defhandlecast inc, state: state, when: is_number(state),
do: new_state(state + 1)
defhandlecast inc, do: new_state(0)
defcall my_request(...), from: from do
...
spawn_link(fn ->
...
GenServer.reply(from, ...)
end)
noreply
end
Timeout:
defstart ... do
# Instructs `ExActor` to include timeout in all responses made via responder
# macros, such as `new_state` or `noreply`. As the result, a `:timeout` message
# will be sent to the server after specified inactivity time.
timeout_after(:timer.seconds(10))
end
Hibernation:
defstart ... do
# Instructs `ExActor` to include `:hibernate` in all responses made via responder
# macros, such as `new_state` or `noreply`.
hibernate
end
May be useful if you need to dynamically generate your requests. For example, if calls/casts simply delegate to some module, we could do something like:
defmodule DynActor do
use ExActor.GenServer
for op <- [:op1, :op2] do
defcall unquote(op), state: state do
SomeModule.unquote(op)(state)
end
end
end
In the following code, ExActor
is used to implement a simple ETS based cache with basic cluster replication:
defmodule Cache do
use ExActor.GenServer
# Starter allows clients to specify cache name. Notice how this is used
# in `gen_server_opts` as a registered name of the server.
defstart start(cache_name, timeout_after \\ :infinity),
gen_server_opts: [name: cache_name]
do
# Specifies timeout which will be used in all handler responses
timeout_after(timeout_after)
:ets.new(cache_name, [:named_table, :set, :protected])
initial_state(cache_name)
end
# Looks up the cache in the client process
def get(cache_name, key) do
case :ets.lookup(cache_name, key) do
[{^key, value}] -> value
[] -> nil
end
end
# An example of a more complex interface function. A get attempt is made
# in the client process, and then we optionally issue a private call request.
def get_or_create(cache_name, key, fun) do
case get(cache_name, key) do
nil -> server_get_or_create(cache_name, key, fun)
existing -> existing
end
end
# Private call request used from `get_or_create`
defcallp server_get_or_create(key, fun), state: cache_name do
case get(cache_name, key) do
nil ->
new = fun.()
store(cache_name, key, new)
# Makes a distributed call to all other nodes
set(Node.list, cache_name, key, new)
new
existing -> existing
end
|> reply
end
# Distributed setter - stores to all nodes in the cluster
defmulticall set(key, value), state: cache_name do
store(cache_name, key, value)
reply(:ok)
end
defp store(cache_name, key, value) do
:ets.insert(cache_name, {key, value})
end
# Stops the server on timeout message
defhandleinfo :timeout, do: stop_server(:normal)
defhandleinfo _, do: noreply
end