Опыт переноса приложения с Rails на Elixir/Phoenix

Прошло уже немало времени с тех пор, как я с головой ушёл в Elixir. Для нашей команды в компании Made by Many язык Ruby, многообещающая продуктивность которого не наносит ущерба чистой производительности и масштабируемости, становится победителем в номинации «лучший серверный язык программирования».

Я всячески пытался ускорить процесс изучения Elixir: читал книгу Дэйва Томаса и выполнял приведенные в ней задания, посетил конференцию в Остине, посвященную Elixir, а ещё я попробовал перенести Rails-приложение моего коллеги на Phoenix.

Речь пойдёт о нестандартном приложении для электронной коммерции его довольно прибыльного чулочно-носочного бизнеса на сайте Form &Thread. В процессе переноса я отметил не только кое-какие любопытные факты, но и несколько подводных камней. Конечно, если кто-то ещё проделает ту же работу, у него, вероятно, сложится совершенно иное мнение и возникнут свои трудности. Прежде чем перейти к деталям, рассмотрим три основных момента.

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

1. Необходимо выкинуть из головы основы объектно-ориентированного подхода, особенно применительно к моделям.

Для Ruby-разработчика изменить эту психологическую установку — то, что в обязательном порядке нужно сделать в первую очередь. Принцип «толстых» моделей и «тонких» контроллеров настолько прочно сидит у рубистов в голове, что облегчить модели не получается даже при условии использования паттернов декоратора/презентера или сервисных объектов.

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

2. Кое-какие модули интеграции нужно будет написать самому.

Экосистема Elixir пока не располагает интеграционными библиотеками для каждого из используемых сервисов. Возможно, придётся засучить рукава и проделать эту работу самостоятельно и тем самым внести свой вклад в развитие экосистемы. Именно с этой целью я и создал для Elixir библиотеку segment.com.

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

3. Кое-что в Elixir/Phoenix устроено совсем по-другому.

Следует отметить, что при программировании на Elixir некоторые шаблоны проектирования должны быть реализованы иным образом. Возьмём, к примеру, асинхронный подход. В Rails-приложениях обычно используются очереди Sidekiq или DelayedJob, которые исполняют задачи в фоновых процессах. Конкурентная модель Elixir позволяет этого избежать. Как правило, достаточно просто обернуть функцию в Task.async или создать своё OTP-приложение.

«Волшебство тут почти ни при чём».

Процесс переноса сборки фронтенда на brunch

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

Первой проблемой, с которой я столкнулся, стало отсутствие в Brunch обработчика SASS, который в Rails идёт по умолчанию. Однако SAAS можно легко подключить при помощи модуля sass-brunch. Я добавил его вместе с другими необходимыми модулями Node в файл package.json.

"sass-brunch": "^1.8.10",
"bourbon": "^4.2.6",
"css-patterns": "^0.2.0",
"normalize.css": "^3.0.3"

Необходимо произвести кое-какие модификации в brunch.config — настроить модуль sass-brunch так, чтобы тот включал путь к директории node_modules. Таким образом, мы подключим библиотеку bourbon, файл normalize.css и  css-шаблоны компании Mady by Many.

plugins: {
  babel: {
    // Do not use ES6 compiler in vendor code
    ignore: [/web\/static\/vendor/]
  },
  sass: {
    options: {
      includePaths: [ 'node_modules' ]
    }
  }
}

Затем импортируем в application.scss пути, соответствующие директории модулей node_modules.

@import “bourbon/app/assets/stylesheets/bourbon";
@import “normalize.css/normalize.css";
@import “css-patterns/stylesheets/patterns";

Также нужно выполнить следующие действия: переместить файлы с расширением js и css из директории assets в директорию web/static, в то время как изображения и шрифты — в директорию web/static/assets.

Ещё одно важное отличие в области фронтенда — это то, что в Phoenix не существует гема sass-rails, который волшебным образом предоставлял бы asset_path или asset_url. Придётся вычистить из кода всё, что к нему относится, и прописать необходимые пути вручную.

url(asset-path('Apercu Bold-webfont.eot'));

Превращается в...

url('/fonts/Apercu Bold-webfont.eot');

Перевод моделей с ActiveRecord на Ecto

Примечание: хотя в Phoenix подобные компоненты называются моделями, в Ecto используется название «schema», и я хотел бы, чтобы Phoenix перенял именно этот термин. Это помогает не думать о них, как о моделях, и избавиться от устоев ООП. Но на данный момент, говоря о Phoenix, я всё-таки буду называть их моделями.

Задание оказалось относительно простым: взять файл schema.rb и создать соответствующие файлы модели в Phoenix при помощи команды mix phoenix.gen.model. Я отловил несколько исключений, но в вашем случае их может и не быть.

  1. В Ecto отсутствует функция has_and_belongs_to_many, поэтому существует необходимость создавать свою промежуточную модель. Затем её можно использовать простым вызовом has_many :through.

  2. Расширение HSTORE не реализовано для Ecto.

Существующая модель данных использует Postgres HSTORE для хранения пар ключ-значение у некоторых моделей. Ecto пока не поддерживает HSTORE, а решение этого вопроса заняло бы слишком много времени. Вместо этого я перевёл данные в формат jsonb, имеющий внутреннее представление в виде словаря Elixir. Подробнее об этом здесь .

На этом этапе важно проверить, чтобы расширение JSON было включено в настройках окружения:

config :form_and_thread, FormAndThread.Repo,
  adapter: Ecto.Adapters.Postgres,
  extensions: [{Postgrex.Extensions.JSON, [library: nil]}],

Далее проводим перенос всех начальных данных из файла priv/repo/seeds.exs в Elixir-приложение с помощью выполнения команды:

mix run priv/repo/seeds.exs

Использование моделей

Ecto и ActiveRecord — это не одно и то же, и нужно будет обратить внимание на некоторые моменты. Например, вот то, что попалось мне на глаза.

Отсутствие скоупов

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

class Order < ActiveRecord::Base
  scope :received_or_shipped, -> { where(state: ['received', 'shipped']) }

Превращается в...

defmodule FormAndThread.Order do
  def received_or_shipped_query(query) do
    from o in query,
    where: o.state == 'received' or o.state == 'shipped'
  end
end

Order |> Order.received_or_shipped_query |> Repo.all

Отсутствие отложенной загрузки ассоциаций

Как вы знаете, ActiveRecord реализует отложенную загрузку ассоциаций. Однако в Ecto во избежание ошибок для этого необходимо использовать функцию Repo.preload. Мы используем подход, при котором сохраняем часто используемые связи в отдельной функции, а потом передаём её внутрь Repo.preload (обратите внимание, что здесь возможно выполнять глубоко вложенные предзагрузки).

defmodule FormAndThread.Order do
  def preloaded do
    [:shipping_address, line_items: [variant: [:product]]]
  end
end

order = get_current_order(conn) |> Repo.preload(Order.preloaded)

Набор изменений вместо функций обратного вызова

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

before_create :set_default_shipping_country, :set_random_number

Превращается в...

def create_changeset(model, params \\ :empty) do
  changeset(model, params)
  |> put_change(:number, random_unique_order_number)
  |> put_change(:shipping_country, @default_shipping_country)
end
«Ecto и ActiveRecord — это не одно и то же»

Перенос контроллера

Структуры контроллеров Rails и Phoenix настолько похожи, что я даже не буду вдаваться в систему маршрутов. При всём при том кое-что всё-таки требует изменений.

Выбранное мной Rails-приложение для вызова данных использует в контроллерах множество событий before_action. В Phoenix можно было бы заменить их на плаги, но я вместо этого объединил в пайплайн различные функции внутри действий. Теперь можно отчётливо видеть, что происходит при каждом рендере пайплайна:

before_action :fetch_product, only: [:show]

def fetch_product
  @product = Product.includes(:variants).find_by(slug: params[:id])
end

Превращается в...

def show(conn, %{"id" => id}) do
  conn
  |> assign_current_order
  |> assign_product(id)
  |> render("show.html")
end

defp assign_product(conn, id) do
  assign(conn, :product, Repo.get!(Product, id, preload [:variants]))
end

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

before_action :check_for_order, only: [:show, :update]

def check_for_order
  redirect_to root_path unless current_order.present?
end

Превращается в...

plug :check_for_order

defp check_for_order(conn, _params) do
  case get_current_order(conn) do
    nil ->
      conn |> redirect(to: "/") |> halt
    order ->
      assign(conn, :order, order)
  end
end

Шаблоны/Представления

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

def line_item_amount(line_item) do
  Decimal.mult(line_item.price, Decimal.new(line_item.quantity))
end

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

<% product.imagery.each_with_index do |image, index| %>

Превращается в...

<%= for {img, index} <- Enum.with_index(product_images(@product)) do %>
Функция product_images находится в FormAndThread.ProductView.

Сервисы

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

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

defmodule FormAndThread.Checkout do
...
def complete(changeset) do
  changeset
  |> Repo.update!
  |> Repo.preload(Order.preloaded)
  |> charge_customer
  |> deliver_confirmation_email
  |> reconcile_stock_levels
  |> mark_as_received
end

def charge_customer(order) do
  Gateway.charge_customer(order)
end

def deliver_confirmation_email(order) do
  Mailer.send_order_received_email(order)
end

def reconcile_stock_levels(order) do
  Repo.transaction(fn ->
    for li <- order.line_items do
      Repo.update!(%{li.variant | stock_level: li.variant.stock_level - li.quantity})
    end
  end)

  order
end

def mark_as_received(order) do
  Order.changeset(order, %{state: "received", completed_at: Ecto.DateTime.local()})
  |> Repo.update!
end

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

Я уверен, что со временем появится множество различных схем переноса Rails-приложений, но для себя я отметил, что этот процесс не только оказался «безболезненным», но и позволил получить превосходные результаты. Не говоря уже о том, что Elixir-приложение работает гораздо быстрее (показатель времени отклика менее 100 мс льёт мне бальзам на душу).