Elixir Introduction with Servy
- Servy
- High-Level Transformations
- Simple Pattern Matching
- Immutable Data
- Function Clauses
- Additional Pattern Matching
- Advanced Pattern Matching
- Reading a File, Case Operator
- Module Attributes
- Code Reorganization
- Struct Usage
- Matching Heads and Tails
- Recursion
- Slicing and Dicing with Enum
- Comprehensions
- Test Automation
- Rendering JSON
- Web Server Sockets
- Concurrent, Isolated Processes
- Sending and Receiving Messages
- Asynchronous Tasks
- Stateful Server Processes
- Refactoring Towards GenServer
- OTP GenServer
- Another GenServer
- Linking Processes
- Fault Recovery with OTP Supervisors
- Final OTP Application
Servy
This post is a summary of the excellent introductory course to Elixir from The Pragmatic Studio.
Please also refer to the source code for Servy, where all of this is documented again in the README file.
High-Level Transformations
|
Simple Pattern Matching
|
Immutable Data
All objects in Elixir are immutable.
|
Function Clauses
Rather than using conditional statements (if/elif/else), it is more idiomatic in Elixir to write function clauses.
|
can be rewritten as
|
Additional Pattern Matching
All related function clauses should be grouped together.
They are evaluated in the order defined in the source code, so putting the catch all at the top will effectively make the other function clauses inaccessible.
Additionally, match operators can be used within a function clause.
|
Advanced Pattern Matching
Rather than transforming route/1
into route/3
function clauses, we can modify route/1
to accept a map and perform pattern matching on the individual keys.
|
Cannot do string concatenation match with dynamic length variable in non tail position. Example overcoming this limitation using Regexp:
|
Reading a File, Case Operator
Function clauses can be written as a case conditional.
|
Using case instead of function clauses.
|
The __DIR__
variable holds the existing file’s directory path, relative to where the program session was started.
Module Attributes
Elixir modules have two built in module attributes, @moduledoc
for the module-level documentation string, and @doc
for the function level documentation.
|
Code Reorganization
An elixir project spanning multiple files can be run in interactive mode using iex -S mix
. Calling individual modules will not correctly compile the dependent modules.
The module naming convention does not imply hierarchy. To reference functions in other modules, you can call the function using {module name}.{function}
or by importing the function into the current scope.
|
When importing modules, the only
option specifies specific functions (provide the function arity) instead of importing everything.
Alternative imports:
|
Struct Usage
Rather than using a map (%{}
) it can be useful to define the keys beforehand to ensure more strict semantics.
Structs are defined within their own module- a module cannot have more than one struct.
Structs are maps that allow default values for keys and compile time assertions.
|
The defstruct
macro takes in a list of fields which are the atom keys. If a list of atoms are provided, they will all default to nil
.
|
See h defstruct
for more information.
Matching Heads and Tails
Using the |
operator on a list will separate out the first element from the rest of the list.
|
Additionally access to the head and the tail can also be done using the functions hd
and tl
.
|
Recursion
Elixir does not have looping, instead traversal of iterables is done through recursion.
|
Or with state, like summing the numbers together:
|
Triple all the numbers in a list:
|
Slicing and Dicing with Enum
|
The ampersand wraps a named function in an anonymous function, and the numbers indicate the argument order.
This can also be done with expressions.
|
Consider these examples for capturing String.duplicate/2
:
|
Guard Clauses
These are conditionals that you can define at the function argument level that sets boolean checks on the argument types for function clause matching.
|
Comprehensions
|
In this above example, the generator is x <- [1, 2, 3]
.
An example with two generators is shown:
|
You can also pattern match within comprehensions:
|
By default, values returned by a do
block of a comprehension are packaged into a list. However, the :into
option can return the values into anything that inherits Collectable
.
|
See an example using a deck of playing cards:
|
EEx Templates
|
The notice the subtle differences in the opening expression tags <%=
and <%
.
In EEx, all expressions that output something to the template must include the equals =
sign.
Test Automation
By default, setting up a mix
project will generate a test
directory that uses ExUnit
for testing.
|
Rendering JSON
The video tutorial uses Poison, but I used Jason instead.
TODO: Look up Protocol Module Consolidation
|
To use external libraries, add it to the dependencies list in mix.exs
. Then run mix deps.get
.
Web Server Sockets
All Erlang
libraries can be used in Elixir
projects because Elixir is transpiled into erlang bytecode to be run in an erlang virtual machine.
Example of converting erlang code into elixir code:
|
|
- Erlang atoms have a lowercase letter start. Elixir atoms start with a colon (
:
) character. - Erlang variables start with an uppercase letter. Elixir atoms start with a lowercase letter.
- Erlang modules are referenced as atoms. E.g. Erlang
gen_tcp
becomes Elixir:gen_tcp
. - Erlang function calls use a colon (
:
) while Elixir function calls use a dot (.
). E.g. Erlanggen_tcp:listen
becomes Elixir:gen_tcp.listen
- Erlang strings are not Elixir strings. Erlang
"hello"
becomes Elixir'hello'
- Erlang, double-quoted strings are a list of characters.
- Elixir: double quoted strings are a sequence of bytes.
To make a list of characters, use a single qoted string.
Wrote a quick webserver using gen_tcp
to hook into our existing handler and serve responses to a http client (such as a web browser).
Concurrent, Isolated Processes
Within Elixir, the spawn
function is used to create a processes that runs concurrently in the background.
|
spawn/1
takes a zero-arity anonymous function.spawn/3
takes the module name, the function name as an atom, and a list of arguments passed to the function.
The functions spawned in the serve function are closures.
All variables that are defined within the scope of the function are deep copied. Processes do not share memory.
To get the PID of the current process, use self()
.
|
We can count the number of Elixir processes like so:
|
Refer to the Erlang system_info function for more details.
Within an iex
session, the :observer.start
function will open up a graphical user interface enabling you to inspect overall system information and the individual Erlang/Elixir processes currently running in the application.
Sending and Receiving Messages
Elixir processes (are not operating system processes and) have the following properties:
- extremely lightweight and fast to spawn
- run concurrently on a single CPU
- if multiple CPU cores are available, runs in parallel
- isolated from other processes (no sharing of memory or variables)
- have their own private mailbox
- communicate with other processes only by sending and receiving messages
|
Asynchronous Tasks
It is common practice to keep track of process IDs when working with asynchronous tasks in order to map message results back to their originating spawn call.
Elixir provides a convenience function for dispatching asynchonous commands and retrieving the corresponding results.
|
Because Task.await
waits for a message to arrive it can only be called once for a given task.
Use Task.yield
to determine if a task has completed.
|
If working with a receive
block, consider setting a timeout using the after
clause.
|
Converting Milliseconds
Erlang timer module has useful built-in millisecond conversion functions.
|
Stateful Server Processes
Within Elixir, modules cannot store state (in most OO languages, you can have class attributes that are shared among all instances of the class).
Instead, you need to spawn a process and pass state into the process by arguments.
Registering Unique Process Names
|
Referring to the Servy.PledgeServer.start/0
function we registered the spawned process under the name :pledge_server
.
|
Agents
The Agent module is a simple wrapper around a server process that can store state and offers access to the state via a client interface.
|
A process is spawned containing an elixir list in memory. The agent is bound to a PID.
|
Additional calls for updating the state are provided. Pass in a function that takes the state and returns the new state.
|
To retrieve the agent’s state, pass the agent’s PID and a function that returns the state.
Refactoring Towards GenServer
In our Servy.PledgeServer
example, we refactored our module such that all ‘Generic Server’ behaviour is defined in the Servy.GenericServer
module.
It supports initialization via start/3
(taking in the callback module, initial state, and name).
It handles blocking actions via call/2
as well as non-blocking actions via cast/2
(both taking in the server PID and message as arguments).
The server listen_loop/2
will handle listening for new call and cast messages, referencing the callback module’s functions for server side logic.
OTP GenServer
Task
: For one-off computations or queriesAgent
: For a simple process that holds stateGenServer
: For long-running server processes that stores state and performs work concurrently- If you need to serialize access to a shared resource or service,
GenServer
is a decent choice - If you need to schedule background work to be performed on a periodic interval,
GenServer
is a decent choice
- If you need to serialize access to a shared resource or service,
GenServer callback functions
handle_call(message, from state)
- Invoked to handle synchronous requests sent by the client using
GenServer.call(pid, message)
. - Typically return
{:reply, reply, new_state}
which sends thereply
to the client and recursively loops with thenew_state
. - Can return
{:stop, reason, new_state}
which will exit the process withreason
. - Default
use GenServer
implementation returns{:stop, {:bad_call, msg}, state}
and stops the server. You should implement ahandle_call
function clause for every message your server can handle.
- Invoked to handle synchronous requests sent by the client using
handle_cast(message, state)
- Invoked to handle asynchronous requests sent by the client using
GenServer.cast(pid, message)
. - Typically return
{:noreply, new_state}
which recursively loops with thenew_state
. - Can return
{:stop, reason, new_state}
which will cause the process to exit withreason
. - Default
use GenServer
implementation returns{:stop, {:bad_cast, msg}, state}
and stops the server. You should implement ahandle_cast
function for every message your server can handle.
- Invoked to handle asynchronous requests sent by the client using
handle_info(message, state)
- Invoked to handle all other requests sent by the client that are not call or cast requests, such as a direct
send
call to the GenServer PID. - Default implementation logs the message and returns
{:noreply, state}
.
- Invoked to handle all other requests sent by the client that are not call or cast requests, such as a direct
init(args)
- Invoked when the server is started.
- e.g. If you start a server like
GenServer.start(__MODULE__, [], name: @name)
theninit
will be called and passed the second argument ofstart
, which is currently[]
. - The default implementation will return
{:ok, args}
where the args parameter is the state used to start the server. - If initialization fails (for whatever reason), you can reutrn
{:stop, reason}
which will causeGenServer.start
to return{:error, reason}
and cause the process to exit withreason
.
terminate(reason, state)
- Invoked when the server is about to terminate. Intended to allow you to do cleanup (like closing resources used by the process).
- There may be situations where
terminate
is not called, so using Supervisor is more reliable. - Default implementation returns
:ok
, ignoring the arguments.
code_change(old_version, state, extra)
- Feature of the Erlang Virtual Machine is hot code-swapping. When a new version of a module is loaded while the server is running, a migration of the old process state structure may be necessary. This callback is invoked to allow for state migration.
- Typically you will not need to implement this callback. By default, this function will return the current state:
def code_change(_old_version, state, _extra) do
{:ok, state}
end
Call Timeouts
Invoking GenServer.call
is synchronous and will wait for 5 seconds by default.
This is overried by passing a timeout value (in milliseconds) as the third argument to call.
|
Debugging and Tracing
Erlang has a sys
odule that can be used to inspect the current state of a running GenServer process.
|
Traces can look like the following:
|
Another GenServer
Defined a new GenServer Servy.SensorServer
that does long polling to periodically fetch images from a mock external API and keeps the results in a cache.
The handle_info
function is used to trigger off a :refresh
event every 5 seconds. The refresh event will fetch from the mock external API and store the results into a cache.
Be sure to add a handle_info
function that is generic after adding the new :refresh
handler, in order to make your server robust to crashes (a new message of :boom
will throw a FunctionClauseError because nothing will match with :boom
otherwise).
Linking Processes
When an Elixir process terminates, it will notify its linked processes by sending it an exit signal.
If the process terminates normally, the exit signal reason is the atom :normal
.
Because the process exits normally, the linked process does not terminate.
If the process has an abnormal termination, the exit reason will be anything other than :normal
. By default, the exit signal indicates that the process terminated abnormally and the linked process will terminate with the same reason unless the linked process is trapping exits.
Linked processes are always bidirectional.
Linking Tasks
Referring back to Task.async
for spawning functions and Task.await
for waiting for the results:
|
The spawned process is automatically linked to the calling process.
If the spawned task process crashes, then the process that calls Task.async
will also crash.
|
Fault Recovery with OTP Supervisors
Supervisors are special processes that that are hierarchical parents of GenServer processes and other supervisors.
If a GenServer terminates, the Supervisor can restart the GenServer.
Restart Strategies
One of the options that can be passed into Supervisor.init
is strategy
:
:one_for_one
: if a child process terminates, only that process is restarted.:one_for_all
: if a child process terminates, all children processes are restarted:rest_for_one
: if a child process terminates, the rest of the child processes (children listed after the terminated child) that were started after it are terminated. All terminated children are then restarted.:simple_one_for_one
: restricted to when a supervisor has one child specification. Used for dynamically spawning child procsses that are then attached to the supervisor (ie a pool of similar worker processes).
Additional options are:
:max_restarts
: indicates max number of restarts allowed within a given time frame (default is 3 restarts):max_seconds
: indicates the time frame for:max_restarts
(default is 5 seconds)
|
Final OTP Application
An application
is a first class citizen in Elixir.
See Application for more details.
Application environment configuration can be specified directly in the mix.exs
file, or by config/config.exs
files.
- The
config
directory files are no longer generated by default - Configuration settings set in
config
directory are restricted to this project, if the project is a dependency of another application then the contents ofconfig/config.exs
are never loaded.
Within the application callback module, the start/2
callback is what is invoked when calling iex -S mix
or mix run
and mix run --no-halt
.