处理 Rails 中 STI 子类的路由的最佳实践
Posted
技术标签:
【中文标题】处理 Rails 中 STI 子类的路由的最佳实践【英文标题】:Best practices to handle routes for STI subclasses in rails 【发布时间】:2011-05-29 06:34:28 【问题描述】:我的 Rails 视图和控制器中充斥着 redirect_to
、link_to
和 form_for
方法调用。有时link_to
和redirect_to
在它们链接的路径中是显式的(例如link_to 'New Person', new_person_path
),但很多时候路径是隐式的(例如link_to 'Show', person
)。
我在我的模型中添加了一些单表继承 (STI)(例如 Employee < Person
),并且所有这些方法都因子类的实例而中断(例如 Employee
);当 rails 执行 link_to @person
时,会出现 undefined method employee_path' for #<#<Class:0x000001022bcd40>:0x0000010226d038>
错误。 Rails 正在寻找由对象的类名定义的路由,即employee。这些员工路线未定义,也没有员工控制器,因此也未定义操作。
以前有人问过这个问题:
-
在***,答案是编辑整个代码库中的每个link_to 等实例,并明确说明路径
再次在*** 上,有两个人建议使用
routes.rb
将子类资源映射到父类(map.resources :employees, :controller => 'people'
)。同一个 SO 问题中的最佳答案建议使用 .becomes
对代码库中的每个实例对象进行类型转换
*** 的另一个人,最佳答案是在 Do Repeat Yourself 阵营中,并建议为每个子类创建重复的脚手架。
Here's 在 SO 上再次出现同样的问题,其中最重要的答案似乎是错误的(Rails magic Just Works!)
在网络的其他地方,我找到了this blog post,F2Andy 建议在代码中各处的路径中进行编辑。
在 Logical Reality Design 的博文 Single Table Inheritance and RESTful Routes 上,建议将子类的资源映射到超类控制器,如上面的 SO 答案 2 中所示。
Alex Reisner 有一个帖子Single Table Inheritance in Rails,他在帖子中主张不要将子类的资源映射到routes.rb
中的父类,因为这只会捕获来自link_to
和redirect_to
的路由中断,而不是来自form_for
。所以他建议改为向父类添加一个方法,让子类对他们的类撒谎。听起来不错,但他的方法给了我错误undefined local variable or method `child' for #
。
因此,看起来最优雅和最一致的答案(但并非所有优雅,也不是那么太多共识)是将资源添加到您的routes.rb
。除非这不适用于form_for
。我需要一些澄清!为了提炼上面的选择,我的选择是
-
将子类的资源映射到
routes.rb
中的超类控制器(希望我不需要在任何子类上调用form_for)
重写 rails 内部方法以使类相互欺骗
编辑代码中隐式或显式调用对象操作路径的每个实例,更改路径或对对象进行类型转换。
对于所有这些相互矛盾的答案,我需要一个裁决。在我看来,没有好的答案。这是rails设计的失败吗?如果是这样,这是一个可以修复的错误吗?或者如果不是,那么我希望有人可以让我明白这一点,让我了解每个选项的优缺点(或解释为什么这不是一个选项),哪个是正确答案,以及为什么。或者有没有我在网上找不到的正确答案?
【问题讨论】:
Alex Reisner 的代码中有一个错字,我在他的博客上发表评论后,他已修正。所以希望现在亚历克斯的解决方案是可行的。我的问题仍然存在:哪个是正确的解决方案? 虽然它已经有 3 年历史了,但我在rookieonrails.blogspot.com/2008/01/… 找到了这篇博文,以及来自 rails 邮件列表的链接对话内容丰富。其中一位响应者描述了多态助手和命名助手之间的区别。 您未列出的一个选项是修补 Rails,以便 link_to、form_for 等适合单表继承。这可能是一项艰巨的工作,但我希望看到它得到解决。 ***.com/questions/604870/… 【参考方案1】:这是我能想到的最简单的解决方案,副作用最小。
class Person < Contact
def self.model_name
Contact.model_name
end
end
现在url_for @person
将按预期映射到contact_path
。
它是如何工作的: URL 助手依靠YourModel.model_name
来反映模型并生成(在许多事情中)单数/复数路由键。这里Person
基本上是在说我就像Contact
伙计,问他。
【讨论】:
我正在考虑做同样的事情,但担心#model_name 可能会在 Rails 的其他地方使用,并且这种更改可能会干扰正常功能。有什么想法吗? 我完全同意神秘的陌生人@nkassis。这是一个很酷的 hack,但你怎么知道你没有破坏 Rails 的内部? Specs. 此外,我们在生产中使用此代码,我可以证明它不会搞砸:1)模型间关系,2)STI 模型实例化(通过build_x
/create_x
)。另一方面,玩魔法的代价是你永远无法 100% 确定会发生什么变化。
如果您尝试根据类为属性使用不同的人名,这会破坏 i18n。
而不是像这样完全覆盖,您可以覆盖您需要的位。见gist.github.com/sj26/5843855【参考方案2】:
我遇到了同样的问题。使用 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
提交后,它似乎对我有用。
【讨论】:
这可行,但在我们的路线中添加了许多不必要的路径。难道没有办法以一种不那么侵入性的方式做到这一点吗? 您可以在您的 routes.rb 文件中以编程方式设置路由,因此您可以进行一些元编程来设置子路由。但是,在没有缓存类的环境中(例如开发),您需要先预加载类。因此,您需要以一种或另一种方式在某处指定子类。有关示例,请参见 gist.github.com/1713398。 在我的例子中,向用户公开对象名称(路径)是不可取的(并且会让用户感到困惑)。【参考方案3】:我建议你看看:https://***.com/a/605172/445908,使用这个方法可以让你使用“form_for”。
ActiveRecord::Base#becomes
【讨论】:
我必须明确设置 url 才能正确呈现来自 和 的保存。<%= form_for @child, :as => :child, url: @child.becomes(Parent)
@lulalala 试试<%= form_for @child.becomes(Parent)
【参考方案4】:
在路由中使用 type:
resources :employee, controller: 'person', type: 'Employee'
http://samurails.com/tutorial/single-table-inheritance-with-rails-4-part-2/
【讨论】:
域名已更改,上述文章现在可以在此找到:samurails.com/tutorial/… 帖子和评论中的链接已失效【参考方案5】:遵循@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。
【讨论】:
【参考方案6】:我也遇到了这个问题,并在与我们类似的问题上得到了这个答案。它对我有用。
form_for @list.becomes(List)
此处显示的答案:Using STI path with same controller
.becomes
方法被定义为主要用于解决 STI 问题,例如您的 form_for
之一。
.becomes
信息在这里:http://apidock.com/rails/ActiveRecord/Base/becomes
超级迟到的回复,但这是我能找到的最佳答案,对我来说效果很好。希望这对某人有所帮助。干杯!
【讨论】:
【参考方案7】:好的,我在 Rails 的这个领域有很多挫败感,并得出了以下方法,也许这对其他人有帮助。
首先请注意,网上和网上的许多解决方案都建议在客户端提供的参数上使用常量化。这是一个已知的 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 变成了 Organization 的“ABN”,而 Person 的“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
为了完成这项工作,在我的基础 ResourceContoller 中,我将 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 中),如果字符串命名的常量不存在,则常量化会失败。那么如何使用它来创建任意新符号呢?【参考方案8】:我最近documented 尝试在 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 方法时遇到的许多其他问题。
【讨论】:
【参考方案9】:我找到的最干净的解决方案是将以下内容添加到基类中:
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 定义的模型名称而导致的任何潜在副作用的风险。
【讨论】:
【参考方案10】:如果你没有嵌套路由,你可以试试这个:
resources :employee, path: :person, controller: :person
或者您可以采用另一种方式并使用一些 OOP 魔法,如下所述:https://coderwall.com/p/yijmuq
在第二种方式中,您可以为所有嵌套模型创建类似的助手。
【讨论】:
【参考方案11】:这是一种安全干净的方法,可以让它在我们使用的表单和整个应用程序中工作。
resources :districts
resources :district_counties, controller: 'districts', type: 'County'
resources :district_cities, controller: 'districts', type: 'City'
然后我有我的形式。为此添加的部分是 as: :district。
= form_for(@district, as: :district, html: class: "form-horizontal", role: "form" ) do |f|
希望这会有所帮助。
【讨论】:
【参考方案12】:如果我考虑这样的 STI 继承:
class AModel < ActiveRecord::Base ; end
class BModel < AModel ; end
class CModel < AModel ; end
class DModel < AModel ; end
class EModel < AModel ; end
在 'app/models/a_model.rb' 我添加:
module ManagedAtAModelLevel
def model_name
AModel.model_name
end
end
然后在 AModel 类中:
class AModel < ActiveRecord::Base
def self.instanciate_STI
managed_deps =
:b_model => true,
:c_model => true,
:d_model => true,
:e_model => true
managed_deps.each do |dep, managed|
require_dependency dep.to_s
klass = dep.to_s.camelize.constantize
# Inject behavior to be managed at AModel level for classes I chose
klass.send(:extend, ManagedAtAModelLevel) if managed
end
end
instanciate_STI
end
因此,我什至可以轻松地选择要使用默认模型的模型,甚至无需触及子类定义。非常干燥。
【讨论】:
【参考方案13】:这种方式对我有用(在基类中定义这个方法):
def self.inherited(child)
child.instance_eval do
alias :original_model_name :model_name
def model_name
Task::Base.model_name
end
end
super
end
【讨论】:
【参考方案14】:您可以创建返回虚拟父对象用于路由目的的方法
class Person < ActiveRecord::Base
def routing_object
Person.new(id: id)
end
end
然后简单地调用 form_for @employee.routing_object 没有类型将返回 Person 类对象
【讨论】:
【参考方案15】:按照@prathan-thananart 的回答,对于多个 STI 类,您可以将以下内容添加到父模型中 ->
class Contact < ActiveRecord::Base
def self.model_name
ActiveModel::Name.new(self, nil, 'Contact')
end
end
这将使每个带有联系人数据的表单以params[:contact]
而不是params[:contact_person]
、params[:contact_whatever]
发送参数。
【讨论】:
【参考方案16】:我赞成使用PolymorphicRoutes
或url_for
根据资源、任何命名空间等动态生成路由。
https://api.rubyonrails.org/classes/ActionDispatch/Routing/PolymorphicRoutes.html
https://api.rubyonrails.org/classes/ActionDispatch/Routing/UrlFor.html
polymorphic_url([:admin, @article, @comment])
# => admin_article_comment_url(@article, @comment)
edit_polymorphic_path(@post)
# => "/posts/1/edit"
使用admin
命名空间
url_for([:admin, Role])
# => "admin/roles" # index
url_for([:admin, Role, action: :new])
# => "admin/roles/new" # new
url_for([:admin, @role])
# => "admin/roles/1" # show; for destroy, use link "method: :delete"
url_for([:edit, :admin, @role])
# => "admin/roles/1/edit" # edit
【讨论】:
【参考方案17】:覆盖model_name
似乎很危险。使用.becomes
似乎是更安全的选择。
一个问题是您不知道您正在处理的模型(以及基本模型)。
我只是想分享一下,在这种情况下,可以使用:
foo.becomes(foo.class.base_class)
为了方便使用,我已将此方法添加到我的ApplicationRecord
:
def becomes_base
becomes(self.class.base_class)
end
将.becomes_base
添加到一些路由辅助方法对我来说似乎没什么大不了的。
【讨论】:
【参考方案18】:hackish,但只是解决方案列表中的另一个。
class Parent < ActiveRecord::Base; end
Class Child < Parent
def class
Parent
end
end
适用于 rails 2.x 和 3.x
【讨论】:
这解决了一个问题,但会产生另一个问题。现在,当您尝试执行Child.new
时,它会返回 Parent
类而不是子类。这意味着您不能通过控制器通过批量分配来创建子类(因为默认情况下type
是受保护的属性),除非您还显式设置了 type 属性。以上是关于处理 Rails 中 STI 子类的路由的最佳实践的主要内容,如果未能解决你的问题,请参考以下文章