Трудности перехода: каков 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?!
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?
Предлагаю рубистам и любителям эликсира найти ошибку. Если никто не найдёт, позже сама в комментариях напишу.
Послесловие
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
Это не значит, что нужно писать на руби в «функциональном стиле». Но возможность задуматься, как именно вы делаете то, что делаете — всегда с вами.
Спасибо за внимание.