Предсказуемый код в Elixir: Выражения как редукторы и макросы

В первой части этой серии статей о сопровождаемом коде Elixir мы начали с применения правил предсказуемости кода при его проектировании. Мы сразу же увидели зарождающийся паттерн: серию преобразований состояния. В этой части мы продолжим изучение этого паттерна.

Сначала мы научимся писать выражения как редукторы. Затем мы будем использовать метапрограммирование для использования редукторов и беспрепятственного соблюдения стиля кода.

В завершение мы рассмотрим пример, в котором все части подходят друг другу.

Давайте начнем!

Краткий обзор первой части и того, что мы будем делать

В первой части мы экспериментировали с применением стилей кода к этому фрагменту:

with %Payment{} = payment <- fetch_payment_information(params),
     {:ok, user} <- Session.get(conn, :user),
     address when !is_nil(address) <- fetch_address(user, params),
     {:ok, order} <- create_order(user, payment, address) do
  conn
  |> put_flash(:info, "Order completed!")
  |> render("checkout.html")
else
  {:error, :payment_failed} ->
    handle_error(conn, "Payment Error")

  %Store.OrderError{message: message} ->
    handle_error(conn, "Order Error")

  error ->
    handle_error(conn, "Unprocessable order")
end
Вход в полноэкранный режим Выход из полноэкранного режима

К концу этого поста у нас будут все инструменты, чтобы переписать его следующим образом:

case Checkout.execute(%{params: params}, options) do
  {:ok, %{order: order}} ->
    conn
    |> put_flash(:info, "Order completed!")
    |> redirect(to: Routes.order_path(conn, order))

  {:error, error}
    conn
    |> put_flash(:error, parse_error(error_description))
    |> render("checkout.html")
end
Вход в полноэкранный режим Выход из полноэкранного режима

Во время написания этого поста я создал ex_pipeline, библиотеку с открытым исходным кодом, которая позволяет разработчикам создавать конвейеры, легко соблюдая стиль кода.

Все примеры кода в этом посте являются упрощенными версиями того, что существует в этом проекте.

Цепочка преобразований

Одним из моих любимых хобби является деревообработка — я не силен в этом, конечно, но это то, что мне нравится. Прежде чем начать новый проект, я всегда записываю все материалы и инструменты, которые мне понадобятся, а также шаги, которые я буду выполнять. Так я прохожу путь от кусков дерева до готового стола, от начального состояния до законченного.

Давайте подумаем, как делается стол: вы получаете сырье — дерево, гвозди, клей — и несколько инструментов. Затем вы выполняете ряд действий по изменению формы и внешнего вида материалов — режете, собираете, полируете и красите.

Вы применяете цепочку преобразований к исходному состоянию (сырье), пока не получите желаемое состояние (стол).

Мы уже говорили о возникающей схеме конвейера. Это то, что мы собираемся изучить в следующих параграфах.

Запись выражений как редукторов в Elixir

Редукторы — это одна из ключевых концепций функционального программирования. Их очень просто понять: вы берете список значений, применяете функцию к каждому элементу и накапливаете результаты.

В Elixir мы можем работать с редукторами, используя функцию Enum.reduce/3
и ее варианты — Enum.reduce/2 и Enum.reduce_while/3.

Например, мы можем использовать reducer для фильтрации всех четных чисел из списка:

numbers = [1, 2, 3, 4, 5, 6]
starting_value = []

Enum.reduce(numbers, starting_value, fn current_number, filtered_values ->
  if rem(current_number, 2) == 0 do   # is the current number even?
    [number | filtered_values]        # if so, add it to the accumulator
  else                                # otherwise...
    filtered_values                   # just ignore it and return the accumulator
  end
end)
Войти в полноэкранный режим Выйти из полноэкранного режима

Этот код вернет список [2, 4, 6].

Чтобы выполнить различные действия над значением, мы можем преобразовать список значений в список функций. Затем мы передаем значение в качестве начального значения для аккумулятора:

transformations = [
  fn x -> x + 1 end, # add 1
  fn x -> x * 2 end  # multiply by two
]
starting_value = 10

Enum.reduce(transformations, starting_value, fn transformation, current_value ->
  apply(transformation, [current_value])
end)
Войти в полноэкранный режим Выйти из полноэкранного режима

