ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SOLID原则实战:五个设计准则拆解与代码重构指南

SOLID原则实战:五个设计准则拆解与代码重构指南 写业务代码年头一长都会撞见同一个噩梦某个核心类越改越大最后谁都不敢碰一碰就出线上事故。我手头就维护过一个接近四千行的上帝类里面混着数据库访问、权限判断、消息推送、日志记录甚至还有一段八竿子打不着的 Excel 导出逻辑。后来团队做代码评审一位资深工程师看完只说了句这五个原则你全违反了。我才老老实实把 SOLID 原则从头啃了一遍。SOLID 是程序设计领域最经典的一套设计准则五个字母分别对应单一职责、开闭原则、里氏替换、接口隔离、依赖倒置。它不绑定任何语言也不依赖任何框架核心就解决一件事在需求不断变化的环境里让代码保持可读、可扩展、可维护。这篇文章我想用真实场景和踩过的坑把五个原则逐个拆开讲透再给你一套能直接拿去自查的清单。不管你是写 Java、Python、C 还是前端 TypeScript这套思考方式都值得放进日常。1. 先搞清楚SOLID 五原则到底在解决什么问题1.1 五个字母各自对应的代码坏味道SOLID 这个名字来自 Robert C. Martin江湖人称 Uncle Bob在 2000 年代初总结的面向对象设计原则后来由 Michael Feathers 提取首字母组成这个缩写。它本质上不是一套正确的废话而是针对五类最典型的代码坏味道开出的药方。我习惯把它们翻译成五个直击要害的问题原则首字母核心关注点对应代码坏味道单一职责 Single ResponsibilityS一个类/模块只有一个被修改的理由上帝类、万能类越堆越胖开闭原则 Open-ClosedO对扩展开放对修改关闭每次加需求都要翻旧代码里氏替换 Liskov SubstitutionL子类必须能完全替换父类继承关系形同虚设行为悄悄变味接口隔离 Interface SegregationI客户端不依赖用不到的方法胖接口实现类塞满空实现依赖倒置 Dependency InversionD依赖抽象不依赖具体实现高层代码到处 new 底层对象这张表也是我后来做架构评审的起手式。一旦发现某段代码和表格右侧的坏味道对上了我就会按对应的原则往下追问一般三五个问题就能把问题定位到具体类和方法。1.2 为什么现在大家都在反复强调这套原则五原则虽然源自面向对象但真正讲的是变更管理。一个项目活过两三年最大的成本往往不是最初写代码的时间而是每次需求变更带来的理解成本、改动成本和回归风险。没有原则约束的代码会随着人月增长从单体大泥球慢慢变成服务大泥球本质都是因为变更冲击没有节点可以缓冲。我在团队里见过最常见的现场是这样的新需求来了开发打开一个老类从第一行看到第五百行然后在某个 if 分支里硬插一段新逻辑跑通测试就算完成。三个月后同一个文件里堆了十几段互相纠缠的业务规则加一个新渠道要改五处地方漏改一处就出线上问题。这种能跑就行的写法和 SOLID 倡导的写法差距不在代码风格而在风险控制。老代码每被改一次之前的所有验证就要打折扣重来改错的风险是指数级上升的。其实很多初学者会觉得这些原则太抽象学完就忘。我的经验是不要试图一次性用上全部五条。先拿最近改过三次以上的那个类开刀用下面每一节的自查问题去套每套出一条记录一条改着改着原则就内化了。2. 单一职责原则最容易被理解偏的一环2.1 一句话定义和一个反直觉的真相单一职责原则英文 Single Responsibility Principle指一个类或模块应该只有一个被修改的理由。注意这里说的是理由而不是功能。一个记账类里有两个方法一个给财务算工资一个给 HR 算考勤功能不同但都可能因为公司薪酬规则更新而需要同时修改那它们其实是同一份职责。反过来一个 UserService 既有保存用户又有发送邮件看着都跟用户相关但前者会因为改了数据库表结构而变后者会因为换了邮件服务商而变——这是两个不同的变更来源。最容易跑偏的理解是把 SRP 当成一个类只干一件事。按这个思路一个注册类最好拆成几十个只响应用户注册的小类结果就是业务流转被拆得七零八落看代码的人得在十几个文件之间来回跳。判断职责是否应该被拆开真正标准是会不会因为同一个人的同一个需求就必须同时改这两个地方如果会它们应该在一起如果完全不会才考虑拆开。这里有个生活化的类比一家餐厅的后厨切菜、炒菜、洗碗分成不同岗位是因为它们的变更来源不同——食材涨价改切配标准菜品升级改炒制流程不会互相干扰。但如果一家小店就三个人你非要分出十个岗位那人效反而更低。职责边界要跟着实际规模和变更频率走不是越细越好。2.2 实战案例把上帝类拆成协作模块先看反面例子当初我们项目里的用户服务大致长这样class UserService: def __init__(self): self.db Database() self.email_client EmailClient() self.logger Logger() def register(self, user): if not user.email: raise ValueError(邮箱不能为空) self.db.insert(users, user) self.email_client.send_welcome(user.email) self.logger.info(f用户 {user.id} 注册成功) return user这段代码的问题在于 register 方法里同时出现了参数校验、数据库写入、邮件发送、日志记录四件事。假如产品说注册之后别发欢迎邮件了改成用户第一次登录时再发你得改 UserService假如 DBA 说users 表要加两个字段你还是要改 UserService。一个类被两拨毫不相干的需求反复修改这就是典型的 SRP 违约。重构后的结构会把数据库操作、通知职责、日志职责分别放到独立模块由外层把协作关系组装起来class UserRepository: def insert(self, user): ... class WelcomeNotifier: def send(self, user): ... class UserRegisterService: def __init__(self, repo: UserRepository, notifier: WelcomeNotifier): self.repo repo self.notifier notifier def execute(self, user): self.repo.insert(user) self.notifier.send(user)这样注册这个业务用例仍然保留在一个入口但数据库再怎么改不会牵动通知逻辑邮件服务商怎么换也不会影响用户数据的处理。改动范围被关进了各自的笼子里。2.3 实操心得SRP 的拆分边界怎么拿捏拆分的粒度永远是个灰色地带。我踩过最深的坑是在一次项目里被要求把职责分到极致结果一个最简单的字段校验需求要改 9 个文件才能完成。那次之后我总结了一个贴近实际的判断方式当你改一个需求时数一数必须打开的文件数。理想状态是一个业务变更对应一到两个核心文件如果改动一个字段要横跨五六个文件那多半已经过度拆分或者分层本身就出了问题。还有一个很实用的团队纪律职责的边界要和变更的实际发出者对齐。财务部门提出的规则变更、运营部门提出的通知模板变更、技术团队提出的表结构变更这三类人各自关心的代码区域就应该是天然的边界。你可以顺着这个思路去检查自己的类一个类被三个以上互不相干的角色改动时不管它有多少行都该考虑拆了。类的大小从来不是硬指标类胖但理由单一比类瘦但理由混杂要健康得多。3. 开闭原则为扩展留门为修改设障3.1 为什么不改旧代码这么重要开闭原则英文 Open-Closed Principle说的是软件实体应当对扩展开放、对修改关闭。翻译成人话加新功能的时候尽量别去动已经稳定、已经上线、已经通过测试的老代码而是通过增加新代码来完成扩展。这个原则的本质是保护已验证的稳定。一段代码一旦上线说明它经过评审、测试和线上验证这时候为了一个新需求去改它哪怕只改一行也意味着之前所有的验证都要重来而且改动很可能引入回归 bug。我见过太多事故都是顺手改了一下老函数引起的。反过来如果你设计了一个清晰的扩展点加新类型就变成新增一个新类老代码一行不动风险就只局限在新代码里评审轻松回滚也容易。如果你维护过那种线上跑了三年的老系统一定会理解这种感觉老模块里的每一行都像高压电线谁也不想伸手。而 OCP 要做的就是给高压电线包上绝缘层让新接线员也能安全地接入新设备。3.2 落地手法多态、策略模式与组合式扩展最直接的落地方式是找出变化封装变化。看一个常见的折扣逻辑# 违反开闭原则的写法 def get_total_price(cart, user_type): total cart.total() if user_type visitor: return total if user_type vip: return total * 0.9 if user_type svip: return total * 0.8 return total每新增一种用户类型就得打开这个函数加一个分支。跑一段时间后这个函数会越来越长每个分支还带着自己的边界条件任何改动都可能碰坏别人的规则。用策略模式重构class DiscountPolicy: def apply(self, total): raise NotImplementedError class VisitorPolicy(DiscountPolicy): def apply(self, total): return total class VipPolicy(DiscountPolicy): def apply(self, total): return total * 0.9 def get_total_price(cart, policy: DiscountPolicy): return policy.apply(cart.total())此后新来一个黑金会员不用改 get_total_price 一行代码只需要新增一个 BlackGoldPolicy 类然后在配置里注册。新代码进来老代码纹丝不动测试也只增不减。组合优先于继承这里得多说一句。很多人以为 OC 就是定义好基类、到处继承。实际经验里用组合把一个策略对象作为参数传进去往往比继承更灵活因为你可以在运行时自由切换策略而继承关系在编译期就定死了。把变化点做成一个协议/接口再提供多个实现比维护一棵深继承树安全得多。3.3 现实项目里的扩展点陷阱OC 也不是越抽象越好。某些框架党的毛病是给每个类都预留扩展点抽象接口一层叠一层结果原作者自己都要找半天才能发现真正落地的实现。抽象本身是有开销的文件变多、跳转变深、新人理解成本上升。如果一个扩展点长期无人使用它就是在给所有阅读者增加认知负担。我建议遵循三次法则同一个地方被改到第三次才值得去抽象扩展点。第一次写死没关系第二次复制粘贴也还能忍第三次出现时未来的样貌基本能看出来了这时候再引入策略或接口收益最大。过早抽象和从不抽象一样危险开闭原则的目标是让扩展变得便宜而不是让一切看起来都很抽象。4. 里氏替换与接口隔离继承和接口的正确打开方式4.1 里氏替换原则子类凭什么能替代父类里氏替换原则由 Barbara Liskov 提出核心是一句话如果一个类型 S 是类型 T 的子类型那么所有期望 T 对象的地方都应该能安全地用 S 对象替换行为不能出岔子。最经典的违反案例是正方形继承长方形。数学上正方形确实是特殊的长方形但在代码里这么建模会出事class Rectangle: def __init__(self, width, height): self.width width self.height height def set_width(self, width): self.width width def set_height(self, height): self.height height def area(self): return self.width * self.height class Square(Rectangle): def set_width(self, width): self.width width self.height width # 保持正方形约束 def set_height(self, height): self.width height self.height height假设有一段代码调的是 Rectangle 的 set_width、set_height然后断言面积等于两者乘积。对普通 Rectangle 成立对 Square 就不成立。你把 Square 当 Rectangle 用行为立刻歪掉。这就是里氏替换原则被破坏的典型特征继承关系在静态概念上说得通但在运行行为契约上说不通。更贴近业务的例子是鸟类。如果定义一个 Bird 类里面放 fly()然后让 Penguin 继承 Bird企鹅就没法支持 fly()——要么抛异常要么空实现。这是典型的 LSP 违约。正确的做法是区分会飞的鸟和不会飞的鸟或者直接定义一个 Flyable 协议让会飞的鸟各自去实现。4.2 接口隔离原则别让实现类吃哑巴亏接口隔离原则强调客户端不应该依赖它不需要的接口方法。换句话说接口要尽量小、尽量内聚别做成一个全包圆的胖接口。看一个非常经典的机器人工人例子class Worker: def work(self): ... def eat(self): ... def sleep(self): ... class HumanWorker(Worker): def work(self): ... def eat(self): ... def sleep(self): ... class RobotWorker(Worker): def work(self): ... def eat(self): raise NotImplementedError(机器人不用吃饭) def sleep(self): raise NotImplementedError(机器人不用睡觉)RobotWorker 被迫实现一堆自己用不上的方法每次别处调用 eat() 还要担心会不会抛异常。更合理的切法是拆成多个内聚接口class Workable: def work(self): ... class Restable: def eat(self): ... def sleep(self): ... class HumanWorker(Workable, Restable): ... class RobotWorker(Workable): ...这样 HumanWorker 和 RobotWorker 各取所需谁也不背空实现的包袱。接口一拆实现类的语义立刻清晰这个机器人到底能干嘛一眼便知。4.3 两个原则在团队协作里的真实表现里氏替换和接口隔离是孪生兄弟一个管继承行为的正确性一个管接口边界的精纯度。在团队里它们的破坏往往不是一次性爆雷而是慢慢累积的。你今天让 TvBox 接口多了一个 stream() 方法明天智能音箱实现类就得写一个 stream() 空实现后天新增的 IoT 设备又得带着这个历史包袱。问题源头都是最初设计接口时没问一句这个接口的所有实现类真的都需要所有方法吗检查接口隔离有个很便宜的技巧数一数项目里抛 NotImplementedError 或 NotImplementedException 的地方有多少。如果一抓一大把基本可以断定接口胖了。再数数重写父类方法时偷偷改了返回值语义的地方这类隐藏的 LSP 破坏单测里经常测不出来要等线上出了诡异 bug 才被发现。我在评审里看到这两类信号都会直接拉着作者坐下来聊几分钟通常五分钟就能定位到问题代码。5. 依赖倒置原则别让高层代码仰望底层实现5.1 核心逻辑抽象不要反过来被细节绑架依赖倒置原则原文有两条高层模块不应该依赖低层模块两者都应该依赖抽象抽象不应该依赖细节细节应该依赖抽象。初看很绕我一般用插座和电器来类比。插座是抽象接口热水壶、吹风机、手机充电器都只依赖插座这个标准而不是互相依赖各自的具体端子。你的高层业务流程也应该像房间里的插座一样稳定具体的电器数据库、第三方 API、消息队列想插就插、想换就换。看一个反面例子class NotificationService: def notify(self, user, message): sender EmailSender() # 直接 new 底层对象 sender.send(user.email, message)如果有一天要加短信通知这个类就被迫修改。按 DIP 改法是让高层依赖一个抽象的 Sender底层的 EmailSender、SmsSender 都实现这个抽象NotificationService 通过构造参数传进来class MessageSender: def send(self, recipient, message): ... class EmailSender(MessageSender): ... class SmsSender(MessageSender): ... class NotificationService: def __init__(self, sender: MessageSender): self.sender sender def notify(self, user, message): self.sender.send(user.contact, message)改动之后NotificationService 完全不知道发送的细节邮件、短信、站内信都可以往里塞配置在组装层决定用哪个实现。高层逻辑稳定底层实现可替换这就是依赖倒置的直观效果。5.2 依赖注入、控制反转和容器到底啥关系聊 DIP 时很多人会同时听到依赖注入DI、控制反转IoC和容器这些词。简单理一下DIP 是设计原则告诉你要面向抽象依赖注入是落地手法具体做法是把依赖通过构造函数、setter 或参数传进来而不是在类内部 newIoC 是更宏观的思想把谁来创建对象谁来决定对象生命周期的控制权从业务代码手里交出去容器则是 IoC 的具体工具实现比如 Spring、Guice。我的建议是从构造函数手动注入开始别一上来就上容器。手动注入的好处是对象关系一目了然IDE 能帮你在调用处看到谁 new 了谁出了问题也容易定位。等项目的组装逻辑复杂到手动维护很痛苦时再考虑引入容器。上来就引入容器最常见的后果是谁都在用自动装配但没人说得清组件之间是怎么连线、靠的是什么值出了问题变成一场寻宝游戏。5.3 依赖倒置容易踩的三个坑第一个坑是把依赖抽象做成接口满天飞。如果只有一个实现类接口可以先不建等第二个实现真的出现时再提取不迟。在接口设计上YAGNI 和 SOLID 并不矛盾过早抽象同样是坏味道。第二个坑是拿容器当全局变量乱用。比如在一个纯计算模块里也去容器里取一个 Service这会让模块的纯函数性质被破坏测试变得非常难写。容器应该在组装层出现而不是散落在业务逻辑里。第三个坑是依赖注入链太长一个构造函数塞进去七八个对象。这时候往往意味着这个类本身职责过多应该回头检查 SRP而不是继续硬塞。依赖倒置解决的是耦合问题不是逻辑堆积问题。链太长就该反思是不是这个类又在不知不觉中长成了小号上帝类。6. 串起来用一个订单模块的 SOLID 重构实录6.1 一份五毒俱全的原始代码前面五节把原则拆开讲你可能会觉得它们是五张互不相关的处方。其实在真实项目里五个原则常常扎堆出现在同一段代码里处理起来也要一起动手。我拿订单模块举个例子先给你看一份五毒俱全的原始代码class OrderService: def process(self, order): # 1. 计算折扣 - 直接用 if 判定用户类型 if order.user.level svip: order.total * 0.8 elif order.user.level vip: order.total * 0.9 # 2. 保存订单 self.db.save(order) # 3. 凑合发个通知直接 new sender EmailSender() sender.send(order.user.email, f订单 {order.id} 支付成功) # 4. 顺手更新对账报表 self.report.add(order) return order这段代码几乎把所有原则都违反了一遍OrderService 同时处理订单状态、折扣规则、持久化、通知、报表任何一方变动都会改它SRP 违反新增用户类型要改 process 内部OCP 违反通知被硬编码成邮件替换成短信只能改 processDIP 违反process 直接使用报表对象的一大堆方法ISP 隐患而用户等级 if/switch这种字段驱动多分支的模式一旦未来对某种用户做特殊折扣就很容易写出歪掉的继承LSP 隐患。6.2 每一步重构对应哪条原则第一步先把折扣逻辑抽出去做成 DiscountPolicy 族process 接收一个 policy 参数。这一步落地 OCP新增折扣类型不再碰 OrderService。第二步把订单保存挪到 OrderRepository。后续表结构变更只发生在 OrderRepository 内部OrderService 完全不知道底层是不是 MySQL。这一步落地 SRP。第三步把通知依赖抽象化。OrderService 构造函数接收一个 MessageSender不再 new EmailSender。这一步落地 DIP顺手解决了将来接短信、企业微信渠道的问题。第四步把报表更新从 process 里摘出去用事件或回调机制让对账模块自己去订阅订单已支付。这一步同时改善了 SRP 和 ISPOrderService 不再需要知道对账模块的细节。第五步检查有没有不该有的继承把一切通过用户类型字段 if/switch实现的分支往策略方向收一收从源头避免 LSP 问题。重构后的结构大概是这样class OrderService: def __init__( self, repo: OrderRepository, policy: DiscountPolicy, sender: MessageSender, report_hookNone, ): ... def process(self, order): order.total self.policy.apply(order.total) self.repo.save(order) self.sender.send(order.user.contact, f订单 {order.id} 支付成功) if self.report_hook: self.report_hook(order) return order6.3 重构后的收益是能摸得着的这类重构做完最直观的变化是改什么都只动一处。加一个新用户等级等于加一个 DiscountPolicy 类再加一行注册接一个新的通知渠道等于加一个 MessageSender 实现数据库从 MySQL 换成 PostgreSQL 只改 OrderRepository。需求方不理解什么叫 SOLID但他们一定能理解这个需求从两周变成三天线上事故从每月一次变成半年一次。还有一个容易被低估的收益新人上手成本。重构之前新人看 OrderService 第一眼就是一团乱麻重构之后文件数量虽然多了但每个文件的职责一目了然新人可以按策略 → 仓储 → 通知的路径快速建立心理模型。代码的可维护性说到底就是看代码时需要同时记住多少件事。7. 常见问题与排查技巧实录7.1 SOLID 应用误区速查表常见误区实际正解我的判断方法SRP 类越小越好类应该只有一个被修改的理由这个类会被几类不相关需求反复修改OCP 用继承做扩展组合优先策略/接口更灵活扩展时老文件是否一行不动LSP 继承关系别乱搭子类必须保持父类的行为契约把子类放进父类的测试套件跑一遍过了才算ISP 接口拆得越细越好接口只放调用方必须依赖的行为实现类里 NotImplementedError 多不多DIP 一见 Singleton 就浑身难受高层依赖抽象具体实现可替换new 出来的底层对象能不能被参数替换这张表建议存一下评审前扫一眼基本能帮新手堵住 80% 的原则性错误。7.2 哪来这种症状说明你已经过度设计了读到这里估计有读者会产生一种焦虑我的代码是不是违反了一百遍 SOLID我可以负责任地讲真实项目里完全没有违反的几乎不存在包括我现在维护的项目也有不少历史债务。重要的不是零违规而是知道哪里违规、为什么违规、要不要现在修。如果你发现自己在为了更符合原则而疯狂加抽象请停下来做三件事一去掉这个抽象代码可读性变好了还是变差了二未来三个月内这个扩展点真的会被用到吗三改一次业务需求需要碰的文件数量是变多还是变少我见过一个项目为了 OCP 把打折策略抽象了四层最后真正改需求时反而要看六个文件那已经不是编程是行为艺术。原则是用来服务业务的不是反过来让业务给原则打工。7.3 团队评审时怎么快速发现违规最后给你一套可以贴在工位上的代码评审自检清单。我每次做交叉评审都会把这五条按顺序过一遍绝大多数 SOLID 违规都逃不掉这个类最近被改过几次每次的改动原因是不是同一个方向加一个新特性你会选择新建文件还是打开这个类加 if某个子类在父类的位置上运行时有没有可能出现意料之外的结果接口里有没有实现类被迫空实现或者抛 NotSupported 的方法高层代码里有没有直接 new 一个不想被替换的底层实现只要五条里有两三条答案不对这块代码就该拿到架构评审的台面上聊一聊。实际评审经验里这类问题越早发现越便宜等代码发布三个月后再回头修成本至少翻三倍。8. 个人实操体会SOLID 学了几年我的最大感受是它更像一套治疗的哲学而不是体检的指标。上面给了很多清单和方法但真到现场你还是会遇到一身反例老系统遗留代码不能动、技术债务排期遥遥无期、领导要求三天上线一个新渠道。这时候硬套原则只会让自己崩溃。我的建议是挑收益最大的一个点先动通常从 OCP 开始最划算因为加需求不改老代码最容易量化也最容易在评审时说服别人。还有一个我私藏的小技巧每次拿到新需求先问自己一句这个需求要改多少旧文件然后试着把答案压到最小。如果发现需要改的老文件数量太多说明边界切错了如果压不下去就把那个改动必经的旧文件打开来仔细看看里面有没有能抽出去的变化点。这样一个需求一个需求地磨SOLID 就会慢慢长在手上而不是停在大脑里。
返回列表