AlterEGO Labs official blog

About ruby, rails and another amazing stuffs…

Rails Helpers: WHY?!?

Наверное самым странным и непонятным (по моему мнению) компонентом Rails являются хелперы (Helpers). Когда мы приходим в мир Rails мы много читаем про MVC и как она реализована в Rails, прозоны ответственности этих компонентов. Но что представляют собой Helpers, которые содержатся в папке app/helpers?

Что предгагают нам рельсы

Хелперы представляют собой обычные модули с набором функций. Например:

1
2
3
4
5
module ApplicationHelper
  def some_useful_method(arg1)
    # some logic here
  end
end

Модули не являются самостоятельными единицами и в практически всегда они инклудятся в определенный контекст для расширения.

Хелперы придумали для выноса части различной мелкой (в некоторых случаях не такой уж и мелкой) логики из контролера и представления (view). Например:

app/helpers/application_helper.rb
1
2
3
4
5
module ApplicationHelper
  def hello_text
    'Hello from Helper!'
  end
end
app/controllers/welcome_controller.rb
1
2
3
4
5
class WelcomeController < ApplicationController
  def index
    # here `hello_text` will be available
  end
end
app/views/welcome/index.html.erb
1
<%= hello_text %>

Какие проблемы

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

Так как хелперы инклудятся в контекст view, то в них доступны instance variables объявленные в контроллере. Поэтому это уменьшает возможность повторного использования, а также увеличивает непонимание того, какие объекты являются “игроками” в той или иной функции, и возвожность протестировать. Например:

app/helpers/application_helper.rb
1
2
3
4
5
module ApplicationHelper
  def build_push_state(except: [], with: {})
    @push_state = PushStateBuilder.new(params, except: except, with: with).build
  end
end

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

Очень часто хелперы используют для формирования html сниппетов:

app/helpers/application_helper.rb
1
2
3
4
5
6
7
module ApplicationHelper
  def error_messages_for(resource, decorated = true)
    return "" if resource.errors.empty?
    errors_text = resource.errors.full_messages.map { |msg| content_tag(:li, msg) }.join
    error_messages errors_text, decorated
  end
end

Мне кажется, что формирование HTML кода необходимо оставлять на view.

Резюмирую: в итоге получается модули, в которых масса отдельно сущестующих методов, которые привязаны к разным логическим контекстам выполнения (controller или view), которые могут генерировать HTML код и много всего другого, чего они не должны делать.

Как уменьшить боль?

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

  1. Не использовать instance variables, объявленные в контроллере или во view. Передавайте явно параметры. При этом можно избежать side effects, когда вы вызываете функцию, а там используется instance variable, которой еще нет. В идеале функции, объявленные в хелперах, должны быть pure functions.

  2. Выносите формирование HTML сниппетов в отдельный partial. Опять же бывают ситуации, когда сниппет - это буквально одна строчка, например, в зависимости от статуса выводить разную иконку.

  3. Отказаться от глобального инклудинга всех хелперов. Для этого необходимо добавить следующую строку, например в config/application.rb:

    config.action_controller.include_all_helpers = false
    
  4. Отказаться от хелперов и посмотреть в сторону View Object или ViewModel :-)

Полезные ссылки

Ruby метапрограмминг и наследование

Ruby не перестает меня удивлять! Как по мне, то самое удивительное и мощное в нем - метапрограмминг. После статического языка, в котором твой арсенал ограничен заложенными ключевыми словами и конструкциями, просто невозможно вообразить насколько развязаны твои руки и что ты можешь со всем этим делать! Используя динамику и выразительность Ruby можно превратить кусок кода практически в понятную фразу на английском.

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

Самое главное вовремя остановится… Но не сейчас)

Варианты объявления класса

Все мы привыкли объявлять наши классы используя элемент синтаксиса ruby, а именно ключевое слово class:

1
2
class User
end

Но существует еще один вариант. Т.к. класс является экземпляром класса Class, то справедлива следующая запись:

1
2
3
User = Class.new do
  attr_accessor :name
end

Используя способ выше также можно указать суперкласс, просто передав его как параметр в метод Class.new:

1
2
3
4
5
6
7
class User
end

RegisteredUser = Class.new(User) do
end

RegisteredUser.ancestors # => [RegisteredUser, User, Object, Kernel, BasicObject]

Но есть одна интересная особенность!

Когда вы несколько раз объявляете класс используя ключевое слово class, то реально класс инициализируется только один раз. Все следующие разы класс открывается для внесения изменений. Например:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class User
end

User.object_id # => 70299452133440

User.new.name # => NoMethodError: undefined method `name' for #<User:0x007fd35b40a930>

class User
  def name
    'Sergey'
  end
end

User.obect_id # => 70299452133440

User.new.name # => Sergey

В случае с объявлением класса, используя метод Class.new, ситуация немного другая. На самом деле слово User, которое мы используем для инициализации экземпляров, является практически такой же переменной, которая ссылается на экземпляр класса Class. Поэтому код ниже дает, может быть, вполне очевидные результаты:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class User
  def name
    'Sergey'
  end
end

User.object_id # => 70162158343080

User.new.name # => Sergey

