Pull to refresh

Секрет объектно-ориентированной разработки в Rails

Reading time10 min
Views2.7K
Original author: Steve Klabnik
Сегодня мы предоставим вашему вниманию перевод поста Стива Клабника (Steve Klabnik), известного разработчика, приверженца Ruby, одного из победителей Ruby Hero Award этого года. Что это за награда? Она присуждается победителями прошлого года тем участникам сообщества, которые наиболее проявили себя: создали значимый обучающий контент, разработали плагины и гемы, участвовали в проектах с открытым кодом. Такая награда была создана для того, чтобы отметить наиболее проявивших себя людей и дать им признание, которое они заслуживают.
Пообщаться со Стивом можно будет на конференции в Киеве RubyC 5-6 ноября этого года.


Я часто говорю людям, что учил Ruby через Rails. Это один из худших способов, но к тому времени я уже выучил столько языков программирования, что это не мешало мне. Тем не менее, это дало мне слегка искаженное ощущение того, насколько тщательно проектировать классы, необходимые для Rails приложений. К счастью, я пристрастно просматриваю код, написанный другими, и заметил, что есть одна важная вещь, которая встречается в разработках у многих уважаемых мною людей.

Мне кажется, эти люди также считают эту вещь уникальной. Это не когда люди, не умеющие писать хороший код, стараются, но все равно получается плохо. Это как флаг, сигнал. Теперь, когда я вижу, как кто-то внедряет эту вещь, я сразу думаю: «он шарит». Возможно, я слишком сильно доверяю своему чувству, но эта продвинутая техника разработки предлагает множество взаимосвязанных преимуществ вашим Rails приложениям, легко применима и ускоряет тестирование на порядок или больше. К сожалению, для многих начинающих Rails разработчиков это неочевидно, но я хотел бы, чтобы вы писали код лучше и вот я здесь, чтобы, с вашего позволения, «раскрыть секрет» и поделиться этой мощной техникой с вами.



Она называется «Старый простой объект Ruby"

Да, именно так. Руби класс, который ничего не наследует. Это настолько просто, что спрятано на видном месте. Любимые создателями Rails, старые простые объекты Ruby Object, или «POROs» как некоторые любят их называть, являются скрытым оружием против сложности. Вот что я имею в виду. Рассмотрим эту «простую» модель:
Copy Source | Copy HTML
  1. class Post < ActiveRecord::Base
  2.   def self.as_dictionary
  3.     dictionary = ('A'..'Z').inject({}) {|h, l| h[l] = []; h}
  4.  
  5.     Post.all.each do |p|
  6.       dictionary[p.title[ 0]] << p
  7.     end
  8.  
  9.     dictionary
  10.   end
  11. end

Мы хотим показать указатель по первой букве для всех наших постов. Для этого мы создаём словарь и кладем в него наши посты. Предположим, нам не надо разбивать список на страницы, так что не обращайте внимание на запрос всех постов из базы. Важна идея: теперь мы можем показать все посты по названию:
Copy Source | Copy HTML
  1. - Post.as_dictionary do |letter, list|
  2.   %p= letter
  3.   %ul
  4.   - list.each do |post|
  5.     %li= link_to post

Конечно. C одной стороны, код не плохой. Но он также и не хорош: мы беспокоимся о представлении внутри модели, предназначенной для бизнес-логики. Так что давайте исправим это используя паттерн Presenter (Представитель):

Copy Source | Copy HTML
  1. class DictionaryPresenter
  2.   def initialize(collection)
  3.     @collection = collection
  4.   end
  5.  
  6.   def as_dictionary
  7.     dictionary = ('A'..'Z').inject({}) {|h, l| h[l] = []; h}
  8.  
  9.     @collection.each do |p|
  10.       dictionary[p.title[ 0]] << p
  11.     end
  12.  
  13.     dictionary
  14.   end
  15. end

