Трудности перехода: каков Elixir на вкус после Ruby

Знакомство

Привет! Меня зовут Наталья. В Каруне я пишу в команде высоконагруженные сервисы на Elixir.

Это третья компания, в которой я работаю на Elixir. До этого я писала на Ruby. Если посмотреть свежее исследование Хабр Карьеры по зарплатам, можно увидеть — зарплаты рубистов растут, а Elixir там нет. Более того, есть истории о том, как люди возвращались с Elixir обратно на Ruby. Я считаю, что на это сильно влияет вход в язык. Elixir классный, но в первые месяцы знакомства с ним мне самой так не казалось. Настолько классный, что я не хочу назад. В этой статье я расскажу про трудности перевода перехода.

Elixir is a functional programming language which looks similar to Ruby.

В IT сообществе существует мнение, что рубисты легко переходят на Elixir. Ещё бы — сам создатель языка Jose Walim в прошлом рубист. Не просто рубист, а core разработчик Ruby on Rails. Можно подумать, что Elixir — это Ruby после прокачки. Тебе будет так же удобно/быстро/классно писать на нём, плюсом идёт вся мощь Erlang’а. Мне в своё время очень нравился Ruby. И предложение перейти на Elixir казалось заманчивой перспективой.

Ruby |> Elixir 

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

После беглого знакомства с документацией в моей голове был примерно такой план перехода между языками. Оператор “|>” означает передачу результата выполнения одного выражения в следующее.

Ruby
|> remove_OOP()
|> add_some_functions()
|> save_TDD()
|> add_OTP()
|> save_syntactic_sugar()
{:ok, Elixir}

Кажется, достаточно простые шаги. Забываем про ООП. В стандартной библиотеке будут знакомые функции. Будет что-то новое. Но тесты никто не отменял. Есть целая новая реальность под названием Open Telecom Platform. Открываем VS Code.

%{a: 1} # map in Elixir
{a: 1} # hash in Ruby # Lists
[1, 2, true, 3] # Elixir, Ruby
# Concatenate lists
[1, 2, true, 3] ++ [5, 7] # Elixir
[1, 2, true, 3] + [5, 7] # Ruby # Calling function/method
String.reverce("hello") # Elixir "hello".reverse # Ruby # An anonymous function
fn(x) -> x * x end # ELixir
-> x {x * x} # lambda in Ruby # Using each
Enum.each([1, 2], &(IO.puts &1)) # Elixir
[1, 2].each { |i| puts i } # Ruby # Defining a function in Elixir
def hello do "result"
end
# Defining a method in Ruby
def hello "result"
end # Defining a module in Elixir
defmodule Example do
end
# Defining a module in Ruby
module Example
end

Очень похоже. Подумаешь: вместо вызова метода у объекта мы вызываем функцию, указывая имя модуля. 

Давайте сделаем что-нибудь полезное. Например, напишем модуль, который будет определять, является ли год високосным. 

Ruby

class Year def self.leap?(year) @year = year div_by?(4) && ( !div_by?(100) || div_by?(400) ) end def self.div_by?(number) @year % number == 0 end
end

Elixir

defmodule Year do def leap?(year) do div_by?(year, 4) && ( !div_by?(year, 100) || div_by?(year, 400) ) end def div_by?(year, number) do rem(year, number) == 0 end
end

В методы приходится пробрасывать год. Остаток от деления получается через функцию. Надо не забывать писать do при объявлении после названия модуля или функции.  

Давайте теперь этот функционал потестируем и заодно посмотрим на фреймворк для тестов в Elixir. Он поставляется вместе с языком. Для полноты сравнения языков я беру со сторону Ruby minitest., т.к. со стороны Elixir у нас классическое Test Driven Development. Бывшие рубисты, естественно, наклепали уже себе espec. Он, правда, не особо прижился. Так что переучиться так или иначе придётся. Давайте посмотрим, насколько сильно — при условии, что minitest вы хоть раз видели. И помним о том, что мы тестируем утверждения.

Ruby

require 'minitest/autorun'
require_relative 'leap' class YearTest < Minitest::Test def test_year_not_divisible_by_4_common_year # skip refute Year.leap?(2015), "Expected 'false', 2015 is not a leap year." end def test_year_divisible_by_4_not_divisible_by_100 assert Year.leap?(1996), "Expected 'true', 1996 is a leap year." end def test_year_divisible_by_100_not_divisible_by_400 refute Year.leap?(2100), "Expected 'false', 2100 is not a leap year." end def test_year_divisible_by_400 assert Year.leap?(2000), "Expected 'true', 2000 is a leap year." end def test_year_divisible_by_200_not_divisible_by_400 # skip refute Year.leap?(1800), "Expected 'false', 1800 is not a leap year." end
end

Elixir

Code.load_file("leap.exs", __DIR__) ExUnit.start()
ExUnit.configure(exclude: :pending, trace: true) defmodule LeapTest do use ExUnit.Case test "2015 year not divisible by 4" do refute Year.leap?(2015) end # @tag :pending test "1996 year divisible by 4 not divisible by 100 leap year" do assert Year.leap?(1996) end test "2100 year divisible by 100 not divisible by 400" do refute Year.leap?(2100) end test "2000 year divisible by 400 leap year" do assert Year.leap?(2000) end test "1800 year divisible by 200 not divisible by 400" do refute Year.leap?(1800) end
end

Снова отличия, кроме уже названных, не так уж велики. Скипать тесты можно, это делается через кастомный тэг. Достаточно его указать в конфигурации как исключающий: “exclude: :pending”.

В этот момент появляется мысль…

Hey, Brain! I can write on Elixir?!
Hey, Brain! I can write on Elixir?!

Ruby on Rails |> Phoenix

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

Rails controller == Phoenix controller (in Context)
Rails model =~ Ecto (Schema + Repo + Query + ...)
Rails view (template) =~ Phoenix view + template
Rails serializer == Phoenix view
Rails seeds/tasks =~ Phoenix seeds/tasks
Rails migrations =~ Ecto migrations
Rails console != Phoenix console

Покажу подробнее те аспекты, в которых наибольшие различия.

Миграции

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

Ruby

rails db:migrate
rails db:rollback
rails db:rollback STEP=2 rails db:migrate VERSION=20181213084911 rails db:migrate:redo VERSION=20181213084911
rails db:migrate:up VERSION=20181213084911
rails db:migrate:down VERSION=20181213084911

Elixir

mix ecto.migrate
mix ecto.migrate -r Custom.Repo
mix ecto.migrate -n 3 mix ecto.rollback
mix ecto.rollback --step 2 mix ecto.migrate -to 20181213084911
mix ecto.rollback -to 20181213084911 # What?!

Ожидаемо мы можем накатывать и откатывать миграции на некоторое количество шагов. Неожиданно появляется какой-то кастомный Repo. Нельзя откатить или накатить конкретную миграцию через вызов таски. Только пачку миграций, друг за другом — до указанной версии.

Если покопаться в API Ecto, то через выполнение кода в принципе можно…

Ecto.Migrator.with_repo(your_repo, &Ecto.Migrator.run(&1, :down, to: version))

Что это за Repo, посмотрим дальше.

Взаимодействие с базой

Это вторая вещь, которая максимально выбивает из колеи. Первая — сама функциональная парадигма. Дело в том, что как мир веб разработки пропитан ООП, так и общение с базой в мире ООП означает повсеместное использование ORM. В Rails мы обращаемся с записью из БД как с объектом с помощью Active Record. Забывая, что под капотом это просто данные. При знакомстве с языком Elixir мы уже утратили часть объектно-ориентированного взгляда на мир. Приходит время утратить его до конца (по возможности). На сцену выходит Ecto. Первое, что нужно уложить в голове: Ecto — это data mapper + query builder DSL.

Active Record is ORM, gives us: - models and their data
- associations between models
- models validation - database operations in an object-oriented style
Ecto differs from other ORMs: - schemas and their data
- associations between schemas
- changeset with validations - database operations in functional style with Ecto modules (Repo, Query, etc.)