Это выглядит как сложный способ написать (10 + 1) * 2, верно? Но, выражая каждое преобразование как функцию, мы можем подключить произвольное количество преобразований. Все они должны принимать одинаковое количество параметров и возвращать следующее обновленное состояние. Другими словами, эти функции должны выглядеть одинаково и быть небольшими.

В итоге мы хотим записать предыдущий пример примерно так:

defmodule MyModule do
  use Pipeline

  def sum_step(value, _), do: {:ok, value + 1}
  def double_step(value, _), do: {:ok, value * 2}
end
Войти в полноэкранный режим Выход из полноэкранного режима

В следующей части мы сможем сделать именно это, используя немного магии макросов!

Вшивание уменьшителей в функции с помощью макросов

Теперь, когда мы знаем, как использовать редукторы, давайте применим их для написания конвейеров.

Цель состоит в том, чтобы сделать процесс написания, чтения и поддержки конвейеров как можно проще. Поэтому вместо добавления DSL или использования сложных конфигураций мы просто хотим писать наши функции, используя один и тот же шаблон имени и arity. Затем мы можем автоматизировать процесс построения конвейера, определяя, какие функции следуют этому шаблону.

Мы создадим новый конвейер, написав код по тому же шаблону.

Состояние: Отслеживание происходящего в Elixir

Прежде чем мы погрузимся в макросы, давайте сделаем шаг назад и немного поговорим о состоянии конвейера.

При выполнении конвейера нам нужно знать несколько вещей: начальное состояние, текущее состояние, если оно все еще актуально, и есть ли какие-либо ошибки. Вся эта информация поможет нам отлаживать и устранять любые проблемы, которые мы можем обнаружить при написании кода.

Кроме того, абстрагировав обновление состояния в этом модуле, мы можем обеспечить выполнение очень важного правила для редукторов: возвращаемое значение должно быть кортежем ok/error.

Начальная точка модуля, управляющего состоянием, должна быть примерно такой:

defmodule Pipeline.State do
  defstruct [:initial_value, :value, :valid?, :errors]

  def new(value) do
    %Pipeline.State{valid?: true, initial_value: value, value: value, errors: []}
  end

  def update(state, {module, function_name} = _function_definition, options \ []) do
    case apply(module, function_name, [state.value, options]) do
      {:ok, value} ->
        %Pipeline.State{state | value: value}

      {:error, error_description} ->
        %Pipeline.State{state | valid?: false, errors: [error_description | state.errors]}

      bad_return ->
        raise "Unexpected return value for pipeline step: #{inspect(bad_return)}"
    end
  end
end
Войти в полноэкранный режим Выйти из полноэкранного режима

Функция new/1 создаст новую корректную и чистую структуру %Pipeline.State{} на основе начального значения, которое мы передаем исполнителю конвейера.

Функция update/3 обновит или аннулирует данное состояние, вызвав function с value состояния и заданными options.

Если данная функция возвращает {:ok, <updated_value>}, то значение состояния обновляется, и мы готовы к работе. Однако, если функция возвращает значение {:error, <error>}, состояние становится недействительным. Возвращенная ошибка добавляется к списку ошибок в состоянии. Если функция возвращает значение, отличное от кортежа ok/error, она вызовет исключение.

Смотрите полную реализацию этого модуля.

Шаги и крючки конвейера

Каждая функция, преобразующая состояние, будет называться шагом. Вот некоторые рекомендации, которым следует придерживаться:

  • Шаг — это функция, имя которой заканчивается на _step и принимает два параметра.
  • Шаги выполняются в том порядке, в котором они объявлены в модуле.
  • Если один шаг не выполняется, то все остальные шаги игнорируются.

Иногда мы все же хотим выполнить код даже во время сбоев — записать журнал, обновить трассировку, опубликовать сообщение и т.д. Для таких случаев у нас есть специальный тип шага — хук. Давайте перечислим рекомендации для хуков:

  • Хук — это функция, имя которой заканчивается на _hook и принимает два параметра.
  • Хуки выполняются в порядке их объявления, как и шаги.
  • Они всегда выполняются в конце конвейера, после выполнения всех шагов.

Хорошо! Но как определить, что шаги и крючки существуют? Ответ —
метапрограммирование! К счастью для нас, в Elixir есть мощные инструменты метапрограммирования, которые делают все это возможным.

Настройка необходимых макросов в Elixir

Нам понадобятся две вещи: макросы и обратные вызовы компиляции для чтения информации о функциях модуля и хранения информации о том, что такое шаг и крючок.

