Декорируем функции с помощью макросов

Однажды передо мной стояла задача запустить в массы Telegram-бота для отслеживания времени прибытия автобусов в Дублине (@dublin_bus_bot). Перед этим я задумался, сколько людей будут им пользоваться (спойлер: почти нисколько), и я решил, что было бы неплохо отследить их количество с помощью Google Analytics.

О решении

Протокол передачи статистических данных Google Analytics позволяет отслеживать данные не только веб-сайтов, но и мобильных приложений, программных решений для интернета вещей и др. На данный момент для этого протокола не существует Elixir клиента, а когда он появится, то будет представлять собой ни что иное, как обёртку над API. Мой план заключался в том, чтобы посылать запросы на сервер Google Analytics с помощью HTTPoison, но хотелось бы избежать вызова функции отслеживания после каждой новой команды бота.

Что мне нравится в Elixir, так это макросы, позволяющие генерировать код на этапе компиляции. Мне в голову пришла идея написать макрос, использование которого похоже на объявление функции. Этот макрос определял бы функцию с таким же телом и дополнительным вызовом функции отслеживания. Этот подход показался мне более естественным для функциональной модели Elixir в сравнении со стандартным синтаксисом декораторов в других языках (@decorator в Python и Javascript).

defmetered sample_function(arg1, arg2) do
    IO.inspect([arg1, arg2])
end
# would generate something similar to
def sample_function(arg1, arg2) do
    track(:sample_function, [arg1: arg1, arg2: arg2])
    IO.inspect([arg1, arg2])
end

Реализация

Описанный выше подход был реализован в пакете meter для использования в созданном мной Telegram-боте.

@doc """
Replace a function definition, automatically tracking every call to the function
on google analytics. It also track exception with the function track_error.
This macro intended use is with a set of uniform functions that can be concettualy
mapped to pageviews (eg: messaging bot commands).
Example:
    defmetered function(arg1, arg2), do: IO.inspect({arg1,arg2})
    function(1,2)
    
will call track with this parameters
    
    track(:function, [arg1: 1, arg2: 2])
Additional parameters will be loaded from the configurationd
"""
# A macro definition can use pattern matching to destructure the arguments
defmacro defmetered({function,_,args} = fundef, [do: body]) do
  # arguments are defined in 3 elements tuples
  # this extract the arguments names in a list
  names = Enum.map(args, &elem(&1, 0))
  # meter will contain the body of the function that will be defined by the macro
  metered = quote do
    # quote and unquote allow to switch context,
    # simplyfing a lot quoted code will run when the function is called
    # unquoted code run at compile time (when the macro is called)
    values = unquote(
      args
      |> Enum.map(fn arg ->  quote do
          # allow to access a value at runtime knowing the name
          # elixir macros are hygienic so it's necessary to mark it
          # explicitly
          var!(unquote(arg))
        end
      end)
    )
    # Match argument names with their own values at call time
    map = Enum.zip(unquote(names), values)
    # wrap the original function call with a try to track errors too
    try do
      to_return = unquote(body)
      track(unquote(function), map)
      to_return
    rescue
      e ->
        track_error(unquote(function), map, e)
        raise e
    end
  end
  # define a function with the same name and arguments and with the augmented body
  quote do
    def(unquote(fundef),unquote([do: metered]))
  end
end

Заключение

Макросы Elixir — мощный инструмент для расширения функциональности приложений и создания DSL. Несмотря на то, что их изучение займёт какое-то время, это полностью оправданно, ведь они помогут вам навести порядок в своём коде.

© 2020 / Россия Любые мысли и вопросы пишите на elixir@wunsh.ru.