Модель превратилась в схему данных. Ассоциации никуда не делись. Чтобы увидеть, из каких полей состоит таблица, больше не надо куда-то ходить. Схема описывает доступные поля таблицы. Есть любопытный нюанс: схема не обязана описывать все поля. Схемой мы ограничиваем подмножество полей таблицы, с которым хотим работать. Зачем? Я могу придумать только пару примеров: 

1. Какое-то поле стало нельзя менять, но оно ещё используются другими клиентами/приложениями. Такое поле можно убрать из схемы, но оставить в БД. 

2. Разные наборы полей для разных контекстов. Разные подмножества полей используются в разных контекстах приложения.

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

Ruby

class OnlineTracking < ActiveRecord::Base belongs_to :startup belongs_to :expert, class_name: 'User' has_one :expert_startup_rate, class_name: 'ExpertAnketa::StartupRate', dependent: :destroy has_many :slots, class_name: 'OnlineTracking::Slot', foreign_key: :online_tracking_id validates :startup_id, presence: true validates :start_at, presence: true validates :weeks, presence: true, numericality: { only_integer: true } enum pause_status: { disabled: 0, # отключение date_changed: 1, # перенос } after_create :make_anketas after_update :update_startup_leads, if: Proc.new { |ot| ot.finished_at.present? || ot.deleted? }

Валидации находятся внутри схемы, но отвечает за них changeset (Ecto.Changeset). Что это означает? Changeset — дополнительное ограничение подмножества полей, с которыми вы хотите работать. Подмножество от подмножества от подмножества… Дело в том, что поля нельзя изменить НЕ через changeset. Хотя стоит сделать оговорку: технически можно воспользоваться Repo.update_all/3, либо выполнить чистый SQL-запрос. Но на мой взгляд лучше пойти в обход. 

Отсутствие прямой возможности изменений кажется странным — но ровно до того момента, когда узнаешь, что у схемы может быть несколько changeset’ов. Самый простой пример, почему удобно использовать такую конструкцию — таблица пользователей. При создании пользователя мы заполняем большинство полей, а при редактировании не хотим давать доступ ко всему подряд. Для ряда пользователей часть полей могут быть недоступными для изменений всегда (собственная роль, например). Сколько потребностей по ограничениям — столько changeset’ов понадобится. Смена пароля, смена email, редактирование профиля, создание пользователя — разные формы, разные changeset’ы. Ещё одно удобство в том, что внутри changeset’а используются только нужные валидации. Те. только те, которые касаются изменяемых полей — в то время как типичная модель ActiveRecord по мере развития проекта превращается в нагромождение валидаций и callback’ов. В этом хаосе очень сложно разбираться. Модель может выполнять различные действия: отправлять два вида нотификаций, считать промежуточные баллы, выдавать результат сложного запроса для отчётов…

Давайте посмотрим, как Phoenix позволяет разделить работу с данными по слоям.

Elixir

defmodule App.Accounts.User do use Ecto.Schema import Ecto.Changeset alias Ecto.Changeset alias App.Accounts.User alias App.CompanyManagement.Employee alias App.EmailType alias App.Repo @required [:email, :password] @optional [:type] schema "users" do field :email, EmailType field :password, :string, virtual: true field :password_confirmation, :string, virtual: true field :password_hash, :string field :recovery_token, :string field :refresh_token, :string field :type, UserTypeEnum has_one :employee, Employee timestamps(type: :naive_datetime_usec) end @required_fields ~w(email)a @optional_fields ~w()a def changeset(%User{} = user, attrs) do user |> cast(attrs, @required_fields ++ @optional_fields) |> validate_required(@required_fields) |> unique_email() end def email_changeset(%User{} = user, attrs) do user |> cast(attrs, [:email]) |> unique_email() end def create_changeset(%User{} = user, attrs) do user |> cast(attrs, @required ++ @optional) |> validate_required(@required) |> unique_email() |> unique_constraint(:id, name: :users_pkey) |> validate_password(:password) |> put_pass_hash() end def recovery_changeset(%User{} = user, %{"password" => password}) do user |> change(%{recovery_token: nil, refresh_token: nil, password: password}) |> validate_password(:password) |> put_pass_hash() end

Помимо changeset’а и схемы User для совершения манипуляций с таблицей нам понадобится Repo. Модуль адаптер — репозиторий для взаимодействия с конкретной базой.

defmodule App.Accounts.UserQueries do alias App.Accounts.User alias App.Repo def create(attrs \\ %{}) do %User{} |> User.create_changeset(attrs) |> Repo.insert() end def update(%User{} = user, attrs) do user |> User.changeset(attrs) |> Repo.update() end def delete(%User{} = user) do Repo.delete(user) end def update_refresh_token(%User{} = user, refresh_token) do user |> User.refresh_token_changeset(%{refresh_token: refresh_token}) |> Repo.update() end
end

Как видно из примеров, функций внутри схемы нет. Данные отдельно, действия с этими данными отдельно. С запросами на чтение данных ситуация поменялась. В Rails у нас скоупы внутри модели и возможность получать через точку ассоциации. 

scope :finished, -> { where('finished_at is not null') }
scope :active, -> { where(active: true) }
scope :recommended, -> { where(status: OnlineTracking.statuses[:recommended]) }
scope :by_start_date, -> (date_from, date_to) { where('start_at BETWEEN ? AND ?', date_from, date_to) }

Ecto для простых запросов предлагает использовать уже знакомый нам Repo, а для более сложных — DSL под названием Ecto.Query.

defmodule App.Catalog.HouseQueries do import Ecto.Query, warn: false alias App.Repo alias App.Catalog.House alias App.Catalog.ResidentialComplex def get(id), do: Repo.get(House, id) def list(params, search_params \\ nil) do list_query() |> filter_city(search_params[:city_id]) |> Repo.paginate(params) end defp list_query() do from h in House, where: is_nil(h.archived_at), preload: ^preload_list() end defp filter_city(query, nil), do: query defp filter_city(query, city_id) do from i in query, join: r in ResidentialComplex, where: i.residential_complex_id == r.id, where: r.city_id == ^city_id end

Возможность писать запросы на чистом SQL есть в обоих языках.

Получается, в Elixir нужно всё писать руками? Получается… 

Пример реализации счётчика дочерних объектов внутри таблицы.

Ruby

class Book < ApplicationRecord belongs_to :author, counter_cache: :count_of_books
end

Elixir

defmodule App.Catalog.ResidentialComplex schema "residential_complexs" do field :houses_count, :integer, default: 0 has_many :houses, Catalog.House
end defmodule App.Catalog.House do # schema def changeset(house, attrs) do house |> cast(attrs, @required ++ @optional) |> validate_required(@required) |> prepare_changes(&complex_houses_count/1) |> assoc_constraint(:residential_complex) end defp complex_houses_count(changeset) do if complex_id = get_change(changeset, :residential_complex_id) do if changeset.action == :update do prev_complex_id = changeset.data.residential_complex_id query = from Catalog.ResidentialComplex, where: [id: ^prev_complex_id] changeset.repo.update_all(query, inc: [houses_count: -1]) end query = from Catalog.ResidentialComplex, where: [id: ^complex_id] changeset.repo.update_all(query, inc: [houses_count: 1]) end changeset end

What?!

Консоль

Ruby нам предоставляет irb, Elixir соответственно iex. И это был мой топ-1 в листе шока от использования. Давайте посмотрим. Запускаем консоль.

rails c

> OnlineTracking.find(409).update(active: false)
=> true
> ActiveRecord::Migration.drop_table(:experts_groups)
=> true
> OnlineTracking.last
> OnlineTracking.startup.user.name

iex -S mix

> Repo.get(ResidentialComplex, 1) |> Ecto.Changeset.change(houses_count: 0) |> Repo.update()
** (UndefinedFunctionError) function Repo.get/2 is undefined (module Repo is not available) Repo.get(ResidentialComplex, 1)
> alias App.Repo
App.Repo
> Repo.get(ResidentialComplex, 1) |> Ecto.Changeset.change(houses_count: 0) |> Repo.update()
** (Protocol.UndefinedError) protocol Ecto.Queryable not implemented for ResidentialComplex, the given module does not exist. This protocol is implemented for: Atom, BitString, Ecto.Query, Ecto.SubQuery, Tuple
> alias App.Catalog.ResidentialComplex
App.Catalog.ResidentialComplex
> Repo.get(ResidentialComplex, 1) |> Ecto.Changeset.change(houses_count: 0) |> Repo.update()
{:ok, %App.Catalog.ResidentialComplex}
> ResidentialComplex |> last() |> Repo.one()

А ведь я пытаюсь сделать элементарные вещи: обновить одно поле, получить одну запись.

Как с этим жить? Принять на веру:

Rails консоль говорит тебе: да, делай что хочешь. 

Хочешь таблицу на живую из консоли удалить? Пожалуйста.

Хочешь пользователю возраст на проде поменять из консоли? Чего бы и нет, один update.

IEX говорит тебе: чти заповеди! Пока не выучишь, лучше не приходи.

Заповеди:

  • Кто ты, что тебе нужно? Я ничего о тебе не знаю. Любые модули, которые хочешь использовать, нужно позвать (через alias, import). Либо можно в корне проекта создать файл .iex.exs и там все часто используемые модули позвать заранее. 

  • В базу ведут несколько ворот. Открывай те, которые нужны — используй модули (Repo, Query, Changeset).

  • Мы не дёргаем товарищей без нужды. Нельзя обратиться к ассоциации просто так. Сначала её нужно подгрузить через preload().

  • Смотри внимательно, что пишешь. По описанию ошибки бывает сложно понять, что ты опечатался.

  • RTFM.

Ошибки

Давайте посмотрим описания ошибок на примерах. Первый пример простой, второй со звёздочкой. 

> alias App.Catalog.ResidentailComplex
App.Catalog.ResidentailComplex
> Repo.get(ResidentialComplex, 1) ** (Protocol.UndefinedError) protocol Ecto.Queryable not implemented for ResidentialComplex, the given module does not exist. This protocol is implemented for: Atom, BitString, Ecto.Query, Ecto.SubQuery, Tuple defmodule AppWeb.Catalog.ResidentialComplexView do def render("show.json", residential_complex: complex) do render_one(complex, ResidentialComplexView, "residential_complex.json") end
Request: GET /api/complexes/2
** (exit) an exception was raised: ** (Phoenix.Template.UndefinedError) Could not render "show.json" for AppWeb.Catalog.ResidentialComplexView, please define a matching clause for render/2 or define a template at "lib/app_web/templates/catalog/residential_complex". No templates were compiled for this module.

В первом примере я позвала модуль, но ошиблась в названии. Буквы перепутала местами. А в запросе правильно написала, но получила ошибку — не знаем такого модуля. 

Во втором примере кусок кода показывает описание вьюхи. Для того, чтобы отрендерить страницу, контроллер пойдёт через вьюху искать нужный шаблон. Во вьюхе мы подставляем данные. Можно эти данные подрезать или как-то по-другому пересобрать. По мне, очень удобно. Вот я вижу ошибку. Что там написано? Что никак нельзя отрендерить json, потому что внутри вьюхи моей нет подходящей функции render. Но она же есть. Вы же её тоже видите? Вьюха называется правильно, json называется правильно. Я в своё время голову себе сломала, пытаясь понять, что не так. 

Brain, is the compiler our friend?
Brain, is the compiler our friend?

Предлагаю рубистам и любителям эликсира найти ошибку. Если никто не найдёт, позже сама в комментариях напишу. 

Послесловие

The devil is in the details.

Переходя с одного языка на другой, можно побывать на всех стадиях принятия. Тезисы типа “Легче ли рубистам переходить, чем всем остальным” и “Лучше ли эликсир, чем руби” на мой взгляд не имеют смысла. Меня при переходе первое время многое раздражало. Прошло какое-то время, и вещи, которые казались избыточными, стали выглядеть правильными и красивыми. А какие-то штуки, которых не хватает, перестали быть проблемой. 

Руби все ещё хорош и продолжает развиваться. Благодаря сильным командам на все ваши потребности найдутся подходящие либы. И даже на все косяки из коробки есть заплатки из коробки или даже какие-то альтернативы. К этому привыкаешь.

Эликсир хорош и развивается. Отсутствие нужных библиотек постепенно закрывается. В коде вещи делаются явно, а значит, возникает прозрачность. Есть всякие классные штуки из коробки, которые позволяют не тащить ничего дополнительного в проект ради background jobs, например. Документация красивая. И к этому тоже привыкаешь.

Мне бы хотелось донести две основные мысли.

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

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

Ruby
|> forget_OOP
|> undestand_functional_programming
|> use_TDD
|> be_ready_and_patient
|> RTFM
|> practice
|> practice Oh! Elixir

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

Покажу утрированный пример с Codewars. Все примеры кода написаны на руби.

Второй по красоте из рейтинга

def human_years_cat_years_dog_years(human_years) cat_year = 15 dog_year = 15 if human_years == 1 human_cat_dog = [human_years, cat_year, dog_year] end if human_years == 2 cat_year += 9 dog_year += 9 human_cat_dog = [human_years, cat_year, dog_year] end if human_years > 2 cat_year += 9 + 4 * (human_years - 2) dog_year += 9 + 5 * (human_years - 2) human_cat_dog = [human_years, cat_year, dog_year] end human_cat_dog end

Мой вариант

def human_years_cat_years_dog_years(human_years) [human_years, cat_years(human_years), dog_years(human_years)]
end def dog_years(years) case years when 1 15 when 2 24 else 24 + (years - 2) * 5 end
end def cat_years(years) case years when 1 15 when 2 24 else 24 + (years - 2) * 4 end
end

