Транзакции в Elixir при помощи Ecto.Multi

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

Одна из приятнейших особенностей Ecto 2.0 — модуль Ecto.Multi. Он разработан специально для того, чтобы предоставить разработчикам возможность объединения многочисленных запросов к базе данных в одну транзакцию, которая либо выполнялась бы целиком, либо вовсе не выполнялась. Можно сказать, этот модуль — своего рода реализация принципа «всё или ничего». Ecto.Multi поможет решить обозначенную выше проблему, избавив от необходимости использования многоуровневых вложений и обеспечив очень удобный API. Давайте скорее опробуем его в деле!

Описание задачи

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

Хотелось бы отметить, что в данном приложении не самым лучшим способом реализовано взаимодействие с базой данных, так как оно прежде всего предназначено для демонстрации работы модуля Ecto.Multi, а не для иллюстрации чистого и хорошо структурированного кода.

Что ж, теперь перейдём к делу.

Что мы будем отслеживать?

Приложение и база данных оперируют следующими абстракциями:

  1. Дверь (Doors)

  2. Человек (People)

  3. Количество проходов человека в помещение (Entries)

  4. Запись в журнале о количестве проходов (Logs)

В нашем случае в простеньком демо-приложении реализуется определённая последовательность действий: всякий раз, когда человек (Person) пытается открыть дверь (Door), отмечается проход (Entry), обновляется кэшированное прежде значение количества проходов для этого человека и делается запись этого значения в журнал (Log). Если на каком-то из этих этапов возникнет ошибка, то система вернётся к прежнему состоянию. Например, если не удалось сделать запись в журнал, то проход не отметится, а общее число проходов не обновится.

Как это делалось раньше

Один из способов реализации транзакций до появления Ecto.Multi упрощённо выглядел так:

defmodule Guard do
  def enter_door(person, door) do
    case Repo.insert(Entry.changeset(%Entry{}, %{door_id: door.id, person_id: person.id}) do
      {:ok, entry} ->
        case Repo.update(Person.increase_entry_count_changeset(person)) do
          {:ok, person} ->
            case Repo.insert(Log.changeset(%Log{}, %{text: "entry"})) do
              {:ok, log} ->
                "Success on all three!"
              {:error, _changeset} ->
                # rollback Person and Entry database operations
                "Failed to save Log"
            end
          {:error, _changeset} ->
            # rollback Entry database operation
            "Failed to save Person"
        end
      {:error, _changeset} ->
        "Failed to save Entry"
    end
  end
end

Писали ли вы когда-нибудь что-то подобное? Быть может, в контроллере в Phoenix? Лично я — да. Признаюсь даже, что такая схема со множеством вложений реализована в одном из разработанных мной действующих приложений.

Как это делает Ecto.Multi

Попробуем спасти положение с помощью Ecto.Multi. Для начала, посмотрим на код, после чего обсудим его подробнее.

defmodule Guard do
  def enter_door(person, door) do
    case entry_transaction(person, door) do
      {:ok, %{entry: entry, log: log, person: person}} ->
        Logger.debug("Success on all three!")
      {:error, :log, _failed_value, _changes_successful} ->
        Logger.debug("Failed to save Log")
      {:error, :person, _failed_value, _changes_successful} ->
        Logger.debug("Failed to save Person")
      {:error, :entry, _failed_value, _changes_successful} ->
        Logger.debug("Failed to save Entry")
    end
  end

  def entry_transaction(person, door) do
    Multi.new
    |> Multi.insert(:entry, Entry.changeset(%Entry{}, %{door_id: door.id, person_id: person.id}})
    |> Multi.update(:person, Person.increase_entry_count_changeset(person))
    |> Multi.insert(:log, Log.changeset(%Log{}, %{text, "entry"}))
    |> Repo.transaction()
  end
end

Прежде всего разберёмся с функцией entry_transaction. Модуль Ecto.Multi содержит функции, аналогичные функциям модуля Ecto.Repo: insert, delete, update и многие другие. Однако в качестве второго аргумента этих функций выступает определённый в документации аргумент name. Он позволяет присвоить имя некоторому набору действий, чтобы потом с ним можно было провести сопоставление с образцом. В приведённом выше примере они названы в соответствии с той записью в базе даных, к которой относятся. Важно, чтобы в пределах транзакции эти имена были уникальными. Поговорим об этом подробнее, когда будем рассматривать функцию enter_door.

На каждом этапе транзакции дальнейшим операциям передаётся результат Multi.new, а затем Repo уже осуществляет запросы к базе данных.

Если на любом из этапов в наборе изменений возникла ошибка или по какой-либо причине не выполнился запрос к базе данных, изменения в базу данных внесены не будут. Запрос, в ходе выполнения которого произошла ошибка, возвратится в кортеже вида {:error, name_of_call, failed_value, changes_that_succeeded}. Переменная failed_value содержит ошибки набора изменений (если он используется) или другие ошибочные значения, возвращаемые запросом, а changes_that_succeeded содержит результаты всех предыдущих успешных операций. Тем не менее, согласно документации «необходимо произвести откат всех успешных операций», если не удалось выполнить всю транзакцию целиком.

Теперь перейдём к функции enter_door. Итак, если во время выполнения транзакции произойдёт ошибка, возвратится кортеж вида {:error, ...}. Если же всё пройдёт успешно, возвратится кортеж {:ok, map}. По заданному нами имени (name) операции в map можно получить доступ к значениям каждой из составляющих транзакцию операций. В текущем примере ключ :entry в map соотносится с результатом операции:

Multi.new
|> Multi.insert(:entry, Entry.changeset(%Entry{}, %{...}))
#               ^^^^^^ where we named our operation
...

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

Использование Ecto.Multi.run для выполнения произвольного кода

Ещё одно занимательное действие с Ecto.Multi — использование функции run/3 для исполнения произвольного кода, содержащего в себе результаты предыдущих успешных операций. Рассмотрим пример.

Допустим, установим такое ограничение, что человек не может пройти в помещение более 10 раз. Обычно (и, возможно, так правильно) это делается внутри набора изменений для Person, но сейчас попробуем воспользоваться функцией run/3, чтобы проверить, сколько раз человек уже открывал эту дверь:

Функция Ecto.Multi.run на входе ожидает кортеж, содержащий либо {:ok, message}, чтобы продолжить выполнение транзакции, либо {:error, message}, чтобы откатить все изменения.

Можно видеть, что проводится сопоставление с образцом по Person, которое содержит результат успешного выполнения предыдущей операции update. Если человек уже прошёл в помещение 10 раз, то после успешного выполнения операции это значение обновится на 11, и возникнет ошибка. Сообщение об ошибке передастся в блок сопоставления с образцом оператора case.

Мощный функционал с простым API

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

Советую вам активно использовать этот модуль в будущих разработках, а также совершенствовать с его помощью предыдущие.

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