User = Class.new do
  def second_name
    'Gernyak'
  end

  def full_name
    "#{second_name} #{name}"
  end
end

User.object_id #=> 70162144228540

User.new.full_name # => NameError: undefined local variable or method `name' for #<User:0x007fdb3b028b18>

User.new.second_name # => Gernyak

Получается мы взяли и заменили значение нашей переменной User на другое. Об этом также свидетельствует значение object_id. Поэтому наш новый класс User ничего не знает о методе #name.

Правда и тут можно сделать хитрость: добавить наш существующий класс в цепочку наследования:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class User
  def first_name
    'Sergey'
  end
end

User.new.name # => Sergey

User = Class.new(User) do # <= Вот сюда
  def second_name
    'Gernyak'
  end

  def full_name
    "#{second_name} #{first_name}"
  end
end

User.new.full_name # => Gernyak Sergey

User.ancestors # => [User, User, Object, Kernel, BasicObject]

Немного магии

А почему бы не объявить динамически класс, как суперкласс для другого класса?

1
2
3
4
class User < Class.new
end

User.ancestors # => [User, #<Class:0x007fd241dae998>, Object, Kernel, BasicObject]

Данную фичу я использовал в одной из наших библиотек exracted_validator.

У меня есть некоторый базовый класс, который содержит общую логику для возможности вынесения валидаций из модели:

1
2
3
4
5
module ExtractedValidator
  class Base < SimpleDelegator
    # Some logic here
  end
end

Но с некоторыми видами валидаций могут возникнуть проблемы, т.к. они жестко завязаны на именование класса, в котором они объявлены. Например, валидация uniqueness. Для того, чтобы иметь возможность использовать такую валидацию в кастомном валидаторе, необходимо переопределить метод .model_class:

1
2
3
4
5
class SignUpUserValidator < ExtractedValidator::Base
  def self.model_class
    User
  end
end

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

Ну почти все… Я не доволен тем, что мне принудительно нужно добавлять еще один метод каждый раз, когда буду объявлять новый валидатор. Поэтому я решил сделать небольшой рефакторинг используя метапрограмминг :-)

И вот что получилось:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
module ExtractedValidator
  class Base < SimpleDelegator
    def self.[](model)
      Class.new(self) do
        define_singleton_method :model_class do
          model
        end
      end
    end
  end
end

class SignUpUserValidator < ExtractedValidator::Base[User]
end

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

1
SignUpUserValidator.ancestors # => [SignUpUserValidator, #<Class:0x007f8ec9b604b8>, ExtractedValidator::Base, SimpleDelegator, Delegator, #<Module:0x007f8ec9c1a138>, BasicObject]

Вот #<Class:0x007f8ec9b604b8> как раз и есть тот самый класс-обертка!

На этом у меня все. Спасибо за внимание!

Первые впечатления от Phoenix Framework

Язык

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

Phoenix Framework написан на языке elixir. В сущности своей язык является “синтаксическим сахаром” для другого известного ЯП erlang. Но самое главное в нем то, что он написан рубистом (Jose Valim) и смотря на код elixir нельзя не заметить насколько велико влияние ruby на elixir. Но некоторое сходство можно найти только в синтаксисе. Все остальное - структуры данных, управляющие блоки - все здесь по другому. Основное, что нужно помнить про elixir - это функциональный язык программирования! Это значит никаких объектов! Но, как оказалось, и без них можно обойтись.

Структура фреймворка

Phoenix является MVC фреймворком. Структурирование очень напонимает рельсы:

  • web/controllers - здесь находятся контроллеры;
  • web/models - здесь находятся модели;
  • web/views - здесь находятся вьюхи.

Но если вы зайдете в web/views, то не найдете там ни одного темплейта. Здесь начинается интересная особенность phoenix.

Для шаблонов имеется другое место в фреймворке - web/templates. А что же тогда views? Оказывается views - это не то, что мы привыкли видеть в рельсах. В Phoenix Framework views выполняют следующие функции (по крайней мере то, что мне удалось выяснить на данный момент):

  • рендеринг темплейтов. В рельсах рендерингом теплейтов занимается контроллер. Здесь данная функциональность вынесена в отдельный класс. И, на мой взгляд, это очень интересное решение, т.к. в рельсах нельзя было, кроме контроллера, отрендерить темплейт;
  • содержит набор вспомогательных функций, которые можно использовать в темплейтах. В рельсах для этого создавались так называемые helpers.

Доступ к данным

Есть несколько вариантов того, где хранить данные:

  • использовать БД mnesia, которая написанная на erlang и является родной для elixir также;
  • использовать аналог ActiveRecord, который называется Ecto. На данный момент есть адаптеры для postgresql и mysql. ActiveRecord эта библиотека, конечно, мало напоминает, но она сразу идет из коробки и является, таким себе, стандартным решением.

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

  • создание БД с помощью специального mix таска;
  • создание моделей;
  • генерирование миграций;
  • ассоциации;
  • валидации;
  • CRUD операции и т.д.

Ассеты

