Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Elixir in Action Part 2

notebooks/chapter_6.livemd

Elixir in Action Part 2

Chapter 6.1

ServerProcess module

A simple implementation of a behavior similar to GenServer

defmodule ServerProcess do
  # Interface functions - implemented in the client process
  def start(callback_module) do
    spawn(fn ->
      initial_state = callback_module.init()
      loop(callback_module, initial_state)
    end)
  end

  # Used for synchronous requests - response is returned
  def call(server_pid, request) do
    send(server_pid, {:call, request, self()})

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

  # Used for async requests - no response returned
  def cast(server_pid, request) do
    send(server_pid, {:cast, request})
  end

  # Implementation functions - implemented in the server process
  defp loop(callback_module, current_state) do
    receive do
      {:call, request, caller} ->
        {response, new_state} = callback_module.handle_call(request, current_state)
        send(caller, {:response, response})
        loop(callback_module, new_state)

      {:cast, request} ->
        new_state = callback_module.handle_cast(request, current_state)
        loop(callback_module, new_state)

      unhandled ->
        IO.puts("Unhandled request: #{inspect(unhandled)}")
        loop(callback_module, current_state)
    end
  end
end

KeyValueStore

A module that uses the behavior provided by ServerProcess to implement functionality that allows for adding things to a key/value store and getting back a list of entries

defmodule KeyValueStore do
  # `init` function is called by the server process to get the initial state
  def init do
    %{}
  end

  # `start` function is used as the client interface for the module
  def start do
    ServerProcess.start(KeyValueStore)
  end

  # `put` and `get` functions are used for the client interface
  def put(pid, key, value) do
    ServerProcess.cast(pid, {:put, key, value})
  end

  def get(pid, key) do
    ServerProcess.call(pid, {:get, key})
  end

  # `handle_cast` and `handle_call` functions are used by 
  # the server process to implement the logic
  def handle_cast({:put, key, value}, state) do
    Map.put(state, key, value)
  end

  def handle_call({:get, key}, state) do
    {Map.get(state, key), state}
  end

  def handle_call(request, state) do
    IO.puts("Unhandled request: #{inspect(request)} - with state #{inspect(state)}")
  end
end
# Calling ServerProcess directly
# pid = ServerProcess.start(KeyValueStore)
# ServerProcess.call(pid, {:put, :some_key, "some value"})
# ServerProcess.call(pid, {:get, :some_key})

# Using interface provided by KeyValueStore
pid2 = KeyValueStore.start()
KeyValueStore.put(pid2, :some_key, "another value")
KeyValueStore.get(pid2, :some_key)
KeyValueStore.put(pid2, :name, "Maisie")
KeyValueStore.get(pid2, :name)

TodoList

The TodoList module as developed in previous chapters

defmodule TodoList do
  defstruct auto_id: 1, entries: %{}

  def new(entries \\ []) do
    Enum.reduce(
      entries,
      %TodoList{},
      &add_entry(&2, &1)
      # fn entry, todo_list_acc ->
      #   add_entry(todo_list_acc, entry)
      # end
    )
  end

  def add_entry(todo_list, entry) do
    entry = Map.put(entry, :id, todo_list.auto_id)

    new_entries =
      Map.put(
        todo_list.entries,
        todo_list.auto_id,
        entry
      )

    %TodoList{
      todo_list
      | entries: new_entries,
        auto_id: todo_list.auto_id + 1
    }
  end

  def entries(todo_list, date) do
    todo_list.entries
    |> Stream.filter(fn {_id, entry} -> entry.date == date end)
    |> Enum.map(fn {_id, entry} -> entry end)
  end

  def update_entry(todo_list, entry_id, updater_fn) do
    case Map.fetch(todo_list.entries, entry_id) do
      :error ->
        todo_list

      {:ok, old_entry} ->
        old_entry_id = old_entry.id
        new_entry = %{id: ^old_entry_id} = updater_fn.(old_entry)
        new_entries = Map.put(todo_list.entries, new_entry.id, new_entry)
        %TodoList{todo_list | entries: new_entries}
    end
  end

  def delete_entry(todo_list, entry_id) do
    case Map.fetch(todo_list.entries, entry_id) do
      :error ->
        todo_list

      {:ok, _} ->
        new_entries = Map.delete(todo_list.entries, entry_id)
        %TodoList{todo_list | entries: new_entries}
    end
  end
end

todo_list =
  TodoList.new()
  |> TodoList.add_entry(%{date: ~D[2023-11-24], title: "Black Friday Shopping"})
  |> TodoList.add_entry(%{date: ~D[2023-11-24], title: "Learn Elixir"})
  |> TodoList.add_entry(%{date: ~D[2023-11-25], title: "Do Nothing"})

TodoList.entries(todo_list, ~D[2023-11-24])

TodoList.update_entry(todo_list, 1, fn entry ->
  Map.put(entry, :title, "Black Friday Perusing")
end)

