пятница, 29 апреля 2011 г.

Карты, деньги, два ствола... Нет... Ruby, Rails, метапрограммирование

Нынче уже некоторое время пишу под Rails. С сожалением смотрю на себя, ибо все таки Python опыт - это опыт Python, но не Ruby. Добрался до некоторой задачи, и захотелось упростить некоторые вещи в шаблоне, а стандартные средства routes не устраивают. Точнее устраивают, но не совсем полностью. Спасибо Печорину Андрею, который подсказал некоторый выход из ситуации, и навел меня на метапрограммирование, которое, как оказалось хорошо применяется в Ruby.
Ничего серьезного, или дико умопомрачительного я не опишу, но покажу, что даже злостный eval (да-да, питонисты, да и вообще, сразу косо посмотрят на меня), может быть полезен, и он довольно таки вписался в Ruby. Точнее был красиво вписан туда.

Покажу я применение, на простой задаче, с которой я встретился. Не то чтобы, тут нет других выходов, не то чтобы, тут по другому не выйти из ситуации, но мне показалось более красивым это решение. Итак, пристегнитесь покрепче. Тем, кто не хочет даже слышать об eval прошу пропустить этот пост, и не читать дальше.
Пример я буду показывать из дипломного на Rails, но это не умоляет применения метода в каких-то насущных задачах.
Имеется контроллер MicroblogController. У него имеется семь основных методов: пять агрегаторов постов, и два относятся к подписке.
В моем приложении все ссылки для текущего пользователя - без префикса в виде его имени пользователя. Приведу кусок из config/routes.rb
    1 # Microblog Controller 
    2 get "microblog/feeds/global" => "microblog#global_feed", 
    3     :default => {:page => 1}, 
    4     :as => :microblog__global_feed 
    5 get "((:username)/)microblog/feeds/local" => "microblog#local_feed", 
    6     :default => {:page => 1}, 
    7     :as => :microblog__local_feed 
    8 get "((:username)/)microblog/feeds/personal" => "microblog#personal_feed", 
    9     :default => {:page => 1}, 
   10     :as => :microblog__personal_feed 
   11 get "((:username)/)microblog/feeds/followings" => "microblog#followings_feed", 
   12     :default => {:page => 1}, 
   13     :as => :microblog__followings_feed 
   14 get "((:username)/)microblog/feeds/followers" => "microblog#followers_feed", 
   15     :default => {:page => 1}, 
   16     :as => :microblog__followers_feed 
   17 get "((:username)/)microblog/subscribes/followings" => "microblog#followings", 
   18     :default => {:page => 1}, 
   19     :as => :microblog__followings 
   20 put "microblog/subscribes/followings/add/:following" => "microblog#add_following", 
   21     :as => :microblog__add_following 
   22 put "microblog/subscribes/followings/remove/:following" => "microblog#remove_following", 
   23     :as => :microblog__remove_following 
   24 get "((:username)/)microblog/subscribes/followers" => "microblog#followers", 
   25     :default => {:page => 1}, 
   26     :as => :microblog__followers 
   27 put "microblog/create_post" => "microblog#create_post", 
   28     :as => :microblog__create_post 
   29 put "microblog/delete_post/:id" => "microblog#delete_post", 
   30     :as => :microblog__delete_post 
Здесь мы видим, что у меня каждый роут именнованный. Ну и имеет некоторые параметры по умолчанию. Генерация ссылки на персональную ленту микроблога моего микроблога будет выглядеть примерно следующим образом: <% link_to "Demiazz Microblog", microblog__personal_feed_path(:username => "demiazz") %>. Нравится? Думаю нет. Как бы удобно не было, как бы красиво не выглядело, но все равно, что то не то.
Но это еще цветочки. В моем приложении, сверху расположено меню, которое предоставляет ссылки на те или иные ленты. Меню изображается в виде табов, и хочется как бы показать пользователю, где он собственно находится.
Какое решение можно придумать? Ну проверить по action_name и вывести либо ссылку с каким-нить CSS классом, либо без него. В итоге в шаблоне мы можем получить что-то типа:
<% if action_name == "personal_feed" %>
  <%= link_to "Personal Feed", microblog__personal_feed_path(:username => "demiazz"), :class => "selected" %>
<% else %>
  <%= link_to "Personal Feed", microblog__personal_feed_path(:username => "demiazz") %>
<% end %>
Я что-то не вижу элегантности чуть больше, чем совсем. Это одна ссылка, а их может быть 5 к примеру. Причем два варианта, для текущего пользователя, и для указанного пользователя в URL. Можно конечно, оставить и так, но мне кажется это не совсем элегантно. Какой выход? Написать хелперы. Сказано сделано. Первоначальный вариант хелперов:

    1 module MicroblogHelper
    2 
    3   def to_microblog_home(name="Microblog", html_options = {})
    4     if action_name == "local_feed"
    5       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
    6     end
    7     link_to name, url_for(:controller => "microblog",
    8                           :action => "local_feed",
    9                           :username => nil), html_options
   10   end
   11 
   12   def to_microblog_user_home(user, name="Microblog", html_options = {})
   13     if action_name == "personal_feed"
   14       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
   15     end
   16     link_to name, url_for(:controller => "microblog",
   17                           :action => "personal_feed",
   18                           :username => user.username), html_options
   19   end
   20 
   21   def to_microblog_global_feed(name="Global", html_options = {})
   22     if action_name == "global_feed"
   23       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
   24     end
   25     link_to name, url_for(:controller => "microblog",
   26                           :action => "global_feed",
   27                           :username => nil), html_options
   28   end
   29 
   30   def to_microblog_local_feed(name="Local", html_options = {})
   31     if action_name == "local_feed"
   32       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
   33     end
   34     link_to name, url_for(:controller => "microblog",
   35                           :action => "local_feed",
   36                           :username => nil), html_options
   37   end
   38 
   39   def to_microblog_personal_feed(name="Personal", html_options = {})
   40     if action_name == "personal_feed"
   41       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
   42     end
   43     link_to name, url_for(:controller => "microblog",
   44                           :action => "personal_feed",
   45                           :username => nil), html_options
   46   end
   47 
   48   def to_microblog_followings_feed(name="Followings", html_options = {})
   49     if action_name == "followings_feed"
   50       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
   51     end
   52     link_to name, url_for(:controller => "microblog",
   53                           :action => "followings_feed",
   54                           :username => nil), html_options
   55   end
   56 
   57   def to_microblog_followers_feed(name="Followers", html_options = {})
   58     if action_name == "followers_feed"
   59       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
   60     end
   61     link_to name, url_for(:controller => "microblog",
   62                           :action => "followers_feed",
   63                           :username => nil), html_options
   64   end
   65 
   66   def to_microblog_user_local_feed(user, name="Local", html_options = {})
   67     if action_name == "local_feed"
   68       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
   69     end
   70     link_to name, url_for(:controller => "microblog",
   71                           :action => "local_feed",
   72                           :username => user.username), html_options
   73   end
   74 
   75   def to_microblog_user_personal_feed(user, name="Personal", html_options = {})
   76     if action_name == "personal_feed"
   77       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
   78     end
   79     link_to name, url_for(:controller => "microblog",
   80                           :action => "personal_feed",
   81                           :username => user.username), html_options
   82   end
   83 
   84   def to_microblog_user_followings_feed(user, name="Followings", html_options = {})
   85     if action_name == "followings_feed"
   86       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
   87     end
   88     link_to name, url_for(:controller => "microblog",
   89                           :action => "followings_feed",
   90                           :username => user.username), html_options
   91   end
   92 
   93   def to_microblog_user_followers_feed(user, name="Followers", html_options = {})
   94     if action_name == "followers_feed"
   95       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
   96     end
   97     link_to name, url_for(:controller => "microblog",
   98                           :action => "followers_feed",
   99                           :username => user.username), html_options
  100   end
  101 
  102   def to_microblog_subscribes(name="Subscribes", html_options = {})
  103     if action_name == "followings"
  104       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
  105     end
  106     link_to name, url_for(:controller => "microblog",
  107                           :action => "followings",
  108                           :username => nil), html_options
  109   end
  110 
  111   def to_microblog_user_subscribes(user, name="Subscribes", html_options = {})
  112     if action_name == "followings"
  113       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
  114     end
  115     link_to name, url_for(:controller => "microblog",
  116                           :action => "followings",
  117                           :username => user.username), html_options
  118   end
  119 
  120   def to_microblog_followings(name="Followings", html_options = {})
  121     if action_name == "followings"
  122       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
  123     end
  124     link_to name, url_for(:controller => "microblog",
  125                           :action => "followings",
  126                           :username => nil), html_options
  127   end
  128 
  129   def to_microblog_followers(name="Followers", html_options = {})
  130     if action_name == "followers"
  131       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
  132     end
  133     link_to name, url_for(:controller => "microblog",
  134                           :action => "followers",
  135                           :username => nil), html_options
  136   end
  137 
  138   def to_microblog_user_followings(user, name="Followings", html_options = {})
  139     if action_name == "followings"
  140       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
  141     end
  142     link_to name, url_for(:controller => "microblog",
  143                           :action => "followings",
  144                           :username => user.username), html_options
  145   end
  146   
  147   def to_microblog_user_followers(user, name="Followers", html_options = {})
  148     if action_name == "followers"
  149       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
  150     end
  151     link_to name, url_for(:controller => "microblog",
  152                           :action => "followers",
  153                           :username => user.username), html_options
  154   end
  155 
  156 end
Я намерено привел весь код, чтобы вы ужаснулись от того, сколько копипасты тут. А ее чуть больше, чем просто огромное количество. Если присмотреться, различия тут только в названии, одном параметре, и применении этого параметра. Все. Но мы пока посмотрим, как теперь будет выглядеть код меню для этого случая.
<article class="content-menu">
  <ul>
    <% if @personal %>
      <li><%= to_microblog_global_feed %></li>
      <li><%= to_microblog_local_feed %></li>
      <li><%= to_microblog_personal_feed %></li>
      <li><%= to_microblog_followings_feed %></li>
      <li><%= to_microblog_followers_feed %></li>
      <li class="separator"></li>
      <li><%= to_microblog_subscribes %></li>
    <% else %>
      <li><%= to_microblog_user_local_feed @user %></li>
      <li><%= to_microblog_user_personal_feed @user  %></li>
      <li><%= to_microblog_user_followings_feed @user %></li>
      <li><%= to_microblog_user_followers_feed @user %></li>
      <li class="separator"></li>
      <li><%= to_microblog_user_subscribes @user %></li>
    <% end %>
  </ul>
</article>
Ну что ж. Это лучше, чем куча условий. Замечу, что это меню и для текущего пользователя, и для указанного в URL. С условиями это выходило где то в 5 раз больше. Что вовсе не радовало глаз, и вообще было страшно и некрасиво.
С одной задачей мы разобрались. Мы избавились от ужасных link_to и условий, скрыв это все в хелперах. Теперь надо подумать, как изменить собственно хелперы, да так, чтобы это выглядело красиво.
Для нетерпеливых, я прилагаю ссылку на то, что я использую для решения этой задачи: Module::module_eval. Надо заметить, что есть еще методы Module#method_exec, Module#define_method. Но это вряд ли, то, что я бы сейчас использовал. Налицо большое применение шаблонов, и руки так и чешутся, чтобы объединить этот код в шаблоны, да по ним делать функции.
Прежде, чем я покажу результат рефакторинга, я вкратце расскажу про Module#module_eval. Эта функция выполняет код в контексте модуля. Что это может нам дать? Это может позволить нам, выполнить код, который объявит функцию, и она в дальнейшем будет доступна так же, как и любая другая в контексте модуля, вплоть до возможностей отладки. Ну разве что, вполне возможно, что отладчик не покажет вам строку где возникла ошибка, но сможет намекнуть на имя функции, которая вызвала ошибку.
Итак. Я немного сократил код хелпера, используя метод module_eval, и в итоге получили примерно следующий код:
    1 module MicroblogHelper
    2 
    3   def MicroblogHelper.included(mod)
    4     %w[global local personal followings followers].each do |feed|
    5       pattern = "
    6         def to_microblog_#{feed}_feed(name=\"#{feed.capitalize}\", html_options = {})
    7           if action_name == \"#{feed}_feed\"
    8             html_options[:class] = html_options[:class].nil? ? \"selected\" : html_options[:class].concat(\" selected\")
    9           end
   10           link_to name, url_for(:controller => \"microblog\",
   11                                 :action => \"#{feed}_feed\",
   12                                 :username => nil), html_options
   13         end"
   14       mod.module_eval pattern
   15     end
   16     %w{local personal followings followers}.each do |feed|
   17       pattern = "
   18         def to_microblog_user_#{feed}_feed(user, name=\"#{feed.capitalize}\", html_options = {})
   19           if action_name == \"#{feed}_feed\"
   20             html_options[:class] = html_options[:class].nil? ? \"selected\" : html_options[:class].concat(\" selected\")
   21           end
   22           link_to name, url_for(:controller => \"microblog\",
   23                                 :action => \"#{feed}_feed\",
   24                                 :username => user.username), html_options
   25         end"
   26       mod.module_eval pattern
   27     end
   28     %w{followings followers}.each do |subscribes|
   29       pattern = "
   30         def to_microblog_#{subscribes}(name=\"#{subscribes.capitalize}\", html_options = {})
   31           if action_name == \"#{subscribes}\"
   32             html_options[:class] = html_options[:class].nil? ? \"selected\" : html_options[:class].concat(\" selected\")
   33           end
   34           link_to name, url_for(:controller => \"microblog\",
   35                                 :action => \"#{subscribes}\",
   36                                 :username => nil), html_options
   37         end"
   38       mod.module_eval pattern
   39       pattern = "
   40         def to_microblog_user_#{subscribes}(user, name=\"#{subscribes.capitalize}\", html_options = {})
   41           if action_name == \"#{subscribes}\"
   42             html_options[:class] = html_options[:class].nil? ? \"selected\" : html_options[:class].concat(\" selected\")
   43           end
   44           link_to name, url_for(:controller => \"microblog\",
   45                                 :action => \"#{subscribes}\",
   46                                 :username => user.username), html_options
   47         end"
   48       mod.module_eval pattern
   49     end
   50   end
   51 
   52   def to_microblog_home(name="Microblog", html_options = {})
   53     if action_name == "local_feed"
   54       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
   55     end
   56     link_to name, url_for(:controller => "microblog",
   57                           :action => "local_feed",
   58                           :username => nil), html_options
   59   end
   60 
   61   def to_microblog_user_home(user, name="Microblog", html_options = {})
   62     if action_name == "personal_feed"
   63       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
   64     end
   65     link_to name, url_for(:controller => "microblog",
   66                           :action => "personal_feed",
   67                           :username => user.username), html_options
   68   end
   69 
   70   def to_microblog_subscribes(name="Subscribes", html_options = {})
   71     if action_name == "followings"
   72       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
   73     end
   74     link_to name, url_for(:controller => "microblog",
   75                           :action => "followings",
   76                           :username => nil), html_options
   77   end
   78 
   79   def to_microblog_user_subscribes(user, name="Subscribes", html_options = {})
   80     if action_name == "followings"
   81       html_options[:class] = html_options[:class].nil? ? "selected" : html_options[:class].concat(" selected")
   82     end
   83     link_to name, url_for(:controller => "microblog",
   84                           :action => "followings",
   85                           :username => user.username), html_options
   86   end
   87 
   88 end
Код сократился. Согласитесь. Если вам нужно будет внести изменения, то все функции в одном месте, в одном паттерне. Меняете одну, меняются 2/4/5 функций. Удобно. И код сократился. И они вполне отлаживаемые и выполняемые хоть сотню раз. И да. При инклуде в модуль или класс - они успешно появятся и там и там.
Но у нас остались четыре функции близнеца тех, что мы генерируем шаблонами. Не проблема. Если бы не дефолтный параметр name, значение которого отличается от близнецов, то мы могли бы использовать метод Module#alias_method. Для решения подобной проблемы, мы просто объявляем четыре функции обертки в теле модуля MicroblogHelper. И теперь модуль выглядит следующим образом:
    1 module MicroblogHelper
    2 
    3   def MicroblogHelper.included(mod)
    4     %w[global local personal followings followers].each do |feed|
    5       pattern = "
    6         def to_microblog_#{feed}_feed(name=\"#{feed.capitalize}\", html_options = {})
    7           if action_name == \"#{feed}_feed\"
    8             html_options[:class] = html_options[:class].nil? ? \"selected\" : html_options[:class].concat(\" selected\")
    9           end
   10           link_to name, url_for(:controller => \"microblog\",
   11                                 :action => \"#{feed}_feed\",
   12                                 :username => nil), html_options
   13         end"
   14       mod.module_eval pattern
   15     end
   16     %w{local personal followings followers}.each do |feed|
   17       pattern = "
   18         def to_microblog_user_#{feed}_feed(user, name=\"#{feed.capitalize}\", html_options = {})
   19           if action_name == \"#{feed}_feed\"
   20             html_options[:class] = html_options[:class].nil? ? \"selected\" : html_options[:class].concat(\" selected\")
   21           end
   22           link_to name, url_for(:controller => \"microblog\",
   23                                 :action => \"#{feed}_feed\",
   24                                 :username => user.username), html_options
   25         end"
   26       mod.module_eval pattern
   27     end
   28     %w{followings followers}.each do |subscribes|
   29       pattern = "
   30         def to_microblog_#{subscribes}(name=\"#{subscribes.capitalize}\", html_options = {})
   31           if action_name == \"#{subscribes}\"
   32             html_options[:class] = html_options[:class].nil? ? \"selected\" : html_options[:class].concat(\" selected\")
   33           end
   34           link_to name, url_for(:controller => \"microblog\",
   35                                 :action => \"#{subscribes}\",
   36                                 :username => nil), html_options
   37         end"
   38       mod.module_eval pattern
   39       pattern = "
   40         def to_microblog_user_#{subscribes}(user, name=\"#{subscribes.capitalize}\", html_options = {})
   41           if action_name == \"#{subscribes}\"
   42             html_options[:class] = html_options[:class].nil? ? \"selected\" : html_options[:class].concat(\" selected\")
   43           end
   44           link_to name, url_for(:controller => \"microblog\",
   45                                 :action => \"#{subscribes}\",
   46                                 :username => user.username), html_options
   47         end"
   48       mod.module_eval pattern
   49     end
   50   end
   51 
   52   def to_microblog_home(name="Microblog", html_options = {})
   53     to_microblog_local_feed(name, html_options)
   54   end
   55 
   56   def to_microblog_user_home(user, name="Microblog", html_options = {})
   57     to_microblog_user_personal_feed(user, name, html_options)
   58   end
   59 
   60   def to_microblog_subscribes(name="Subscribes", html_options = {})
   61     to_microblog_followings(name, html_options)
   62   end
   63 
   64   def to_microblog_user_subscribes(user, name="Subscribes", html_options = {})
   65     to_microblog_user_followings(user, name, html_options)
   66   end
   67 
   68 end
Итак. Наш модуль сократился почти в три раза, нам не нужны теперь именованные route'ы (хотя можно и оставить для красоты, тут дело вкуса). И, кроме того, мы последовали принципу повторного использования кода, правда несколько не стандартным путем.
В данном коде возможны еще два улучшения: 1) сократить количество циклов до одного, с вынесением одного из паттернов, и предварительной обработкой к примеру (global_feed). 2) так как условие, которое обрабатывает css класс зависит от action_name, и привязан к модулю MicroblogController, то в принципе, при include в другие модули/классы это условие просто не нужно, и его генерацию можно отключить.
На этом все. ) Программируйте с удовольствием.