0,0
рейтинг
28 декабря 2009 в 12:39

Разработка → Полиморфные связи

На днях в блоге Ruby on Rails появилась статья о полиморфных связях, в которой автор писал всякие разные вещи, но при этом забыл упоминуть, как их использовать и зачем они нужны (потом, конечно же, исправился, но все равно написал достаточно поверхностно).
Поначалу я даже испугался, что это моя статья каким-то непостижимым образом вырвалась из «черновиков» и попала в общую ленту. Потом разобрался, собрался с мыслями, и решил таки дописать свою.

Что же такое полиморфные связи и для чего они нужны? В одном из своих скринкастов Ryan Bates уже рассказывал об этом, и я ни в коем случае не хочу рассказывать то же самое. Ситуация была следующей:
у нас есть модели Статьи, Фотографии и События. А еще есть модель Комментарии. А еще очень хочется все комментарии (комментарии статей, фотографий и событий) хранить в одной таблице.
Статей по этой проблеме в интернете очень много, но бывают и случаи «наоборот». Далеко ходить не нужно, давайте попробуем разработать функционал постов Хабрахабра!

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

Хотел расписать все «для самых маленьких», но примерно в середине статьи понял, что как-то много получается (да и пользуются полиморфными связями, в основном, далеко не новички). И да, совсем забыл: давайте немного урежем наш функционал и будем проектировать связи из расчета 3 моделей: Топик, Ссылка и Подкаст (а то уж больно много кода получится, а делается все аналогично.

Итак, поехали!
Создадим 4 модели: Post, Topic, Link и Podcast. Непонятная модель Post как раз и будет «родительской» для остальных и, собственно, будет содержать все ненужные общие поля.
bash-3.2$ script/generate model post title:string published:boolean content_id:integer content_type:string
bash-3.2$ script/generate model topic body:text
bash-3.2$ script/generate model podcast link:string description:text
bash-3.2$ script/generate model link link:string description:text
Как видите, ссылки и подкасты имеют одинаковые поля, давайте сделаем еще одну полиморфную связь :)

При создании миграции Post мы указали общие поля для всех остальных таблиц (в данном случае это заголовок (title), статус статьи (published) и дату создания/изменения(они дописались автоматически)). Кроме того, есть 2 поля, в которых будут храниться идентификатор элемента (content_id) и модель, которой принадлежит данный элемент (content_type; мы разберемся с этим чуть позже).
Миграцию модели Post трогать больше не будем, а вот во всех остальных миграциях удалим эту строку:
t.timestamps
Ведь поля created_at и updated_at (которые генерирует хелпер timestamps) теперь одни на всех — в таблице posts.