Из коробки компиляция ассетов происходит с помощью nodejs пакета brunch. Т.е. если вы хотите писать ассеты на coffeescript и sass, то нужно также ставить nodejs. При деплое на продакшн необходимо принудительно выполнять специальную команду для прекомпиляции. На первый взгляд все понятно и прозрачно, еще не успел наспотыкаться)

На момент написания статьи вышла новая версия Phoenix Framework 0.12.0. В ней появился новый mix таск для прекомпиляции ассетов, наподобие, `rake assets:precompile` в rails.

Разработка

В процессе разработке я не заметил каких-то значимых особенностей. Очень похожая инфраструктура с рельсами:

  • есть свой пакетный менеджер;
  • большое количество библиотек, плагинов;
  • есть свой аналог rake тасков - mix;
  • генерирование приложения с помощью mix таска;
  • различные scaffold генераторы;
  • есть свой аналог Gemfile’а - mix.exs.

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

Деплой

Очень важным шагом является деплой своего приложения на продакшн. Инфраструктура rails немного разбаловала нас, что деплой можно делать всего одной командой. Хотя начальная настройка бывает затяжной. А также разгребание после очередного деплоя тоже :-) Но все же все понятно и очевидно. В официальной документации нет целостной картины, что из себя представляет деплой phoenix приложения и как его готовить.

Я столкнулся с множеством интересных моментов, когда пытался залить приложение на сервер, но все же через несколько часов попыток все заработало. На самом деле проблемы связаны даже не с самим фреймворком, а с незнанием, что делать с языком и его окружением - erlang и его виртуальной машиной. Для людей знающих все это труда не составит, но вот программисту-рельсовику придется голову поломать.

В своей следующей статье я более подробно остановлюсь на этом вопросе.

Заключение

Я не пытался сделать подробное описание всех особенностей или сравнение с рельсами. Это просто вольные мысли насчет первого опыта создания приложения с использованием Phoenix Framework. Учитывая все факторы, а также, что еще идет активная разработка, то я считаю этот опыт положительным и вселяющим надежду, что Phoenix Framework станет отличной альтернативой Ruby on Rails. Будем с охотой наблюдать за развитием!

Полезные ссылки

Проблемы с апдейтом Bundler 1.9

Несколько дней назад вышел апдейт известного гема для менеджмента зависимостей bundler. Вот пруф линк.

Я сразу же поспешил обновить его, т.к. у меня, как оказалось, стояла еще версия 1.7.7. Но оказалось не все так радостно.

После апдейта перестали запускаться rails приложения. Вывод после выполнения команды rails s стал следующим:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/activesupport-4.0.4/lib/active_support/values/time_zone.rb:283: warning: circular argument reference - now
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/newrelic_rpm-3.8.1.221/lib/new_relic/agent/configuration/default_source.rb:674: warning: duplicated key at line 985 ignored: :disable_mongo
bin/rails:6: warning: already initialized constant APP_PATH
/Users/sergio/Work/railsapps/tiporbet/bin/rails:6: warning: previous definition of APP_PATH was here
Usage: rails COMMAND [ARGS]

The most common rails commands are:
 generate    Generate new code (short-cut alias: "g")
 console     Start the Rails console (short-cut alias: "c")
 server      Start the Rails server (short-cut alias: "s")
 dbconsole   Start a console for the database specified in config/database.yml
             (short-cut alias: "db")
 new         Create a new Rails application. "rails new my_app" creates a
             new application called MyApp in "./my_app"

In addition to those, there are:
 application  Generate the Rails application code
 destroy      Undo code generated with "generate" (short-cut alias: "d")
 plugin new   Generates skeleton for developing a Rails plugin
 runner       Run a piece of code in the application environment (short-cut alias: "r")

All commands can be run with -h (or --help) for more information.

Я подумал, что проблема в spring и перезапустил его командой spring stop, но ничего не изменилось.

Следующим шагом я решил прегенерить binstubs.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
sergio@sergios-MacBook-Pro ~/Work/railsapps/tiporbet (master●)$ bundle config --delete bin                                                                                                                                                                         [ruby-2.2.0]
sergio@sergios-MacBook-Pro ~/Work/railsapps/tiporbet (master●)$ rake rails:update:bin                                                                                                                                                                              [ruby-2.2.0]
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/activesupport-4.0.4/lib/active_support/values/time_zone.rb:283: warning: circular argument reference - now
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/newrelic_rpm-3.8.1.221/lib/new_relic/agent/configuration/default_source.rb:674: warning: duplicated key at line 985 ignored: :disable_mongo
rake aborted!
LoadError: Please require this file from within a Capistrano recipe
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/capistrano-2.15.4/lib/capistrano/configuration/loading.rb:18:in `instance'
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/rvm-capistrano-1.5.1/lib/rvm/capistrano/helpers/base.rb:16:in `rvm_with_capistrano'
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/rvm-capistrano-1.5.1/lib/rvm/capistrano/helpers/_cset.rb:3:in `<top (required)>'
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:229:in `require'
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:229:in `block in require'
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:214:in `load_dependency'
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:229:in `require'
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/rvm-capistrano-1.5.1/lib/rvm/capistrano/base.rb:1:in `<top (required)>'
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:229:in `require'
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:229:in `block in require'
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:214:in `load_dependency'
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:229:in `require'
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/rvm-capistrano-1.5.1/lib/rvm/capistrano/selector.rb:1:in `<top (required)>'
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:229:in `require'
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:229:in `block in require'
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:214:in `load_dependency'
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:229:in `require'
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/rvm-capistrano-1.5.1/lib/rvm/capistrano.rb:3:in `<top (required)>'
/Users/sergio/.rvm/gems/ruby-2.2.0@global/gems/bundler-1.9.1/lib/bundler/runtime.rb:85:in `require'
/Users/sergio/.rvm/gems/ruby-2.2.0@global/gems/bundler-1.9.1/lib/bundler/runtime.rb:85:in `rescue in block in require'
/Users/sergio/.rvm/gems/ruby-2.2.0@global/gems/bundler-1.9.1/lib/bundler/runtime.rb:68:in `block in require'
/Users/sergio/.rvm/gems/ruby-2.2.0@global/gems/bundler-1.9.1/lib/bundler/runtime.rb:61:in `each'
/Users/sergio/.rvm/gems/ruby-2.2.0@global/gems/bundler-1.9.1/lib/bundler/runtime.rb:61:in `require'
/Users/sergio/.rvm/gems/ruby-2.2.0@global/gems/bundler-1.9.1/lib/bundler.rb:134:in `require'
/Users/sergio/Work/railsapps/tiporbet/config/application.rb:6:in `<top (required)>'
/Users/sergio/Work/railsapps/tiporbet/Rakefile:4:in `require'
/Users/sergio/Work/railsapps/tiporbet/Rakefile:4:in `<top (required)>'
/Users/sergio/.rvm/gems/ruby-2.2.0/bin/ruby_executable_hooks:15:in `eval'
/Users/sergio/.rvm/gems/ruby-2.2.0/bin/ruby_executable_hooks:15:in `<main>'
LoadError: cannot load such file -- rvm-capistrano
/Users/sergio/.rvm/gems/ruby-2.2.0@global/gems/bundler-1.9.1/lib/bundler/runtime.rb:76:in `require'
/Users/sergio/.rvm/gems/ruby-2.2.0@global/gems/bundler-1.9.1/lib/bundler/runtime.rb:76:in `block (2 levels) in require'
/Users/sergio/.rvm/gems/ruby-2.2.0@global/gems/bundler-1.9.1/lib/bundler/runtime.rb:72:in `each'
/Users/sergio/.rvm/gems/ruby-2.2.0@global/gems/bundler-1.9.1/lib/bundler/runtime.rb:72:in `block in require'
/Users/sergio/.rvm/gems/ruby-2.2.0@global/gems/bundler-1.9.1/lib/bundler/runtime.rb:61:in `each'
/Users/sergio/.rvm/gems/ruby-2.2.0@global/gems/bundler-1.9.1/lib/bundler/runtime.rb:61:in `require'
/Users/sergio/.rvm/gems/ruby-2.2.0@global/gems/bundler-1.9.1/lib/bundler.rb:134:in `require'
/Users/sergio/Work/railsapps/tiporbet/config/application.rb:6:in `<top (required)>'
/Users/sergio/Work/railsapps/tiporbet/Rakefile:4:in `require'
/Users/sergio/Work/railsapps/tiporbet/Rakefile:4:in `<top (required)>'
/Users/sergio/.rvm/gems/ruby-2.2.0/bin/ruby_executable_hooks:15:in `eval'
/Users/sergio/.rvm/gems/ruby-2.2.0/bin/ruby_executable_hooks:15:in `<main>'
(See full trace by running task with --trace)

Как оказалось - это проблема с гемом rvm-capistrano и началась она еще с апдейта bundler 1.8.0, но так как у меня стояла более поздняя версия, то все было нормально. Решением этой проблемы стало указанием require: false в Gemfile:

1
gem 'rvm-capistrano', '1.5.1', require: false

Затем я нормально смог перегенерить binstubs:

1
2
3
4
5
6
7
8
9
10
11
12
sergio@sergios-MacBook-Pro ~/Work/railsapps/tiporbet (master●)$ rake rails:update:bin                                                                                                                                                                              [ruby-2.2.0]
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/activesupport-4.0.4/lib/active_support/values/time_zone.rb:283: warning: circular argument reference - now
/Users/sergio/.rvm/gems/ruby-2.2.0/gems/newrelic_rpm-3.8.1.221/lib/new_relic/agent/configuration/default_source.rb:674: warning: duplicated key at line 985 ignored: :disable_mongo
WARNING: Nokogiri was built against LibXML version 2.9.0, but has dynamically loaded 2.8.0
       exist  bin
   identical  bin/bundle
    conflict  bin/rails
Overwrite /Users/sergio/Work/railsapps/tiporbet/bin/rails? (enter "h" for help) [Ynaqdh] Y
       force  bin/rails
    conflict  bin/rake
Overwrite /Users/sergio/Work/railsapps/tiporbet/bin/rake? (enter "h" for help) [Ynaqdh] Y
       force  bin/rake

И вот, наконец-то, мое rails app запустилось!

Перечисления в Ruby

Во многих языках программирования можно встретить, так называемые, перечисления или enum, которые представляют из себя набор именованых констант. Например, в языке C# их можно объявить следующим образом:

land:csharp Листинг 1 - Enum в C#
1
2
3
enum Days {Mon, Tue, Wed, Thu, Fri, Sat, Sun};

day = Days.Mon;

В ruby нет встроенного типа enum, но есть несколько приемов как такое можно реализовать из того, что есть под рукой :-)

Вариант 1 - Быстрый и понятный

Все что нам нужно - модуль и константы. Код будет следующим:

Листинг 2 - Ruby Enum 1
1
2
3
4
5
6
7
module Days
  MON = 1
  TUE = 2
  # и так далее
end

puts Days::MON # => 1

Я думаю, что здесь особо ничего не нужно комментировать.

Вариант 2

Вот такой интересный вариант я нагуглил:

Листинг 3 - Ruby Enum 2
1
2
3
Days = [Mon = 1, Tue = 2, ...]

puts Mon # => 1

Но данный подход, как по мне, имеет несколько недостатков:

  • Теряется namespace, для этого нужно код выше поместить еще в модуль
  • Необходимо вручную прописывать значения для каждого элемента перечисления
  • Не очень удобно (но иногда все таки необходимо) использовать числа в качестве значений

Вариант 3

Необходимо будет написать дополнительно пару строк кода и… вуаля!

Листинг 4 - Ruby Enum 3
1
2
3
4
5
6
7
8
9
10
11
12
13
module Enumable
  def enum(*args)
    args.flatten.each { |const| const_set(const, const) }
  end
end

module Days
  include Enumable

  enum "MON", "TUE", "WED", ...
end

puts Days::MON # => 'MON'

Такой вариант очень удобен и читабелен, особенно если вы хотите устанавливать определенные значения для какой-либо записи в БД. Ведь сразу понятнее такая информация event.day_of_week # => 'MON', чем event.day_of_week # => 1.

Заключение

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

P.S. Вот, на мой взгляд, одна из самых лучших реализаций ruby enum

Коллизия имен класса и модуля в Ruby

Вот такая вот ситуация

В основе статьи лежит ситуация, с которой я и мои коллеги столкнулись при работе над одним проектом.

Представим себе следующее. У меня есть модель Item:

Листинг 0 - Модель Item app/models/item.rb
1
2
class Item < ActiveRecord::Base
end

Затем нам дали задачу на новую фичу: пользователи могут создавать списки и добавлять туда элементы. Мы сразу видим модель List:

Листинг 1 - Модель List app/models/list.rb
1
2
class List < ActiveRecord::Base
end

Но для хранения связи между элементом и листом мы решили использовать модель не ListItem, а положить ее в namespace List, т.е. логически выделить эту часть подсистемы, т.к. мы предполагаем, что будут еще связанные модели. И получается следующее:

Листинг 2 - Модель List::Item app/models/list/item.rb
1
2
3
4
module List
  class Item < ActiveRecord::Base
  end
end

Как бы все идет хорошо до тех пор, пока вы не захотите использовать List::Item модель где-нибудь в коде приложения:

Листинг 3 - Использование List::Item в rails console
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Loading development environment (Rails 4.0.4)
2.1.2 :001 > List::Item.new
TypeError: List is not a module
  from /Users/sergio/Work/railsapps/namespace_collision_demo/app/models/list/item.rb:1:in `<top (required)>'
 from /Users/sergio/.rvm/gems/ruby-2.1.2/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:424:in `load'
  from /Users/sergio/.rvm/gems/ruby-2.1.2/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:424:in `block in load_file'
 from /Users/sergio/.rvm/gems/ruby-2.1.2/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:616:in `new_constants_in'
  from /Users/sergio/.rvm/gems/ruby-2.1.2/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:423:in `load_file'
 from /Users/sergio/.rvm/gems/ruby-2.1.2/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:324:in `require_or_load'
  from /Users/sergio/.rvm/gems/ruby-2.1.2/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:463:in `load_missing_constant'
 from /Users/sergio/.rvm/gems/ruby-2.1.2/gems/activesupport-4.0.4/lib/active_support/dependencies.rb:184:in `const_missing'
  from (irb):1
  from /Users/sergio/.rvm/gems/ruby-2.1.2/gems/railties-4.0.4/lib/rails/commands/console.rb:90:in `start'
 from /Users/sergio/.rvm/gems/ruby-2.1.2/gems/railties-4.0.4/lib/rails/commands/console.rb:9:in `start'
  from /Users/sergio/.rvm/gems/ruby-2.1.2/gems/railties-4.0.4/lib/rails/commands.rb:62:in `<top (required)>'
 from bin/rails:4:in `require'
  from bin/rails:4:in `<main>'

