Ruby与Rails中的面向对象思维:从过程式到优雅设计

发布时间:2026/10/12 8:34:26
Ruby与Rails中的面向对象思维:从过程式到优雅设计 我见过很多从 Java、C# 转过来的朋友第一天写 Ruby 都会有种怎么可以这么爽的感觉调方法不用写括号attr_accessor一行搞定读写器Rails 脚手架刷刷刷就把项目拉起来了。但大约从第二周开始画风就变了——代码开始乱、开始绕、开始怎么改都不对劲。这不是语法问题问题出在想东西的方式没换脑子里还是过程式的主流程控制一切手底下却已经在用 Ruby 和 Rails 了。这正是题里想说的 Object-Oriented 思维它不是 Ruby 附带的一个可选特性而是这门语言几乎每一寸设计都在逼你养成的心智模式。如果你是搜display: ruby 是什么意思点进来的先把这个谜底揭开免得你会错意CSS 里的display: ruby是排版注音文本用的显示类型配合ruby、rt、rb这些标签做日文假名或中文拼音注音跟 Ruby 编程语言完全是两码事纯粹名字撞车。别在 Rails 代码里看到ruby字样时把它和 CSS 混在一起那就会闹经典笑话了。这篇文章要聊的是真正的 Object-Oriented 思维在 Ruby 和 Rails 里到底怎么落地。我会从思维拆解开始再用一个订单折扣的经典案例把一段看着就想重构的过程式代码一步步改成可扩展、可测试、每个人都能看懂的面向对象设计。适合谁适合已经开始写 Rails、但总觉得项目越改越乱的人也适合学过 Ruby 语法、却还没想明白类与对象给工程带来了什么的人。1. 先别急着写代码把面向对象这四个字重新拆开1.1 面向对象不是类 继承是一种思考委托的方式大多数入门教材把面向对象讲成类是模板对象是实例继承是父子的关系这个说法没有错但太语法化了。它解释不了为什么很多初学者把一个类写得又长又臭也解释不了为什么同一个功能在两个人手里写出来风格完全不同。真正的面向对象思维可以类比成一家咖啡馆。你走进店里不需要知道咖啡机的锅炉温度是多少、研磨刻度是几也不需要知道咖啡豆的产区配方你只需要对服务员说一句一杯拿铁。这句话本身就是向咖啡店这个对象系统发了一条消息门面背后的细节由不同岗位的对象各自完成。这个过程里你关心的不是咖啡师今天心情怎么样而是他能不能交付我点的那杯饮品。过程式思维恰好是反过来的你自己冲进吧台打开豆仓、调磨、压粉、萃取每一步用什么数据、按什么顺序全由你的主流程控制。早年写 C 或者写一些脚本逻辑的时候这种上帝视角还算好使。可一旦业务规则多了你的主流程就会变成一串斩不断的 if/else每加一个需求都要把整个流程重读一遍然后小心翼翼地找一个位置插入新分支。面向对象的本质是把主流程控制一切变成对象之间相互委托。你写的代码不再是我告诉每一个变量该怎么办而是我把某个任务交给一个对象让它自己决定怎么完成。这句话听上去轻飘飘的但真正做到之后代码的形态会完全不同。1.2 面向对象到底解决了什么问题变化总是局部化很多人以为面向对象是为了代码复用。代码复用当然重要但我觉得面向对象真正解决的核心问题是当需求发生变化时改动能不能被限制在一个小范围里。举个最常见的场景一个订单系统今天要做 VIP 打八折明天要做满 200 减 20后天又来个新客立减 50。如果所有规则都堆在同一个方法里需求每来一次你就要去那个几百行的方法里改一次改完还要担心碰坏之前的逻辑。这种代码不是写出来的是长出来的将来迟早要还债。面向对象思维下的解决方案是把每种折扣看成一个独立的规则对象。这些对象只需要暴露一个统一的接口比如apply(order)外部经过一个调度对象把规则依次执行。新增一种折扣就新增一个类文件主流程完全不用动。老代码稳定新代码可扩展这就是开闭原则在真实业务里的样子。可能有人会抬杠这听起来就是把 if/else 拆到不同的类文件里换汤不换药。还真不是。关键是思维的锚点变了过程式代码锚定在流程顺序上每个分支按顺序执行顺序错了结果就错面向对象代码锚定在对象职责上每个对象守着自己那一摊规则互相之间不干涉。需求变的是规则而规则被装进了对象改动自然就局部化了。1.3 热搜里的 display: ruby 与 Ruby 语言的误会既然题面里有这个热搜词我顺手澄清一下也算给读者排个雷。CSS 里的display: ruby是 W3C 规定的内联显示类型用于与ruby、rt、rb、rp这些标签配合把注音文本和正文文本排在一起日文网页里的假名注音、中文网页里的拼音注音用的就是这套机制。Ruby 编程语言的命名则来自宝石据说松本行弘的同事的生日石是 Ruby这门语言因此得名。它和 CSS 的 ruby 注音压根没有血缘关系。你搜display: ruby搜到的是排版知识你搜Ruby 编程语言才是 Rails 背后的那个东东。对 Rails 开发者来说这个乌龙倒也有点启示意义名字只是标签真正重要的是对象能做什么——这句话留着我们在后面的鸭子类型里还会再见。2. Ruby 里的对象观消息、鸭子与边界2.1 方法调用就是给对象发消息Ruby 有一个被反复强调但总是被忽略的观点你写的obj.some_method本质上不是调用某个函数而是给 obj 这个对象发一条消息消息叫 some_method。对象收到消息之后也许调用了某个方法也许通过method_missing动态响应了也许干脆拒绝响应——这是对象的自由。这个心智模型一旦建立你写代码的方式会发生变化。你会开始下意识问自己这句话应该由谁来回答举个例子我见过很多代码是这么写订单税额的# 反例外部连续追问对象的内部状态然后自己做决定 def order_total_with_tax(order) if order.status confirmed order.tax_rate !order.exempt? order.total * order.tax_rate else order.total end end这段代码的问题不是结果不对而是决策权放错了地方。外部调用方像侦探一样去翻订单的状态、税率、豁免标记然后替订单决定该怎么算税。今天订单的判断条件变一个这个外部方法就要跟着改。更好的做法是把算税这个行为交还给订单自己class Order def total_with_tax taxable? ? total * tax_rate : total end end外部代码只需要一句order.total_with_tax至于订单内部怎么判断 taxable、税率怎么取外部一概不关心。这就是实践里常说的 Tell, Dont Ask——告诉对象你想要的不要一直追问对象的家底。用沟通场景来打比方的话过程式写法就像你问同事表格到第几行了、格式是不是你负责的、数据有没有校验完然后自己动手改表格面向对象写法是直接说这个表请你在周五前整理好——你委托了行为而不是拆解了别人的内部流程。2.2 鸭子类型与最小承诺Ruby 里没有接口强制约束这个东西。它遵循的是鸭子类型如果一样东西走起来像鸭子、叫起来像鸭子那就把它当鸭子用。在写代码时这意味着一件事你依赖的是对象的能力而不是对象的类名。实际开发里鸭子类型最典型的运用场景是替换外部服务。比如支付网关你并不关心传入的gateway是 StripeGateway 还是 PaypalGateway你只要求它响应charge(amount:)这个方法。测试的时候甚至可以做一个小抄class FakeGateway def charge(amount:) { success: true, reference: fake_#{SecureRandom.hex(4)} } end end只要 FakeGateway 有charge方法整个结算流程就照跑不误。这给代码带来的松弛度是巨大的业务逻辑不绑定具体类只绑定抽象行为。当然自由是有代价的。鸭子类型把约束从编译期挪到了运行期甚至测试期如果团队没有测试习惯报错就会来得比较晚。这时候你会发现面向对象思维在 Ruby 里其实是和测试文化紧紧绑定在一起的正因为没有编译器替你检查类型你才需要一套测试去锁住对象之间的契约。这不是缺陷是这门语言的性格。2.3 封装边界attr_ 与初始化不是摆设很多人用attr_accessor用得顺手但这玩意儿其实是把双刃剑。attr_accessor同时暴露了读和写意味着任何外部代码都能直接改对象的状态。面向对象思维里这个口子开得越少越好。举个典型的坑订单状态。如果你在模型上写了attr_accessor :status那外部代码可能随手写order.status cancelled绕过了所有校验和联动逻辑。过两天你发现取消订单时要同时发通知、退库存、记录审计日志但外部直接赋值根本触发不了这些联动。责任边界一下子就碎了。更好的封装是让状态通过行为改变class Order def cancel(reason:) return false unless cancellable? update_status!(cancelled) notify_user(reason) release_inventory! end end外部只发cancel(reason: 拍错了)这一条消息订单自己完成内部的整套动作。如果有一天取消订单还要额外退优惠券你只需要改Order#cancel内部所有调用方都不用动。这就是封装的意义对象对外暴露行为接口对内保护状态一致性。同时initialize也不是一个随便写写的构造方法。它承担的是对象一出生状态就该正确这一职责。Ruby 3 之后建议多用关键字参数调用方写起来语义清晰Order.new(total_amount: 100, user: current_user, status: :draft)而不是传三个裸参数调用处全靠猜顺序。好的构造函数读起来应该像一句清晰的指令。2.4 继承不是万能膏药组合与模块才是 Ruby 的日常业务代码里继承是最容易被滥用的一种关系。很多初学作者看到两个类有公共方法下意识就是建一个父类让它们去继承。但继承描述的是一种是什么的关系而业务里更多时候需要的是能做什么的关系。那你用模块就好了。Ruby 的模块天生就是为组合准备的。比如报告系统里有 PdfReport、CsvReport它们共享一份可导出的行为你不必让它们都继承自一个 Report只需要抽出Exportable模块module Exportable def exportable? started? !empty? end end class PdfReport include Exportable end class CsvReport include Exportable end同样的能力模块可以被任意类复用还没把两个本来没有父子关系的类强行绑到一棵继承树上。Rails 里更常见的其实是继承框架类比如ActiveRecord::Base这层继承是框架运行的需要但你自己写的业务类之内的继承能用组合就用组合。脑子里多一根组合优先于继承的弦你会少踩很多坑。3. Rails 项目中的面向对象实战从胖模型到服务对象3.1 Rails 的约定优于配置会让人变懒Rails 最著名的口号是约定优于配置Convention over Configuration。这个设计确实让新手起飞得很快模型放app/models控制器放app/controllers路由写 resource 就能获得一整套 CRUD。但它的副作用是新人很容易把框架的默认目录结构当成代码归宿控制器是处理请求的地方模型是放数据的地方逻辑往模型里塞总没错吧。于是项目长到三个月后你把一个User模型的文件打开发现它有关联、验证、回调、自定义方法、计算逻辑、状态机、甚至导出 CSV 的代码文件轻轻松松上了 1000 行。这其实是把过程式的思维搬了个家以前是 controller 里的 if/else 大杂烩现在是 model 里的 if/else 大杂烩换汤不换药。面向对象思维在 Rails 里落地最关键的一件事就是不要默认所有业务都该放在 Model 里。模型只是 ActiveRecord 映射出来的对象它天然合适做的是单个实体自身的状态和行为跨实体的用例编排、复杂的业务规则组合更适合另立门户。3.2 重构案例订单折扣的三种写法我把这个案例当成全文的重头戏因为它几乎浓缩了本文想表达的所有内容。假设我们要计算一张订单的折扣VIP 用户打八折订单金额满 200 打九折商品在促销期内再额外减 5%。先看过程式版本。最糟糕的写法是把它直接写进控制器class OrdersController ApplicationController def checkout order current_user.orders.new(order_params) if order.user.vip? order.discount order.total * 0.2 elsif order.total 200 order.discount order.total * 0.1 end if Time.current order.promotion_deadline order.items.any? { |item| item.product.promotion? } order.discount order.total * 0.05 end order.save! redirect_to order_path(order) end end这段代码问题一眼就能看出来业务规则完全裸露在控制器里控制器要同时处理请求参数、业务判断、持久化想给这段逻辑写单元测试得先构造一堆模型实例再通过 HTTP 请求才行下个月加一个新客立减五十你又要回控制器改这个 action。有人会把这段逻辑搬进模型变成一个apply_discount!方法class Order ApplicationRecord def apply_discount! self.discount 0 discount total * 0.2 if user.vip? discount total * 0.1 if total 200 discount total * 0.05 if promotion_period? self.total - discount if discount.positive? self end end比写在控制器好一点但本质上还是过程式的顺序判断、顺序累加、每加一条规则都要改这个方法的 if 语句、新增规则越多这个方法越长最后又是一团面粉。现在把视角切换到面向对象把每个折扣规则封装成一个对象再让一个调度对象去组织它们。class OrderDiscount def initialize(order, rules: default_rules) order order rules rules end def total rules.sum { |rule| rule.apply(order) } end private def default_rules [VipUserRule.new, TotalThresholdRule.new, PromotionPeriodRule.new] end end class VipUserRule def apply(order) order.user.vip? ? order.total * 0.2 : 0 end end class TotalThresholdRule THRESHOLD 200 RATE 0.1 def apply(order) return 0 if order.total THRESHOLD order.total * RATE end end class PromotionPeriodRule def apply(order) return 0 unless order.items.any? { |item| item.product.promotion? } order.total * 0.05 end end这时候控制器只需要一句话def checkout order current_user.orders.new(order_params) order.discount OrderDiscount.new(order).total order.save! end新增一条新客立减 50规则就再加一个FirstOrderRule类实现相同的apply(order)接口然后在default_rules数组里插一下原有的三个规则类一行都不用动。测试也变得爽快每个规则类都是独立对象丢一个fake_order进去就能验证计算结果。我不否认这是策略模式但它不是为模式而模式。换个角度看这里的关键依然是谁拥有规则规则被放进了属于自己的对象里各自的判断、各自的阈值互不干扰这才是面向对象思维在业务里的真实落地。3.3 Service Object 与 Form Object职责的进一步划分当跨模型、跨系统的用例逻辑多起来的时候光靠模型自身就不够了。比如下单结算这个动作通常要操作订单、扣库存、扣余额、发通知、写日志这些职责不可能全部塞给Order。合适的做法是抽一个 Service Object放在app/services下面class OrderCheckout def initialize(order, payment_gateway:) order order payment_gateway payment_gateway end def call ActiveRecord::Base.transaction do order.calculate_total! result payment_gateway.charge(amount: order.total) order.confirm!(transaction_id: result[:reference]) order.items.each(:decrement_stock!) end end endService Object 的核心特征是一个对象负责一个用例公开接口往往只有一个call内部编排对象之间的协作。它最大的好处是让控制器变薄也让模型的职责不要无限膨胀。不过要提醒一句Service Object 不是万能回收站。我见过有人把任何一段超过三行的逻辑都塞进 Service最后app/services变成了第二个app/models那不过是给过程式代码换了个衣柜而已。一个 Service 只解决一个场景场景一变宁可新建一个也不要往里加参数。表单场景里还有 Form Object 这个工具。比如注册时同时创建User和Profile这时候直接用User.create会有点痛因为你要处理两个模型的校验和联动。用ActiveModel::Model做一个表单对象会更干净class RegistrationForm include ActiveModel::Model attr_accessor :email, :password, :nickname validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } validates :password, length: { minimum: 8 } def save return false unless valid? ActiveRecord::Base.transaction do user User.create!(email: email, password: password) user.create_profile!(nickname: nickname.presence || email.split().first) end true end end表单对象的好处是校验逻辑有了独立归属而不是散落在控制器或者模型里。它也是一种典型的对象思维应用让每个动作/概念都拥有自己的类。3.4 Concerns 的正确打开方式有用但别变成垃圾堆Rails 社区的ActiveSupport::Concern一度被当作治理胖模型的灵丹妙药很多人一股脑把所有模块都抽成 Concern。用得好代码结构确实清爽用不好就是灾难。Concern 本质上是一种 Rails 风格的模块组合它可以携带关联、回调、实例方法、类方法等一整套模型片段。一个我认为值得借鉴的分寸是只有当代码块的存在能用一个明确的业务名词描述时才去拆 Concern。比如可计价的订单PriceCalculable、带时间戳的审计Auditable这些听起来像是一个清晰的能力域而User 的常用方法这种东西一旦抽出来最后所有模型都想 include 它。module Order::PriceCalculable extend ActiveSupport::Concern included do has_many :order_lines end def subtotal order_lines.sum { |line| line.quantity * line.unit_price } end end这样的 Concern 让Order模型瘦身的同时没有破坏聚合关系。但一定注意Concern 里的方法仍然要遵守封装不要在 Concern 里直接访问另一个 Concern 定义的内部变量那就是在制造隐式耦合。4. 面向对象思维下的常见误区与自检清单4.1 贫血模型只有数据没有行为是最隐蔽的僵尸贫血模型这个词最初来自领域驱动设计圈子但 Rails 项目里遍地都是。意思是你的模型类里只有一堆字段、关联、验证几乎没有真正的业务行为所有业务逻辑跑到了控制器、视图辅助器或者外面各种工具方法里。它的问题在于对象的决策权被外部夺走了模型变成一只被动装数据的袋子。这不是说模型里一定要塞满方法而是说要让每个对象守好自己的那一摊责任。计算总价、判断能不能取消、决定用什么税率这些跟订单自身息息相关的事情应该以某种形式附着在订单模型或其关联的领域对象上而不是散落在控制器和视图里。我在实际项目里判断贫血模型有个土办法打开一个模型文件滚动一遍脑子里问一句这个类能不能回答 70% 关于它自己的问题如果它连自己优惠后应该是多少钱都要去别处找答案那它多半已经贫血了。4.2 神对象与过度设计两种相反方向的坑和贫血模型相反的方向是神对象。什么方法都往 User、Order 上挂最后这些核心模型成了无所不能的神。这种问题的典型征兆是改一行模型代码十几个模块的测试跟着崩。原因是对象自身塞进了大量不属于它的协作逻辑对外依赖太乱。过度设计则是另一个极端。有些团队很喜欢在需求还很模糊的时候就上缓存、上策略模式、上服务定位器、上消息队列理由是以后一定会用到。最后抽象层次太多新人根本没法入手。YAGNIYou Arent Gonna Need It这个原则一定要记牢判断标准很简单当你准备新建一个抽象时先问自己现在有没有至少两个使用方没有就先写简单逻辑等第二个使用方真的出现再提炼不迟。面向对象思维的核心从来不是多用几个设计模式而是让改动发生在正确的位置。我的经验是一个好的对象边界往往不是设计出来的而是在第二次修改时自然浮现的。第一次把代码写直白第二次修改时再抽象这个节奏比较踏实。4.3 习惯成自然之前用清单检查你的代码如果你刚开始实践这套思维可以效率高一点把下面几个问题贴在编辑器旁边写完就自查这段逻辑的决策权明确吗有没有外部代码在替这个对象做本该它自己做的事外部使用方是请求行为还是追问内部数据后自己算如果是后者考虑 Tell, Dont Ask。如果这个需求下个月变了我需要改几个地方越多越说明边界画错了。条件分支超过两层时有没有可能里的每一种分支是一个候选策略对象测试是不是要 mock 掉大量内部实现细节如果要 mock 的太多说明职责纠缠太深。这个清单不是要你死守而是提供一个检查视角。写得多了这些判断就会慢慢变成肌肉记忆。5. 踩坑记录与排查技巧实录5.1 三个真实的尬点我替你们踩过我自己的代码里有三次特别典型的面向对象翻车写出来给大家当镜子。第一次是因为偷懒在控制器里写了三个嵌套 if 处理支付状态。当时觉得就几十行放控制器里省事。后来加了一种新的支付方式那个 action 的逻辑越滚越大测试也从 6 个用例涨到 20 个还是盖不全。最后抽出来一个PaymentStatus对象每个状态转换变成独立方法世界清净了。我的体会是控制器里一旦出现业务判断就是它在向你发出求救信号。第二次是为了方便展示数据给前端给一堆模型加了attr_accessor暴露内部字段。结果前端代码可以直接改状态后端一点保障都没有。后来某个状态下出现了脏数据排查了很久才定位到是外部直接赋值绕过了验证。从此我把暴露写入能力当成一件需要审批的事能不暴露就不暴露。第三次是给ApplicationHelper塞了一个又一个工具方法最后这个 helper 变成了一个混乱的杂物间。Rails 的 helper 本意是处理视图相关的小逻辑但我把业务计算也塞了进去导致视图层开始决定业务结果。折让以后凡是跟订单金额、统计、状态判断有关的一律去模型或服务对象里找视图只负责展示。5.2 测试驱动反而是最好的面向对象教练很多人觉得测试是一种额外负担但从我的实际经验说测试写起来别扭往往恰好暴露了面向对象设计的问题。比如你要测一个下单结算流程发现要 mock 订单、要 mock 库存、要 mock 邮件服务、还要 mock 支付网关用例写起来像在调试一台机器这通常意味着这些逻辑纠缠在了一个大对象里职责不清。把逻辑拆成 Service Object 之后测试对象变成了一个 专注的对象给它假的订单、假的支付网关验证 . 方法行为测试自然清爽了。写测试逼着你思考这个对象需要什么依赖、对外暴露什么接口、内部会不会失控这些问题本身就是面向对象设计问题。所以如果你觉得面向对象很难试着先用测试驱动思路去写为每个需要的行为写一个测试然后让实现最小化地通过测试。你会发现在这个过程里对象边界会自动清晰。5.3 关于心态的一点个人体会面向对象思维不是一个能通过读三天书练成的技能它更像一种肌肉记忆要在真实项目的迭代里反复锤炼。我个人的体感是每次接一个新需求先别急着打开编辑器写代码拿三分钟在脑子里画一画这个需求会让哪些对象发生变化变化是新增了一个对象还是要修改现有对象的行为如果要修改现有对象修改面积有多大当修改面积开始变大时说明旧代码的边界划错了——这时候不要硬塞而是停下来做好重构再续写业务。这个过程一开始很费时间但半年之后你会发现自己写代码的节奏变了不再怕需求变更反而会把每一次需求变更当成检验对象边界的考试。如果你现在正被一堆又长又乱的 Rails 代码逼得想重构我建议你先挑一条最简单的用例练手把里面的业务规则抽成独立对象。等你体会到新需求只是加一个类的快感后你就再也不想回到那个全是 if/else 的世界了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询