CLU
We believe it is helpful to associate two structures with a program: its logical structure and its physical structure.
- Barbara Liskov, Programming with Abstract Data Types (1974)
Abstract Data Type: a class of abstract objects which are entirely characterized by the operations available on those objects.
Figure 1: Erlang Maps, Under the Surface
Atlas: Flexible Software Composition Using Protocols
Quinn Wilton / @wilton_quinn / quinnwilton.com / SYNOPSYS
Let's go back in time a year...
Introducing Atlas!
-
A unified reporting dashboard for a disparate set of security scanners
-
Each scanner...
-
Was deployed as a separate web service
-
Operated in different domains (web apps, APIs, etc)
-
Required new APIs to expose the data we needed
-
Development Goals
-
Close the feedback loop between building new remote APIs and frontend code that depends on them
-
Quickly iterate on frontend development with extensive generation of mock data
-
Simplify local development by building fully-functional mocks of the remote services
defmodule Twitter do
@callback get_by_username(username :: String.t) :: %Twitter.User{}
@callback followers_for(username :: String.t) :: [%Twitter.User{}]
end
defmodule TwitterMock do
@behaviour Twitter
def get_by_username("josevalim") do
%Twitter.User{
username: "josevalim"
}
end
def followers_for("josevalim") do
# return literally everyone
end
end
Mocks and explicit contracts
defmodule Twitter do
@callback register(username :: String.t()) :: :ok | :already_taken
@callback get_by_username(username :: String.t) :: %Twitter.User{}
end
assert nil == TwitterMock.get_by_username("quinn")
assert :ok == TwitterMock.register("quinn")
assert %Twitter.User{username: "quinn"} = TwitterMock.get_by_username("quinn")
** (MatchError) no match of right hand side value: nil
Stateful behaviours?
- Modify the behaviour to take a reference to some sort of non-global mutable state, like a PID
- Access shared global state, using a named process, ETS table, or persistent term
defprotocol Twitter do
def register(twitter, username)
def get_by_username(twitter, username)
end
defmodule Twitter.InMemory do
defstruct [:pid]
def new(opts \\ []) do
{:ok, pid} = start_link(opts)
%__MODULE__{pid: pid}
end
def start_link(opts \\ []) do
Agent.start_link(fn -> %{} end)
end
defimpl Twitter do
def register(%{pid: pid}, username) do
Agent.get_and_update(pid, fn
%{^username => _user} = state ->
{:already_taken, state}
state ->
{:ok, Map.put(state, username, %Twitter.User{username: username})}
end)
end
def get_by_username(%{pid: pid}, username) do
Agent.get(pid, &Map.get(&1, username))
end
end
end
Once more, with ad-hoc polymorphism
iex(1)> twitter = Twitter.InMemory.new
%Twitter.InMemory{pid: #PID<0.282.0>}
iex(2)> Twitter.get_by_username(twitter, "quinn")
nil
iex(3)> Twitter.register(twitter, "quinn")
:ok
iex(4)> Twitter.get_by_username(twitter, "quinn")
%Twitter.User{username: "quinn"}
Once more, with ad-hoc polymorphism
-
Can build frontends that depend on APIs that don't exist yet
-
simplify development by not requiring those services to be running
-
enable exploration of the domain model without leaving Elixir
-
take advantage of extensive mock data generation
Norm
-
Library for specifying, validating, and generating data
-
Given a data specification, Norm automatically infers:
-
Conformers, for validation
-
Generators, for property testing
-
Data Specification
id = spec(is_integer() and (&(&1 > 0)))
name =
with_gen(
spec(is_binary()),
StreamData.string(:alphanumeric, min_length: 5, max_length: 15)
)
product_schema =
schema(%{
id: id,
name: name
})
Data Validation
iex> conform!(%{id: 5, name: "GRiSP 2"}, selection(product_schema))
%{id: 5, name: "GRiSP 2"}
iex> conform!(%{id: 5, name: nil}, selection(product_schema))
** (Norm.MismatchError) Could not conform input:
val: nil in: :name fails: is_binary()
(norm 0.12.0) lib/norm.ex:64: Norm.conform!/2
Data Generation
product_generator = gen(product_schema)
iex> Enum.take(gen(product_schema), 5)
[
%{id: 1, name: "gbHa8"},
%{id: 1, name: "IuI6D"},
%{id: 3, name: "3q49g"},
%{id: 3, name: "gZRjG"},
%{id: 1, name: "1Bg3M"}
]
demo time
Accounts service
defprotocol EasyWire.Accounts.Service do
def get_account_for_profile(service, profile_id)
def deposit_money(service, profile_id, amount)
end
defimpl EasyWire.Accounts.Service, for: EasyWire.Accounts.InMemory do
alias EasyWire.Accounts.InMemory
defdelegate get_account_for_profile(service, profile_id), to: InMemory
defdelegate deposit_money(service, profile_id, amount), to: InMemory
end
Accounts service
defmodule EasyWire.Accounts.InMemory do
alias EasyWire.Accounts.Account
defstruct [:pid]
def new(opts) do
{:ok, pid} = start_link(opts)
%__MODULE__{pid: pid}
end
def start_link(opts) do
Agent.start_link(fn ->
profile_ids = Keyword.get(opts, :profile_ids, [])
generation_size = Keyword.get(opts, :generation_size, 1)
model = %{
generation_size: generation_size,
accounts: %{}
}
Enum.reduce(profile_ids, model, fn profile_id, model ->
account =
Account.schema()
|> Norm.gen()
|> StreamData.resize(model.generation_size)
|> Enum.at(0)
|> Map.put(:profile_id, profile_id)
Map.update!(model, :accounts, &Map.put(&1, account.profile_id, account))
end)
end)
end
...
end
Profile Schema
defmodule EasyWire.Profiles.Profile do
import Norm
alias EasyWire.Types
defstruct [
:id,
:name,
:trust,
:company
]
def schema,
do:
schema(%__MODULE__{
id: Types.id(),
name: Types.person_name(),
trust: trust(),
company: company()
})
defp trust() do
alt(
verified: :verified,
unverified: :unverified
)
end
defp company() do
generator =
StreamData.sized(fn _ ->
StreamData.constant(Faker.Company.name())
end)
with_gen(spec(is_binary), generator)
end
end
🚨Here be dragons🚨
Service mesh
defmodule EasyWire.ServiceRouter do
use ServiceMesh.Router, otp_app: :easy_wire
middleware ServiceMesh.Middleware.Telemetry
middleware ServiceMesh.Middleware.SimulateLatency, latency_ms: 100
middleware ServiceMesh.Middleware.SimulateNetworkFailure, failure_rate: 0.01
register :accounts, EasyWire.Accounts.Service
register :profiles, EasyWire.Profiles.Service
register :transactions, EasyWire.Transactions.Service
end
# in application.ex
def start(_type, _args) do
children = [
{ServiceMesh, EasyWire.ServiceRouter},
]
Supervisor.start_link(children, ...)
end
Service router
# in config/config.exs
config :easy_wire, EasyWire.ServiceRouter, fn ->
profile_ids =
EasyWire.Types.id()
|> Norm.gen()
|> Enum.take(10)
%{
profiles: EasyWire.Profiles.InMemory.new(profile_ids: profile_ids),
accounts: EasyWire.Accounts.InMemory.new(profile_ids: profile_ids),
transactions: EasyWire.Transactions.InMemory.new(profile_ids: profile_ids)
}
end
Service Configuration
{:ok, account} =
ServiceMesh.call(
:accounts,
:get_account_for_profile,
[profile_id]
)
Service Usage
"program one decision at a time"
defmodule EasyWire.Search.Parallel do
defstruct [
:services
]
defimpl EasyWire.Search.Service do
alias EasyWire.Search
def search(%{services: services}, query, opts) do
services
|> Enum.map(fn service -> Task.async(&Search.Service.search(query, opts)) end)
|> Task.await_many()
|> List.flatten()
|> Enum.sort_by(&(&1.score))
end
end
end
Transparent concurrency
defmodule Analytics.Experiment do
defstruct [
:old,
:new
]
defimpl Analytics.Service do
require Logger
def some_complicated_query(service) do
[old, new] =
Task.await_many([
Task.async(fn -> Analytics.Service.some_complicated_query(service.old) end),
Task.async(fn -> Analytics.Service.some_complicated_query(service.new) end)
])
if old != new do
Logger.warning(fn -> "Expected: #{old}, got: #{new}" end)
end
old
end
end
end
Refactoring Experiments
defmodule AuthService.Migration do
defstruct [
:map_service_fn
]
defimpl AuthService do
def authenticate(service, credentials) do
inner_service = service.map_service_fn.(credentials)
AuthService.authenticate(inner_service, credentials)
end
end
end
# in config
%AuthService.Migration{
map_service_fn: fn scan ->
case credentials.version do
:v0 -> %AuthService.LegacyService{...}
:v1 -> %AuthService.NewService{...}
end
end
}
migrations
stateful Property Based testing
-
Variant of property based testing
-
A model of a complex system is defined
-
Sequences of commands are generated
-
Those commands are run against the real system
-
The real system is compared against the model
-
-
Requires PropEr
-
PropCheck is an Elixir wrapper
-
-
See Mentat: https://github.com/keathley/mentat
Summary
- Protocols allow you to separate the logical structure of your program from the physical
- Protocols + Norm allow you to quickly prototype by modeling remote services
- Protocol composition enables you to layer new behavior onto an existing system
Thank you!
Quinn Wilton / @wilton_quinn / quinnwilton.com
Atlas: Flexible Software Composition using Protocols
By quinnwilton
Atlas: Flexible Software Composition using Protocols
- 494