Есть несколько путей решения этой проблемы.

Решение 0 - Внести название модуля в название класса

Я до конца не понимаю почему, но вот если изменить код модели List::Item на следующий:

Листинг 4 - Решение 0 для модели List::Item app/models/list/item.rb
1
2
class List::Item < ActiveRecord::Base
end

то все рабоет нормально:

Листинг 5 - Результат решения 0
1
2
2.1.2 :001 > List::Item.new
 => #<List::Item id: nil, created_at: nil, updated_at: nil>

Решение 1 - Заменить module на класс

Изучая объектную модель ruby мы обнаружили одну интересную вещь: класс - это тот же модуль, но с дополнительным функционалом!

Листинг 6 - Отношения Class и Module
1
2
3
4
2.1.2 :013 > List::Item.class
 => Class
2.1.2 :014 > List::Item.class.superclass
 => Module

Значит Class наследует весь функционал Module и он также может выступать как контейнер. Давайте попробуем это.

Листинг 7 - Решение 1 для модели List::Item app/models/list/item.rb
1
2
3
4
class List
  class Item < ActiveRecord::Base
  end
end

И правда - все работает отлично:

Листинг 8 - Результат решения 1
1
2
2.1.2 :001 > List::Item.new
 => #<List::Item id: nil, created_at: nil, updated_at: nil>

