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)