TodoList.delete_entry(todo_list, 3)

entries = [
  %{date: ~D[2023-11-25], title: "Fall Cleanup"},
  %{date: ~D[2023-11-26], title: "Archery League"}
]

list2 = TodoList.new(entries)

TodoServerB - Refactored to use ServerProcess

A server using the simple ServerProcess module to provide TodoList functionality and keep track of the existing items

defmodule TodoServerB do
  # Interface functions used by the ServerProcess
  def init() do
    TodoList.new()
  end

  def handle_call({:entries, date}, state) do
    {TodoList.entries(state, date), state}
  end

  def handle_cast({:add_entry, new_entry}, state) do
    TodoList.add_entry(state, new_entry)
  end

  # Interface functions used by the TodoServerB client
  def start do
    ServerProcess.start(TodoServerB)
  end

  def add_entry(todo_server, new_entry) do
    ServerProcess.cast(todo_server, {:add_entry, new_entry})
    # send(todo_server, {:add_entry, new_entry})
  end

  def entries(todo_server, date) do
    ServerProcess.call(todo_server, {:entries, date})
  end
end
tds = TodoServerB.start()
TodoServerB.add_entry(tds, %{date: ~D[2023-11-24], title: "Black Friday Shopping"})
TodoServerB.add_entry(tds, %{date: ~D[2023-11-28], title: "Set up new MacBook!"})
TodoServerB.entries(tds, ~D[2023-11-28])

KeyValueStoreB - Using GenServer

defmodule KeyValueStoreB do
  use GenServer

  # Server Functions 

  # Must return {:ok, initial_state}
  # @impl enables compile-time checking against the behavior
  @impl GenServer
  def init(_) do
    {:ok, %{}}
  end

  # Must return {:noreply, new_state}
  @impl GenServer
  def handle_cast({:put, key, value}, state) do
    {:noreply, Map.put(state, key, value)}
  end

  # Second argument includes the request id and caller pid
  # Must return {:reply, response, new_state}
  @impl GenServer
  def handle_call({:get, key}, _request_meta, state) do
    {:reply, Map.get(state, key), state}
  end

  # Interface Functions 

  def start() do
    # GenServer.start(KeyValueStoreB, nil)

    # use a name for local registration
    # GenServer.start(KeyValueStoreB, nil, name: KeyValueStoreB)

    # Use the __MODULE__ identifier to dynamically set name
    GenServer.start(__MODULE__, nil, name: __MODULE__)
  end

  # If name isn't provided - must include a pid for these calls
  def put(pid, key, value) do
    GenServer.cast(pid, {:put, key, value})
  end

  def get(pid, key) do
    GenServer.call(pid, {:get, key})
  end

  # But if a name is provided - you don't need to send the pid anymore
  def put(key, value) do
    # GenServer.cast(KeyValueStoreB, {:put, key, value})
    GenServer.cast(__MODULE__, {:put, key, value})
  end

  def get(key) do
    # GenServer.call(KeyValueStoreB, {:get, key})
    GenServer.call(__MODULE__, {:get, key})
  end
end
# Without the local name
{:ok, pid} = KeyValueStoreB.start()
# KeyValueStoreB.put(pid, :name, "Chase")
KeyValueStoreB.get(pid, :name)

GenServer.stop(KeyValueStoreB)

# # With the local name - 
# # singleton instance, can only have one per BEAM instance
{:ok, _pid} = KeyValueStoreB.start()
# KeyValueStoreB.put(:name, "Chase")
KeyValueStoreB.get(:name)

GenServer.stop(KeyValueStoreB)

The put requests above are commented out, because timing issues can arise with starting/stopping servers. The put requests use handle_cast, which is asynchronous.

If a local name isn’t used, then multiple instances can be started, and everything is fine. But with a local name - the server process becomes a singleton. Only one instance can be running at a time, and it has to be stopped before a new instance can be started.

TodoServer - Using GenServer

defmodule TodoServer do
  use GenServer
  # Server functions used by GenServer

  @impl GenServer
  def init(_) do
    {:ok, TodoList.new()}
  end

  @impl GenServer
  def handle_call({:entries, date}, _request_meta, state) do
    {:reply, TodoList.entries(state, date), state}
  end

  @impl GenServer
  def handle_cast({:add_entry, new_entry}, state) do
    {:noreply, TodoList.add_entry(state, new_entry)}
  end

  # Interface functions used by the TodoServer client

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

  def add_entry(new_entry) do
    GenServer.cast(__MODULE__, {:add_entry, new_entry})
  end

  def entries(date) do
    GenServer.call(__MODULE__, {:entries, date})
  end
end
# TodoServer.start()

# TodoServer.add_entry(%{date: ~D[2023-11-24], title: "Black Friday Shopping"})
# TodoServer.add_entry(%{date: ~D[2023-11-28], title: "Set up new MacBook!"})
# TodoServer.entries(~D[2023-11-28])

GenServer.stop(TodoServer)