Решение 2 - К черту всю эту возню с объектной моделью!

В данном случае предлагается поступить следующим образом: выделить namespace и ложить туда все модели, которые логически связаны. В соответствии с нашим примером будет следующее:

Листинг 9 - Модель Lists::List app/models/lists/list.rb
1
2
3
4
module Lists
  class List < ActiveRecord::Base
  end
end
Листинг 10 - Модель Lists::Item app/models/lists/item.rb
1
2
3
4
module Lists
  class Item < ActiveRecord::Base
  end
end

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

Ruby с функциональным блеском

Вот и наступил 2015 год. Подводя итоги прошлого года можно отметить две мэйнстримовые тенденции:

  1. Статическая типизация
  2. Функциональное программирование

Сегодня пойдет речь о втором пункте.

Год перемен

2014 год однозначно был функциональным. Вторую жизнь получили многие забытые функциональные языки, много новых появилось. Их начинали пихать куда можно и куда нельзя, где уместно их использовать, а где нет. Меня тоже немного задела эта волна. Совершенно случайно необходимо было написать слой приложения на любом языке кроме ruby. Задача выбора языка сейчас очень сложна из-за их огромного выбора. Но не на этот раз! Все больше и больше информации появлялось о новом молодом языке elixir. Он привлекал еще больше внимания, т.к. его создатель - Rails Core Team Member (Jose Valim)!. И как раз вышла стабильная версия 1.0.0! И он как раз еще и функциональный язык! Бинго! Как раз есть возможность окунуться в мир ФП на реальном примере! Работка обещала быть интересной :-)

Другой мир

На первый взгляд все казалось намного проще. Но в самом начале был тупик… ООПшный тупик… Нет классов и, соответственно, нет наследования и всех плюшек ООП. Есть только функции и их можно объединять в модули и все. Совсем другой мир! Но не менее интересный) Но я не буду полностью все описывать, а остановлюсь на нескольких, как мне кажется, особо интересных особенностях.

Трансформация данных

Для тех, кто хочет познакомится с elixir и функциональным программированием, обязательно стоит посмотреть доклад Дейва Томаса (Dave Thomas) Elixir: Power of Erlang, Joy of Ruby. Основная идея, которую я подчерпнул из данного доклада - трансформация данных. Т.е., рассматривая какую-либо часть архитектуры как черный ящик, то на вход подается данные_1, а на выходе мы ожидаем данные_2. Я думаю, что это применимо и к ОПП подходу, но в ФП это более выражено и есть даже специальный оператор!

Pipe-оператор

В elixir есть специальный оператор |>, который называется pipe-оператор, который выполняет операции над данными слева. Например:

Листинг 1 - Пример использования pipe-оператора
1
2
str = "   sergey     "
str |> String.strip # => "sergey"

Причем количество операций преобразования может быть несколько

Листинг 2 - Пример комбинирования операций преобразования через pipe-оператор
1
2
str = "   sergey     "
str |> String.strip |> String.capitalize # => "Sergey"

Это очень напоминает оператор |, используемый в bash.

А что в ruby?

Ruby полностью объектно-ориентированный язык, поэтому в основе лежат объекты и связь между объектами с помощью сообщений. Но сколько же раз вы видели подобный код:

Листинг 3
1
2
3
def fetch_name(html)
  normalize_name(strip_html(html))
end

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

Давайте попробуем реализовать подобное в ruby. Мы будем использовать здесь monckeypatching (начиная с ruby 2.0 необходимо использовать refinements)!

Листинг 4 - Monckeypatching
1
2
3
4
5
6
7
8
9
10
11
12
class String
  def strip_html
    # some code
  end
  def normalize_name
    # some code
  end
end

def fetch_name(html)
  html.strip_html.normalize_name
end

Вот чем вам не код, как в листинге 2? В отличии от листинга 3, здесь код намного понятен с первого взгляда, т.к. сразу можно понять: слева объект, над которым производятся трансформации, а далее идут операции. Стоит отметить, что в данном примере все операции производятся над типом String. Если в цепочке обработки используются несколько типов данных, то необходимо патчить все соответствующие классы.

А как же коллекции?

Pipe-оператор также отлично справляется и с коллекциями. Вот, например, код, который увеличивает каждый элемент в массиве на 1:

Листинг 5
1
2
3
4
5
6
7
8
defmodule Transform do
  def increment(el) do
    el + 1
  end
end

arr = [1, 2, 3]
arr |> Enum.map(&Transform.increment(&1)) #=> [2, 3, 4]

Или даже лучше так:

Листинг 6
1
2
3
4
5
6
7
8
9
10
11
12
defmodule Transform do
  def increment(el) do
    el + 1
  end

  def process_element(el) do
    el |> increment
  end
end

arr = [1, 2, 3]
arr |> Enum.map(&Transform.process_element(&1)) #=> [2, 3, 4]

В ruby это выглядело бы следующим образом:

Листинг 7
1
2
3
4
5
6
def process_element(el)
  el + 1
end

arr = [1, 2, 3]
arr.map { |e| process_element(e) } # => [2, 3, 4]

Или в стиле elixir’a (для этого будем использовать магический метод Kernel#method):

Листинг 8
1
2
3
4
5
6
def process_element(el)
  el + 1
end

arr = [1, 2, 3]
arr.map &method(:process_element) # => [2, 3, 4]

Что в итоге?

Я очень люблю ruby! Каждый день для себя открываю все больше и больше возможностей и не перестаю восхищатся его мощью и выразительностью. Но всегда не покидает чувство, что чего-то не хватает и ты находишь это в другом языке и пытаешься реализовать в ruby. Кто знает, возможно лет через N это запилят и в ruby :-)

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

Пример TDD разработки библиотеки на Ruby

Лирическое отступление

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

Lets go!

Задача

Сколько людей - столько и мнений. Наверное сейчас очень сложно придумать такую задачу, чтобы она была уникальной. Поэтому появилось устоявшееся выражение - писать велосипед. Вот таким велосипедом я сейчас и займусь (я даже специально не гуглил на эту тему, чтобы заранее себя не расстраивать). А проблема вот какая:

Последнее время очень часто возникает необходимость сливать базу данных с продакшена и накатывать ее на девелопменте, т.к. править различные штуки удобнее локально. Для этого приходится делать достаточно много шагов: зайти по ssh и сделать дамп базы, зайти через Filezilla и скачать этот дамп, затем накатить его на dev базу. И тут я задался вопросом ведь яжпрограммист или кто?!

Поэтому я решил написать на ruby утилиту, которая поможет мне автоматизировать этот процесс.

Я понимаю, что это велосипед, но ведь он свой :-)

Цель

С самого начала я взял листок бумаги и накидал кусок кода, который, возможно, должен получится в конце. Можно сказать своеобразный DSL, при помощи которого я смог бы описывать шаги, которые я делал вручную. Получилось что-то такое:

Листинг 1 - Идея
1
2
3
4
5
6
DbFetcher.steps do
  step ssh_login: { host: 'some.com', username: 'user', password: 'password' }
  step ssh_run: { command: '...' }
  step sftp: { serverpath: '...', localpath: '...' }
  step local_run: { command: '...' }
end

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

Будем пробовать TDD

Есть многие вещи, которые ты не поймешь зачем они нужны, пока ты сам до этого не дойдешь, даже если какой-нибудь гуру будет тебе говорить, что это классно. Такая история и с TDD. Сама идея писать код для проверки кода звучит дико, не правда ли? Но не нужно смотреть на TDD только через призму, того что нужно писать дополнительный код, ведь программисты и так ленивые. Это целая методология со своими условными ‘правилами’ и ее главная цель уменьшить вашу головную боль. Но не буду заострять внимание на этом. Просто будте готовы писать тесты!

Ну-с начнем

Самый сложный момент. С чего начать, когда есть только идея? Существуют два варианта движения:

  1. Inside-out
  2. Outside-in

Выбор зависит индивидуально от программиста (смотря как у кого работает голова), а также от того, какую информацию содержит ваша первоначальная идея (листинг 1).

Давайте еще раз посмотрим, что у нас есть - это пример АПИ, которое должна предоставлять библиотека или, другими словами, ее outside часть. Для нас очевиден путь (по крайней мере я выбрал этот вариант и возможно к концу написания библиотеки пойму, что он был неудобный) outside-in.

Первые шаги

С самого начала хочу определить структуру каталога, чтобы была возможность ссылаться на нее, а также вам для наглядности:

1
2
3
<project_root>
|- lib
|- test

Я думаю тут вопросов не должно возникнуть)

В первую очередь давайте настроем наше test environment. Надеюсь вы заметили, что я ни слова не сказал насчет какого-нибудь фреймворка? Да, вы правильно поняли - это будет проект на чистом ruby. Поэтому любые настройки придется делать вручную. Но это даже к лучшему! Так вот, если взять rails, то там уже из коробки идет настроенный unit testing, но я всегда сразу устанавливал Rspec, и файл с настройками назывался test_helper или spec_helper (для rspec). Давайте и у нас создадим такой файл, в котором будут содержаться общие настройки для всех наших тестов:

Листинг 2 - test/test_helper.rb
1
2
3
4
5
require 'minitest/autorun'

Dir[File.dirname(__FILE__) + '/../lib/**/*.rb'].each do |file|
  require file
end

