329 lines
9.5 KiB
Org Mode
329 lines
9.5 KiB
Org Mode
|
#+TITLE: Practicing Elixir by Building Concurrent, Distributed, Multiplayer Games in the Terminal
|
||
|
#+AUTHOR: Lizzy Hunt (Simponic)
|
||
|
#+STARTUP: fold inlineimages
|
||
|
|
||
|
* Reminder: linux.usu.edu
|
||
|
This meeting should be being streamed live at [[https://linux.usu.edu/streams]].
|
||
|
|
||
|
* Introduction
|
||
|
#+BEGIN_SRC elixir
|
||
|
defmodule Hello do
|
||
|
def hello() do
|
||
|
"Hello, Linux Club!"
|
||
|
|> IO.puts
|
||
|
end
|
||
|
end
|
||
|
|
||
|
Hello.hello()
|
||
|
#+END_SRC
|
||
|
|
||
|
** CheSSH
|
||
|
CheSSH is a multiplayer distributed game of chess over SSH - let's take a quick look before diving into Elixir!
|
||
|
|
||
|
[[https://chessh.linux.usu.edu]]
|
||
|
|
||
|
* Elixir - What You Need
|
||
|
Elixir is a self-proclaimed "dynamic, functional language for building scalable and maintainable applications".
|
||
|
|
||
|
** Basic Data Types
|
||
|
1. ~int~'s, ~bool~'s, ~string~'s are all here
|
||
|
+ ~1~, ~true~, ~"Hello"~
|
||
|
2. Atoms: prefixed with ":" are named constants whose name is their value, similar to symbols in LISP
|
||
|
+ ~:x~, ~:three~
|
||
|
4. Maps: regular key-value store; keys can be literally anything, including other maps
|
||
|
+ ~%{%{a: 1}: 2, %{a: 2}: :an_atom}~
|
||
|
5. Lists: lists are singly-linked elements of "stuff"
|
||
|
+ ~[1,2,3]~, ~[]~, ~[1, [2, :three, %{}]]~
|
||
|
6. Tuples: tuples are fixed-size collections of "stuff"
|
||
|
+ ~{1,2,3}~, ~{1, {2, 3}}~
|
||
|
|
||
|
** Pattern Matching
|
||
|
The match operator "=" does not mean its convential meaning of assignment, but instead an assertion of equivalence. This gives way to a unique
|
||
|
feature of Elixir - pattern matching (similar to that found in Rust's ~match~ or Scala's ~case~).
|
||
|
|
||
|
With pattern matching we can access data from complex structures declaratively.
|
||
|
|
||
|
For example:
|
||
|
#+BEGIN_SRC elixir
|
||
|
[head | tail] = [1,2,3]
|
||
|
%{a: a_value} = %{a: 10}
|
||
|
{:ok, result} = {:ok, 2}
|
||
|
[head, tail, a_value, result]
|
||
|
#+END_SRC
|
||
|
|
||
|
And will raise an exception when the pattern cannot match:
|
||
|
|
||
|
#+BEGIN_SRC elixir
|
||
|
%{a: a_value} = %{b: 10}
|
||
|
#+END_SRC
|
||
|
|
||
|
*** Error Handling
|
||
|
Functions that can error will typically return a two-tuple, the first element of which is either the atom ~:ok~ or ~:error~, and the second is the
|
||
|
error info or value.
|
||
|
|
||
|
For many scenarios, the fact that a failed pattern match raises an exception is enough information to know we shouldn't execute further.
|
||
|
#+BEGIN_SRC elixir
|
||
|
defmodule Sequences do
|
||
|
def fib(n) when n < 0, do: {:error, :too_small}
|
||
|
def fib(n) when n <= 1, do: {:ok, n}
|
||
|
def fib(n) when n > 1 do
|
||
|
{:ok, n1} = fib(n-1)
|
||
|
{:ok, n2} = fib(n-2)
|
||
|
|
||
|
{:ok, n1 + n2}
|
||
|
end
|
||
|
end
|
||
|
|
||
|
{:ok, f10} = Sequences.fib(10)
|
||
|
{:ok, fn1} = Sequences.fib(-1)
|
||
|
|
||
|
IO.puts(fn1)
|
||
|
#+END_SRC
|
||
|
|
||
|
But sometimes we do want to capture that error information! In this case, we use ~case~!
|
||
|
|
||
|
#+BEGIN_SRC elixir
|
||
|
case Sequences.fib(-1) do
|
||
|
{:ok, val} -> val
|
||
|
{:error, err} ->
|
||
|
IO.puts("Ran into :error #{inspect(err)}")
|
||
|
0
|
||
|
end
|
||
|
#+END_SRC
|
||
|
|
||
|
** Piping
|
||
|
Elixir's pipe operator ~|>~ allows programmers to easily write statements as a composition of functions. It simply takes the value of the
|
||
|
function on the left, and passes it as the first argument to the function on the right.
|
||
|
|
||
|
For example, to find the length of the longest string in a list of strings:
|
||
|
#+BEGIN_SRC elixir
|
||
|
["Hello, world", "Another string", "Where are all these strings coming from"]
|
||
|
|> Enum.map(&String.length/1)
|
||
|
|> Enum.max()
|
||
|
#+END_SRC
|
||
|
|
||
|
** Meta-programming
|
||
|
Akin to my favorite language of all time, LISP, Elixir provides a way to interact directly with code as data (and thus the AST) via a powerful macro system.
|
||
|
|
||
|
However, they are not as elegant, and for that reason, Chris McCord suggests in his book "Metaprogramming Elixir":
|
||
|
|
||
|
#+BEGIN_QUOTE
|
||
|
Rule 1 : Don't Write Macros
|
||
|
#+END_QUOTE
|
||
|
|
||
|
The main reasoning is that it becomes difficult to debug, and hides too much from the user. These are fine trade-offs when you're working alone.
|
||
|
|
||
|
*** when-prime the functional way
|
||
|
#+BEGIN_SRC elixir
|
||
|
defmodule Prime do
|
||
|
def is_prime(2), do: true
|
||
|
def is_prime(n) when rem(n, 2) == 0 or n <= 1, do: false
|
||
|
def is_prime(n) do
|
||
|
is_prime_helper(n, 3)
|
||
|
end
|
||
|
|
||
|
defp is_prime_helper(n, i) when i * i > n, do: true
|
||
|
defp is_prime_helper(n, i) when rem(n, i) == 0, do: false
|
||
|
defp is_prime_helper(n, i) do
|
||
|
is_prime_helper(n, i + 2)
|
||
|
end
|
||
|
end
|
||
|
#+END_SRC
|
||
|
|
||
|
#+BEGIN_SRC elixir
|
||
|
when_prime_do = fn n, when_true, when_false ->
|
||
|
if Prime.is_prime(n) do
|
||
|
when_true.()
|
||
|
else
|
||
|
when_false.()
|
||
|
end
|
||
|
end
|
||
|
|
||
|
when_prime_do.(10, fn -> "10 is prime" end, fn -> "10 is not prime" end)
|
||
|
#+END_SRC
|
||
|
|
||
|
*** when-prime the metaprogramming way
|
||
|
#+BEGIN_SRC elixir
|
||
|
defmodule When do
|
||
|
defmacro prime(n, do: true_body, else: false_body) do
|
||
|
quote do
|
||
|
if Prime.is_prime(unquote(n)), do: unquote(true_body), else: unquote(false_body)
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
require When
|
||
|
When.prime 10, do: "10 is prime", else: "10 is not prime"
|
||
|
#+END_SRC
|
||
|
|
||
|
*** Real-world use-case: ~use~
|
||
|
One such use case for macros (besides those covered previously in my LISP presentation) is to emulate module "inheritance" to share functions.
|
||
|
|
||
|
We can think of a module in Elixir as a set of functions. Then, we can perform unions of modules by the ~use~ macros.
|
||
|
|
||
|
Additionally, with ~behaviours~ we can define callbacks to implement in each unioned module.
|
||
|
|
||
|
#+BEGIN_SRC elixir
|
||
|
defmodule Animal do
|
||
|
@callback noise() :: String.t()
|
||
|
|
||
|
defmacro __using__(_opts) do
|
||
|
quote do
|
||
|
@behaviour Animal
|
||
|
|
||
|
def speak() do
|
||
|
IO.puts("#{__MODULE__} says #{noise()}")
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
defmodule Dog do
|
||
|
use Animal
|
||
|
|
||
|
def noise() do
|
||
|
"Bark"
|
||
|
end
|
||
|
end
|
||
|
|
||
|
defmodule Cat do
|
||
|
use Animal
|
||
|
|
||
|
def noise() do
|
||
|
"Meow"
|
||
|
end
|
||
|
end
|
||
|
|
||
|
Cat.speak()
|
||
|
Dog.speak()
|
||
|
#+END_SRC
|
||
|
|
||
|
* Elixir - Concurrency
|
||
|
Elixir is built on top of (and completely interoperable with) Erlang - a language developed to build massively fault-tolerant systems in the 80's
|
||
|
for large telephone exchanges with hundreds of thousands of users.
|
||
|
|
||
|
You can imagine (if you look past the many problems with this statement), Elixir and Erlang to be analogous to Python and C, respectively - but
|
||
|
without the massive performance penalty.
|
||
|
|
||
|
** The BEAM
|
||
|
The BEAM powers Elixir's concurrency magic; by running a VM executing Erlang bytecode that holds one OS thread per core,
|
||
|
and a separate process scheduler (and queue) on each.
|
||
|
|
||
|
Imagine an army of little goblins, and you give each a todo list. The goblins then go complete the tasks in the order best
|
||
|
suited for them, and have the added benefit that they can talk to each other.
|
||
|
|
||
|
** Concurrency - Demo!
|
||
|
Here we will open up two terminals: one running an Elixir REPL on my machine, and another to SSH into my android:
|
||
|
|
||
|
#+BEGIN_SRC python
|
||
|
import subprocess
|
||
|
import string
|
||
|
import random
|
||
|
cookie = ''.join(random.choices(string.ascii_uppercase +
|
||
|
string.digits, k=32))
|
||
|
host = "host"
|
||
|
android = "a02364151-23.bluezone.usu.edu"
|
||
|
|
||
|
h = subprocess.Popen(f"alacritty -e rlwrap --always-readline iex --name lizzy@{host} --cookie {cookie}".split())
|
||
|
a = subprocess.Popen(f"alacritty -e ssh u0_a308@{android} -p 2222 rlwrap --always-readline iex --name android@{android} --cookie {cookie}".split())
|
||
|
#+END_SRC
|
||
|
|
||
|
#+BEGIN_SRC elixir
|
||
|
defmodule SpeakServer do
|
||
|
@sleep_between_msg 2000
|
||
|
|
||
|
def loop(queue \\ []) do
|
||
|
case queue do
|
||
|
[head | tail] ->
|
||
|
speak(head)
|
||
|
|
||
|
:timer.sleep(@sleep_between_msg)
|
||
|
loop(tail)
|
||
|
[] ->
|
||
|
receive do
|
||
|
msg ->
|
||
|
loop(queue ++ [msg])
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
defp speak(msg) do
|
||
|
System.cmd("espeak", [msg])
|
||
|
end
|
||
|
end
|
||
|
#+END_SRC
|
||
|
|
||
|
#+BEGIN_SRC elixir
|
||
|
defmodule KVServer do
|
||
|
require Logger
|
||
|
@max_len_msg 32
|
||
|
|
||
|
def start(speak_server_pid, port) do
|
||
|
{:ok, socket} =
|
||
|
:gen_tcp.listen(port, [:binary, packet: :line, active: false, reuseaddr: true])
|
||
|
|
||
|
loop_acceptor(socket, speak_server_pid)
|
||
|
end
|
||
|
|
||
|
defp loop_acceptor(socket, speak_server_pid) do
|
||
|
{:ok, client} = :gen_tcp.accept(socket)
|
||
|
Task.start_link(fn -> serve(client, speak_server_pid) end)
|
||
|
|
||
|
loop_acceptor(socket, speak_server_pid)
|
||
|
end
|
||
|
|
||
|
defp serve(socket, speak_server_pid) do
|
||
|
msg = socket
|
||
|
|> read_line()
|
||
|
|> String.trim()
|
||
|
|
||
|
if valid_msg(msg) do
|
||
|
send(speak_server_pid, msg)
|
||
|
end
|
||
|
|
||
|
serve(socket, speak_server_pid)
|
||
|
end
|
||
|
|
||
|
defp read_line(socket) do
|
||
|
{:ok, data} = :gen_tcp.recv(socket, 0)
|
||
|
data
|
||
|
end
|
||
|
|
||
|
defp valid_msg(msg), do: String.length(msg) < @max_len_msg && String.match?(msg, ~r/^[A-Za-z ]+$/)
|
||
|
end
|
||
|
|
||
|
android = :"android@a02364151-23.bluezone.usu.edu"
|
||
|
|
||
|
Node.connect(android)
|
||
|
speak_server_pid = Node.spawn(android, &SpeakServer.loop/0)
|
||
|
|
||
|
KVServer.start(speak_server_pid, 42069)
|
||
|
#+END_SRC
|
||
|
|
||
|
This demo shows how we can:
|
||
|
+ Connect nodes running Elixir
|
||
|
+ Spawn processes on nodes and inter process communication
|
||
|
+ Basic Elixir constructs (pattern matching, atoms, function calls, referencing functions)
|
||
|
|
||
|
* CheSSH
|
||
|
With a brief quick exploration into concurrency with Elixir, we can now explore the architecture of CheSSH,
|
||
|
and the hardware cluster it runs on:
|
||
|
|
||
|
[[./pis.jpeg]]
|
||
|
|
||
|
** Erlang SSH Module - (maybe) building a tic tac toe game!
|
||
|
So much networking stuff is built on top of Erlang that its standard library - OTP - has implementations for tons of stuff you'd regularly reach for a library to help; ssh, snmp,
|
||
|
ftp, are all built in "OTP Applications".
|
||
|
|
||
|
It requires a little bit of time with headaches, but the docs are generally pretty good (with occasional source code browsing): [[https://www.erlang.org/doc/man/ssh.html]]
|
||
|
|
||
|
** Architecture
|
||
|
[[./architecture.png]]
|
||
|
|
||
|
** Lessons Learned
|
||
|
1. Use Kubernetes (~buildscripts~ is so horribly janky it's actually funny)
|
||
|
2. Docker was a great idea
|
||
|
3. Don't hardcode IP's
|
||
|
4. Don't try to use Multicast
|
||
|
5. Load balancing SSH
|