49
Elixir OTP - Basics with project example
OTP (Open Telecom Platform) can be defined with three components: is based on Erlang, has a set of libraries from BEAM (Erlang VM) and follow a system design principles.
If you want to know more about the design principles, take a look at the book Designing for Scalability with Erlang/OTP from Francesco Cesarini and Steve Vinoski.
In the core of the OTP we have the processes, not those of the operating system, they are created directly in BEAM, lighter, totally isolated and communicate via messages asynchronously.
In addition to all these advantages, Elixir also provides some abstractions for developers to work faster and more productively with them, through tools that allow the creation of new processes, communication and finalization, besides, thanks to immutability we don't need to worry with saving state, then problems with race condition can be avoided more easily.
An example of process creation:
iex(1)> process = spawn(fn -> IO.puts("hey there!") end)
Hey there!
#PID<0.108.0>
We can create several processes but so far we have no control over them, what happens if one dies? If we need to group by context? How to organize them? For this, the OTP provides the Supervisor, with him we can start and end a list of pre-defined processes, define a behavior so that when a process dies, it is executed, for example, restarted, in addition to leaving the structure according to the context through the Supervision Tree .
After understanding the basics, we will create a small project to demonstrate the ease of creating and killing processes, in addition to the communication between them, in this project we will manage the number of store employees.
Creating the project:
mix new otp_test --sup
cd otp_test
We will create the store struct:
otp_test/lib/otp_test/store/struct.ex
defmodule Store.Struct do
@enforce_keys [:name, :employees]
defstruct [:name, :employees]
end
Let's implement the GenServer behavior, with our customization:
otp_test/lib/otp_test/core/store.ex
defmodule Core.Store do
use GenServer
alias Store.Struct, as: Store
def start_link(%Store{} = store) do
GenServer.start_link(__MODULE__, store, name: String.to_atom(store.name))
end
@impl true
def init(%Store{} = store) do
{:ok, store}
end
@impl true
def handle_call(:store, _from, %Store{} = store) do
{:reply, store, store}
end
@impl true
def handle_cast({:add_employees, amount}, %Store{} = store) do
store =
store
|> Map.put(:employees, store.employees + amount)
{:noreply, store}
end
end
Here we implement the GenServer behavior, let's analyze the functions/callbacks:
- start_link: Function to start the process, we define the name as an atom of the store name.
- init: Function executed as soon as the process is started.
- handle_call: Callback for synchronous executions, in this case we just want to return the store.
- handle_cast: Callback for asynchronous executions, here we add more employees according to the sent parameter.
We can now test the implementation, checking if the init function is executed when generating the process:
iex -S mix
iex(1)> store = %Store.Struct{name: "test1", employees: 2}
%Store.Struct{employees: 2, name: "test1"}
iex(2)> Core.Store.start_link store
{:ok, #PID<0.164.0>}
Let's add some employees:
iex(3)> GenServer.cast String.to_atom(store.name), {:add_employees, 4}
:ok
iex(4)> GenServer.call String.to_atom(store.name), :store
%Store.Struct{employees: 6, name: "test1"}
Now this store has 6 employees, we were able to change the value as we configured! 🎉
A simpler way to deal with GenServer is separating its implementation to a public api, let's do that:
otp_test/lib/otp_test/store/management.ex
defmodule Store.Management do
alias Store.Struct, as: Store
def open(%Store{} = store) do
store
|> Core.Store.start_link()
end
def get_store(%Store{} = store) do
GenServer.call(String.to_atom(store.name), :store)
end
def add_employees(%Store{} = store, amount) do
GenServer.cast(String.to_atom(store.name), {:add_employees, amount})
end
end
Here we abstract the implementation for use in a public api that calls GenServer but doesn't care about implementing its callbacks.
Lets test:
iex(7)> store = %Store.Struct{name: "test2", employees: 3}
%Store.Struct{employees: 3, name: "test2"}
iex(8)> Store.Management.open store
{:ok, #PID<0.179.0>}
iex(9)> Store.Management.add_employees store, 4
:ok
iex(10)> Store.Management.get_store store
%Store.Struct{employees: 7, name: "test2"}
So far we've created processes, but we don't use Supervisors, let's put it into practice:
otp_test/lib/otp_test/store/supervisor.ex
defmodule Store.Supervisor do
use Supervisor
alias Store.Struct, as: Store
def start_link(init_arg) do
Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
def init(_init_arg) do
children = [
create_store(%Store{name: "Test1", employees: 2}),
create_store(%Store{name: "Test2", employees: 2})
]
Supervisor.init(children, strategy: :one_for_one)
end
defp create_store(%Store{} = store) do
%{
id: String.to_atom(store.name),
start: {Core.Store, :start_link, [store]}
}
end
end
This way we create a unique supervisor for the store context, when we start, we create two children processes.
The strategy we define for this supervisor's processes is :one_for_one
, which restarts each time one of them dies.
To test that our project will start with this supervisor, it is necessary to include it in the Supervisors list, in otp_test/lib/otp_test/application.ex
defmodule OtpTest.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
# Starts a worker by calling: OtpTest.Worker.start_link(arg)
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: OtpTest.Supervisor]
Supervisor.start_link(children, opts)
Store.Supervisor.start_link([])
end
end
To test our Supervisor started, let's restart the application and check how many processes are in it:
iex(1)> Supervisor.which_children Store.Supervisor
[
{:Test2, #PID<0.143.0>, :worker, [Core.Store]},
{:Test1, #PID<0.142.0>, :worker, [Core.Store]}
]
iex(2)> Supervisor.which_children OtpTest.Supervisor
[]
We were able to confirm that when starting the application, Store.Supervisor created the two initial processes, let's test killing one to see if it will restart:
iex(3)> :sys.terminate :Test1, :kill
:ok
iex(4)>
18:56:44.901 [error] GenServer :Test1 terminating
** (stop) :kill
Last message: []
State: %Store.Struct{employees: 2, name: "Test1"}
nil
iex(5)> Process.whereis :Test1
#PID<0.149.0>
iex(6)>
It worked! 🎉
We killed the :Test1 process, which had the id 142, a new one was automatically generated, this time with the id 149.
With this example I hope that the importance and ease of using OTP in elixir applications is clear, its ease and extensibility allows for great productivity in projects.
Github code: https://github.com/Lgdev07/otp_test
I appreciate everyone who has read through here, if you guys have anything to add, please leave a comment.
49