Skip to content

Latest commit

 

History

History
442 lines (321 loc) · 11 KB

generic_server.livemd

File metadata and controls

442 lines (321 loc) · 11 KB

Generic Server

Mix.install([
  {:youtube, github: "brooklinjazz/youtube"},
  {:hidden_cell, github: "brooklinjazz/hidden_cell"},
  {:tested_cell, github: "brooklinjazz/tested_cell"},
  {:utils, path: "#{__DIR__}/../utils"}
])

Navigation

Return Home Report An Issue

Setup

Ensure you type the ea keyboard shortcut to evaluate all Elixir cells before starting. Alternatively, you can evaluate the Elixir cells as you read.

This lesson is highly inspired by Saša Jurić. Saša has graciously allowed us to use code examples directly from his book Elixir in Action.

Generic Server

It's a common pattern to implement a GenericServer process which can receive messages, delegate to some module, then send a message back to the caller.

A generic server stores a short-term in-memory state. The state can only be updated by other processes sending messages to the generic server.

flowchart
  subgraph GenericServer
    State --> h
    h[handle message]
    h --new state--> State
  end
  Process --message--> GenericServer
Loading

Typically the callback module defines an init function to define the initial state of the GenericServer process.

flowchart LR
  CallbackModule --> init --> state --> GenericServer
Loading
defmodule GenericServer do
  def start(callback_module) do
    spawn(fn ->
      initial_state = callback_module.init()
      loop(callback_module, initial_state)
    end)
  end
end

defmodule CallbackModule do
  def init() do
    "example state"
  end
end

GenericServer.start(CallbackModule)

The GenericServer should be able to send a message and receive a response.

flowchart
  GS2[Genserver 1]
  GS1[Genserver 2]
  c[call]

  GS1 --> c --request--> GS2
  GS2 --response--> c

Loading
defmodule GenericServer
  def call(server_pid, request) do
    send(server_pid, {request, self()})

    receive do
      {:response, response} ->
        response
    end
  end
  ...
end

The callback module will also define event handlers for when the GenericServer receives a message.

flowchart LR
  GenericServer --> receive
  receive --request, state--> CallbackModule --> h["handle_call(request, state)"]
  --request, new_state --> receive
Loading
defmodule CallbackModule do
  ...

  def handle_call(:increment, state) do
    new_state = state + 1
    response = {:ok, new_state}
    {response, new_state}
  end
end

The GenericServer then loops and receives messages while delegating to the CallbackModule

flowchart LR
Caller --> GenericServer --> loop
--> receive --> c[CallbackModule.handle_call] --> response --> send --> Caller
Loading
defmodule GenericServer do
  ...

  defp loop(callback_module, current_state) do
    receive do
      {request, caller} ->
        {response, new_state} =
          callback_module.handle_call(
            request,
            current_state
          )

        send(caller, {:response, response})

        loop(callback_module, new_state)
    end
  end
end

Putting all of that together, we get the full flow of our GenericServer and CallbackModule.

flowchart
  GenericServer --> loop --state--> receive --new state--> loop
  Caller --> c[call] --request--> receive --response--> c
  receive --request, state--> CallbackModule --> h["handle_call(request, state)"]
  --request, new_state --> receive

  style GenericServer fill:orange,stroke:#333,stroke-width:2px
  style Caller fill:yellow,stroke:#333,stroke-width:2px
  style CallbackModule fill:#f9f,stroke:#333,stroke-width:2px

  style receive fill:orange
  style loop fill:orange

  style c fill:yellow

  style h fill:#f9f


Loading

We have a full GenericServer module.

defmodule GenericServer do
  def call(server_pid, request) do
    send(server_pid, {request, self()})

    receive do
      {:response, response} ->
        response
    end
  end

  def start(callback_module) do
    spawn(fn ->
      initial_state = callback_module.init()
      loop(callback_module, initial_state)
    end)
  end

  defp loop(callback_module, current_state) do
    receive do
      {request, caller} ->
        {response, new_state} =
          callback_module.handle_call(
            request,
            current_state
          )

        send(caller, {:response, response})

        loop(callback_module, new_state)
    end
  end
