In this article, I want to describe how you can use elixir metaprogramming to avoid some runtime errors caused by typos. If you’re passing around some handcrafted messages ex. %{name: "app_one_hello", payload: "payload"}
you can easily introduce errors. Sending app_one_hello
and listening for app_ane_hello
will not make your system works correctly. Imagine you have thousands of messages. It’s really hard to manage them like that. To make things better, you can create a list of all messages and generate functions to send and receive them. Now when you will create a typo, elixir’s compiler will tell you!
Under the umbrella we have 3 apps.
dispatcher
app_one
app_two
app_one
and app_two
will send and receive messages managed by the dispatcher
This it the application we will be working on. This is the main module:
defmodule Dispatcher do
@moduledoc """
Dispatcher is for dispatching messages.
"""
use GenStage
require Logger
def start_link(_opts) do
GenStage.start_link(__MODULE__, [], name: __MODULE__)
end
def message(message) do
GenStage.cast(__MODULE__, message)
end
def init(_) do
{:producer, %{enabled: true}, dispatcher: GenStage.BroadcastDispatcher}
end
def handle_info(:disable, state) do
{:noreply, [], %{state | enabled: false}}
end
def handle_info(:enable, state) do
{:noreply, [], %{state | enabled: true}}
end
def handle_cast(message, %{enabled: true} = state) when is_map(message) do
Logger.info("Dispatch message: #{inspect(message)}")
{:noreply, [message], state}
end
def handle_demand(_demand, state), do: {:noreply, [], state}
end
This is simple pubsub using gen_stage
. The message will be send to any subscribed processes.
To make it easier to manage each app, there is also a Listener
defmodule Dispatcher.Listener do
@moduledoc """
Listener for messages from dispatcher
"""
@callback on_message(message :: map) :: any
@doc false
defmacro __using__(_opts) do
quote location: :keep do
@behaviour Dispatcher.Listener
use GenStage
def start_link(opts) do
GenStage.start_link(__MODULE__, opts, name: __MODULE__)
end
def init(state) do
{:consumer, state, subscribe_to: [Dispatcher]}
end
def handle_events(messages, _from, state) do
for message <- messages do
on_message(message)
end
{:noreply, [], state}
end
def on_message(_), do: :nothing
defoverridable on_message: 1
end
end
end
This module makes easier to listen to messages.
Now you can send message like this:
Dispatcher.message(%{name: "app_one_hello", payload: "some_kind_of_payload"}
And receive it with a little module:
defmodule AppOne.Listener do
@moduledoc """
AppOne listener
"""
use Dispatcher.Listener
def on_message(%{name: "app_one_hello", payload: payload}) do
AppOne.hello(payload)
end
def on_message(_), do: :nothing
end
It works fine, but you can easily type app_twe_hello
instead of app_two_hello
. Also when you have thousands of messages you need to search the codebase for names. It might be difficult.
Why not use the compiler to track such errors?
We will use an elixir’s neat feature for that!. Metaprogramming.
First we need a module to store all of the messages:
defmodule Dispatcher.Message do
@moduledoc """
List of all messages
"""
@list ~w[
app_one_hello
app_two_hello
]a
def list, do: @list
end
And functions to send them:
defmodule Dispatcher.Message do
@moduledoc """
List of all messages
"""
@list ~w[
app_one_hello
app_two_hello
]a
def list, do: @list
Enum.each(@list, fn message ->
def unquote(message)(payload \\ nil) do
GenStage.cast(
Dispatcher,
{:message,
%{
name: unquote(message),
payload: payload
}}
)
end
end)
end
Great! We can now send a message with:
Dispatcher.Message.app_one_hello("some_payload")
There are few advantages of this:
Now we gonna take care of listener.
defmodule Dispatcher.Listener do
@moduledoc """
Listener for messages from dispatcher
"""
@callback on_message(message :: map) :: any
@doc false
defmacro __using__(_opts) do
quote location: :keep do
@behaviour Dispatcher.Listener
use GenStage
import Dispatcher.Listener
def start_link(opts) do
GenStage.start_link(__MODULE__, opts, name: __MODULE__)
end
def init(state) do
{:consumer, state, subscribe_to: [Dispatcher]}
end
def handle_events(messages, _from, state) do
for message <- messages do
on_message(message)
end
{:noreply, [], state}
end
def on_message(_), do: :nothing
defoverridable on_message: 1
end
end
Enum.each(Dispatcher.Message.list(), fn message ->
defmacro unquote(message)(payload, block) do
message = Macro.escape(unquote(message))
payload = Macro.escape(payload)
block = Macro.escape(block, unquote: true)
quote bind_quoted: [message: message, payload: payload, block: block] do
def on_message(%{name: unquote(message), payload: unquote(payload)}), do: unquote(block)
end
end
end)
defmacro other(name, payload, block) do
name = Macro.escape(name)
payload = Macro.escape(payload)
block = Macro.escape(block, unquote: true)
quote bind_quoted: [name: name, payload: payload, block: block] do
def on_message(%{name: unquote(name), payload: unquote(payload)}), do: unquote(block)
end
end
end
Looks a little bit complicated, but it’s not!
We’ve just built a simple DSL for receiving messages. Now the listening module looks like this:
defmodule AppOne.Listener do
@moduledoc """
AppOne listener
"""
use Dispatcher.Listener
app_one_hello(payload) do
AppOne.hello(payload)
end
other(_, _) do
:nothing
end
end
Again! Typos are handled by the compiler, and we can use IDE to complete functions names.
I’ve used message name for all of the functions. If you want you can use prefix those function. For example send_app_one_hello
and on_app_one_hello
.
Check out my github for example app: