本文概述
- 你的应用值得重构吗?
- 实际脱钩
- 代码
- 生命周期
- 指导方针
- 重构
- 重构后的结构
- 总结
没问题。今天, 我们将讨论如何使用OOP的最佳做法来使你的代码更简洁, 更隔离和更分离。
你的应用值得重构吗? 首先, 我们来看看如何确定你的应用是否适合重构。
文章图片
这是我通常要求自己确定我的代码是否需要重构的指标和问题的列表。
- 缓慢的单元测试。 PORO单元测试通常使用隔离良好的代码来快速运行, 因此运行缓慢的测试通常可以指示不良的设计和过度耦合的职责。
- FAT模型或控制器。具有200多行代码(LOC)的模型或控制器通常是重构的理想选择。
- 过多的代码库。如果你的ERB / HTML / HAML的LOC超过30, 000, 或者Ruby源代码(无GEM)的LOC超过50, 000, 那么你很有可能应该进行重构。
查找应用程序-iname” * .rb” -type f -exec cat {} \; | wc -l
该命令将搜索/ app文件夹中所有扩展名为.rb的文件(红宝石文件), 并打印出行数。请注意, 此数字仅是近似值, 因为注释行将包含在这些总数中。
另一个更精确, 更有用的选择是使用Rails rake任务统计信息, 该统计信息输出代码行, 类数, 方法数, 方法与类的比率以及每种方法的代码行比率的快速摘要:
bundle exec rake stats
+----------------------+-------+-----+-------+---------+-----+-------+
| Name| Lines | LOC | Class | Methods | M/C | LOC/M |
+----------------------+-------+-----+-------+---------+-----+-------+
| Controllers|195 | 153 |6 |18 |3 |6 |
| Helpers|14 |13 |0 |2 |0 |4 |
| Models|120 |84 |5 |12 |2 |5 |
| Mailers|0 |0 |0 |0 |0 |0 |
| Javascripts|45 |12 |0 |3 |0 |2 |
| Libraries|0 |0 |0 |0 |0 |0 |
| Controller specs|106 |75 |0 |0 |0 |0 |
| Helper specs|15 |4 |0 |0 |0 |0 |
| Model specs|238 | 182 |0 |0 |0 |0 |
| Request specs|699 | 489 |0 |14 |0 |32 |
| Routing specs|35 |26 |0 |0 |0 |0 |
| View specs|5 |4 |0 |0 |0 |0 |
+----------------------+-------+-----+-------+---------+-----+-------+
| Total|1472 |1042 |11 |49 |4 |19 |
+----------------------+-------+-----+-------+---------+-----+-------+
Code LOC: 262Test LOC: 780Code to Test Ratio: 1:3.0
- 我可以在代码库中提取循环模式吗?
假装我们想编写一个跟踪慢跑时间的应用程序。在主页上, 用户可以看到他们输入的时间。
文章图片
每个时间条目都有一个日期, 距离, 持续时间以及其他相关的” 状态” 信息(例如天气, 地形类型等), 以及可以在需要时计算的平均速度。
我们需要一个报告页面来显示每周的平均速度和距离。
如果输入的平均速度高于整体平均速度, 我们将通过短信通知用户(在此示例中, 我们将使用Nexmo RESTful API发送短信)。
主页将允许你选择慢跑的距离, 日期和时间, 以创建类似于以下内容的条目:
文章图片
我们还有一个统计页面, 该页面基本上是每周报告, 其中包含每周的平均速度和距离。
文章图片
- 你可以在此处查看在线示例。
?tree
.
├── assets
│└── ...
├── controllers
│├── application_controller.rb
│├── entries_controller.rb
│└── statistics_controller.rb
├── helpers
│├── application_helper.rb
│├── entries_helper.rb
│└── statistics_helper.rb
├── mailers
├── models
│├── entry.rb
│└── user.rb
└── views
├── devise
│└── ...
├── entries
│├── _entry.html.erb
│├── _form.html.erb
│└── index.html.erb
├── layouts
│└── application.html.erb
└── statistics
└── index.html.erb
我不会讨论用户模型, 因为它没有什么特别之处, 因为我们将其与Devise一起使用以实现身份验证。
至于Entry模型, 它包含我们应用程序的业务逻辑。
每个条目都属于一个用户。
我们验证每个条目的距离, time_period, date_time和status属性的存在。
文章图片
每次创建条目时, 我们都会将用户的平均速度与系统中所有其他用户的平均速度进行比较, 并使用Nexmo通过SMS通知用户(尽管我想讨论如何使用Nexmo库, 演示使用外部库的情况)。
- 要点样本
entry_controller.rb具有主要的CRUD操作(尽管没有更新)。 EntriesController#index获取当前用户的条目并按创建日期对记录进行排序, 而EntriesController#create创建一个新条目。无需讨论EntriesController#destroy的显而易见的职责:
- 要点样本
- 要点样本
文章图片
下面是列出已登录用户(index.html.erb)条目的视图。这是用于在条目控制器中显示索引操作(方法)的结果的模板:
- 要点样本
- 要点样本
- 要点样本
- 要点样本
- 要点样本
你们中的大多数人都认为重构这违反了KISS原则, 会使系统更加复杂。
那么这个应用程序真的需要重构吗?
绝对不是, 但我们将其仅出于演示目的。
毕竟, 如果你查看了上一部分, 并且指出了应用程序需要重构的特征, 那么很明显, 我们示例中的应用程序不是重构的有效候选者。
生命周期 因此, 让我们从解释Rails MVC模式结构开始。
通常, 它由浏览器发出请求开始, 例如https://www.srcmini.com/jogging/show/1。
Web服务器接收请求, 并使用路由找出要使用的控制器。
控制器执行解析用户请求, 数据提交, cookie, 会话等的工作, 然后要求模型获取数据。
这些模型是Ruby类, 用于与数据库进行对话, 存储和验证数据, 执行业务逻辑以及执行其他繁重的工作。用户可以看到的视图是:HTML, CSS, XML, Javascript, JSON。
如果我们想显示Rails请求生命周期的顺序, 它看起来像这样:
文章图片
我要实现的目标是使用普通的旧红宝石对象(PO??RO)添加更多抽象, 并使模式类似于以下用于创建/更新操作:
文章图片
【使用普通的旧Ruby对象构建流畅的Rails组件】列表/显示操作类似以下内容:
文章图片
通过添加POROs抽象, 我们将确保职责SRP之间的完全隔离, 这是Rails不太擅长的事情。
指导方针 为了实现新设计, 我将使用下面列出的指南, 但请注意, 这些并不是你必须遵循的规则。将它们视为灵活的指南, 可以使重构更加容易。
- ActiveRecord模型可以包含关联和常量, 但不能包含其他任何内容。因此, 这意味着没有回调(使用服务对象并在其中添加回调)和验证(使用Form对象包括模型的命名和验证)。
- 将Controller保留为薄层, 并始终调用Service对象。你们中的有些人会问, 既然我们想继续调用服务对象来包含逻辑, 为什么还要使用控制器呢?好吧, 控制器是拥有HTTP路由, 参数解析, 身份验证, 内容协商, 调用正确的服务或编辑器对象, 异常捕获, 响应格式以及返回正确的HTTP状态代码的好地方。
- 服务应该调用查询对象, 并且不应该存储状态。使用实例方法, 而不是类方法。与SRP保持一致的公共方法应该很少。
- 查询应在查询对象中完成。查询对象方法应返回对象, 哈希或数组, 而不是ActiveRecord关联。
- 避免使用助手, 而使用装饰器。为什么? Rails助手的一个常见陷阱是, 它们可能会变成大量的非OO函数, 它们全部共享一个命名空间并相互促进。但是更糟糕的是, 没有一种很好的方法将任何类型的多态与Rails帮助器一起使用-为不同的上下文或类型提供不同的实现, 覆盖或子类化帮助器。我认为, Rails帮助程序类通常应用于实用程序方法, 而不应用于特定的用例, 例如, 为任何类型的表示逻辑格式化模型属性。保持它们轻盈凉爽。
- 避免使用顾虑, 而改用装饰器/委托器。为什么?毕竟, 关注点似乎是Rails的核心部分, 并且可以在多个模型之间共享时使代码干燥。尽管如此, 主要的问题是, 顾虑并没有使模型对象具有更大的凝聚力。该代码只是组织得更好。换句话说, 模型的API并没有真正的改变。
- 尝试从模型中提取值对象, 以使代码更整洁并对相关属性进行分组。
- 始终为每个视图传递一个实例变量。
如果你觉得要在职责之间进行更多的分离或隔离(即使这意味着添加更多的代码和新文件), 那么通常这是一件好事。毕竟, 将应用程序解耦是一个很好的做法, 这使我们更容易进行适当的单元测试。
我不会讨论诸如将逻辑从控制器转移到模型之类的事情, 因为我假设你已经这样做了, 并且你对使用Rails感到很舒服(通常是Skinny Controller和FAT模型)。
为了使本文更加简洁, 我在这里不讨论测试, 但这并不意味着你不应该进行测试。
相反, 在继续前进之前, 你应该始终从测试开始, 以确保一切正常。这是必须的, 尤其是在重构时。
然后, 我们可以实施更改, 并确保所有测试都通过了代码的相关部分。
提取值对象
首先, 什么是价值对象?
马丁·福勒(Martin Fowler)解释说:
值对象是一个小对象, 例如货币或日期范围对象。它们的关键特性是它们遵循值语义而不是引用语义。有时, 你可能会遇到这样一种情况, 一个概念应该得到自己的抽象, 其平等不是基于价值, 而是基于身份。示例包括Ruby的日期, URI和路径名。提取到值对象(或域模型)非常方便。
何苦?
Value对象的最大优点之一是它们可帮助你在代码中实现表达。你的代码往往会更加清晰, 或者至少如果你具有良好的命名习惯, 那么代码可能会更加清晰。由于Value Object是抽象的, 因此可以使代码更简洁, 错误更少。
文章图片
另一个大赢家是不变性。对象的不变性非常重要。当我们存储可以在值对象中使用的某些数据集时, 我通常不希望对这些数据进行操作。
什么时候有用?
没有一个单一的, 适合所有人的答案。在任何给定情况下, 都要做对自己最有利的事情, 并做出有意义的事情。
不过, 除此之外, 还有一些指导原则可用来帮助我做出决定。
如果你认为一组方法与Value对象相关, 则它们更具表现力。这种表达方式意味着Value对象应该代表一组独特的数据, 你的普通开发人员可以通过查看对象的名称来推断出它们。
怎么做?
值对象应遵循一些基本规则:
- 值对象应具有多个属性。
- 属性在对象的整个生命周期中都应该是不变的。
- 平等取决于对象的属性。
- 要点样本
我们可以修改Entry模型以使用我们创建的值对象:
- 要点样本
- 要点样本
那么什么是服务对象?
服务对象的工作是保存特定业务逻辑的代码。与” 胖模型” 样式不同, 在” 胖模型” 样式中, 少量对象包含许多用于所有必要逻辑的方法, 而使用Service对象会导致产生许多类, 每个类都有一个目的。
文章图片
为什么?有什么好处?
- 去耦。服务对象可帮助你实现对象之间的更多隔离。
- 能见度。服务对象(如果名称正确)显示应用程序的功能。我可以浏览一下服务目录以查看应用程序提供的功能。
- 清理模型和控制器。控制器将请求(参数, 会话, cookie)转换为参数, 将其传递给服务, 并根据服务响应进行重定向或呈现。虽然模型仅处理关联和持久性。从控制器/模型中提取代码到服务对象将支持SRP, 并使代码更加分离。这样, 该模型的责任将仅是处理关联和保存/删除记录, 而服务对象将具有单一责任(SRP)。这导致更好的设计和更好的单元测试。
- 干和拥抱变化。我使服务对象尽可能的简单和小巧。我将服务对象与其他服务对象组成, 然后重用它们。
- 清理并加快测试套件的速度。由于服务是只有一个入口点的小型Ruby对象(调用方法), 因此服务易于测试且快速。复杂服务与其他服务组成, 因此你可以轻松拆分测试。同样, 使用服务对象可以更轻松地模拟/存根相关对象, 而无需加载整个rails环境。
- 随时随地均可致电。可能会从控制器以及其他服务对象, DelayedJob / Rescue / Sidekiq作业, Rake任务, 控制台等中调用服务对象。
什么时候应该提取服务对象?
这里也没有硬性规定。
通常, 服务对象更适合中大型系统。具有超出标准CRUD操作的大量逻辑的代码。
因此, 每当你认为某个代码段可能不属于你要添加该代码段的目录时, 最好重新考虑一下并查看是否应将它放到服务对象中。
以下是何时使用Service对象的一些指示:
- 动作很复杂。
- 该动作涉及多个模型。
- 该动作与外部服务交互。
- 该操作不是基础模型的核心问题。
- 有多种执行操作的方法。
为服务对象设计类相对简单, 因为你不需要特殊的知识, 不需要学习新的DSL, 并且可以或多或少地依赖你已经拥有的软件设计技能。
我通常使用以下准则和约定来设计服务对象:
- 不要存储对象的状态。
- 使用实例方法, 而不是类方法。
- 公开方法应该很少(最好是一种支持SRP的方法)。
- 方法应返回丰富的结果对象, 而不是布尔值。
- 服务位于app / services目录下。我鼓励你对业务逻辑繁重的域使用子目录。例如, 文件app / services / report / generate_weekly.rb将定义Report :: GenerateWeekly, 而app / services / report / publish_monthly.rb将定义Report :: PublishMonthly。
- 服务以动词开头(而不以服务结尾):ApproveTransaction, SendTestNewsletter, ImportUsersFromCsv。
- 服务响应调用方法。我发现使用另一个动词使它有点多余:ApproveTransaction.approve()读得不好。同样, call方法是lambda, proc和方法对象的事实上的方法。
在我们的例子中, 让我们创建Report :: GenerateWeekly并从StatisticsController中提取报告逻辑:
- 要点样本
- 要点样本
作业:考虑将Value对象用于WeeklyReport而不是Struct。
从控制器中提取查询对象
什么是查询对象?
查询对象是代表数据库查询的PORO。可以在应用程序中的不同位置重用它, 同时隐藏查询逻辑。它还提供了良好的隔离单元进行测试。
你应该将复杂的SQL / NoSQL查询提取到自己的类中。
每个查询对象负责根据条件/业务规则返回结果集。
文章图片
在此示例中, 我们没有任何复杂的查询, 因此使用Query对象效率不高。但是, 出于演示目的, 让我们在Report :: GenerateWeekly#call中提取查询, 然后创建generate_entries_query.rb:
- 要点样本
def call
@user.entries.group_by(&
:week).map do |week, entries|
WeeklyReport.new(
...
)
end
end
与:
def call
weekly_grouped_entries = GroupEntriesQuery.new(@user).callweekly_grouped_entries.map do |week, entries|
WeeklyReport.new(
...
)
end
end
查询对象模式有助于使你的模型逻辑与类的行为严格相关, 同时还可以使控制器保持苗条。由于它们只不过是普通的旧Ruby类, 因此查询对象不需要从ActiveRecord :: Base继承, 并且应仅负责执行查询。
将创建条目提取到服务对象
现在, 让我们提取为新服务对象创建新条目的逻辑。让我们使用约定并创建CreateEntry:
- 要点样本
def create
begin
CreateEntry.new(current_user, entry_params).call
flash[:notice] = 'Entry was successfully created.'
rescue Exception =>
e
flash[:error] = e.message
endredirect_to root_path
end
将验证移到表单对象中
现在, 这里的事情开始变得越来越有趣。
请记住, 在我们的准则中, 我们同意我们希望模型包含关联和常量, 但没有别的(没有验证也没有回调)。因此, 让我们首先删除回调, 然后使用Form对象。
Form对象是一个普通的旧Ruby对象(PO??RO)。它从需要与数据库进行通信的任何地方接管控制器/服务对象。
为什么要使用Form对象?
在寻求重构应用程序时, 始终牢记单一责任原则(SRP)是一个好主意。
SRP可帮助你围绕类应负责的内容做出更好的设计决策。
例如, 你的数据库表模型(在Rails上下文中为ActiveRecord模型)代表代码中的单个数据库记录, 因此没有理由使它与用户的操作有关。
这是Form对象进入的地方。
Form对象负责在你的应用程序中表示一个表单。因此, 每个输入字段都可以视为类中的一个属性。它可以验证那些属性是否符合某些验证规则, 并且可以将” 干净” 数据传递到需要去的地方(例如, 数据库模型或搜索查询生成器)。
什么时候应该使用Form对象?
- 当你想从Rails模型中提取验证时。
- 当可以通过一个表单提交来更新多个模型时, 你可能想要创建一个Form对象。
如何创建一个Form对象?
- 创建一个普通的Ruby类。
- 包括ActiveModel :: Model(在Rails 3中, 你必须包括命名, 转换和验证)。
- 开始使用新的表单类, 就好像它是常规的ActiveRecord模型一样, 最大的区别是你无法持久存储此对象中的数据。
- 要点样本
class CreateEntry......
......def call
@entry_form = ::EntryForm.new(@params)if @entry_form.valid?
....
else
....
end
end
end
注意:有些人会说不需要从Service对象访问Form对象, 而我们可以直接从控制器直接调用Form对象, 这是一个有效的参数。但是, 我希望流程清晰, 这就是为什么我总是从Service对象调用Form对象的原因。
将回调移到服务对象
正如我们之前所同意的, 我们不希望我们的模型包含验证和回调。我们使用Form对象提取了验证。但是我们仍在使用一些回调(Entry模型中的after_create compare_speed_and_notify_user)。
为什么我们要从模型中删除回调?
Rails开发人员通常会在测试过程中开始注意到回调的痛苦。如果你不测试ActiveRecord模型, 则随着应用程序的增长以及调用或避免回调需要更多逻辑, 以后你会开始注意到痛苦。
after_ *回调主要用于保存或持久化对象。
保存对象后, 就可以实现对象的目的(即责任)。因此, 如果在保存对象后仍然看到回调被调用, 则很可能会看到回调超出了对象的职责范围, 这就是我们遇到问题的时候。
在我们的例子中, 保存条目后, 我们将向用户发送SMS, 这与条目的域并不真正相关。
解决此问题的一种简单方法是将回调移动到相关的服务对象。毕竟, 为最终用户发送SMS与CreateEntry服务对象有关, 而与Entry模型本身无关。
这样做, 我们不再需要在测试中添加compare_speed_and_notify_user方法。我们无需创建SMS就可以轻松创建条目, 并且通过确保类具有单一职责(SRP)来遵循良好的面向对象设计。
所以现在我们的CreateEntry看起来像:
- 要点样本
尽管我们可以轻松使用视图模型和装饰器的Draper集合, 但就本文而言, 我将坚持使用PORO, 就像我到目前为止所做的那样。
我需要的是一个将在装饰对象上调用方法的类。
我可以使用method_missing来实现, 但我将使用Ruby的标准库SimpleDelegator。
以下代码显示了如何使用SimpleDelegator来实现我们的基本装饰器:
% app/decorators/base_decorator.rb
require 'delegate'class BaseDecorator <
SimpleDelegator
def initialize(base, view_context)
super(base)
@object = base
@view_context = view_context
endprivatedef self.decorates(name)
define_method(name) do
@object
end
enddef _h
@view_context
end
end
那么为什么使用_h方法呢?
此方法充当视图上下文的代理。默认情况下, 视图上下文是视图类的实例, 默认视图类为ActionView :: Base。你可以按以下方式访问视图助手:
_h.content_tag :div, 'my-div', class: 'my-class'
为了更加方便, 我们向ApplicationHelper添加了decorate方法:
module ApplicationHelper# .....def decorate(object, klass = nil)
klass ||= "#{object.class}Decorator".constantize
decorator = klass.new(object, self)
yield decorator if block_given?
decorator
end# .....end
现在, 我们可以将EntriesHelper助手移动到装饰器:
# app/decorators/entry_decorator.rb
class EntryDecorator <
BaseDecorator
decorates :entrydef readable_time_period
mins = entry.time_period
return Time.at(60 * mins).utc.strftime('%M <
small>
Mins<
/small>
').html_safe if mins <
60
Time.at(60 * mins).utc.strftime('%H <
small>
Hour<
/small>
%M <
small>
Mins<
/small>
').html_safe
enddef readable_speed
"#{sprintf('%0.2f', entry.speed)} <
small>
Km/H<
/small>
".html_safe
end
end
我们可以像这样使用visible_time_period和read_speed:
# app/views/entries/_entry.html.erb
-<
td>
<
%= readable_speed(entry) %>
<
/td>
+<
td>
<
%= decorate(entry).readable_speed %>
<
/td>
-<
td>
<
%= readable_time_period(entry) %>
<
/td>
+<
td>
<
%= decorate(entry).readable_time_period %>
<
/td>
重构后的结构 我们最终得到了更多文件, 但这并不一定是一件坏事(请记住, 从一开始, 我们就知道该示例仅用于说明目的, 不一定是重构的好用例):
app
├── assets
│└── ...
├── controllers
│├── application_controller.rb
│├── entries_controller.rb
│└── statistics_controller.rb
├── decorators
│├── base_decorator.rb
│└── entry_decorator.rb
├── forms
│└── entry_form.rb
├── helpers
│└── application_helper.rb
├── mailers
├── models
│├── entry.rb
│├── entry_status.rb
│└── user.rb
├── queries
│└── group_entries_query.rb
├── services
│├── create_entry.rb
│└── report
│└── generate_weekly.rb
└── views
├── devise
│└── ..
├── entries
│├── _entry.html.erb
│├── _form.html.erb
│└── index.html.erb
├── layouts
│└── application.html.erb
└── statistics
└── index.html.erb
总结 即使我们在此博客文章中专注于Rails, RoR也不依赖于所描述的服务对象和其他PORO。你可以将这种方法用于任何Web框架, 移动或控制台应用程序。
通过将MVC用作Web应用程序的体系结构, 所有内容都可以保持耦合, 并使你的速度变慢, 因为大多数更改都会影响到该应用程序的其他部分。此外, 它迫使你考虑将一些业务逻辑放在何处–应该将其放入模型, 控制器或视图中?
通过使用简单的PORO, 我们已经将业务逻辑转移到了不继承自ActiveRecord的模型或服务上, 这已经是一个很大的胜利, 更不用说我们的代码更简洁, 可以支持SRP和更快的单元测试。
干净的体系结构旨在将用例放在结构的中心/顶部, 以便你可以轻松查看应用程序的功能。由于它具有更多的模块化和隔离性, 因此它也更容易采用更改。
我希望我能说明如何使用Plain Old Ruby Objects和更多抽象方法使关注点分离, 简化测试并帮助产生干净, 可维护的代码。
相关:Ruby on Rails有什么好处?经过两个十年的编程, 我使用了Rails
推荐阅读
- 使用Elm使你的Web前端更可靠
- 如果你对电子商务很认真,请使用Magento
- HTTP请求测试(开发人员的生存工具)
- 使用Redux在JavaScript中保持不变
- 建立自助管理区的艺术
- 关于DateTime操作的权威指南
- 开始使用Angular 2(从1.5升级)
- npm指南(Node.js程序包管理器)
- Android Facebook Graph API