在Rails中处理STI子类路由的最佳实践

183

我的Rails视图和控制器中充斥着redirect_tolink_toform_for方法调用。有时候link_toredirect_to在它们链接的路径上是明确的(例如:link_to '新建联系人', new_person_path),但很多时候路径是隐含的(例如:link_to '显示', person)。

我在模型中添加了一些单表继承(STI)(比如Employee < Person),对于子类(比如Employee)的所有这些方法都会出问题;当Rails执行link_to @person时,它会报错:undefined method employee_path' for #<#<Class:0x000001022bcd40>:0x0000010226d038>。Rails正在寻找被对象类名定义的路由,而这个类名是employee。这些employee路由没有被定义,也没有employee控制器,因此这些操作也没有被定义。

此问题已经被问过:

  1. StackOverflow上,答案是在整个代码库中编辑每个link_to等实例,并显式声明路径。
  2. StackOverflow上,有两个人建议使用routes.rb将子类资源映射到父类(map.resources :employees, :controller => 'people')。那个问题的最佳答案还建议使用.becomes对代码库中的每个实例对象进行类型转换。
  3. StackOverflow上,最佳答案属于重复自己派系,并建议为每个子类创建重复脚手架。
  • 这里是一个关于相同问题的SO问答,其中最佳答案似乎是错误的(Rails的魔法就是这么神奇!)
  • 在其他网站上,我找到了这篇博客文章,F2Andy建议在代码的各个地方编辑路径。
  • 在Logical Reality Design的博客文章Single Table Inheritance and RESTful Routes中,建议将子类的资源映射到超类控制器,如上面的SO答案2。
  • Alex Reisner在他的博客文章Single Table Inheritance in Rails中反对在routes.rb中将子类的资源映射到父类中,因为这只能捕获link_toredirect_to中的路由错误,而不能捕获form_for中的错误。因此,他建议在父类中添加一个方法,使子类虚假其类别。听起来不错,但他的方法给我带来了错误,即undefined local variable or method `child' for #
  • 因此,看起来最优雅且具有最多共识的答案(但也不是非常优雅,也没有那么多共识),是将资源添加到routes.rb中。但这对于form_for无效。我需要一些明确的指导!总结以上选择,我的选项是

    1. routes.rb中将子类的资源映射到父类控制器(并希望不需要在任何子类上调用form_for
    2. 重写Rails内部方法,使分类相互欺骗
  • 编辑代码中每个隐式或显式调用对象操作路径的实例,无论是更改路径还是类型转换对象。
  • 由于这些相互冲突的答案,我需要一个裁决。在我看来,好像没有正确的答案。这是 rails 设计上的缺陷吗?如果是,那么这可能会得到修复的 bug 吗?或者如果不是,那么我希望有人能够给我解释每个选项的利弊(或解释为什么这不是一个选项),并说明哪个是正确的答案以及为什么。或者是否有一个正确的答案我在网上没有找到?


    1
    在Alex Reisner的代码中有一个错别字,我在他的博客上评论后他已经修复了。所以现在希望Alex的解决方案是可行的。我的问题仍然存在:哪个是正确的解决方案? - ziggurism
    1
    虽然这篇博客文章已经有三年的历史了,但我发现它仍然很有价值。你可以在 http://rookieonrails.blogspot.com/2008/01/sti-views-revisited-or-polymorphic.html 找到这篇文章以及相关的邮件列表讨论。其中一个回复者描述了多态辅助函数和命名辅助函数之间的区别,非常有启发性。 - ziggurism
    2
    你没有列出的一个选项是修补Rails,使得link_to、form_for等与单表继承兼容。这可能是一项艰巨的工作,但我很希望看到它被修复。 - M. Scott Ford
    1
    https://dev59.com/8HRB5IYBdhLWcg3wgHWr#605172 - Felixyz
    18个回答

    144

    这是我能想到的最简单的解决方案,副作用最小。

    class Person < Contact
      def self.model_name
        Contact.model_name
      end
    end
    

    现在,url_for @person 将按预期映射到 contact_path

    工作原理: URL 助手依赖于 YourModel.model_name 来反映模型并生成(除其他外)单数/复数路由键。这里的 Person 基本上是在说“我就像 Contact 伙计一样,问问他吧”。


    5
    我曾考虑过做同样的事情,但是担心#model_name可能在Rails的其他地方使用,并且这个改变可能会干扰正常的功能。你有什么想法? - nkassis
    3
    我完全同意神秘陌生人@nkassis的看法。这是一个很酷的黑客技巧,但你怎么知道自己没有破坏了Rails的内部结构? - tsherif
    7
    规格。 此外,我们在生产中使用这个代码,并且我可以证明它不会破坏以下内容:1)模型之间的关系,2)STI模型实例化(通过build_x/create_x)。但是,使用魔法的代价是你永远无法百分之百确定可能会发生什么变化。 - Prathan Thananart
    14
    如果你试图在不同的类别中给属性使用不同的人名,这样做会破坏国际化(i18n)的效果。 - Rufo Sanchez
    4
    不必像这样完全覆盖,只需覆盖需要的位。请参见 https://gist.github.com/sj26/5843855。 - sj26
    显示剩余6条评论

    48

    我遇到了同样的问题。在使用STI之后,form_for方法会将提交的数据发送到错误的子URL。

    NoMethodError (undefined method `building_url' for
    

    最终我添加了子类的额外路由,并将它们指向相同的控制器。

     resources :structures
     resources :buildings, :controller => 'structures'
     resources :bridges, :controller => 'structures'
    

    此外:

    <% form_for(@structure, :as => :structure) do |f| %>
    
    在这种情况下,结构实际上是一个建筑物(子类)。
    使用form_for进行提交后,它似乎对我有效。

    3
    这样做虽然有效,但会在我们的路由中添加很多不必要的路径。有没有一种不那么侵入式的方法来实现这个目标? - Anders Kindberg
    1
    您可以在routes.rb文件中以编程方式设置路由,因此您可以进行一些元编程来设置子路由。但是,在类没有被缓存的环境中(例如开发环境),您需要先预加载这些类。因此,您需要在某个地方指定子类。请参见https://gist.github.com/1713398以获取示例。 - Chris Bloom
    在我的情况下,向用户公开对象名称(路径)是不可取的(并且会让用户感到困惑)。 - laffuste

    35

    2
    我必须显式地设置网址,以便它可以同时正确地呈现表单和保存。<%= form_for @child, :as => :child, url: @child.becomes(Parent) - lulalala
    4
    @lulalala 请尝试 <%= form_for @child.becomes(Parent) - Richard Jones

    20

    我也遇到了这个问题,后来在一个类似的问题中找到了这个答案。对我有用。

    form_for @list.becomes(List)
    

    这里显示答案:使用相同控制器的STI路径

    .becomes 方法主要用于解决STI问题,例如您的form_for 问题。

    .becomes信息在此:http://apidock.com/rails/ActiveRecord/Base/becomes

    虽然回复晚了,但是这是我能找到的最好的答案,并且对我很有用。希望这能帮助到某些人。干杯!


    19

    域名已更改,所提到的文章现在可以在此处找到:http://samurails.com/tutorial/single-table-inheritance-with-rails-4-part-2/ - T_Dnzt
    帖子和评论中的链接已经失效。 - saurabh

    17

    在@Prathan Thananart的想法基础上,尝试着不破坏任何东西(因为这里有太多的魔法参与)。

    class Person < Contact
      model_name.class_eval do
        def route_key
         "contacts"
        end
        def singular_route_key
          superclass.model_name.singular_route_key
        end
      end
    end
    

    现在,url_for @person 将按预期映射到 contact_path。


    我正在使用 Rails 6.1.4 版本,出现了 undefined local variable or method 'superclass' 的错误。我的快速解决方法是像这样添加类名:Person.superclass.model_name.singular_route_key - buncis
    这应该是被接受的答案,它只覆盖了URL助手,这也是OP要求的,而不会破坏其他事情,如i18n。 - UsAndRufus

    5

    好的,我在Rails这个领域遇到了很多挫折,最后找到了以下方法,希望能对其他人有所帮助。

    首先要注意,许多解决方案在Internet上建议使用客户端提供的参数进行constantize,这是已知的DoS攻击向量,因为Ruby不会垃圾回收符号,从而允许攻击者创建任意符号并消耗可用内存。

    我采用了下面的方法来支持模型子类的实例化,并且是安全的,能够避免上述的contantize问题。 这与Rails 4非常相似,但允许多个级别的子类(不像Rails 4)并且适用于Rails 3。

    # initializers/acts_as_castable.rb
    module ActsAsCastable
      extend ActiveSupport::Concern
    
      module ClassMethods
    
        def new_with_cast(*args, &block)
          if (attrs = args.first).is_a?(Hash)
            if klass = descendant_class_from_attrs(attrs)
              return klass.new(*args, &block)
            end
          end
          new_without_cast(*args, &block)
        end
    
        def descendant_class_from_attrs(attrs)
          subclass_name = attrs.with_indifferent_access[inheritance_column]
          return nil if subclass_name.blank? || subclass_name == self.name
          unless subclass = descendants.detect { |sub| sub.name == subclass_name }
            raise ActiveRecord::SubclassNotFound.new("Invalid single-table inheritance type: #{subclass_name} is not a subclass of #{name}")
          end
          subclass
        end
    
        def acts_as_castable
          class << self
            alias_method_chain :new, :cast
          end
        end
      end
    end
    
    ActiveRecord::Base.send(:include, ActsAsCastable)
    

    尝试了多种方法解决“开发中的子类加载问题”,类似于上面建议的方法,但我发现唯一可靠的方法是在我的模型类中使用“require_dependency”。这可以确保类加载在开发中正常工作,并且不会在生产中出现任何问题。在开发中,如果没有“require_dependency”,AR将无法了解所有子类,这会影响用于匹配类型列的SQL。此外,如果没有“require_dependency”,您还可能陷入同时具有多个模型类版本的情况!(例如,当您更改基类或中间类时,子类并不总是重新加载,并且仍然从旧类继承)
    # contact.rb
    class Contact < ActiveRecord::Base
      acts_as_castable
    end
    
    require_dependency 'person'
    require_dependency 'organisation'
    

    我也不像上面建议的那样覆盖model_name,因为我使用I18n并需要针对不同的子类属性使用不同的字符串,例如:tax_identifier在组织中变为“ABN”,在个人中变为“TFN”(在澳大利亚)。

    我也使用路由映射,如上所述,设置类型:

    resources :person, :controller => 'contacts', :defaults => { 'contact' => { 'type' => Person.sti_name } }
    resources :organisation, :controller => 'contacts', :defaults => { 'contact' => { 'type' => Organisation.sti_name } }
    

    除了路由映射之外,我还使用了InheritedResources和SimpleForm,并且我为新操作使用以下通用表单包装器:
    simple_form_for resource, as: resource_request_name, url: collection_url,
          html: { class: controller_name, multipart: true }
    

    对于编辑操作:

    simple_form_for resource, as: resource_request_name, url: resource_url,
          html: { class: controller_name, multipart: true }
    

    为了使这个工作正常,在我的基本ResourceController中,我将InheritedResource的resource_request_name作为视图的辅助方法公开:

    helper_method :resource_request_name 
    

    如果您没有使用InheritedResources,那么请在“ResourceController”中使用以下类似的内容:
    # controllers/resource_controller.rb
    class ResourceController < ApplicationController
    
    protected
      helper_method :resource
      helper_method :resource_url
      helper_method :collection_url
      helper_method :resource_request_name
    
      def resource
        @model
      end
    
      def resource_url
        polymorphic_path(@model)
      end
    
      def collection_url
        polymorphic_path(Model)
      end
    
      def resource_request_name
        ActiveModel::Naming.param_key(Model)
      end
    end
    

    很高兴听到他人的经验和改进意见。


    根据我的经验(至少在Rails 3.0.9中),如果字符串命名的常量不存在,constantize会失败。那么如何使用它来创建任意新符号? - Lex Lindsey

    4

    最近我记录了在Rails 3.0应用程序中尝试获得稳定STI模式的过程。以下是TL;DR版本:

    # app/controllers/kase_controller.rb
    class KasesController < ApplicationController
    
      def new
        setup_sti_model
        # ...
      end
    
      def create
        setup_sti_model
        # ...
      end
    
    private
    
      def setup_sti_model
        # This lets us set the "type" attribute from forms and querystrings
        model = nil
        if !params[:kase].blank? and !params[:kase][:type].blank?
          model = params[:kase].delete(:type).constantize.to_s
        end
        @kase = Kase.new(params[:kase])
        @kase.type = model
      end
    end
    
    # app/models/kase.rb
    class Kase < ActiveRecord::Base
      # This solves the `undefined method alpha_kase_path` errors
      def self.inherited(child)
        child.instance_eval do
          def model_name
            Kase.model_name
          end
        end
        super
      end  
    end
    
    # app/models/alpha_kase.rb
    # Splitting out the subclasses into separate files solves
    # the `uninitialize constant AlphaKase` errors
    class AlphaKase < Kase; end
    
    # app/models/beta_kase.rb
    class BetaKase < Kase; end
    
    # config/initializers/preload_sti_models.rb
    if Rails.env.development?
      # This ensures that `Kase.subclasses` is populated correctly
      %w[kase alpha_kase beta_kase].each do |c|
        require_dependency File.join("app","models","#{c}.rb")
      end
    end
    

    这种方法可以避免您列出的问题,以及其他一些人在使用STI方法时遇到的问题。

    3
    我找到最简单的解决方案是在基类中添加以下内容:
    def self.inherited(subclass)
      super
    
      def subclass.model_name
        super.tap do |name|
          route_key = base_class.name.underscore
          name.instance_variable_set(:@singular_route_key, route_key)
          name.instance_variable_set(:@route_key, route_key.pluralize)
        end
      end
    end
    

    它适用于所有的子类,并且比覆盖整个模型名称对象要安全得多。通过仅针对路由键,我们解决了路由问题,而不会破坏I18n或冒任何因覆盖Rails定义的模型名称而引起的潜在副作用的风险。


    2

    以下是我们使用的一种安全、干净的方法,可在表单和整个应用程序中使用。

    resources :districts
    resources :district_counties, controller: 'districts', type: 'County'
    resources :district_cities, controller: 'districts', type: 'City'
    

    我在表单中添加了一个新的部分,它的名称是:district。

    = form_for(@district, as: :district, html: { class: "form-horizontal",         role: "form" }) do |f|
    

    希望这能帮到你。

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接