Growing Rails Applications in Practice - 2

Creating a system for growth - 上

Dealing with fat models

在前面的章节,我们把代码从 controller 移动到 model 中。我们可以看到这样带来的很多好处:controller 变得很简单,更容易测试商业逻辑。但一段时间之后,model 开始产生如下问题:

  • 你害怕保存数据会引起意外回调,比如给客户发送个电子邮件啥的。
  • 过多的回调和验证让你很难创建一个简单的数据来进行单元测试。
  • 不同的 UI 界面需要你的 model 提供不同的支持。比如:用户自己注册的时候,发送给他一个欢迎邮件。而管理员从后台注册用户就不触发这个动作。

All of these problems of so-called fat models can be mitigated. But before we look at solutions, let’s understand why models grow fat in the first place.

Why models grow fat

model 体积变大的主要原因是因为它们要承担越来越多的任务。例如一个用户模型要支持如下的用例:

  • A new user signs up through the registration form
  • An existing user signs in with her email and password
  • A user wants to reset her lost password
  • A user logs in via Facebook (OAuth)
  • A users edits her profile
  • An admin edits a user from the backend interface
  • Other models need to work with User records
  • Background tasks need to batch-process users every night
  • A developer retrieves and changes User records on the Rails console

所有这些用例都会影响模型中的其它用例。 You end up with a model that is very hard to use without undesired side effects getting in your way.

咱们看看,当这个项目你维护了1年之后,你的 User 模型都会包含些啥:

Use case Scar tissue in User model
New user registration form Validation for password strength policy
Accessor and validation for password repetition
Accessor and validation for acceptance of terms
Accessor and callback to create newsletter subscription
Callback to send activation e-mail
Callback to set default attributes
Protection for sensitive attributes (e.g. admin flag)
Code to encrypt user passwords
Login form Method to look up a user by either e-mail or screen name
Method to compare given password with encrypted password
Password recovery Code to generate a recovery token
Code to validate a given recovery token
Callback to send recovery link
Facebook login Code to authenticate a user from OAuth
Code to create a user from OAuth
Disable password requirement when authenticated via OAuth
Users edits her profile Validation for password strength policy
Accessor and callback to enable password change
Callback to resend e-mail activation
Validations for social media handles
Protection for sensitive attributes (e.g. admin flag)
Admin edits a user Methods and callbacks for authorization (access control)
Attribute to disable a user account
Attribute to set an admin flag
Other models that works with users Default attribute values
Validations to enforce data integrity
Associations
Callbacks to clean up associations when destroyed
Scopes to retrieve commonly used lists of users
Background tasks processes users Scopes to retrieve records in need of processing
Methods to perform required task

你很难从这一大堆代码中理出头绪。即便代码的可读性没问题,但是这样的 model 是很难使用的:

  • 你害怕更新记录,因为不知道会触发什么回调。比如,当用户更新了自己的个人资料,后台任务会同步该数据并发送一份邮件通知用户。
  • 有时想要创建一个用户,却发现自己无法保存记录,因为有些回调禁止你这么做(在管理员后台这么做是有意义的)。
  • 不同的用户表单需要不同类型的验证,无处不在的验证让你开始像猜谜一样,修改 model 的配置来启用或禁用验证行为。
  • 单元测试几乎不可能,因为每一个与 User 的交互都会引发无数的副作用,你必须在它们中间开辟出一条路,才能确保测试无误。

The case of the missing classes

Fat model 往往意味着一些未被发现的抽象模型。上表中的功能,可以被抽象出如下模型:

  • PasswordRecovery
  • AdminUserForm
  • RegistrationForm
  • ProfileForm
  • FacebookConnect

我们曾说过 large applications are large。当你需要实现恢复密码的功能,但是又不新建一个地方来放置这些代码,那么 Code never goes away。它必然会横跨几个类,并让这些类都变得难读难用。

Code never goes away 意思是你需要把代码组织到一个正确的地方,否则它会感染现有类。

想想,假如你要处理一批信件。这些信在你家里散落到各处,可能是办公桌,床上,餐桌,鞋盒子等等,因为它们没有一个固定地方,很难找到你要找的那封信,它们弄乱了你的办公桌。你不知道去哪找信来阅读,也不知道自己今天是否遗漏了某封重要信件。

当然,你可以可以创建一个收件箱来解决这个问题。只需要每天从收件箱取信然后放在办公桌前处理就好。

注意,我们并没有让你的信件消失,也不是让你不处理信件了。只是我们让这一切更有序,可以提高我们处理信件的效率。越高的结构化,往往意味着我们的项目更长寿

Getting into a habit of organizing

做个收件箱不难,难的是要认清你身边有无数混乱的信件的能力。

类似的,当你要找个地方新添加代码的时候,不要马上去找已有的 ActiveRecord 类,试着去寻找一个新类来包含这些逻辑。

下面的章节我们将展示:

  • 如何在你的代码中发现未被抽象的概念
  • 如何将相互作用的代码和逻辑放在单独的类中,而让核心类苗条
  • 如何识别出一段代码不该插入现有的 ActiveRecord 模型而是应该将其抽取到一个 service 类
  • 如何借用 ActiveRecord 的功能方便的做到这一切

A home for interaction-specific code

大多数成熟的 Rails 项目忍受巨大 model 的原因都是它们承担了太多功能。前面我们通过一个典型的 User 模型来描述为什么 model 会越来越巨大。

将一个巨大的 model 砍小的第一步是将互相之间有关系的代码抽取成一个单独的 model,并让核心 model 保持简洁。

你的核心 model 应该只包含如下内容:

  • 最低程度的验证确保数据的完整
  • 声明联合关系(belongs_tohas_many)
  • 用来查找或操作数据相关的方法,并且这些方法是通用且重要的

核心 model 不该包含特殊的与表单或用户界面相关的逻辑,例如:

  • 只在特定表单才进行的验证,例如:only the sign up form requires confirmation of the user password
  • 虚拟属性,以支持那些并非和数据库表对应的表单,例如:tags are entered into a single text field separated by comma, the model splits that string into individual tags before validation
  • 仅在特定界面或用例才执行的回调,例如:只有用户注册页面了才发送邮件
  • 关于授权验证的代码 access control
  • 用来渲染复杂视图的辅助方法

所有这些只与一个 UI(或一组相关的 UI) 有关系的逻辑和代码,最好被放在一个单独的 model。

如果你一直保持这种将特定的业务逻辑抽取成 model 的做法,那么你的核心 model 也会保持简洁,并且不会被回调干扰。

A modest approach to form models

关于模型的重构不是什么新鲜事。你会从 Github 上发现一大堆和 presentersexhibitsform models 相关的 gems。不幸的是,其中很大部分看起来都不像从实践中出发。下面是一些我们在实践中遇到的问题:

  • 无法和 Rails 的表单方法工作,比如 form_for
  • 无法使用 ActiveRecord 宏风格的代码,比如 validates_presence_of
  • 无法使用嵌套资源型表单
  • 只针对单个对象工作,面对一组对象就无效了,比如 index 动作
  • 创建新记录和编辑已经存在的记录需要不同的主持类, 虽然用户界面是相同的
  • 你需要从核心类中复制粘贴验证和逻辑代码
  • 频繁使用委托(过多的委托,会引起自身的混乱)
  • 增加许多文件,加大理解难度

这些 gems 让我们一次又一次失望,我们想知道:是否有可能既享受 ActiveRecord 的便利,又能把不同的逻辑放在各自的类中。

我们发现这是可行的,甚至不需要引入什么特殊的 gem 就能做到这一点。我们需要的就是利用普通的继承把和页面交互相关的代码从 model 中抽取到另外一个地方。

让我们来看一个例子。思考下面这个已经有点巨大的 User model 如何才能变得好起来:

class User < ActiveRecord::Base

  validates :email, presence: true, uniqueness: true
  validates :password, presence: true, confirmation: true
  validates :terms, acceptance: true

  after_create :send_welcome_email

  private

  def send_welcome_email
    Mailer.welcome(user).deliver
  end

end

很明显这些代码涉及注册表单,应该从 User 核心类移除。当某些情况下想要跳过验证(管理员在后台操作)或者某种情况下创建用户不发送欢迎邮件的时候(控制台创建用户),当前这个 model 让你开始遇到麻烦。

下面我们减小 model 的体积,仅保留基本的逻辑:

class User < ActiveRecord::Base
  validates :email, presence: true, uniqueness: true
end

所有跟注册有关的代码都移动到 User::AsSignUp 类中,该类继承 User 类:

class User::AsSignUp < User

  validates :password, presence: true, confirmation: true
  validates :terms, acceptance: true

  after_create :send_welcome_email

  private

  def send_welcome_email
    Mailer.welcome(user).deliver
  end
end

需要注意的是我们不必在新类中重复验证 email 属性。由于这个新 model 继承自核心 model,它自动继承了核心 model 的全部行为,并在此基础上进行自己的定义。

当我们使用 User::AsSignUp 来代替原来的 User 类之后,用来处理注册表单的 controller 也非常简单:

class UsersController < ActionController

  def new
    build_user
  end

  def create
    build_user
    if @user.save
      redirect_to dashboard_path
    else
      render 'new'
    end
  end

  private

  def build_user
    @user ||= User::AsSignUp.new(user_params)
  end

  def user_params
    user_params = params[:user]
    user_params.permit(
      :email,
      :password,
      :password_confirmation,
      :terms
    )
  end
end

需要注意的是,我们使用 User::AsSignUp 作为类名仅仅是因为大家都喜欢用这样的命名约定而已,并没有强迫你使用 As 作为前缀或命名空间。但是,我们推荐使用这种目录结构来帮助你更好的组织模型。

File Class definition
user.rb class User < ActiveRecord::Base
user/as_sign_up.rb class User::AsSignUp < User
user/as_profile.rb class User::AsProfile < User
user/as_admin_form.rb class User::AsAdminForm < User
user/as_facebook_login.rb class User::AsFacebookLogin < User

后面的章节我们会介绍如何用命名空间来组织代码。

More convenience for form models

在不断地实践中,我们发现可以通过适当增加一些小的调整,能让我们更方便的和 models 交互。最后,我们把这些小的功能打包成了前面的章节提到过的 ActiveType 这个 gem。即使你并不需要使用 ActiveType 从 model 带来的便利,这里有几个例子说明为什么 ActiveType 能让你的开发工作更轻松:

  • url_forform_forlink_to 这样的辅助方法会根据类名判断出路由地址。如果使用 User::AsSignUp 这样的名字,这些方法就无法正确工作。而 ActiveType 内部已经帮忙处理这个问题,它会把 link_to(@user) 方法路由到 user_path(@user),即便 @user 是一个 User::AsSignUp 的实例。
  • 当使用单表继承时,你很可能不想在 type 字段存一个叫做 User::AsSignUp 的值,ActiveType 也解决了这个问题,在这种情况下,我们不存 User::AsSignUp,仍然存 User
  • 对于表单模型,你需要控制一些虚拟属性(某些属性自动转换为整数或日期类型)。尤其当你的模型不适合数据库映射的时候。ActiveType 附带了一些简单的语法糖方便我们在类中定义这些属性。

切换到 ActiveType 很简单,你只需要从

class User::AsSignUp < User

改成

class User::AsSignUp < ActiveType::Record[User]

一切都和原来一样,但是你得到了上述我们介绍的优点。

需要注意的时,ActiveType 除了能让表单更方便还有其它优势,你仍然可以像原 Ruby 类一样使用前面章节我们讨论过的 ActiveModel API。

下一章我们将讨论如何进一步减少核心 model 的体积。

Can’t I just split my model into modules?

有很多技术可以把 fat models 分割成多个 modules,比如 DDH 推荐的 Put chubby models on a diet with concerns

虽然这种技术可以有效地在多个模型中共享行为,但你必须了解这并非是文件组织的唯一形式。由于所有这些 modules 都会在运行时加载进入你的 model,实际上它并没有降低 model 的代码量。你的回调和方法不会因为被划分在多个模块中而消失。仅仅只是换了个地方而已。

如果觉得我的文章对您有用,请在支付宝公益平台找个项目捐点钱。 @Victor Jan 26, 2015

奉献爱心