ARTICLE DETAIL

资讯详情

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

SOLID原则实战拆解:从设计理论到业务代码落地

SOLID原则实战拆解:从设计理论到业务代码落地 写代码这事儿做到后面你会发现真正拉开水平差距的往往不是语法多熟练、框架多花哨而是对设计原则的把握。SOLID原则是面向对象程序设计里流传最广的一套设计准则面试必问代码评审也绕不开。但我在实际带团队和做技术评审时发现一个现象很多人能背出五个字母的完整拼写却不知道它们在自己的业务代码里到底该怎么落地。要么写的时候完全忘掉要么矫枉过正把一个本来好好的类硬拆成七八个文件代码量没少反而更难维护了。这个教程我想换个讲法不跟你逐条背定义而是把SOLID放到真实的业务场景里逐条拆开讲清楚它解决什么问题、怎么判断自己写歪了、具体怎么修正。同时会配上可以直接参考的示例代码和排查思路。如果你正在写业务代码开始觉得类越来越臃肿、改一个小功能牵一发动全身或者正在准备面试想真正理解SOLID这篇文章应该能帮到你。1. 先建立整体认知SOLID到底在解决什么1.1 五个字母分别对应哪五件事SOLID是五个原则首字母的合称。很多教程喜欢直接给出五条定义但理解效果往往不好。我先用大白话把每一句话点一下后续每个原则再展开讲。SSRP单一职责原则一个类只负责一件事只有一个引起它变化的原因。OOCP开闭原则对扩展开放对修改关闭。新增功能时尽量加新代码而不是反复改旧代码。LLSP里氏替换原则子类必须能替换掉父类并且程序行为不能被破坏。IISP接口隔离原则客户端不应该被强行依赖它用不到的方法。DDIP依赖倒置原则高层模块不依赖低层模块两者都依赖抽象。你会发现一个有意思的细节这五条原则虽然名字都不一样但它们服务的终极目标高度一致——降低代码的耦合度提升可维护性。所有原则的落脚点都是为了让系统在需求变化时尽量少改动、少出bug、少互相牵连。你可以把它们看作同一个目标下的五个不同视角。1.2 用一条业务主线贯穿五条原则纯讲概念容易飘我后面所有的示例都会围绕一个非常常见的业务场景订单系统。这个系统里有订单实体、订单计算、库存扣减、用户通知、数据存储。之所以选它是因为订单系统几乎包含了业务开发的典型元素有实体、有计算逻辑、有外部依赖、有策略分支而且大家都很熟悉不需要额外铺垫业务背景。在这个场景下我们会一步步看到SOLID的五条原则分别怎么介入类变臃肿时用SRP拆新增优惠活动时用OCP扩展继承用户类型出问题时用LSP修正接口方法太多时用ISP隔离数据存储方案切换时用DIP解耦。等你走完这一整条线SOLID就不再是五个孤立的概念了而是一套在同一个项目里协同工作的设计方法。2. 单一职责原则SRP一个类只有一个改变的理由2.1 它和“一个函数只干一件事”是两码事先说一个常见的误解。很多人以为SRP说的是“函数体要短、一个方法只做一件事”这是把函数设计原则和类设计原则搞混了。函数层面的“单一职责”更多是控制复杂度和可读性而SRP针对的是类。**SRP的定义是一个类应该只有一个引起它变化的原因。**也就是说当你需要修改这个类时应该只因为一个业务维度上的原因去改它。如果你的订单类今天因为折扣规则变化要改明天因为库存流程变化要改后天因为通知文案变化还要改那这个类就已经承担了至少三个“职责”——它有了三个不同的变化来源。你可能会想那一个类里有好几个方法不是天经地义的吗没错方法的数量不重要重要的是这些方法是不是在围绕同一个职责服务。比如一个订单计算类里有计算商品小计、计算运费、计算优惠它们都服务于“算钱”这个角色那这些方法再多也没问题。但如果在同一个类里既算钱又查库存还发短信就是三个角色混在一起了。2.2 落地判断这个类里到底有几个“角色”判断一个类是否违反SRP我有一个很实用的方法**不要看它做了几件事而是看它是从哪几个角度变化的。**你可以问自己一个问题如果接下来一个月产品经理提了三个不同的需求分别改了价格、库存、通知文案这个类会被改几次价格变了它要改。库存逻辑变了它要改。通知文案变了它还要改。一个类因为三个完全不相干的业务方向而修改这就是SRP被破坏的最直接信号。反过来如果这个类只负责价格计算那么无论库存系统怎么折腾、通知平台怎么换它都纹丝不动这才算做到了单一职责。辅助判断还可以看类的命名。如果你发现自己很难用一句话准确命名一个类比如只能叫OrderService这种含糊名字甚至叫OrderHelper、OrderUtil那大概率是职责混在一起了。常见的命名陷阱是“把所有跟订单沾边的方法都塞进OrderService”最后这个Service成了一个大杂烩。2.3 实操示例拆分一个臃肿的订单服务我经常在代码评审里看到类似下面这个类它乍一看很完整实际上把所有能做的事都揽进来了。示例用类Python伪代码写你可以在C、Java、C#里找到对应的表达class OrderService: def create_order(self, cart): # 校验商品 for item in cart.items: if item.stock 0: raise Exception(f{item.name} 库存不足) # 计算价格 total 0 for item in cart.items: total item.price * item.quantity if cart.coupon: total self.apply_coupon(total, cart.coupon) # 扣减库存 for item in cart.items: item.stock - item.quantity # 生成订单 order Order(...) # 保存订单 db.save(order) # 发送通知 sms.send(f您的订单已创建金额{total}) return order这个类看起来很连贯但计算、校验、持久化、通知全在一个方法里。一旦通知平台从短信换成邮件或者新增一个满减活动你都要动这个已经测试过的核心流程。正确的做法是按职责拆成多个类class ProductValidator: def validate_stock(self, cart): ... class PriceCalculator: def calc_total(self, cart, coupon): ... class StockManager: def deduct(self, cart): ... class OrderRepository: def save(self, order): ... class OrderNotifier: def send_created_message(self, order): ...拆分后OrderService只需要负责编排class OrderService: def __init__(self, validator, price_calc, stock_mgr, repo, notifier): ... def create_order(self, cart, coupon): self.validator.validate_stock(cart) total self.price_calc.calc_total(cart, coupon) order self.repo.save(cart, total) self.stock_mgr.deduct(cart) self.notifier.send_created_message(order) return order这么做带来的直接好处是下次产品说“通知加一个站内信”你只改OrderNotifier动都不动价格和库存的代码。我在实际项目里发现拆完之后每个类都能起一个准确的名字代码评审的时候别人一眼就能看出这个类是干什么的沟通成本明显降下来了。不过这里也要提醒一句别把SRP当作拆类的KPI。有些开发者为了追求“拆分”把一个原本合理的小类继续切开结果是满屏的微类找逻辑要找五六个文件。拆到哪个程度合适我的判断标准是当这个类存在两个以上“独立变化方向”时再拆。如果两个方法永远一起变它们留在同一个类里反而更合适。3. 开闭原则OCP用扩展来应对变化3.1 所谓“开”与“闭”的平衡点开闭原则的原文是“软件实体类、模块、函数应该对扩展开放对修改关闭”。这句话每年面试都要被翻出来考一次但它也是被误解最严重的一条。很多新手一听“对修改关闭”就觉得以后代码不能改了这显然不现实。项目的需求天天在变怎么可能不修改代码**开闭原则真正想表达的意思是当你新增功能时应该尽量通过添加新代码来实现而不是反复修改已有的、经过验证的代码。**核心手段是抽象和多态。我举个生活中的例子。家里的插座就是标准的开闭原则电器要新增不需要拆墙改线路只需要生产一个新的插头就能接入。插头是“扩展”墙里的线路是“稳定不变的”。对应到代码里我们要设计出稳定的“插座”让新的业务可以插进来而不是每来一个新需求就把旧的逻辑删除重写。3.2 策略模式实战把折扣计算从if-else地狱里捞出来订单系统里最常见的OCP破坏现场就是折扣计算。一开始只有一个普通折扣后来加了满减再后来加了会员折扣、新人价代码很快变成这样def calc_discount(self, order, user): if user.is_new: return order.total * 0.9 elif user.is_vip: if order.total 200: return order.total * 0.8 else: return order.total * 0.85 elif order.has_coupon: return order.total - order.coupon.amount else: return order.total这段代码的恐怖之处在于每新增一种营销玩法你都要打开这个方法在中间插入一个新的elif分支。改着改着突然发现某个分支的顺序影响结果了或者老用户突然享受到了新人价。这个函数就是一座不断加高的大楼每一层都有塌方的风险。用开闭原则的思路改造方向是把每一种折扣策略封装成一个独立的类让它们在同一个抽象接口下可替换。class DiscountStrategy: def apply(self, order, user) - float: raise NotImplementedError class NewUserDiscount(DiscountStrategy): def apply(self, order, user): return order.total * 0.9 if user.is_new else order.total class VipDiscount(DiscountStrategy): def apply(self, order, user): if user.is_vip and order.total 200: return order.total * 0.8 return order.total class CouponDiscount(DiscountStrategy): def apply(self, order, user): if order.has_coupon: return order.total - order.coupon.amount return order.total然后由一个工厂或配置决定当前用户适用哪种策略def get_strategy(user, order): if user.is_new: return NewUserDiscount() elif user.is_vip: return VipDiscount() return CouponDiscount()这样改完之后下一次产品新增一个“618大促折扣”你只需要新建一个PromoDiscount类然后在工厂里加一行。原来的VipDiscount、CouponDiscount代码一行都不用动。它们已经被测试过了不会被你无意中改坏。这就是开闭原则的价值所在保护已经稳定运行的代码把变化圈在一小块新代码里。3.3 操作心得别急着给所有东西套OCP不过我必须泼一盆冷水。开闭原则虽然好但它是SOLID里最容易被滥用的原则。我见过太多人为了“对扩展开放”第一版代码就疯狂抽象接口、套策略模式结果项目都上线半年了策略只有一种接口倒是有五个实现类纯属给自己增加阅读负担。我的经验是**先写清晰的if-else等到分支真正出现了重复的结构或者你预测到近两周内业务会扩展再考虑抽象。**开闭原则的落地不是银弹它需要一个前提——你判断出变化的方向。如果变化方向都不清晰抽象出来的接口很可能是错的到时候重构成本更大。做技术评审的时候我经常问提代码的同事一个问题你有几个真实的扩展场景如果说不出第二个那这个策略模式大概率是过度设计。好的抽象是等出来的不是设计出来的。4. 里氏替换原则LSP继承不是用来炫技的4.1 子类对父类的一种“承诺”里氏替换原则的定义相对学术如果S是T的子类型那么T类型的对象可以被S类型的对象替换而不会改变程序的正确性。翻译成人话就是凡是能用父类的地方你换上子类程序不该表现异常。这个原则的本质是一种“契约”。子类继承父类实际上是在承诺我不会比父类做得更少我不会抛出父类没有的异常我不会修改父类方法的基本行为。一旦破坏这个承诺调用方就会在不知情的情况下拿到预料之外的行为。我常用一个比喻去理解LSP父类是一份“产品说明书”子类是这份说明书的“补充条款”。补充条款可以增加细节但不能推翻说明书里已经写死的核心功能。如果子类连基本功能都给改了那说明书还有什么意义4.2 经典反例鸵鸟、正方形与“坏掉的鸟”LSP最著名的反例有两个。第一个是正方形继承长方形。长方形有宽和高可以分别设置正方形要求宽高必须相等。如果你在子类里重写set_width方法强行把高度也改了那所有依赖“先设宽再设高最后算面积”的调用方都会得到错误结果。第二个是鸵鸟继承鸟。鸟类的基类有fly()方法鸵鸟不会飞要么重写成一个空方法要么直接抛异常。而外部代码遍历所有“鸟”调用fly()时就会出问题。订单系统里也有类似的场景。假设基类是User普通用户下单要满99才免运费VIP用户无门槛免运费。有人图省事直接让VipUser继承User然后重写一个calcShippingFee方法返回0。表面看没问题但如果后续代码是这样写的def checkout(user, order): if isinstance(user, VipUser): fee 0 else: fee order.calc_default_shipping()这段代码里已经出现了isinstance判断说明“子类替代父类”已经不安全了。一旦某个新来的同事不知道这个潜规则直接拿VipUser走普通逻辑VIP用户就会被收运费。只要你在业务代码里频繁用isinstance去区分父类和子类那基本可以断定LSP被破坏了。4.3 修正方案该怎么调整继承结构遇到LSP被破坏的情况常见的修法有两个方向。方向一把公共行为收敛到基类差异放到子类。以运费为例正确的做法不是让VIP子类重写父类方法返回0而是让父类定义一个可覆盖的“运费计算”接口并保证子类的计算结果不会违背“运费非负”的基本约束class User: def __init__(self, level): self.level level def shipping_fee(self, order_total): raise NotImplementedError class NormalUser(User): def shipping_fee(self, order_total): return 0 if order_total 99 else 10 class VipUser(User): def shipping_fee(self, order_total): return 0这样外部代码只需要依赖User这个抽象类型调用shipping_fee()完全不需要isinstance。无论传入的是普通用户还是VIP用户逻辑都是统一的。方向二偏好在组合而不是继承。很多时候你会发现“VIP用户”本质上不是“一种用户”而是“用户身上贴了一个标签”。这时候用组合比继承更合适class User: def __init__(self, profile): self.profile profile class UserProfile: def __init__(self, is_vip): self.is_vip is_vip def shipping_fee(self, order_total): if self.is_vip: return 0 return 0 if order_total 99 else 10**“组合优先于继承”不是一句口号它能帮我们从源头上规避大量LSP问题。**需要继承的时候请先确认子类是否真的满足了“is-a”关系而不是“has-a”关系。在评审代码时我会要求所有继承必须同时说明子类比父类多做了什么它会不会改变现有方法的行为这两个问题答不上来就先别用继承用组合顶着。5. 接口隔离原则ISP别让调用方拿着用不到的筹码5.1 胖接口是怎么一点一点形成的接口隔离原则简单说就是**客户端不应该被迫依赖它不使用的接口方法。**很多人会把它和SRP混淆但它们视角不同。SRP关心类的职责是不是太多ISP关心的是接口的使用者是不是被塞了一堆用不到的方法。“胖接口”在项目里几乎都是这么形成的一开始接口里只有一个方法A后来有人需要一个方法B就往同一个接口里加再后来有人需要方法C再加。没人愿意为此新建接口因为“加一个方法看起来成本最低”。结果就是接口越来越胖实现类被迫写一堆空实现或者直接抛NotSupportedException。我举个业务例子。假设有一个Worker接口表示能干活的人class Worker: def work(self): ... def eat(self): ...普通员工类需要实现这两个方法没问题。但如果你引入了一个RobotWorker它只会work()不会eat()。你被迫让RobotWorker实现一个空方法或者抛异常。这时候调用eat()的外部代码如果拿到一个RobotWorker程序就乱了。把这个例子映射到订单系统如果你的订单流程接口里有calculateTotal()、applyCoupon()、printReceipt()三个方法而后台订单不需要打印小票那后台订单实现类就必须写一个空printReceipt()。将来如果有人加了打印逻辑并修改了printReceipt()后台订单也会跟着受影响。这就是在支付接口隔离原则的成本。5.2 实操示例拆分工作接口正确的做法是把胖接口拆成独立的小接口让每个客户端只依赖它真正需要的部分。class Workable: def work(self): ... class Eatable: def eat(self): ...class HumanWorker(Workable, Eatable): def work(self): ... def eat(self): ... class RobotWorker(Workable): def work(self): ...这样RobotWorker就只需要关心work()。调用eat()的地方只接受Eatable类型的对象它不可能收到机器人。类型系统本身就在帮你挡掉一部分编程错误。这个方案的额外好处是未来如果出现一个只吃饭不干活的“客人”类它也能直接复用Eatable。在代码评审里我判断ISP是否被破坏的方法很直接**看实现类里有多少方法是空实现、抛异常或者只是return null的。**一旦超过一个就说明接口的抽象粒度有问题。这时候就需要按调用方维度对接口进行拆分。拆分时不要按“功能模块”拆而是按“调用方角色”拆——谁用得到哪些方法就把哪些方法打包成一个接口。5.3 与SRP的边界感很多人看完ISP会觉得它不就是SRP换个说法吗其实区别还是很清楚的。SRP关注的是一段代码本身承担的职责也就是“类内部的状态和行为的聚合是不是合理”ISP关注的是接口对外暴露的能力是不是最小化也就是“调用方看到的契约是不是被污染了”。一个类完全可以只负责一件事但它的对外接口方法设得太宽照样违反ISP。打个比方。食堂窗口负责打饭打菜它的职责是“出餐”这是SRP关心的。但窗口旁边却不必要地挂着“维修公示”“员工排班表”来打饭的顾客根本用不到这些信息这是ISP关心的——接口暴露的信息超出了使用者的需要。实际开发中这两条经常同时出现修正方案也经常重叠所以我会把这两个原则放在一起审视但心里的判断标尺是不同的。6. 依赖倒置原则DIP让高层不再被细节绑架6.1 “倒置”倒的是什么依赖倒置原则是SOLID里最抽象的一条它的原文也比较绕高层模块不应该依赖低层模块两者都应该依赖抽象抽象不应该依赖细节细节应该依赖抽象。我先解释这个“倒置”是怎么来的。在传统的分层开发里业务层高层会直接调用数据访问层低层的类这就是一种“高层依赖低层”。而DIP要求把这个依赖关系倒过来高层不直接依赖低层的具体类而是依赖低层抽象出来的接口低层的具体类反过来去实现这个接口。用一个生活例子说明。你的笔记本电脑需要供电但你的电脑插头上不会写着“必须使用XX品牌电厂的电”。它依赖的是一个抽象的“电力标准”——插座。即使今天电厂A供电明天换成电厂B电脑一点感觉都没有。DIP要解决的就是这种更换底层实现时的“无感”。6.2 从直接new到依赖注入的改造订单系统里最常见的DIP破坏现场是数据存储。假设OrderService直接依赖了一个MySqlOrderRepositoryclass OrderService: def __init__(self): # 直接new了一个具体实现 self.repo MySqlOrderRepository() def create_order(self, order): self.repo.save(order)这段代码看起来没有问题直到有一天你需要把订单数据同时写到消息队列或者把存储层从MySQL改成MongoDB问题就来了你必须打开OrderService把MySqlOrderRepository替换成MongoOrderRepository。每次存储方案一变业务代码就要跟着改高层被低层的细节绑架了。DIP的思路是让OrderService依赖一个抽象接口OrderRepositoryclass OrderRepository: def save(self, order): ... def find(self, order_id): ...class MySqlOrderRepository(OrderRepository): def save(self, order): ... def find(self, order_id): ... class MongoOrderRepository(OrderRepository): def save(self, order): ... def find(self, order_id): ...然后OrderService不再自己new一个具体类而是通过构造函数把repository“注入”进来class OrderService: def __init__(self, repo: OrderRepository): self.repo repo def create_order(self, order): self.repo.save(order)这样OrderService只认识OrderRepository这个接口。创建订单的时候具体是存MySQL还是MongoDB由组装方依赖注入容器、工厂、配置中心决定。以后你要加一种缓存存储只需要新增RedisOrderRepository然后在组装的地方换一行OrderService本体一行不改。这就是DIP带来的直接收益。6.3 一些实用的取舍DIP虽然听着很完美但我也要泼第二盆冷水。依赖倒置不等于“每个类都要接接口”。如果某个依赖在可预见的未来根本不会变强行给它套接口就是在增加无谓的复杂度。我的一个经验习惯是从外部接入的系统数据库、消息队列、第三方支付、短信平台是最值得做DIP的因为它们大概率会换而同一进程内的内部工具类通常直接依赖就够了。另一个要注意的点是DIP和依赖注入DI是两回事别搞混。DIP是一个设计原则说的是依赖关系应该朝向抽象依赖注入是实现这个原则的一种手段。你可以在不用任何DI框架的情况下手工注入比如上面示例里的构造参数也可以完全不用DIP却用DI框架。理解这个区别能帮你在面试和技术讨论时更准确。7. 常见问题与排查技巧实录7.1 高频问题速查表在带团队和做培训的过程中我整理了一份SOLID实践里最常见问题的速查表分享给你参考。问题表现可能违反的原则排查方向一个类经常因为不相关需求被修改SRP找出类里的多个独立变化方向按角色拆分新增一个折扣类型就要改老代码OCP检查是否用了策略模式或类似的多态方案业务代码里频繁出现isinstanceLSP检查继承结构是否合理考虑组合替代继承实现类有大量空方法或抛异常ISP按调用方角色拆分接口换存储/换通知平台时业务代码要改DIP检查高层是否直接依赖了具体实现类7.2 五条原则在同一个模块里怎么配合很多人觉得SOLID五条原则是五个独立招式实际上它们在同一个模块里会互相配合。我遇到过几次比较典型的组合场景可以拿出来说说。还是回到订单创建流程。最早我看到的代码是全流程塞在一个类里这是SRP问题。为了扩展新优惠我们引入了策略模式这就是OCP。用户体系里用了继承导致调用方不得不isinstance区分这是LSP问题。把接口按调用方角色拆分开是ISP问题。最后把存储、通知都改成构造注入是DIP问题。有意思的地方在于当你把五条原则都用上之后最后得到的代码往往比中间某个阶段的版本更简单而不是更复杂。因为每一条原则都在消除一类“被动的修改”。代码评审时我会重点看这个类为什么会被修改修改的来源有几个这是一个很有效的过滤器能快速定位哪些地方违背了SOLID的精神。7.3 什么情况下可以“合法”违反SOLID最后聊一个比较犀利的话题SOLID不是所有代码的金科玉律有些地方可以适度妥协而且作为一个有经验的工程师你需要知道什么时候该妥协。第一类是DTO和配置类。UserDto、OrderRequest这类纯数据载体天然就聚合了一堆字段你硬要给它按职责拆分反而可笑。我见过有人把订单创建请求按“基础信息”“地址信息”“商品信息”拆成三个类最后组装逻辑写了一堆纯属浪费时间。数据类的“职责”就是承载数据这不是SRP能约束的场景。第二类是工具类。像日期转换、字符串处理、数学计算这类无状态工具类本身就很难说有“变化方向”。强行给它们拆接口、给每个工具方法定义抽象最后只会得到一堆没有实际意义的XxxUtilsInterface。第三类是状态不会膨胀的核心逻辑。如果你的业务规则已经稳定运行了三五年没有任何扩展迹象就不要为了让代码“看起来符合OCP”去重构它。重构有风险而风险需要用收益去对冲。我见过团队为了“扩展性”把一段纯计算逻辑抽象成三层结果为了追一个bug翻了五个文件这就是典型的负优化。我的实操经验是**SOLID的真正意义不是让你写一个完美的、永不修改的系统而是让你把修改集中到尽量小的范围里把出bug的风险控制住。**与其追求每个类都符合五条原则不如在动手写代码前多问自己一句这个模块未来的变化方向到底在哪敲定方向之后再用SOLID的眼光去审视这套原则才会真正发挥价值。我个人在实践里还有一个体会背熟SOLID的定义并不难难的是“闻到坏味道”。当你发现自己改一个很小的需求却需要打开七八个文件或者一个类越写越厚又或者使用if (type ...)去处理不同类型的时候不妨停下来翻一翻SOLID的清单大概率你能找到对应的那一条。设计原则这种东西只有落到自己手头的代码里才会变成真正的经验。
返回列表