 Хм, а так ли обязательно накапливать результаты в переменных?

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

def human_years_cat_years_dog_years(human_years) cat_years=(human_years>=2)? 24+(human_years-2)*4:15 dog_years=(human_years>=2)? 24+(human_years-2)*5:15 return [human_years,cat_years,dog_years]
end 

Это не значит, что нужно писать на руби в “функциональном стиле”. Но возможность задуматься, как именно вы делаете то, что делаете — всегда с вами. 

Спасибо за внимание.

Читайте так же:

  • Google Ads: умные торговые и локальные кампании будут обновлены до Performance MaxGoogle Ads: умные торговые и локальные кампании будут обновлены до Performance Max Google Ads автоматически обновит умные торговые кампании и локальные кампании до Performance Max в период с июля по сентябрь 2022 года. К концу сентября умные торговые и локальные кампании будут упразднены. Компания также запустит инструмент переноса «в один клик» для тех рекламодателей, […]
  • Сооснователь YouTube раскритиковал отключение дизлайковСооснователь YouTube раскритиковал отключение дизлайков Джавед Карим, один из создателей YouTube, раскритиковал решение сервиса скрыть дизлайки под всеми видео. Он изложил свои мысли по этому поводу в обновленном описании опубликованного им первого видео на YouTube. Карим обратил внимание, что хотя YouTube позиционирует отказ от дизлайков […]
  • 10 самых безумных историй 2021 года из мира финансов: деньги из ниоткуда и в никуда10 самых безумных историй 2021 года из мира финансов: деньги из ниоткуда и в никуда 2021 год был очень странным; но втройне странным он был для всех, кто хоть немного интересуется финансами и инвестициями. В этом обзоре я собрал десять историй и явлений, которые в ушедшем году по праву больше всего заслужили плашки «да не может быть!» и «лол, што?!».10. Китайские акции: […]
  • Google о крупных CTA-элементах над основным контентомGoogle о крупных CTA-элементах над основным контентом Во время последней видеовстречи для вебмастеров сотрудник Google Джон Мюллер ответил на вопрос о том, как Google относится к крупным CTA-элементам над основным контентом. По его словам, алгоритмы поисковой системы обращают внимание на рекламу, которая сдвигает основной контент вниз. В […]