end

GenericServer is incredibly powerful because it lets us reuse generic state and message passing functionality with more domain-specific callback modules.

Let's re-implement the Counter with some extra functionality to use with the GenericServer.

The Counter module is now a domain-specific CallbackModule.

defmodule Counter do
  def init() do
    0
  end

  def handle_call(:increment, state) do
    new_state = state + 1
    response = {:ok, new_state}
    {response, new_state}
  end
end

counter_process = GenericServer.start(Counter)

Now we can call the counter process to update its internal state.

GenericServer.call(counter_process, :increment)

handle_call/2 lets us define generic messages we can send to the Counter process. For example, in the message, we could :add an integer as a payload.

defmodule AddableCounter do
  def init() do
    0
  end

  def handle_call({:add, payload}, state) do
    new_state = state + payload
    response = {:ok, new_state}
    {response, new_state}
  end
end

add_counter_process = GenericServer.start(AddableCounter)

Here's how we would send the :add message with a payload.

GenericServer.call(add_counter_process, {:add, 10})

Your Turn

In the Elixir cell below, create a NoteBook callback module.

A NoteBook's initial state should be an empty list. It should implement a handle_call/2 function adds a new note like so.

note_book = GenericServer.start(NoteBook)
{:ok, ["new note 1"]} = GenericServer.call(note_book, {:add_note, "new note 1"})
{:ok, ["new note 1", "new note 2"]} = GenericServer.call(note_book, {:add_note, "new note 2"})
defmodule Notebook do
end

GenServer

It's useful to build your own generic server for the purpose of understanding how they work. However, we can and should rely on the built-in GenServer provided by Elixir.

We use the GenServer provided by Elixir. Under the hood, this defines the generic server functionality for the module.

Then much like our GenericServer we can define an init function. The init function now accepts options, which we could use to set the initial state. We'll ignore _opts for now.

We use @impl to specify that init is a callback function for the GenServer. You can see the @impl documentation for more on why.

defmodule Counter do
  use GenServer

  @impl true
  def init(_opts) do
    {:ok, 0}
  end
end

We use GenServer.start_link/2 to start the new Counter process with the new counter process. There are no options, so the second parameter is an empty list.

{:ok, pid} = GenServer.start_link(Counter, [])

We can define handle_call/3 functions. These look mostly the same as before, except the second parameter will be the caller pid, and the third will be the state.

We also always return {:reply, response, new_state} in a handle_call/3 function.

defmodule Counter do
  use GenServer

  @impl true
  def init(_opts) do
    {:ok, 0}
  end

  @impl true
  def handle_call(:increment, _from, state) do
    new_state = state + 1
    response = {:ok, new_state}
    {:reply, response, new_state}
  end
end

{:ok, pid} = GenServer.start_link(Counter, 0)

Now let's test our new Counter! You can execute this Elixir cell below a few times and notice that the count increments.

GenServer.call(pid, :increment)

call executes the handle_call/3 function in the internal code of GenServer, just like our GenericServer. The parent process then receives a response.

flowchart
  P[Parent Process] --> GenServer --> c[call] --> h[handle_call/3] --response--> P
  h --new_state--> GenServer
Loading

This is the heart of generic servers. We can create an in-memory process have it store some state. Then, we send the process messages to perform some work and return a response. Generic servers are often the go-to tool for short-term in-memory persistence.

The built-in GenServer also has some additional functionality, which will become more important as you work with concurrency. But, for now, we're going to focus on using it as a tool for persistence, so you have everything you currently need.

Your Turn

In the Elixir cell below, use the built-in GenServer module to create a Journal module where you can :add_entry. All journal entries should be stored in state and returned as a list for the response.

{:ok, journal_pid} = GenServer.start_link(Journal, [])

GenServer.call(:add_entry, "first entry")
["first entry"]

GenServer.call(:add_entry, "second entry")
["first entry", "second entry"]
defmodule Journal do
end

Commit Your Progress

Run the following in your command line from the beta_curriculum folder to track and save your progress in a Git commit.

$ git add .
$ git commit -m "finish generic server section"

Up Next

Previous Next
Processes Traffic Light Server