Трудности перехода: каков 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  

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

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


ссылка на оригинал статьи https://habr.com/ru/company/karuna/blog/650835/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *