Знакомство

Привет! Меня зовут Наталья. В Каруне я пишу в команде высоконагруженные сервисы на 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  

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

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

Комментарии (4)


  1. wpostmean
    11.02.2022 14:41
    +1

    Во втором примере render/2 вторым аргументом должна принимать map, а не keyword list, от чего и сопоставление с образцом не срабатывет.


    1. x_girl Автор
      11.02.2022 14:44

      Именно так. Должно быть `%{residential_complex: complex}`.


  1. avtozavodetz
    11.02.2022 15:09

    1. x_girl Автор
      11.02.2022 15:20

      Ваша правда. Я смотрела исследование за первое полугодие 2021.