Presentation #17
@ -1,5 +1,6 @@
|
||||
#+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]].
|
||||
@ -21,14 +22,11 @@ CheSSH is a multiplayer distributed game of chess over SSH - let's take a quick
|
||||
|
||||
[[https://chessh.linux.usu.edu]]
|
||||
|
||||
* Elixir - Functional Programming & Meta-Programming
|
||||
Elixir is a self-proclaimed "dynamic, functional language for building scalable and maintainable applications". Obviously, one of Elixir's
|
||||
main selling points is that of its functional paradigm - it's the second in the list.
|
||||
|
||||
We'll take a quick look at some features of Elixir, and find that functional programming brings a lot to the table.
|
||||
* 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. ~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~
|
||||
@ -40,11 +38,165 @@ We'll take a quick look at some features of Elixir, and find that functional pro
|
||||
+ ~{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.
|
||||
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~).
|
||||
|
||||
This gives way to one of Elixir's unique features of 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
|
||||
@ -154,9 +306,23 @@ This demo shows how we can:
|
||||
+ Basic Elixir constructs (pattern matching, atoms, function calls, referencing functions)
|
||||
|
||||
* CheSSH
|
||||
With a very brief and quick exploration into concurrency with Elixir, we can now explore the architecture of 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
|
||||
|
355
presentation/tic_tac_toe.exs
Normal file
355
presentation/tic_tac_toe.exs
Normal file
@ -0,0 +1,355 @@
|
||||
defmodule Generator do
|
||||
def gen_reference() do
|
||||
min = String.to_integer("100000", 36)
|
||||
max = String.to_integer("ZZZZZZ", 36)
|
||||
|
||||
max
|
||||
|> Kernel.-(min)
|
||||
|> :rand.uniform()
|
||||
|> Kernel.+(min)
|
||||
|> Integer.to_string(36)
|
||||
end
|
||||
end
|
||||
|
||||
defmodule TicTacToe.GameManager do
|
||||
use GenServer
|
||||
|
||||
defmodule State do
|
||||
defstruct games: %{},
|
||||
joinable_games: [],
|
||||
player_games: %{}
|
||||
end
|
||||
|
||||
def start_link(_) do
|
||||
GenServer.start_link(__MODULE__, %{
|
||||
pid: nil
|
||||
})
|
||||
end
|
||||
|
||||
def init(_) do
|
||||
{:ok, %State{}}
|
||||
end
|
||||
|
||||
defp create_board(), do: Enum.map(0..2, fn _ -> Enum.map(0..2, fn _ -> :empty end) end)
|
||||
|
||||
defp create_game(game_id, player) do
|
||||
%{
|
||||
x: player,
|
||||
o: nil,
|
||||
board: create_board()
|
||||
}
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
{:join, %{client_pid: client_pid, username: username, player_id: connection_id} = player},
|
||||
%State{player_games: player_games, games: games, joinable_games: joinable_games} = state
|
||||
) do
|
||||
if length(joinable_games) == 0 do
|
||||
game_id = Generator.gen_reference()
|
||||
send(client_pid, {:join_game, game_id})
|
||||
|
||||
{:ok,
|
||||
%State{
|
||||
state
|
||||
| games: Map.put(games, game_id, create_game(game_id, player)),
|
||||
joinable_games: joinable_games ++ [game_id],
|
||||
player_games: Map.put(player_games, player_id, game_id)
|
||||
}}
|
||||
else
|
||||
[joining_game_id | rest] = joinable_games
|
||||
game = Map.get(games, joining_game_id)
|
||||
send(game.x.client_pid, :player_joined)
|
||||
send(client_pid, {:join_game, game_id})
|
||||
|
||||
{:ok,
|
||||
%State{
|
||||
state
|
||||
| games: Map.put(games, game_id, %{game | o: player}),
|
||||
joinable_games: rest,
|
||||
connection_games: Map.put(player_games, connection_id, game_id)
|
||||
}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defmodule TicTacToe.SSHDaemon do
|
||||
@port 4000
|
||||
@key_dir "/tmp/keys"
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
def start_link(_) do
|
||||
GenServer.start_link(__MODULE__, %{
|
||||
pid: nil
|
||||
})
|
||||
end
|
||||
|
||||
def init(state) do
|
||||
send(self(), :start)
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def handle_info(:start, state) do
|
||||
game_manager_pid =
|
||||
case GenServer.start_link(TicTacToe.GameManager, [%{}]) do
|
||||
{:ok, game_manager_pid} ->
|
||||
game_manager_pid
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
|
||||
case :ssh.daemon(
|
||||
@port,
|
||||
system_dir: @key_dir,
|
||||
ssh_cli:
|
||||
{TicTacToe.SSHListener,
|
||||
[
|
||||
%TicTacToe.SSHListener.State{
|
||||
game_manager_pid: game_manager_pid
|
||||
}
|
||||
]},
|
||||
disconnectfun: &on_disconnect/1,
|
||||
id_string: :random,
|
||||
parallel_login: true,
|
||||
max_sessions: 1_000,
|
||||
subsystems: [],
|
||||
no_auth_needed: true
|
||||
) do
|
||||
{:ok, pid} ->
|
||||
Logger.info("SSH server started on port #{port}, on #{inspect(pid)}")
|
||||
|
||||
Process.link(pid)
|
||||
|
||||
{:noreply, %{state | pid: pid, game_manager_pid: game_manager_pid}, :hibernate}
|
||||
|
||||
{:error, err} ->
|
||||
raise inspect(err)
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_info(_, state), do: {:noreply, state}
|
||||
|
||||
defp on_disconnect(_reason) do
|
||||
Logger.info("#{inspect(self())} disconnected")
|
||||
end
|
||||
end
|
||||
|
||||
defmodule TicTacToe.SSHListener do
|
||||
alias Chessh.SSH.Client
|
||||
|
||||
alias IO.ANSI
|
||||
|
||||
require Logger
|
||||
|
||||
@behaviour :ssh_server_channel
|
||||
@session_closed_message [
|
||||
ANSI.clear(),
|
||||
["This session has been closed"]
|
||||
]
|
||||
|
||||
defmodule State do
|
||||
defstruct channel_id: nil,
|
||||
client_pid: nil,
|
||||
game_manager_pid: nil,
|
||||
connection_ref: nil
|
||||
end
|
||||
|
||||
def init([%State{} = init_state]) do
|
||||
{:ok, init_state}
|
||||
end
|
||||
|
||||
def handle_msg({:ssh_channel_up, channel_id, connection_ref}, %State{} = state) do
|
||||
Logger.debug("SSH channel up #{inspect(:ssh.connection_info(connection_ref))}")
|
||||
|
||||
username =
|
||||
:ssh.connection_info(connection_ref)
|
||||
|> Keyword.fetch!(:user)
|
||||
|> String.Chars.to_string()
|
||||
|
||||
{:ok,
|
||||
%State{
|
||||
state
|
||||
| channel_id: channel_id,
|
||||
connection_ref: connection_ref,
|
||||
player: %{
|
||||
id: Generator.gen_reference(),
|
||||
username: username
|
||||
}
|
||||
}}
|
||||
end
|
||||
|
||||
def handle_msg(
|
||||
{:EXIT, client_pid, _reason},
|
||||
%State{client_pid: client_pid, channel_id: channel_id} = state
|
||||
) do
|
||||
send(client_pid, :quit)
|
||||
{:stop, channel_id, state}
|
||||
end
|
||||
|
||||
def handle_msg(
|
||||
{:send_data, data},
|
||||
%State{connection_ref: connection_ref, channel_id: channel_id} = state
|
||||
) do
|
||||
:ssh_connection.send(connection_ref, channel_id, data)
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def handle_msg(
|
||||
:session_closed,
|
||||
%State{connection_ref: connection_ref, channel_id: channel_id} = state
|
||||
) do
|
||||
:ssh_connection.send(connection_ref, channel_id, @session_closed_message)
|
||||
{:stop, channel_id, state}
|
||||
end
|
||||
|
||||
def handle_msg(msg, term) do
|
||||
Logger.debug("Unknown msg #{inspect(msg)}, #{inspect(term)}")
|
||||
end
|
||||
|
||||
def handle_ssh_msg(
|
||||
{:ssh_cm, _connection_handler, {:data, _channel_id, _type, data}},
|
||||
%State{client_pid: client_pid} = state
|
||||
) do
|
||||
send(client_pid, {:data, data})
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def handle_ssh_msg(
|
||||
{:ssh_cm, connection_handler,
|
||||
{:pty, channel_id, want_reply?, {_term, _width, _height, _pixwidth, _pixheight, _opts}}},
|
||||
%State{} = state
|
||||
) do
|
||||
Logger.debug("#{inspect(state.player_session)} has requested a PTY")
|
||||
:ssh_connection.reply_request(connection_handler, want_reply?, :success, channel_id)
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def handle_ssh_msg(
|
||||
{:ssh_cm, connection_handler, {:env, channel_id, want_reply?, var, value}},
|
||||
state
|
||||
) do
|
||||
:ssh_connection.reply_request(connection_handler, want_reply?, :failure, channel_id)
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def handle_ssh_msg(
|
||||
{:ssh_cm, _connection_handler,
|
||||
{:window_change, _channel_id, _width, _height, _pixwidth, _pixheight}},
|
||||
%State{client_pid: client_pid} = state
|
||||
) do
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def handle_ssh_msg(
|
||||
{:ssh_cm, connection_handler, {:shell, channel_id, want_reply?}},
|
||||
%State{player: player} = state
|
||||
) do
|
||||
:ssh_connection.reply_request(connection_handler, want_reply?, :success, channel_id)
|
||||
|
||||
{:ok, client_pid} =
|
||||
GenServer.start_link(Client, [
|
||||
%Client.State{
|
||||
tui_pid: self(),
|
||||
player: player
|
||||
}
|
||||
])
|
||||
|
||||
send(client_pid, :refresh)
|
||||
{:ok, %State{state | client_pid: client_pid}}
|
||||
end
|
||||
|
||||
def handle_ssh_msg(
|
||||
msg,
|
||||
%State{channel_id: channel_id} = state
|
||||
) do
|
||||
Logger.debug("UNKOWN MESSAGE #{inspect(msg)}")
|
||||
# {:stop, channel_id, state}
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def terminate(_reason, _state) do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defmodule TicTacToe.Client do
|
||||
alias IO.ANSI
|
||||
use GenServer
|
||||
|
||||
@clear_codes [
|
||||
ANSI.clear(),
|
||||
ANSI.home()
|
||||
]
|
||||
|
||||
defmodule State do
|
||||
defstruct tui_pid: nil,
|
||||
game_manager_pid: nil,
|
||||
player: %{},
|
||||
game_id: nil
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init([%State{game_manager_pid: game_manager_pid, player: player} = state]) do
|
||||
player = %{
|
||||
player
|
||||
| client_pid: self()
|
||||
}
|
||||
|
||||
send(game_manager_pid, {:join, player})
|
||||
|
||||
{:ok,
|
||||
%State{
|
||||
player: player
|
||||
}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:quit, %State{} = state) do
|
||||
{:stop, :normal, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:join_game, game_id}, %State{} = state) do
|
||||
state = %State{state | game_id: game_id}
|
||||
render(state)
|
||||
{:stop, :normal, state}
|
||||
end
|
||||
|
||||
def handle(
|
||||
{:data, data},
|
||||
%State{} = state
|
||||
) do
|
||||
case keymap(data) do
|
||||
:quit ->
|
||||
{:stop, :normal, state}
|
||||
end
|
||||
end
|
||||
|
||||
def handle(
|
||||
:player_joined,
|
||||
%State{} = state
|
||||
) do
|
||||
render(state)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
defp render(%State{
|
||||
tui_pid: tui_pid
|
||||
}) do
|
||||
send(tui_pid, {:send_data, ["Testing"]})
|
||||
end
|
||||
|
||||
def keymap(key) do
|
||||
case key do
|
||||
# Exit keys - C-c and C-d
|
||||
<<3>> -> :quit
|
||||
<<4>> -> :quit
|
||||
x -> x
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue
Block a user