I'm curious if there's a better way to write this module using macros

So I’m trying to get a better grasp of Elixir macros. In order to do so I’ve written a module. It’s called GenRouter, and what it does it allow the user to write a GenServer like module, but when it’s started, it will n instances of that server, and route calls to one of those instances, either by :caller (ensuring that every pid will always reach the same server), or by :rand (each pid could hit any one of the server instances). This is not something I need, but just a dummy module to learn macros. I guess it’s sort of like a pool. Below is the code as well as it being used

defmodule GenRouter do
  @moduledoc """
  GenRouter is a module that allows you to define a single GenServer like module.
  When that module is started it will start multiple processes running the same code.
  Calls to the module will be routed across each of the instances, like a pool.

  When you define a module that use's GenRouter, it will define a module called __MODULE__.Sup
  that is the module you want to start when your application starts.

  Options:
  num_children:
    This is the number of copies of your module that will be started and have requests routed to them
    Default: 2

  route_by:
    This is the method by which a call will be routed to a particuler instance of the module
    If `:caller` is chosen, then every process will always be routed to the same instance of the module, this
    is good if you have lots of different processes calling the module, however if you have only a few proccess sending
    lots of messages and it doesn't matter that they are always handled by the same instance, then the option
    `:rand` will ensure that all calls are evenly spread across the instances, but the different callers may be handled
    by a different instance on each call
    options: [:caller, :rand]
    Default: :caller
  """
  defmacro __using__(opts) do
    quote do
      use GenServer
      @num_children Keyword.get(unquote(opts), :num_children, 2)
      @route_by Keyword.get(unquote(opts), :route_by, :caller)

      def start_link(args \\ nil) do
        GenServer.start_link(__MODULE__, args, [])
      end

      def init(_), do: {:ok, nil}

      defoverridable init: 1

      @before_compile GenRouter
      import GenRouter
    end
  end

  defmacro defaction(fundef, do: body) do
    quote do
      # @type unquote(fundef) :: any()
      def unquote(fundef) do
        pid = __MODULE__.Sup.call()

        case unquote(body) do
          {:call, args} -> GenServer.call(pid, args)
          {:cast, args} -> GenServer.cast(pid, args)
        end
      end
    end
  end

  defmacro __before_compile__(_) do
    quote do
      mod = __MODULE__

      supervisor_contents =
        quote do
          use DynamicSupervisor

          def start_link(_) do
            {:ok, pid} = DynamicSupervisor.start_link(__MODULE__, nil, name: __MODULE__)
            start_children()
            {:ok, pid}
          end

          @impl true
          def init(_init_arg) do
            DynamicSupervisor.init(strategy: :one_for_one)
          end

          def call() do
            case unquote(@route_by) do
              :caller ->
                num = DynamicSupervisor.count_children(__MODULE__)[:workers]
                children = DynamicSupervisor.which_children(__MODULE__)
                {_, pid, _, _} = Enum.at(children, :erlang.phash2(self(), num))
                pid

              :rand ->
                num = DynamicSupervisor.count_children(__MODULE__)[:workers]
                children = DynamicSupervisor.which_children(__MODULE__)
                rand = :rand.uniform(num)
                {_, pid, _, _} = Enum.at(children, rand - 1)
                pid
            end
          end

          def start_children() do
            for i <- 1..unquote(@num_children) do
              DynamicSupervisor.start_child(__MODULE__, %{
                id: i,
                start: {unquote(mod), :start_link, []}
              })
            end
          end
        end

      Module.create(__MODULE__.Sup, supervisor_contents, Macro.Env.location(__ENV__))
    end
  end
end
defmodule GenRouterImp do
  use GenRouter, num_children: 10, route_by: :caller

  defaction put_and_get(key, val) do
    {:call, {:put_and_get, {key, val}}}
  end

  def init(_), do: {:ok, %{}}

  def handle_call({:put_and_get, {k, v}}, _from, state) do
    new_state = Map.put(state, k, v)
    {:reply, new_state, new_state}
  end
end

This would create a GenRouterImp.Sup module that would be started. This would then start 10 instance of GenRouterImp.

What I’d like to know is if there’s a cleaner way to do this with macros. Especiailly if there’s a way to do it so the implementing module doesn’t need to use the defaction. Is there way to make it more like a regular GenServer implementation (perhaps with a module doc above each function stating whether it’s a call or a cast). I know that ExUnit makes use of module docs above each test in order to alter the way they behave at run time.

2 Likes