Теперь мы можем использовать DictionaryPresenter.new(Post.all).as_dictionary. У него множество преимуществ: мы вынесли логику представления из модели. Мы уже добавили новую возможность: любая коллекция теперь может быть отображена с указателем. Мы можем легко написать отдельные тесты для этого класса-представителя и они будут быстрыми.

Несмотря на мою любовь к паттерну «Представитель», этот пост не о нем. Этот принцип появляется также и в других местах, «эта концепция заслуживает собственного класса». Перед тем, как перейти к другому примеру, давайте расширим этот: если мы хотим отсортировать наши посты по названию, этот класс будет работать, но показать, скажем, пользователей не получится, потому что у пользователей нет названий (поля title). Более того, мы получим большое количество постов на «А», потому что названия довольно часто начинаются с артикля «а», и на самом деле нам нужна первая буква второго слова. Мы можем сделать 2 вида Представителей, но тогда мы утратим общность и концепция «индекса» снова будет иметь 2 Представителя в нашей системе. Как Вы правильно поняли: PORO спасет нас!

Давайте слегка изменим нашего Представителя, чтобы он принимал объект политики:

Copy Source | Copy HTML
  1. class DictionaryPresenter
  2.   def initialize(policy, collection)
  3.     @policy = policy
  4.     @collection = collection
  5.   end
  6.  
  7.   def as_dictionary
  8.     dictionary = ('A'..'Z').inject({}) {|h, l| h[l] = []; h}
  9.  
  10.     @collection.each do |p|
  11.       dictionary[@policy.category_for(p)] << p
  12.     end
  13.  
  14.     dictionary
  15.   end
  16. end

Теперь мы можем добавить политики и сделать их разными:
Copy Source | Copy HTML
  1. class UserCategorizationPolicy
  2.   def self.category_for(user)
  3.     user.username[ 0]
  4.   end
  5. end
  6.  
  7. class PostCategorizationPolicy
  8.   def self.category_for(post)
  9.     if post.starts_with?("A ")
  10.       post.title.split[1][ 0]
  11.     else
  12.       post.title[ 0]
  13.     end
  14.   end
  15. end
  16.  

Бам!
Copy Source | Copy HTML
  1. DictionaryPresenter.new(PostCategorizationPolicy, Post.all).as_dictionary

Да, становится немного длинновато. Бывает :) Зато теперь вы можете видеть, что каждая концепция имеет одно представление в нашей системе. Представителю все равно как устроены вещи, и только политики диктуют как они устроены. На самом деле, мои названия немного отстойны, возможно, стоило бы лучше назвать «UsernamePolicy» или «TitlePolicy», вообще-то. Нам даже все равно какого они класса!

И так во всем. Сочетая гибкость Руби с моим любимым примером из «Эффективной работы с унаследованным кодом», мы можем превратить в объекты сложные вычисления. Вгляните на этот код:

Copy Source | Copy HTML
  1. class Quote < ActiveRecord::Base
  2.   #<snip>
  3.   def pretty_turnaround
  4.     return "" if turnaround.nil?
  5.     if purchased_at
  6.       offset = purchased_at
  7.       days_from_today = ((Time.now - purchased_at.to_time) / 60 / 60 / 24).floor + 1
  8.     else
  9.       offset = Time.now
  10.       days_from_today = turnaround + 1
  11.     end
  12.     time = offset + (turnaround * 60 * 60 * 24)
  13.     if(time.strftime("%a") == "Sat")
  14.       time += 2 * 60 * 60 * 24
  15.     elsif(time.strftime("%a") == "Sun")
  16.       time += 1 * 60 * 60 * 24
  17.     end
  18.  
  19.     "#{time.strftime("%A %d %B")} (#{days_from_today} business days from today)"
  20.   end
  21. end

Ой! Этот метод выводит время возврата (вычисления), но, как вы видите, это сложное вычисление. Мы бы могли понять это более просто, если бы мы использовали выделение метода (метод рефакторинга Method Extract) несколько раз чтобы разбить его, но тогда мы рискуем засорить наш класс Quote большим количеством кода нужным только для красивого вычисления времени. Также, пожалуйста, не смотрите на то, что модель реализует логику представления — это только пример громоздкого кода.

Так, теперь первый шаг этого рефакторинга, который Feather (автор «Working Effectively With Legacy Code») называет «Break Out Method Object». Вы можете открыть свой экземляр «Working Effectively With Legacy Code» на стр. 330 и почитать больше. Если ее у вас нет, купите :). Вообще-то, я отвлекся. Вот план действий:
1. Создайте новый класс вычислений.
2. Определите в нем метод для работы по-новому.
3. Скопируйте тело старого метода и замените ссылки на указатели объектами.
4. Создайте ему конструктор, который принимает аргументы для назначения переменных, использованных в шаге 3.
5. Делегируйте старый метод новому классу и методу.

Я слегка поменял оригинальный шаблон для Руби, поскольку мы не можем положиться на компилятор (Lean On The Compiler) и несколько шагов Feather занимается как раз этим. В любом случае давайте попробуем на этом коде. Шаг первый:

Copy Source | Copy HTML
  1. class Quote < ActiveRecord::Base
  2.   def pretty_turnaround
  3.     #snip
  4.   end
  5.  
  6.   class TurnaroundCalculator
  7.   end
  8. end

Второй:

Copy Source | Copy HTML
  1. class TurnaroundCalculator
  2.   def calculate
  3.   end
  4. end

Третий:

Copy Source | Copy HTML
  1. class TurnaroundCalculator
  2.   def calculate
  3.     return "" if @turnaround.nil?
  4.     if @purchased_at
  5.       offset = @purchased_at
  6.       days_from_today = ((Time.now - purchased_at.to_time) / 60 / 60 / 24).floor + 1
  7.     else
  8.       offset = Time.now
  9.       days_from_today = @turnaround + 1
  10.     end
  11.     time = offset + (@turnaround * 60 * 60 * 24)
  12.     if(time.strftime("%a") == "Sat")
  13.       time += 2 * 60 * 60 * 24
  14.     elsif(time.strftime("%a") == "Sun")
  15.       time += 1 * 60 * 60 * 24
  16.     end
  17.  
  18.     "#{time.strftime("%A %d %B")} (#{days_from_today} business days from today)"
  19.   end
  20. end

Я люблю давать обычные названия в начале, а потом изменять их на шаге 5 после того, как понятно что именно он делает. Скорее всего наш код сам подскажет нам хорошее имя.

Четвертый:

Copy Source | Copy HTML
  1. class TurnaroundCalculator
  2.   def initialize(purchased_at, turnaround)
  3.     @purchased_at = purchased_at
  4.     @turnaround = turnaround
  5.   end
  6.  
  7.   def calculate
  8.     #snip
  9.   end
  10. end

Пятый:

Copy Source | Copy HTML
  1. class Quote < ActiveRecord::Base
  2.   def pretty_turnaround
  3.     TurnaroundCalculator.new(purchased_at, turnaround).calculate
  4.   end
  5. end

Готово! Мы должны запустить тесты и посмотреть как они пройдут. Даже если «запустить тесты» означает проверить вручную…

Так в чем преимущество? Ну, сейчас мы можем начать процесс рефакторинга, но мы в нашей собственной маленькой чистой комнате. Мы можем внести методы в наш класс TurnaroundCalcuator без засорения класса Quote, мы можем написать быстрые тесты только для Calculator и разбить идею вычислений в одном месте, где позже её можно будет легко изменить. Вот наш класс после нескольких рефакторингов:

Copy Source | Copy HTML
  1. class TurnaroundCalculator
  2.   def calculate
  3.     return "" if @turnaround.nil?
  4.  
  5.     "#{arrival_date} (#{days_from_today} business days from today)"
  6.   end
  7.  
  8.   protected
  9.  
  10.   def arrival_date
  11.     real_turnaround_time.strftime("%A %d %B")
  12.   end
  13.  
  14.   def real_turnaround_time
  15.     adjust_time_for_weekends(start_time + turnaround_in_seconds)
  16.   end
  17.  
  18.   def adjust_time_for_weekends(time)
  19.     if saturday?(time)
  20.       time + 2 * 60 * 60 * 24
  21.     elsif sunday?(time)
  22.       time + 1 * 60 * 60 * 24
  23.     else
  24.       time
  25.     end
  26.   end
  27.  
  28.   def saturday?(time)
  29.     time.strftime("%a") == "Sat"
  30.   end
  31.  
  32.   def sunday?(time)
  33.     time.strftime("%a") == "Sun"
  34.   end
  35.  
  36.   def turnaround_in_seconds
  37.     @turnaround * 60 * 60 * 24
  38.   end
  39.  
  40.   def start_time
  41.     @purchased_at or Time.now
  42.   end
  43.  
  44.   def days_from_today
  45.     if @purchased_at
  46.       ((Time.now - @purchased_at.to_time) / 60 / 60 / 24).floor + 1
  47.     else
  48.       @turnaround + 1
  49.     end
  50.   end
  51. end

Ух-ты! Этот код, который я написал 3 года назад не идеален, но его почти можно понять. И каждый кусок имеет смысл. Это после 2-х или 3-х волн рефакторинга, которые я, возможно, раскрою в отдельном посте, потому что то, что получилось сейчас несколько более иллюстративно, чем я думал. В любом случае, вы поняли идею. Это то что я имею в виду, когда говорю что нацелен на пяти-строчные методы в Руби; если ваш код понятен, Вам легко его править.

Идея извлечения чистых объектов Руби справедлива и в Rails. Посмотрите на этот маршрут:

Copy Source | Copy HTML
  1. root :to => 'dashboard#index', :constraints => LoggedInConstraint

А? LoggedInConstraint?

Copy Source | Copy HTML
  1. class LoggedInConstraint
  2.   def self.matches?(request)
  3.     current_user
  4.   end
  5. end

О. Да. Объект, описывающий политику маршрутизации. Замечательно. Также примеры валидации, бесстыдно украденные с omgbloglol:

Copy Source | Copy HTML
  1. def SomeClass < ActiveRecord::Base
  2.   validate :category_id, :proper_category => true
  3. end
  4.  
  5. class ProperCategoryValidator < ActiveModel::EachValidator
  6.   def validate_each(record, attribute, value)
  7.     unless record.user.category_ids.include?(value)
  8.       record.errors.add attribute, 'has bad category.'
  9.     end
  10.   end
  11. end

Это не чистый Руби класс, но вы поняли идею.

Сейчас вы наверно думаете: «Стив, это не только для Rails, ты солгал!» Ну да, вообще-то, вы поймали меня: это не секрет для объектно-ориентированного Rails, это больше общий объектно-ориентированный подход. Но есть кое что специфическое для Rails, что как будто манит вас в ловушку никогда не разбивать классы. Возможно, lib/ кажеться таким хранилищем мусора. Возможно, что 15-ти минутные примеры только включают в себя модели ActiveRecord. Возможно, больше закрытых Rails приложений, (Внимание: лишь мое собственное мнение) чем открытых не-Rails, так что, у нас не так много хороших примеров, на которые можно опереться. (У меня есть такая догадка, поскольку Рельсы часто используются для разработки сайтов для компаний. Гемы? Точно? Мое веб приложение? Не так много. Все же, у меня нет статистики для подтверждения.)

В общем: извлечение объектов предметной области — это хорошо. Они делают ваши тесты быстрыми, код – коротким, и облегчают в дальнейшем внесение изменений. У меня есть еще что сказать об этом, особенно про «быстрые тесты», но я уже исчерпал возможную длину поста. До следующего раза!
Tags:
Hubs:
Total votes 60: ↑53 and ↓7+46
Comments19

Articles