Что мы здесь сделали?

  1. Подключили фреймворк для unit тестирования - Minitest, который идет в ruby stdlib
  2. Загрузили все файлы из папки lib

И вот теперь, наконец-то, можно приступать к написанию кода! А если точнее - теста. Вот тут начинается еще одна интересная вещь - а что тестировать, если ничего нет?

Небольшое отступление. Сейчас мне в голову пришла мысль и я хочу немного изменить и дополнить нашу первоначальную идею:

Листинг 3 - Идея измененная
1
2
3
4
5
6
7
runner = DbFetcher.define_runner do
  step ssh_login: { host: 'some.com', username: 'user', password: 'password' }
  step ssh_run: { command: '...' }
  step sftp: { serverpath: '...', localpath: '...' }
  step local_run: { command: '...' }
end
runner.run

И теперь стало немного понятнее с чего нам начать. Как я уже говорил выше я выбрал путь outside-in, т.е. мы будем двигаться в сторону уточнения внутренней реализации. Поэтому начнем с теста внешнего АПИ. Да, я не опечатался - начнем с ТЕСТА)

Листинг 4 - Первая проба написания теста test/lib/db_fetcher_test.rb
1
2
3
4
5
6
7
require_relative '../test_helper'

class DbFetcherTest < Minitest::Test
  def test_define_new_runner
    assert_instance_of DbFetcher::Runner, DbFetcher.define_runner {}
  end
end

Сейчас давайте разберем листинг 4:

  1. Мы заинклудили наш test_helper, поэтому в данном контексте доступен Minitest
  2. Написали тест, который проверяет правильно работы метода define_runner (здесь лучше сказать, что мы проверяем не правильность, а то, какой результат ожидаем после вызова данного метода). Все, что мы можем сейчас проверить - метод define_runner возвращает новый экземпляр класса DbFetcher::Runner.

А давайте запустим наш тест и посмотрим результат? (подсказка: для запуска теста набирайте ruby test/lib/db_fetcher_test.rb):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Run options: --seed 60105

# Running:

E

Finished in 0.001982s, 504.5409 runs/s, 0.0000 assertions/s.

  1) Error:
DbFetcherTest#test_define_new_runner:
NameError: uninitialized constant DbFetcherTest::DbFetcher
    test/lib/db_fetcher_test.rb:10:in `test_define_new_runner'

1 runs, 0 assertions, 0 failures, 1 errors, 0 skips

Упс… Но все правильно! Ведь кода у нас еще нет! Сейчас мы должны написать минимум кода для того, чтобы удовлетворить данный тест.

Листинг 5 - lib/db_fetcher.rb
1
2
3
4
5
6
7
module DbFetcher
  extend self

  def define_runner(&block)
    Runner.new
  end
end

Так-с… Попытка следующая:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Run options: --seed 17668

# Running:

E

Finished in 0.001540s, 649.3506 runs/s, 0.0000 assertions/s.

  1) Error:
DbFetcherTest#test_define_new_runner:
NameError: uninitialized constant DbFetcher::Runner
    test/lib/db_fetcher_test.rb:10:in `test_define_new_runner'

1 runs, 0 assertions, 0 failures, 1 errors, 0 skips

Вот черт! Совсем забыл про Runner. Давайте определим и его!

Листинг 6 - lib/db_fetcher/runner.rb
1
2
3
4
module DbFetcher
  class Runner
  end
end

А вот теперь скрестили пальцы и-и-и-и:

1
2
3
4
5
6
7
8
9
Run options: --seed 57197

# Running:

.

Finished in 0.001258s, 794.9126 runs/s, 794.9126 assertions/s.

1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

Отлично! 1 assertions значит, что наш тест прошел! Фууух.

Итерационность

Главное слово в этом процессе - итерационность. После того, как вы добились чтобы тест прошел, можете писать тест дальше. Затем вы опять пишете код, удовлетворяющий новые тесты, но и не сломал предыдущие! И этот процесс повторяется снова и снова. Самой главной величиной этого процесса является размер вот этого шага или количество логики, заложенной в тесте, после написания которого вы приступаете к написанию кода. Здесь нет никаких пожеланий и вступает в силу индивидуальность разработчика. Каждый выбирает этот шаг так, чтобы было комфортно: если напишите много тестов - зациклитесь на написании кода, а также есть вероятность сделать тесты неактуальными, т.к. при их написании не учли каких-либо архитектурных моментов; если напишите мало - то придется слишком часто переключать ваш контекст между кодом и тестами, что тоже не гуд, т.к. опять же можно что-то упустить.

Продолжим?

Спешу вас разочаровать(а может и обрадовать) - я не буду полностью описывать весь процесс разработки данной библиотеки, т.к. это будет слишком много неинтересного текста и чередующихся листингов тесты-код-тесты-код-тесты… и т.д. Поэтому я предлагаю мне дописать библиотеку и затем я выложу исходники на github, и затем кину сюда ссылку, хорошо?) Ну и отлично!

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

UPDATE. Как и обещал, выкладываю ссылку на репозитарий db_fetcher. Библиотека еще будет дорабатываться, но базовые концепции уже реализованы и протестированы.