Let's talk about GenServers

Genservers are important part of the Elixir (Erlang) infrastructure. In this blog post I wanted to explain, line by line, what each of GenServer module methods are doing and why do we need them. This blog post is suitable for those who understand basic process management in Elixir, but are just getting started with OTP.
Our task would to build a logger that persists log messages in memory. Please note that I will use name GenServer to refer to GenServer module and genserver will be used to speak about our server.
When I build Elixir applications, I like to think about the interface first. I want the interface to look something like this:

Logger.Memory.start
Logger.Memory.info("This is info level log entry")
Logger.Memory.warn("This is warn level log entry")
Logger.Memory.log_entries #=>
[
"[2015/01/01 20:30:33][warn] This is warn #level log entry",
"[2015/01/01 20:30:34][info] This is info level log entry"
]

Okay, so by the looks of it, it would be a single and named process. Let's begin by implementing our start/0 function:

defmodule Logger.Memory do
  use GenServer

  def start do
    GenServer.start(__MODULE__, [], name: __MODULE__)
  end
end

Let me explain what I did here. First of all, we added a function start to our Logger.Memory module. It will be the interface for calling GenServer.start/3 that takes first parameter as a name of the module that will handle functions of its process. Second parameter is the initial state of our genserver and in our case initial state would be an empty list. The third argument is keyword list with parameter name. This will be the name of our process. This will allow to refer to our process by name and not pid. By doing process naming, we avoid necessity to pass pid every time we interact with our module.
With that out of the way, let's implement our info function:

defmodule Logger.Memory do
  use GenServer

  def start do
    GenServer.start(__MODULE__, [], name: __MODULE__)
  end

  def info(log_entry) do
    GenServer.cast(__MODULE__, {:log, :info, log_entry})
  end
end

Newly created info/1 function calls GenServer.cast/2 which essentially is fire and forget. In other words we do not wait for response from our genserver. First argument of cast function is pid or pid name for process identification. In our case genservers pid name is the name of our module - Logger.Memory. Second argument is actual message that we are sending to our genserver. The convention is that first element of the tuple is name of the operation that we want to execute and all other values are arguments to your function. In our case the name of the operation is :log, next we have log level of :info, and as a third argument we see log_entry itself.

Now we have interface function in place it is time to handle the internals. To receive the message from caller, we need to implement handle_cast/2 function. Let's do it:

defmodule Logger.Memory do
  use GenServer

  def start do
    GenServer.start(__MODULE__, [], name: __MODULE__)
  end

  def info(log_entry) do
    GenServer.cast(__MODULE__, {:log, :info, log_entry})
  end
  
  def handle_cast({:log, log_level, log_entry}, log_entries) do
    formatted_log_entry = format_log_message(log_level, log_entry)
    new_log_entries_state = [formatted_log_entry | log_entries]
    {:noreply, new_log_entries_state}
  end

  defp format_log_message(log_level, log_entry) do
    {{year, month, date}, {hour, minute, second}} = :calendar.local_time
    "[#{year}/#{month}/#{date} #{hour}:#{minute}:#{second}][#{log_level}] #{log_entry}"
  end

end

First to make handle_cast/2 work, we need to distinguish the message type we want to handle. handle_cast/2 can be implemented multiple times for different messages that our genserver can respond to. In our case we need to handle message where first element of the tuple is :log, second element is binding to log_level and third element is binding to log_entry. Second argument of the handle_cast/2 function is the state of the application. On the first cast it will be an empty list, since we set it as an initial state in start/0 function. First thing to do is to format our passed message and add time stamp to it. For this purpose we created private format_log_message/2 function that takes log_level and log_entry and returns nicely formated log message. Then we append this new log entry to our current state or to log_entries and return tuple with :noreply symbol and new state of our genserver. You might notice that we did not have any recursive functions or explicit receive blocks here. That's what GenServer is for. It takes care of all nasty stuff that you would otherwise would need to handle yourself. Anyways, let's continue by implementing warn/1 interface function.

defmodule Logger.Memory do
  use GenServer

  def start do
    GenServer.start(__MODULE__, [], name: __MODULE__)
  end

  def info(log_entry) do
    GenServer.cast(__MODULE__, {:log, :info, log_entry})
  end

  def warn(log_entry) do
    GenServer.cast(__MODULE__, {:log, :warn, log_entry})
  end

  def handle_cast({:log, log_level, log_entry}, log_entries) do
    formatted_log_entry = format_log_message(log_level, log_entry)
    new_log_entries_state = [formatted_log_entry | log_entries]
    {:noreply, new_log_entries_state}
  end

  defp format_log_message(log_level, log_entry) do
    {{year, month, date}, {hour, minute, second}} = :calendar.local_time
    "[#{year}/#{month}/#{date} #{hour}:#{minute}:#{second}][#{log_level}] #{log_entry}"
  end
end

Well, this was easy - notice that everything else is handled by our handle_cast/2 function. I wont get much in details here, since this code change is pretty obvious. Now only part that we are currently missing is log_entries/0 function that will return list of all log entries in our server. This time we will need to reply to our caller. Let's get our hands dirty again:

defmodule Logger.Memory do
  use GenServer

  def start do
    GenServer.start(__MODULE__, [], name: __MODULE__)
  end

  def info(log_entry) do
    GenServer.cast(__MODULE__, {:log, :info, log_entry})
  end

  def warn(log_entry) do
    GenServer.cast(__MODULE__, {:log, :warn, log_entry})
  end

  def handle_cast({:log, log_level, log_entry}, log_entries) do
    formatted_log_entry = format_log_message(log_level, log_entry)
    new_log_entries_state = [formatted_log_entry | log_entries]
    {:noreply, new_log_entries_state}
  end

  defp format_log_message(log_level, log_entry) do
    {{year, month, date}, {hour, minute, second}} = :calendar.local_time
    "[#{year}/#{month}/#{date} #{hour}:#{minute}:#{second}][#{log_level}] #{log_entry}"
  end

  def log_entries do
    GenServer.call(__MODULE__, {:log_entries})
  end
end

Please notice that we are using GenServer.call/2 instead of GenServer.cast/2. The difference is that call expects response back, where cast just sends a message and does not care what happens with it. Again we are calling our named pid and as a second argument we are passing tuple with single element :log_entries in it. The last thing we need to do is to handle this call. Abrakadabra:

defmodule Logger.Memory do
  use GenServer

  def start do
    GenServer.start(__MODULE__, [], name: __MODULE__)
  end

  def info(log_entry) do
    GenServer.cast(__MODULE__, {:log, :info, log_entry})
  end

  def warn(log_entry) do
    GenServer.cast(__MODULE__, {:log, :warn, log_entry})
  end

  def handle_cast({:log, log_level, log_entry}, log_entries) do
    formatted_log_entry = format_log_message(log_level, log_entry)
    new_log_entries_state = [formatted_log_entry | log_entries]
    {:noreply, new_log_entries_state}
  end

  defp format_log_message(log_level, log_entry) do
    {{year, month, date}, {hour, minute, second}} = :calendar.local_time
    "[#{year}/#{month}/#{date} #{hour}:#{minute}:#{second}][#{log_level}] #{log_entry}"
  end

  def log_entries do
    GenServer.call(__MODULE__, {:log_entries})
  end
  
  def handle_call({:log_entries}, from, log_entries) do
    {:reply, log_entries, log_entries}
  end

end

The handle_call/3 is little different beast. It takes three parameters and it is expected of you to return tuple with three elements. We have second argument as a tuple with callers reference and pid. We can ignore it and prepend it with underscore, otherwise complier will yell at us about unused variable. The return value for this function should be tuple with three elements. Naturally first argument means that we need to reply to our caller. Since we are not planning to modify state when we are getting our log entries our state will be the same as the reply value to the caller. That's why second and third arguments are the same.

Now, only thing left to do is to test this stuff out. Let's fire up iex and load our code:

➜  lets_talk_about_genservers  iex example.exs
Erlang/OTP 17 [erts-6.3.1] [source] [64-bit] [smp:8:8] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Interactive Elixir (1.0.3) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> Logger.Memory.start
iex(2)> Logger.Memory.info("This is info log entry")
:ok
iex(3)> Logger.Memory.info("This is second log entry")
:ok
iex(4)> Logger.Memory.warn("This is third warn log entry")
:ok
iex(5)> Logger.Memory.log_entries
{#PID<0.62.0>, #Reference<0.0.0.194>}
["[2015/2/28 23:21:13][warn] This is third warn log entry",
 "[2015/2/28 23:21:0][info] This is second log entry",
  "[2015/2/28 23:20:53][info] This is info log entry"]
  iex(6)>

So, as you saw from this post, GenServer is useful abstraction in Elixir/Erlang ecosystem. It allows us to hide recursions, message passing and create simple client/server processes really easy. I hope this helped you to understand GenServers a little bit better.

Janis Miezitis

Read more posts by this author.

Subscribe to Janis Miezitis personal blog

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!