pragdave / jeeves

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

tl;dr

  • create anonymous, named, and pooled services by just specifying their functions
  • have them automatically wrapped in standard OTP GenServers and Supervisors.
  • simplify testing with automatically generated nonservice implementations.

Here's a pool of between 2 and 5 Fibonacci number services, each supervised and run in a separate, parallel process:

defmodule Fib do
  use Jeeves.Pooled

  def fib(n), do: _fib(n)

  defp _fib(0), do: 0
  defp _fib(1), do: 1
  defp _fib(n), do: _fib(n-1) + _fib(n-2)    # terribly inefficient
end

You'd start the pool using

Fib.run

And invoke one of the pool of workers using

Fib.fib(20)   # => 6765

We can use GenServer state to cache already calculated values, making the process O(n) rather than O(1.6ⁿ).

defmodule Fib do
  use Jeeves.Pooled, 
      state:       %{ 0 => 0, 1 => 1}, 
      state_name: :cache

  def fib(n) do
    case cache[n] do
    nil ->
      fib_n = fib(n-2, cache) + fib(n-1, cache)
      update_state(Map.put(cache, n, fib_n)) do
        fib_n
        end
    cached_result ->
      cached_result      
    end 
  end
end

In the previous example each worker maintains its own cache. In the Fibonacci example, that's fine, as the cost of loading the cache is small. But if we wanted to share a single cache between all workers, we can add it as a named service:

defmodule FibCache do
  use Jeeves.Named, state: %{ 0 => 0, 1 => 1 }

  def get(n), do: state[n]
  def put(n, fib_n) do
    state
    |> Map.put(n, fib_n)
    |> update_state(do: fib_n)
  end
end

defmodule Fib do
  use Jeeves.Pooled, 

  def fib(n) do
    case FibCache.get(n) do
    nil ->
      with fib_n = fib(n-2) + fib(n-1),
      do: FibCache.put(n, fib_n)   # => returns result
    cached_result ->
      cached_result
    end 
  end
end

end tl;dr

Jeeves—at your service

Erlang encourages us to write our code as self-contained servers and applications. Elixir makes it even easier by removing much of the boilerplate needed to create an Erlang GenServer.

However, creating these servers is often more work than it needs to be. And, unfortunately, following good design practices adds even more work, in the form of duplication.

The Jeeves library aims to make it easier for newcomers to craft well designed services. It doesn't replace GenServer. It is simply a layer on top that handles the most common GenServer use cases. The intent is to remove any excuse that people might have for not writing their Elixir code using a ridiculously large number of trivial services.

Basic Example

You can think of an Erlang process as a remarkably pure implementation of an object. It is self contained, with private state, and with an interface that is accessed by sending messages. This harks straight back to the early days of Smalltalk.

Jeeves draws on that idea. When you include it in a module, that module's public functions become the interface to the service. You write the functions, and Jeeves rewrites them into a GenServer.

Here's a simple service that implements a key-value store.

defmodule KVStore do

  use Jeeves.Anonymous, state: %{}

  def put(store, key, value) do
    set_state(Map.put(store, key, value)) do
      value
    end
  end
  
  def get(store, key) do
    store[key]
  end
end

The first parameter to put and get is the current state, and the second is the value being passed in.

You'd call it using

rt = KVStore.run()

KVStore.put(rt, :name, "Elixir")
KVStore.get(rt, :name)   # => "Elixir"

Behind the scenes, Jeeves has created a pure implementation of our totaller, along with a GenServer that delegates to that implementation. What does that code look like? Add the :show_code option to our original source.

defmodule KVStore do

  use Jeeves.Anonymous, 
      state: %{},
      show_code: true

  def put(store, key, value) do
    # . . .

During compilation, you'll see the code that will actually be run:

# defmodule RunningTotal do

  import(Kernel, except: [def: 2])
  import(Jeeves.Common, only: [def: 2, set_state: 2])
  @before_compile({Jeeves.Anonymous, :generate_code_callback})
  def run() do
    run(%{})
  end
  def run(state) do
    {:ok, pid} = GenServer.start_link(__MODULE__, state, server_opts())
    pid
  end
  def init(state) do
    {:ok, state}
  end
  def initial_state(default_state, _your_state) do
    default_state
  end
  def server_opts() do
    []
  end
  defoverridable(initial_state: 2)

  use(GenServer)

  def put(store, key, value) do
    GenServer.call(store, {:put, key, value})
  end
  def get(store, key) do
    GenServer.call(store, {:get, key})
  end
  def handle_call({:put, key, value}, _, store) do
    __MODULE__.Implementation.put(store, key, value)
    |> Jeeves.Common.create_genserver_response(store)                                                                  
  end
  def handle_call({:get, key}, _, store) do
    __MODULE__.Implementation.get(store, key)
    |> Jeeves.Common.create_genserver_response(store)                                                                         
  end
  
  defmodule Implementation do
    def put(store, key, value) do
      set_state(Map.put(store, key, value)) do
        value
      end
    end
    def get(store, key) do
      store[key]
    end
  end

# end

Testing

We can test this implementation without starting a separate process by simply calling functions in KVStore.Implementation. You have to supply the state, and allow for the fact that the responses will include the updated state if set_state is called.

alias KVI KVStore.Implementation

@state %{}

test "we can put a KV pair, then get it, retrieves the correct value" do
  { :reply, Elixir, state } = KVI.put(@state, :name, Elixir)
  assert KVI.get(state, name) == Elixir
end

Creating a Named (Singleton) Service

It's sometimes convenient to create a global, named, service. Logging is a good example of this, as are registry services, global caches, and the like.

We can make out KV store a global named service with some trivial changes:

defmodule NamedKVStore do

  use Jeeves.Named, 
      state_name:   :kvs, 
      state:        %{}, 

  def put(key, value) do
    set_state(Map.put(kvs, key, value)) do
      value
    end
  end
  
  def get(key) do
    kvs[key]
  end
end

Notice there's a bit of magic here. A named service can be called from anywhere in your code. It doesn't require you to remember a PID or any other handle, as the service's API invokes the service process by name. However, the service process itself contains state (the map in the KVStore example). The client doesn't need to know about this internal state, so it is never exposed via the API. Instead, it is automatically made available inside the service's functions in a variable. By default, this variable is called state, but the NamedKVStore example changes this to something more meaningful, kvs.

You'd call the named KV store service using

NamedKVStore.put(:name, "Elixir")
NamedKVStore.put(:engine, "BEAM")  
NamedKVStore.get(:name)            # => "Elixir"

Pooled Services

Named services can be turned into pools of workers by changing to Jeeves.Pooled.

defmodule TwitterFeed do

  use Jeeves.Pooled,
      pool: [ min: 5, max: 20 ]

  def fetch(name) do
    # ...
  end
end

Calls to TwitterFeed.fetch would run in parallel, up to a maximum of 20 processes.

Inline code

Finally, we can tell Jeeves not to generate a server at all.

defmodule RunningTotal do

  use Jeeves.Inline, state: 0

  def add(total, value) do
    set_state(value+total)
  end
end

The cool thing is we can switch between not running a process, running a single server, or running a pool of servers by changing a single declaration in the module.

More Information

  • Anonymous services:

    • documentation: Jeeves.Anonymous
    • example
  • Named services:

    • documentation: Jeeves.Named
    • example
  • Pooled services:

    • documentation: Jeeves.Pooled
    • example
  • Some background

To do

  • Implement anonymous pools
  • Add declarative supervision (when child_spec becomes available)
  • Tests!

Author

Dave Thomas (dave@pragdave.me, @pragdave)

License: see the file LICENSE.md

About

License:Other


Languages

Language:Elixir 100.0%