Делаем rake db:migrate и… и остается последний штрих: добавляем связи моделям.
# app/models/post.rb<br/>class Post < ActiveRecord::Base<br/>  belongs_to :content, :polymorphic => true, :dependent => :destroy<br/>end<br/> <br/># app/models/topic.rb<br/>class Topic < ActiveRecord::Base<br/>  has_one :post, :as => :content, :dependent => :destroy<br/>end<br/> <br/># app/models/link.rb<br/>class Link < ActiveRecord::Base<br/>  has_one :post, :as => :content, :dependent => :destroy<br/>end<br/> <br/># app/models/podcast.rb<br/>class Podcast < ActiveRecord::Base<br/>  has_one :post, :as => :content, :dependent => :destroy<br/>end <br/>
Вместо того, чтобы в модели Post писать «has_many :links (:topics, :podcasts)», мы говорим, что Post крепко-накрепко связан семейными узами полиморфной связью с неким :content, при этом теперь любая модель, в которой мы напишем
:has_one :post, :as => content
станет дочерней для нашего Post'a. Что мы, собственно, и проделали выше.
Теперь мы полностью готовы перейти в консоль и возрадоваться:)
bash-3.2$ script/console
>> t = Topic.new(:body => "Just one more test topic body here")
>> t.save
>> p = Post.new(:title => "Some test title", :published => true, :content => t)
>> p.save
Создали новый топик (указав только тело), сохранили, создали новый пост (указав заголовок, статус и сам контент (можно было написать и :content_id => t.id, :content_type => t.class (как бы подразумевая при этом еще и .to_s)).

Вне всякого сомнения, чтобы поле content_type сразу заполнялось значением, мы можем написать так:
>> Post.topics.new
=> #<Post id: nil, title: nil, published: nil, content_id: nil, content_type: "Topic", created_at: nil, updated_at: nil>

Попробуем просмотреть все топики:
>> Post.find_all_by_content_type("Topic")
Согласен, неудобно; давайте добавим немного named_scope'ов в модель Post:
named_scope :topics, :conditions => { :content_type => "Topic" }<br/>named_scope :links, :conditions => { :content_type => "Link" }<br/>named_scope :podcasts, :conditions => { :content_type => "Podcast" } <br/>
Заходим опять в консоль, делаем reload! и осматриваемся:
>> Post.topics
>> Post.links
>> Post.podcasts
Теперь нужно понять, как получать доступ ко всем свойствам наших постов.
>> p.body
NoMethodError: undefined method `body' for #<Post:0x2653e00>
>> t.title
NoMethodError: undefined method `title' for #<Topic id: 8, body: "Just one more test topic body here">
Оказывается, что не так:) Попробуем что-нибудь вроде этого:
>> p.content.body
=> "Just one more test topic body here"
>> t.post.title
=> "Some test title"
Поигрались и хватит, пора делать контроллеры (а там и до представления недалеко). Выходим из рельсо-консоли, нас ждет обычная:)
bash-3.2$ script/generate controller posts index
bash-3.2$ script/generate controller posts/topics index show
bash-3.2$ script/generate controller posts/podcasts index show
bash-3.2$ script/generate controller posts/links index show
bash-3.2$ script/generate controller home index
Дальше идем в config/routes.rb и приводим его в такой вид:
ActionController::Routing::Routes.draw do |map|<br/>  map.root :controller => 'home'<br/> <br/>  map.namespace(:posts) do |post|<br/>    post.resources :topics, :links, :podcasts<br/>  end<br/>  map.resources :posts<br/> <br/>  map.connect ':controller/:action/:id'<br/>  map.connect ':controller/:action/:id.:format'<br/>end <br/>
А теперь запустим сервер и посмотрим, что у нас получилось:bash-3.2$ script/server
А получилось у нас вот что:
/posts (здесь будет список всех постов)
/posts/topics (здесь — только посты–топики)
/posts/links (а тут — только посты–ссылки)
/posts/podcasts (и вы никогда не угадаете, что же будет тут ;)

Само собой, доступен весь REST, в нем можно даже не сомневаться ;)

Теперь заполним кодом контроллеры:
# app/controllers/posts_controller.rb<br/>class PostsController < ApplicationController<br/>  def index<br/>    @posts = Post.find(:all)<br/>  end<br/>end<br/> <br/># app/controllers/posts/topics_controller.rb<br/>class Posts::TopicsController < ApplicationController<br/>  def index<br/>    @posts = Post.topics.find(:all)<br/>  end<br/> <br/>  def show<br/>    @post = Post.topics.find(params[:id])<br/>  end<br/>end<br/> <br/># app/controllers/posts/links_controller.rb<br/>class Posts::LinksController < ApplicationController<br/>  def index<br/>    @posts = Post.links.find(:all)<br/>  end<br/> <br/>  def show<br/>    @post = Post.links.find(params[:id])<br/>  end<br/>end<br/> <br/># app/controllers/posts/podcasts_controller.rb<br/>class Posts::PodcastsController < ApplicationController<br/>  def index<br/>    @posts = Post.podcasts.find(:all)<br/>  end<br/> <br/>  def show<br/>    @post = Post.podcasts.find(params[:id])<br/>  end<br/>end <br/>
В posts_controller пока что заполним только index, show там не нужен. В остальных заполняем и index (там, как видно, будут отображаться только «нужные» посты), и show (а здесь будет отображаться сама статья/ссылка/подкаст). Думаю, тут можно обойтись без пояснений, весь этот код мы уже писали в консоли.

Сразу перейдем к представлениям, и первое — posts#index:
<!-- app/views/posts/index.html.erb --><br/><% @posts.each do |post| %><br/>  <%= link_to post.content.class.to_s.pluralize, "/posts/#{post.content.class.to_s.downcase.pluralize}" %> &rarr;<br/>  <%= link_to post.title, "/posts/#{post.content.class.to_s.downcase.pluralize}/#{post.id}" %><br/><br/><% end %> <br/>
Сначала я написал именно так, потому что городить в представлении тонны if'ов — еще хуже (имхо). Потом мне стало стыдно, что на Хабре люди увидят такой ужас, и решил сделать этот ужас чуть менее ужасным. Итак, открываем app/helpers/posts_helper.rb и пишем в него что-нибудь вроде
module PostsHelper<br/>  def posts_smth_path(post)<br/>    case post.content.class.to_s.downcase<br/>      when "topic" : posts_topic_path(post)<br/>      when "link" : posts_link_path(post)<br/>      when "podcast" : posts_podcast_path(post)<br/>    end<br/>  end<br/> <br/>  def posts_smths_path(post)<br/>    case post.content.class.to_s.downcase<br/>      when "topic" : posts_topics_path<br/>      when "link" : posts_links_path<br/>      when "podcast" : posts_podcasts_path<br/>    end<br/>  end<br/>end <br/>
Теперь у нас есть 2 метода: posts_smth_path и posts_smths_path, которые являются частным случаем posts_topic_path и posts_topics_path (вместо topic/topics, само собой, там может быть еще link/links и podcast/podcasts). Сделав работу над ошибками, смотрим что у нас получилось:
<!-- app/views/posts/index.html.erb --><br/><% @posts.each do |post| %><br/>  <%= link_to post.content.class.to_s.pluralize, posts_smths_path(post) %> &rarr;<br/>  <%= link_to post.title, posts_smth_path(post) %><br/><br/><% end %> <br/>
Думаю, для черновика вполне достаточно. Теперь остальные представления:
<!-- app/views/posts/topics/index.html.erb --><br/><% @posts.each do |post| %><br/>  <%= link_to post.title, posts_topic_path(post) %><br/><br/><% end %><br/><p><br/>  <%= link_to "Add new Topic", new_posts_topic_path %><br/></p> <br/>
Это метод index, и за исключением методов posts_topic_path и new_posts_topic_path он везде одинаковый, нет смысла городить тут еще тонну кода. В двух других будет posts_link_path/new_posts_link_path и posts_podcast_path/new_posts_podcast_path соответственно.
<h1><%= @post.title %></h1><br/><%= @post.content.body %> <br/>
А это show, и в данном примере он вообще везде одинаковый :)

А теперь — самое, пожалуй, интересное: добавление новый записей. Как вы уже заметили, в предыдущем листинге присутствует строка
<%= link_to "Add new Topic", new_posts_topic_path %>
Хелпер link_to сгенерирует ссылку, при нажатии на которую мы перейдем на страницу /posts/topics/new, поэтому нам просто жизненно необходимо создать файл app/views/posts/topics/new.html.erb и написать в него что-нибудь такое:
<!-- app/views/posts/topics/new.html.erb --><br/><% form_for [:posts, @topic] do |form| %><br/>  <% form.fields_for @post do |p| %> <br/>    <p><br/>      <%= p.label :title %><br/><br/>      <%= p.text_field :title %> <br/>    </p><br/>    <p><br/>      <%= p.check_box :published %><br/>      <%= p.label :published %><br/>    </p><br/>  <% end %><br/> <br/>  <p><br/>    <%= form.label :body %><br/><br/>    <%= form.text_area :body %> <br/>  </p><br/> <br/>  <p><%= form.submit "Create" %></p><br/><% end %> <br/>
Сразу оговорюсь, пока что речь будет идти только про топики, в остальных контроллерах/представлениях будет аналогичный код.

Чтобы все встало на свои места, я приведу код метода new контроллера topics:
def new<br/>  @topic = Topic.new<br/>  @post = Post.topics.new<br/>end<br/>
И, для полной ясности, я повторю код, который мы писали в файле routes.rb:
map.namespace(:posts) do |post|<br/>  post.resources :topics, :links, :podcasts<br/>end <br/>
Когда-то давно мы определили namespace, и теперь при создании форм для топиков вместо form_for topic do… мы будем указывать наш namespace, то есть писать form_for [:posts, topic] do… (аналогично для ссылок и подкастов).
В самом конце формы мы помещаем поле body и кнопку submit'a формы, а перед этим пользуемся хелпером fields_for, который аналогичен по поведению хелперу form_for, разве что не создает теги формы. Таким образом у нас получается как бы 2 формы, при этом одна вложена в другую.

Заполняем форму, нажимаем кнопку Create и переходим к методу create контроллера topics. Напишем в него что-нибудь работающее, и добавление статей готово!
def create<br/>  @topic = Topic.new(:body => params[:topic][:body])<br/>  if @topic.save<br/>    @post = Post.new({ :content => @topic }.merge params[:topic][:post])<br/>    if @post.save<br/>      redirect_to root_url<br/>    else<br/>      render :new<br/>    end<br/>  else<br/>    render :new<br/>  end<br/>end <br/>
Я искренне извиняюсь за такое обилие кода в методе, я уверен что этот код можно (и нужно!) вынести в модель, но я так не умею. Надеюсь, кто-нибудь из более опытных товарищей меня поправит.

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

Отнеситесь со всей строгостью, это уже не первая моя статья на Хабре!

UPD:
Полезные материалы по теме:
STI — одна таблица и много моделей
Создание мульти-модельных форм
Огневский Андрей @ognevsky
карма
9,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

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

  • –5
    А зачем в статье про полиморфные связи описывать script/generate и элементарные контроллеры?
    • +2
      На данный момент статья висит на главной (на самом верху!) около 30 минут. За это время появился 1 комментарий.
      Это, видимо, говорит о том, что никто ничего не понял. Если убрать отсюда половину «лишнего», для большинства вся статья превратится в непонятный поток слов.
      Не настолько сложно знающему человеку пролистать тривиальный код вниз, сколь сложно незнающему этот код выдумать.
      • НЛО прилетело и опубликовало эту надпись здесь
        • –4
          ну мне, как программисту на пэхапэ, пллиморфные связи понятны, и юзаю их достаточно часто. Вопрос стоит ли перечитывть ради этого статью напичканную руби-кодом и вызовом генераторов…
          • +1
            Было бы глупо помещать в блог «Ruby on Rails» статью, руби-кодом и вызовом генераторов не напичканную :)
            • –1
              Ага, с вещами, которы и так каждый руби-реилс-программист знает ) Я на рельсах тоже писал, потому Америку статья про полиморфные связи мне не откроет. Но могла б заинтересоватьлюдей, которые пишут н адругих языках. Концепт ведь не зависит от языка программирования…
              • +4
                Ваши комментарии напоминают по стилю: «у меня был мак, но макос говно, поэтому я теперь опять на винду перешел».
                Писать на рельсах, знать (и понимать) все, о чем написано выше и после этого писать на пхп? Что-то вы явно недоговариваете :)
                • 0
                  руби для души, пхп для денег — такой вариант даже не рассматриваете?
                  • +2
                    год—полтора назад рассматривал бы. сейчас на руби можно найти (практически без труда) хорошую работу.
              • +1
                автор писал статью для тех, кто изучает Ruby и (возможно) веб-программирование в целом.

                лично мне было интересно прочитать ее, она написана понятным языком и с хорошими примерами, и правильно ответила на изначальный вопрос «что такое полиморфные связи и как их использовать в rails»
              • НЛО прилетело и опубликовало эту надпись здесь
      • +2
        поддерживаю

        иногда, чтобы понять, что как работает, важно создать рабочий пример, погонять его, попробовать что-то поменять, подебажить. В статье есть всё для этого необходимое. Если бы этого тривиального кода не было, часть желающих попробовать просто отсеялась
  • +2
    Полезная статья. У меня по-поводу этого примера есть только один вопрос: как элегантно избавиться от работы с моделью определенного типа контента в контроллере?

    Насколько я понимаю идеологию рельсы (я в ней новичок) — один контроллер должен работать с одной моделью, а основная модель в данном случае — Post.
    • +1
      Не всегда получается так, как задумано в рельсах. Ведь если мне на определенной странице нужно показать одновременно и новости, и статьи, то мне и придется из контроллера обратиться к двум моделям (если не использовать новомодные презентеры/ячейки, но до этого я еще не дорос)
  • 0
    Отлично!
  • +1
    Некрасиво получается, что для создания модели надо трогать два класса — саму модель и Post. В этом ключе техника STI выглядит намного привлекательнее и удобнее.
    • 0
      Много раз уже слышал про STI, так никогда и не читал. Теперь обязательно посмотрю повнимательнее в эту сторону, спасибо
      • +1
        Там все просто как два пальца :) В таблицу добавляется поле type, а для моделей используется обычное наследование.
    • 0
      поддерживаю. как раз данный пример выглядел бы более элегантно с использованием STI.
      а в качестве примера для полиморфизма можно придумать, например, приложение для комплектации компа для сборщиков.
      Начать ее с «Computer has_many :parts», ну и т.д. :)
      полиморфизм будет в том что Parts это могут быть CPU, MemoryModule, VideoCard и т.п.
      • 0
        Хорошая идея, будет время — обязательно узнаю, что же это за STI такой, да может и напишу такую статью:)
    • +1
      Так тут уже дело не в красиво/некрасиво, а что больше подходит для конкретной задачи. STI хорошо ложиться на логику, в случае когда модели совершенно не различаются, либо различаются незначительно. Ну и, конечно же, если есть желание за счет избыточности увеличить скорость работы, но это совсем другая история.
  • +1
    Статья хорошая, довольно просто получилось рассказать о непростом для понимания вопросе. Мне как начинающему рельсовику подобных статей не хватает.
    P.S. Можно было даже фото с рубином в титьках вставить.
    • 0
      Спасибо, скоро будет еще несколько статей (я надеюсь)
  • +1
    А еще можно в дополнение к статье добавить про nested attributes, которые появились в Rails 2.3

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

    И код можно будет оптимизировать, сначала хотел привести примеры, но потом посмотрел что комментарий уже начинает тянуть на мини-статью

    Хоть это и не совсем касается полиморфных связей, но также будет полезно тем кого интересуют связи между моделями
    • 0
      Я думаю, что никто не будет против примеров, каким бы ни был размер коммнтария:)
  • +1
    Можно было бы сделать скринкаст ну и картинки добавить, для наглядности, для новичков. Получилось бы более компактней и опять же наглядней.
    • 0
      Из всех скринкастов, которые я когда-либо видел, только railscasts от Ryan Bates я могу смотреть. Остальные — неперевариваемый бред (долгие паузы, мэ'кание постоянное). Я не спешу пополнять список говно-скринкастеров:)

      А картинки… Ну, разве что код вместо текста картинками выложить, но за это меня возненавидят высшие силы:)
  • +1
    а можно еще указать получившуюся структуру базы данных после миграций?
    • 0
      Файл schema.rb получился такой:
      create_table "posts", :force => true do |t|
        t.string "title"
        t.boolean "published"
        t.integer "content_id"
        t.string "content_type"
        t.datetime "created_at"
        t.datetime "updated_at"
      end
       
      create_table "topics", :force => true do |t|
        t.text "body"
      end
       
      create_table "links", :force => true do |t|
        t.string "link"
        t.text "description"
      end
       
      create_table "podcasts", :force => true do |t|
        t.string "link"
        t.text "description"
      end
      Собственно, в бд структура такая же.
  • +1
    Возможно, здесь было бы удобно использовать accepts_nested_attributes_for(*attr_names).
    Подробности и примеры здесь: api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html.
    • 0
      спасибо, посмотрю
    • 0
      я так понял, что представление остается таким же, меняется только контроллер?
      • 0
        Если вы про формы, и действия по созданию, то наоборот.
        Контроллер останется прежним, а во вью можно использовать api.rubyonrails.org/classes/ActionView/Helpers/FormHelper.html#M001605, — есть примеры для данных случаев Nested Attributes Examples.
        • 0
          а, блин, запутался:)
          спасибо большое!
    • 0
      я это уже предложил на несколько постов выше -)

      Вот даже написал статью: vizakenjack.habrahabr.ru/blog/79595/

      только кармы не хватает опубликовать, подожду немного)
      • 0
        мы тут щас дружно поднапряжемся, да попробуем поднять, ради такой-то статьи!
  • 0
    Ваши методы posts_smth_path(post) и posts_smths_path(post) можно заменить вот такими методами в хелпере:

    def edit_posts_content_path(content)
        eval("edit_posts_#{content.class.to_s.downcase}_path(content)")
    end
    


    Это метод возвращающий путь для edit. Так, если content это, например, Topic, то будет возвращено значение edit_posts_topic_path(content).

    Методы для show и index пишутся аналогично.

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