Начиная с базового модуля Pipeline, мы хотим использовать Pipeline в других модулях. Для этого мы объявляем специальный макрос __using__/1. Этот макрос вызывается ключевым словом use в Elixir.

В этом макросе мы добавляем компиляционный хук к целевому модулю, чтобы мы могли внедрить в него код. Хук @before_compile делает именно это, вызывая заданную функцию, когда компилятор собирается сгенерировать основной байткод. Он принимает либо модуль, либо кортеж с именем модуля и функции. Мы выберем второй вариант, чтобы упростить работу.

defmodule Pipeline do
  defmacro __using__(_options) do
    quote do
      # Calls Pipeline.build_pipeline/1 before generating the final bytecode, allowing us to analyze, generate and
      # inject code whenever we call `use Pipeline`.
      @before_compile {Pipeline, :build_pipeline}
    end
  end

  defmacro build_pipeline(env) do
    # inject code!
  end
end
Войти в полноэкранный режим Выход из полноэкранного режима

Все, что вызывается внутри блока quote, будет инжектировано в вызывающий модуль — включая результат работы хука @before_compile.

Более подробную информацию о блоках quote и unquote смотрите в официальной документации.

Теперь, когда мы хотим создать конвейер, мы вызываем use:

defmodule MyPipeline do
  use Pipeline # <- code will be injected!
end
Войти в полноэкранный режим Выйти из полноэкранного режима

Обнаружение шагов и крючков

У нас есть все необходимое для внедрения кода, но нам все еще нужно сгенерировать код, который будет внедрен. Давайте развернем макрос build_pipeline/1:

defmacro build_pipeline(env) do
  # Fetch all public functions from the caller module
  definitions = Module.definitions_in(env.module, :def)

  # Filter functions that are steps and hooks
  steps = filter_functions(env.module, definitions, "_step", 2)
  hooks = filter_functions(env.module, definitions, "_hook", 2)

  # Generate code on the caller module
  quote do
    # returns all steps and hooks
    def __pipeline__, do: {unquote(steps), unquote(hooks)}

    # Syntatic sugar so we can execute directly from the module that defines it
    def execute(value, options \ []), do: Pipeline.execute(__MODULE__, value, options)
  end

  # Filter functions from the given module based on their suffix and arity
  defp filter_functions(module, definitions, suffix, expected_arity) do
    functions =
      Enum.reduce(definitions, [], fn {function, arity}, acc ->
        # Detect if the function name ends with the desired suffix: _step or _hook
        valid_name? =
          function
          |> Atom.to_string()
          |> String.ends_with?(suffix)

        # Detect if the function arity matches the expected arity
        has_expected_args? = arity == expected_arity

        cond do
          valid_name? and has_expected_args? ->
            # In this case, the function does end with the desired suffix and has the correct arity.
            # We include it in the accumulator along with the line where it was declared so we can
            # order them by their position in their module
            {_, _, [line: line], _} = Module.get_definition(module, {function, arity})
            [{module, function, line} | acc]

          valid_name? ->
            # If the function has the desired suffix but the arity is not correct, we raise this error
            # because we don't want people trying to figure out why their steps or hooks are not
            # being executed.
            raise(PipelineError, "Function #{function} does not accept #{expected_arity} parameters.")

          true ->
            # If the function doesn't match our filters, it's not a part of the pipeline and can be
            # just ignored
            acc
        end
      end)

    # At this point we have filtered the functions that match the filter criteria but they
    # are not in order. We use the line number information to sort them and then drop it
    # once we don't need it anymore.
    functions
    |> Enum.sort(fn {_, _, a}, {_, _, b} -> a <= b end) # order by line number
    |> Enum.map(fn {m, f, _l} -> {m, f} end)            # drop line number
  end
end
Вход в полноэкранный режим Выйти из полноэкранного режима

Этот макрос получает от компилятора env, который содержит много контекстной информации. Здесь важным битом является вызывающий модуль, доступный через атрибут env.module.

Затем мы используем специальную функцию module.definitions_in/2, чтобы получить список всех функций, объявленных в модуле env.module с ключевым словом def.

Мы вызываем функцию Pipeline.filter_functions/4 для фильтрации этих определений по их суффиксу и степени сложности, по сути, обнаруживая наши шаги и крючки! Функция Pipeline.filter_functions/4 довольно большая, поэтому я добавил комментарии, чтобы помочь вам сориентироваться в ней.

Как уже говорилось, все, что находится внутри блока quote, инжектируется непосредственно в вызывающий модуль. Поэтому всякий раз, когда мы вызываем use Pipeline, модуль будет иметь две дополнительные функции: __pipeline__/0, и execute/2.

Модуль Pipeline использует первую функцию для выполнения нашего пользовательского конвейера, а функция execute/2 — это просто удобная функция, которая будет выполнять Pipeline.execute/3. Она позволяет нам выполнить наш конвейер, вызвав MyPipeline.execute(value, options).

Говоря о Pipeline.execute/3, пришло время дать ей определение. Эта функция является ядром этого механизма, и она отвечает за вызов редуктора, который будет работать с нашим конвейерным механизмом:

defmodule Pipeline do
  # ...

  def execute(module, value, options) do
    # Build a new initial state of the pipeline
    initial_state = State.new(value)

    # Fetch steps and hooks from the module
    {steps, hooks} = apply(module, :__pipeline__, [])

    # Run each step from the pipeline and build the final state
    final_state =
      Enum.reduce(steps, initial_state, fn reducer, current_state ->
        State.update(current_state, reducer, options)
      end)

    # Run each hook with the final state
    Enum.each(hooks, fn {module, function} ->
      apply(module, function, [final_state, options])
    end)

    # Transform the state value into an ok/error tuple
    case final_state do
      %State{valid?: true, value: value} ->
        {:ok, value}

      %State{errors: errors} ->
        {:error, errors}
    end
  end
end
Вход в полноэкранный режим Выход из полноэкранного режима

Ознакомьтесь с полной версией этого модуля здесь.

Примечание: В полной версии этого модуля есть еще один вид хуков, называемый асинхронным хуком. Асинхронные хуки работают так же, как и представленные здесь хуки, но выполняются параллельно, а не последовательно. Они не будут рассматриваться в этой статье, так как по сути это хук, выполняемый вместе с задачей.

Построение и выполнение конвейера

Просто вызывая use Pipeline и записывая имена функций с суффиксом _step или _hook, мы теперь можем строить наши конвейеры. Давайте перепишем наш последний пример из первой части статьи с этой новой механикой:

defmodule Checkout do
  use Pipeline

  def fetch_payment_information_step(value, options) do
    # ...
  end

  def fetch_user_step(value, options) do
    # ...
  end

  def fetch_address_step(value, options) do
    # ...
  end

  def create_order_step(value, options) do
    # ...
  end

  def report_checkout_attempt_hook(state, options) do
    # ...
  end
end
Вход в полноэкранный режим Выход из полноэкранного режима

Мы можем выполнить этот новый конвейер следующим образом:

case Checkout.execute(%{params: params}, options) do
  {:ok, %{order: order}} ->
    conn
    |> put_flash(:info, "Order completed!")
    |> redirect(to: Routes.order_path(conn, order))

  {:error, error}
    conn
    |> put_flash(:error, parse_error(error_description))
    |> render("checkout.html")
end
Войти в полноэкранный режим Выйти из полноэкранного режима

Больше нет блоков with, и нет необходимости вручную обрабатывать ошибки. Теперь контроллер сосредоточен на обработке HTTP-уровня, а функция оформления заказа получила официальное место жительства. Добавление нового шага в процесс оформления заказа требует лишь написания функции в нужном месте!

Подведение итогов

В этом посте мы узнали, как выразить набор преобразований в виде редуктора. Затем, используя возможности макросов, мы автоматически создали конвейеры, определив сигнатуру функций во время компиляции.

Функции, использующие механику конвейера, являются явными и содержательными — мы точно знаем, чего ожидать и куда идти при их изменении. Мы ожидаем, что все шаги будут выглядеть и вести себя одинаково. Основы обработки ошибок уже заложены, вам нужно только решить, что делать. Эти конвейеры действительно предсказуемы.

Надеюсь, вам была полезна эта серия из двух частей о поддержании работоспособности вашего кода на Elixir. До следующего раза, счастливого кодинга!

P.S. Если вы хотите читать посты Elixir Alchemy сразу после их выхода из печати, подпишитесь на нашу рассылку Elixir Alchemy и не пропустите ни одного поста!

Оцените статью
devanswers.ru
Добавить комментарий