定义关注的角色
介绍
Ruby on Rails (RoR) 致力于提高生产力,其方法是专注于重要的事情,而将不那么重要的事情留给约定优于配置来处理。约定优于配置 - 我们都听说过这个,我越深入研究这种语言,就越能理解它的强大之处。Ruby 的动态行为与 Rails 非常契合,为开发人员带来了很大的灵活性,但能力越大,责任越大。我最近在处理一个代码,它开始耦合得太快,我的第一个方法是尝试使用继承来改进它。
场景
我有一个方法,可以对集合进行递归迭代。起初,停止规则很明确,一切都运行得很好;代码简洁明了,每个人都很开心。然后,混乱随之而来。抛开戏剧性,发生的事情(总是)是范围发生了变化,另一个停止规则敲响了我的门。此时,我决定将第一个方法提取到方法中并根据新要求创建另一个方法就足够了,我可以调用递归函数并传递将停止循环的规则。猜猜接下来会发生什么:另一条规则。还有另一条。还有另一条。还有另一条。我不能简单地继续用方法使类变得臃肿,我相信你已经想象到代码中嘲笑我的类似内容:
case <restriction type>
when <criteria 1>
method1(params)
when <criteria 2>
method2(params)
when <criteria 3>
method3(params)
else
default_method(params)
end
很巧妙,对吧?其实不然。我的第一反应是将这些方法提取到它们自己的类中。但这并不能解决case-when语句的问题,除非我为它们创建一个基类并使用 duck 类型将停止规则注入到方法中。
然后,事情开始变得有意义了。鸭子类型可以让我免于在主类中实例化类,这会将其与标准结合起来,完全忽略单一责任原则。我的类会知道太多东西。
但事情还是不对劲。我无法完全理解我为什么会起鸡皮疙瘩。代码可以运行,但我并不满意。我知道几周后,我将很难记住所有这些类的用途或它们应该做什么。如果其他人必须实施一条新规则,他们会知道应该覆盖什么方法吗?发现它需要多长时间?我该如何简化到处重复的测试?
解决方案
Sandi Metz 来救我了。我当时正巧读了她的书《Ruby 中的实用面向对象设计》(我强烈推荐),在这本书中我找到了秘密解决方案。我明白了为什么那段代码让我如此困扰,并改变了我的参考框架,将其指向正确的方向。以下是我得到的答案:
|-- 基础规则.rb
|--- max_hops_rule.rb
|--- 最大成本规则.rb
|--- 无重复规则.rb
|--- …
编码
为了让事情更直观,让我们写一些代码。我们有一个Node类,它是一对原点-目的地(假设为 A->B)值。我们还有一个节点列表,这些节点必须找到从原点到目的地的路径,并且将应用一个规则(或一组规则,但为了简化代码,我们只使用一个规则)来停止循环。例如,我们可以将路径限制为最多 30 跳,或至少 5 跳。首先,表示整个路径中一步的对象:
class Node
attr_reader :origin, :destination
def initialize(origin, destination)
@origin = origin
@destination = destination
end
def to_s
"#{@origin}->#{@destination}"
end
end
.to_s方法只是为了简化正在发生的事情的可视化。例如,我们可以打印它并看到“A->B”而不是<Node:0x007fe2aa857250 @origin="A", @destination="B">,而不是查看对象。它在测试中也有很大帮助。接下来,规则及其基类:
class BaseRule
def stop?(_)
true
end
end
class MaxHopRule < BaseRule
def initialize(max: 5)
@max = max
end
def stop?(list)
list.count == @max
end
end
最后,但并非最不重要的,是我们的主类,它将遍历节点并找到从原点到所需目的地的路径。如果您对 Ruby 不太熟悉,请不要担心。这里的想法只是一个遵循以下简单规则的循环:
- 获取从原点开始的列表中的所有节点。
- 迭代此列表,检查其中是否有任何节点以目标节点结尾。如果是,则将到达该节点的路径以及结果列表中的当前节点保存起来。
- 对于此列表中的每个节点,再次递归调用该方法,但将origin参数更改为当前节点的目的地(如果没有停止规则阻止它这样做)。
代码如下:
class Route
attr_reader :nodes, :results
def initialize(nodes)
@nodes = nodes
@results = []
end
def find(origin, destination, base_rule)
navigate(origin, destination, base_rule)
results
end
def navigate(origin, destination, stop_rule, breadcrumb = [])
valid_nodes(origin: origin).each do |node|
current_list = breadcrumb + [node]
results << current_list if node.destination == destination
unless stop_rule.stop?(current_list)
navigate(node.destination, destination, stop_rule, current_list)
end
end
end
private
def valid_nodes(origin:)
nodes.select { |node| node.origin == origin }
end
end
使用规则
继承虽然在某些情况下很有效,但并不是解决所有问题的答案。如果子类之间的关系只是为了强制存在一个通用方法,那么很难看出必须在子类上重写什么。我从错误的角度看待这个问题:
我不应该关注那些将要停止循环的类,而应该关注那些使用递归方法的类。
我注入到类中的规则在主类上下文之外没有任何意义;它们仅用于与主类上下文交互。为了解决这个问题,我只需要意识到这些规则正在发挥作用,并且在设计应用程序时对我来说唯一重要的是它们将如何与我的find方法交互。
规则类是限制器。它们与Route交互,并要求它根据它们旨在执行的一组规则停止。将通用方法用作鸭子类型的需要只不过是一个接口,虽然这在 Java 或 C# 等语言中是一种常见的做法,但对于 Ruby on Rails 来说,它们并不十分明确。
但不要害怕。我们没有明确声明接口并不意味着它不存在。当一组类通过继承或任何其他你能想到的解决方案响应相同的方法时,它们就是在响应接口。
隐式接口的问题在于,未来的开发人员(或您未来的自己,这种情况经常发生)很难看到需要实现什么,也很难理解首先存在一个接口。
基于角色的关系
到目前为止,我们已决定从继承解决方案转向基于角色的解决方案。我们有一堆从BaseRule继承的类,它们的唯一目的是提供一个由我们的主类调用的通用方法,我们将切断这个继承链。通过接受从传统继承转向基于角色的关系是一个很好的举措(情况并非总是如此),我们可以享受随之而来的一些好处:
- 任何其他类都可以充当该角色,无论它是什么或从谁那里继承。这是因为我们将“ is-a”关系留给了更像“behavior”的关系。
- 我们可以关注类别之间传递的信息,而不是类别本身。
- 充当角色的类可以有自己的议程,它们不严格地与任何其他类或继承链绑定。
这并不意味着继承不好,也不意味着包含模块是所有可能情况的更优解决方案。它只是一种新的可能性(甚至可以与继承相结合),但要注意不要为了这样做而增加太多复杂性。
结论
在这种情况下,模块的引入非常有效,因为它清楚地向我展示了每个角色的职责,我一眼就知道他们应该做什么(以前隐藏在传统继承的深处)。任何新开发人员都可以立即了解现在发生了什么。在我的第一个代码中,事情并不那么明显(尽管并非不可能掌握)。
这些角色很小而且可插入。只要遵守契约,它们就可以轻松地在其他上下文中使用,并且一个角色的更改几乎不会影响其他角色,因为它们是独特且独立的。如果设计得当,即使是大型系统重构也不会破坏任何一个,这意味着它们会引起系统的变化,而这首先是面向对象的主要目的。正如Sandi Metz所说,
“这些小对象具有单一职责并指定自己的行为。它们是透明的;代码很容易理解,并且很清楚如果代码发生变化会发生什么。此外,组合对象独立于层次结构意味着它继承的代码很少,因此通常不会因层次结构中上级类的变化而产生副作用。”
关于关注点,我最欣赏的是我们如何在测试中记录类之间的关系。我们唯一真正最新的文档是代码本身,而测试是了解事物设计目的的绝佳场所。考虑到角色,有些测试肯定会针对每个角色类重复进行。例如,我们可能希望确保它们都响应某个方法(例如 stop ?方法)。
我们可以创建适用于所有角色的共享示例,而不必重复自己。包含我们模块的每个类都将自动获得这些测试,并且不会有一行重复的代码。
shared_examples_for 'a restrictor' do
it { is_expected.to respond_to('stop?') }
end
不仅如此,包含角色共享测试的代码还告诉我们该类的行为就像一个角色:
it_behaves_like 'a restrictor'
提示:创建包含共享示例的文件时,不要在文件名上使用后缀 spec,否则测试将运行两次。
只需看一下测试文件的顶部,我们立即知道它的行为就像一个限制器,这意味着它同意一个隐式接口,因此将响应一组预期的方法。要删除继承,我们只需要进行很小的更改。首先,删除BaseRule类。然后,我们可以创建我们的关注点:
module Restrictor
extend ActiveSupport::Concern
included do
def stop?(list)
true
end
end
end
免责声明:本内容来源于第三方作者授权、网友推荐或互联网整理,旨在为广大用户提供学习与参考之用。所有文本和图片版权归原创网站或作者本人所有,其观点并不代表本站立场。如有任何版权侵犯或转载不当之情况,请与我们取得联系,我们将尽快进行相关处理与修改。感谢您的理解与支持!
请先 登录后发表评论 ~