Originally published on WyeWorks blog: https://www.wyeworks.com/blog/2019/07/19/slimming-down-fat-channels-in-phoenix/
In this article, we are going to discuss an approach we applied in a Phoenix project. Channels were an important part of the solution given that the application includes real-time collaboration features.
We had a specific channel module with an increasing number of message handler functions. In terms of design, we thought that having all the interactions managed by only one channel module still made sense. However, we started to notice the module itself was growing out of control, with a lot of non-related logic and a high number of lines of code. For this reason, we started to think about how to split the code across different modules, but in a way that still works using only one Phoenix channel.
The problem
Let's say we want to implement a multi-step wizard interface having some sort of collaborative interaction during each step. It means that everyone in the group will be working on the same step, at a given moment. Here is how we initially implemented this wizard.
defmodule MyExampleAppWeb.WizardChannel do
use Phoenix.Channel
def join("wizard:" <> id, _message, socket) do
...
end
# The message that begins the multi-step experience
def handle_in("start_first_step", body, socket) do
...
end
# This message is valid in step 1
def handle_in("send_info", body, socket) do
...
end
# This message is valid in step 1 but takes the group into step 2
def handle_in("start_second_step", body, socket) do
...
end
# This message is valid in step 2
def handle_in("pick_option", body, socket) do
...
end
end
As you may have already noticed, this approach doesn't scale well. As more actions within steps and more steps themselves are added to the wizard, the channel module gets more and more complex.
Splitting the logic in different modules
Creating specific modules for each step is probably the most natural solution to our problem, so we tried it:
defmodule MyExampleAppWeb.WizardChannel do
use Phoenix.Channel
alias MyExampleAppWeb.WizardChannel.{FirstStep, SecondStep}
...
def handle_in("start_first_step", body, socket) do
FirstStep.start(data, socket)
end
def handle_in("send_info", body, socket) do
FirstStep.send_info(data, socket)
end
...
end
defmodule MyExampleAppWeb.WizardChannel.FirstStep do
def start(body, socket) do
...
broadcast(socket, "first_step_started", ...)
{:noreply, socket}
end
def send_info(body, socket) do
...
broadcast(socket, "info_received", ...)
{:noreply, socket}
end
end
Simple enough. We are just moving the logic to dedicated modules, and at the same time, adding some trivial code that delegates from the channel module. Well, there is a problem. This solution does not compile 🙈!
We have issues with the broadcast/3
function that is not found in the context of the FirstStep
module. The same would happen if we were using any of the available functions provided by Phoenix.Channel like push/3
, reply/2
and so on.
Looking for the missing functions
All those channel-specific functions are available in our WizardChannel
because we have this line there:
use Phoenix.Channel
We can not simply do the same in our auxiliary modules, such as MyExampleAppWeb.WizardChannel.FirstStep
, because this line does more than importing a bunch of functions: it defines a process that will be spawn on runtime and be responsible for handling all the messages going back and forth in our websocket connection.
The solution is quite easy. We can directly import the necessary functions defined in Phoenix.Channel
module. There is no impediment to access to those functions and they're part of the public API of the framework (although it is easier to find examples of the full usage of Phoenix.Channel
module in the official documentation)
A working solution for our FirstStep
is the following:
defmodule MyExampleAppWeb.WizardChannel.FirstStep do
#### Updated line ####
import Phoenix.Channel, only: [broadcast: 3]
def start(body, socket) do
...
broadcast(socket, "first_step_started", ...)
{:noreply, socket}
end
def send_info(body, socket) do
...
broadcast(socket, "info_received", ...)
{:noreply, socket}
end
end
A new DSL is born
Let's look for a moment how our WizardChannel
module looks after adding a few more steps and handler functions:
defmodule MyExampleAppWeb.WizardChannel do
use Phoenix.Channel
alias MyExampleAppWeb.WizardChannel.{FirstStep, SecondStep, LastStep}
...
# First step messages
def handle_in("start_first_step", body, socket), do: FirstStep.start(body, socket)
def handle_in("send_info", body, socket), do: FirstStep.send_info(body, socket)
# Second step messages
def handle_in("start_second_step", body, socket), do: SecondStep.start(body, socket)
def handle_in("pick_option", body, socket), do: SecondStep.pick_option(body, socket)
def handle_in("notify_option", body, socket), do: SecondStep.notify_option(body, socket)
...
# Last step messages
def handle_in("confirm", body, socket), do: LastStep.confirm(body, socket)
def handle_in("end_wizard", body, socket), do: LastStep.end_wizard(body, socket)
end
We noticed the code was mostly repetitive, having no much logic other than defining what the proper module and function are. Moreover, we were grouping the functions and adding those comment lines that refer to the different steps, so that the file was easier to navigate.
Here we started to see a chance to implement a custom DSL, in an attempt to have a more legible and maintainable piece of code. Elixir and its metaprogramming capabilities make possible to build DSLs with a few lines of code (although understanding that code would require to learn how macros work in Elixir).
Let's see how this module looked after implementing this fresh idea:
defmodule MyExampleAppWeb.WizardChannel do
use Phoenix.Channel
use MyExampleAppWeb.WizardChannel.MessagesHandler
alias MyExampleAppWeb.WizardChannel.{FirstStep, SecondStep, LastStep}
...
handle_step_messages [:start_first_step, :send_info], with_module: FirstStep
handle_step_messages [:start_second_step, :pick_option, :notify_option], with_module: SecondStep
handle_step_messages [:confirm, :end_wizard], with_module: LastStep
end
A lot of repetition has gone! And we're happy to see this code better communicates the intention (assuming certain familiarity with our custom DSL, of course).
How does it works? Note we have added the following to this module:
use MyExampleAppWeb.WizardChannel.MessagesHandler
Let's explore how the handle_step_messages
macro is implemented, looking at the WizardChannel.MessagesHandler
module code.
defmodule MyExampleAppWeb.WizardChannel.MessagesHandler do
defmacro __using__(_opts) do
quote do
import unquote(__MODULE__)
end
end
defmacro handle_step_messages(messages, with_module: module) do
quote bind_quoted: [messages: messages, module: module] do
Enum.each(messages, fn message ->
def handle_in(unquote(Atom.to_string(message)), data, socket) do
apply(unquote(module), unquote(message), [data, socket])
end
end)
end
end
end
The __using__
macro is invoked when the use
directive is used. We're using that special macro just to make sure all the functions and macros from this module are available in our host module (which is MyExampleAppWeb.WizardChannel
in our case).
Elixir macros are used to programmatically produce code that is injected where the macro is invoked at compile time. We have now a macro that generates all the functions we used to write by hand. It receives a list of atoms with the names of the messages and the module where the custom logic is implemented for each specific wizard step.
We are making use of a few conventions here. The functions in the different modules are equals to message names. For instance, the message type :send_info
will be handled by the function FirstStep.send_info/2
. Also, we're assuming that those functions have a fixed arity, receiving the message body and the Phoenix.Socket
struct.
So, we are iterating over the message types list and generating a function definition for each one. The body of each generated function is the following
apply(unquote(module), unquote(message), [data, socket])
which is relying on Kernel.apply/3
. It is possible to dynamically invoke the correct function from the specified module because the message names are passed now as variables instead of literals.
It's not the intention of this article to fully explain the metaprogramming aspect of this solution. If things like quotes
and unquotes
still puzzle you, I highly recommend reading the related documentation in the Elixir guides.
The result
After implementing this DSL we felt much better with this part of the code. We ended up with a decent way to divide the channel functionality in different modules, and also a clear way to write all the glue code in the channel module using our handle_step_messages/2
macro.
This approach opens new opportunities and challenges. For example, we have evolved the presented solution to support functions with different arity (some functions were not making use of the Phoenix.Socket
struct parameter). In addition, we are now exploring some approaches to run shared validations depending on the target module. Anyway, taking the metaprogramming approach too far could lead to an over-engineered solution, hard to understand to other developers dealing with the code later, so it's clear we need to have a balance between code conciseness and simplicity.
Have you found similar issues with fat Phoenix channels? How did you manage it? Please comment if you have tried different solutions or if you find our story useful.
Happy coding with Elixir and Phoenix 👩🏽💻👨🏻💻!
Acknowledgements
Huge thanks to Nicolás Ferraro and Javier Morales for helping to write this article. Both of them were involved in the implementation of the described solution.
Top comments (0)