☰
SOLID原则实战:五大设计原则重构代码的完整解析
2026/10/22 4:51:03 网站建设 项目流程

1. 单一职责原则:先学会给代码划边界

1.1 我接到过的最糟糕的重构任务

前几年接手过一个内部报表系统,其中一个类摆了快三千行,名字叫ReportService。看名字感觉它应该只负责报表生成,真正打开文件才发现事情远没有那么简单。它要连数据库查数据、做各种汇总计算、按模板渲染HTML,顺便还负责把报表以邮件形式发给指定列表里的人,甚至还在类里直接写了记录操作日志的逻辑,logger、mailer、excel工具类全部new在方法内部。

那个项目当时已经跑了一年多,每次提需求都像拆炸弹。有一次产品经理说邮件模板想换一套配色,我改完模板渲染方法,结果发现发送逻辑里有一处判断依赖旧模板的某个字段,直接整条链路报错。还有一次数据库表新增了一个字段,按理说只是查询语句加一列的事,结果因为解析和渲染耦合在同一个方法里,连格式转换都跟着出了偏差。一个看似小小的变更,兜兜转转改了一整天。这就是典型的单一职责原则没守住的结果。

单一职责原则(SRP)说的是一个类应该只有一个引起它变化的原因。这句话听起来像哲学,翻译成大白话就是:你因为什么原因去修改这个类?如果"邮件服务器换了"要改它、"报表排版变了"也要改它、"数据库表结构调整了"还要改它,那这个类就背负了至少三个不同的职责。职责不是一个动作,而是一组高内聚的、会因为同一类需求而变化的行为。把它想成"变化的轴线"更好理解——每一条导致类需要修改的业务原因,就是一条独立的轴线,一个类不该同时被多条轴线拉扯。

1.2 怎么判断一个类是不是"管得太宽"

很多人问我,代码里类分到什么程度才算职责单一,有没有量化标准。我的判断方法很粗暴:列出这个类所有公开方法,然后用一句话描述"谁会因为什么原因要求修改它"。如果这个类同时要回答"数据从哪里来""数据长什么样""数据发给谁""操作有没有留下审计记录"这四类问题,那基本可以断定它管得太宽了。

有个更实用的办法是看测试。给一个职责复杂的类写测试时,你会发现要么需要mock一大堆依赖,要么一个用例里塞满了完全不相关的断言。比如测试ReportService,你得先启动一个假数据库、再配一个假SMTP服务器、还要准备一堆模板文件,最后你想验证的其实只是"当输入的数字小于零时,报表里应该显示红字警告",但这个验证被埋在了十几个前置条件后面。测试困难的根源往往是职责过重,而不是测试框架不够好。

我习惯用"角色-依赖"法来验证边界:先给类定一个明确角色,比如"报表数据装配器"、"邮件发送客户端"、"审计日志写入器",然后给每个角色列出它应有的依赖。如果一个类同时依赖数据库连接池、SMTP配置、Excel渲染库和日志SDK,那它大概率是多个角色挤在一个文件里。拆分的动作并不复杂,把渲染部分挪到HtmlReportRenderer,把邮件发送挪到MailDispatcher,把日志抽取到独立的AuditLogger,ReportService只负责编排顺序。难点不在于拆这个动作,而在于你愿不愿意承认当初那个"顺手写进去"的便利正在变成债务。

1.3 单一职责的实操边界:不是拆得越碎越好

但我也踩过另一个方向的坑。有一段时间我过度痴迷单一职责,把一个只有几个方法的业务服务硬拆成七八个类,每个类只有一个public方法,名字都长到快超过代码行宽。结果是调用方为了完成一件小事要在Controller里手动装配五个对象,类之间的依赖图变成一团乱麻,同事看了代码直摇头。后来我才意识到,单一职责处理的是"变化原因"的聚合度,而不是"方法数量"的多少。一个类完全可以承载多个相关联的内部方法,只要这些方法都是同一个业务目标的组成部分。

这里的判断标准是"内聚性"。一份订单的处理逻辑里,"校验订单状态""计算优惠金额""保存订单记录"虽然对外暴露了三个方法,但它们都属于"完成一次下单"这个整体职责,放在同一个类里完全没问题。真正需要警惕的,是把"下单"和"给运营发通知"两种变化绑定在一起。通知渠道从邮件换成短信时,不应该牵连到订单金额的计算逻辑。所以拆类的时候我会先问自己:这两段代码会因为同一个业务决策而变化吗?如果不是,拆;如果是,聚着。

单一职责落实到团队协作里还有一个隐性作用:减少合并冲突。我观察过,凡是职责清晰的文件,多人并行开发的冲突率明显低。大家各自修改自己负责的类,交集小,代码评审也更好做。反观那些三五千行的"上帝类",每次需求变更都挤在同一块区域改,冲突、覆盖、改A坏B的惨案几乎必然发生。

2. 开闭原则:把"改代码"变成"加代码"

2.1 一个需求变更打回原形的真实案例

开闭原则(OCP)应该是我最早"听懂了"但最晚"做对"的一条原则,它的核心是:软件实体应该对扩展开放,对修改关闭。有一回我做一个消息推送模块,最初只支持邮件。代码写得很直接,一个notify方法里放了一个if判断,判断传入的类型是"mail"就走邮件发送逻辑。后来产品说要多支持短信,我加了一段else if;再后来要支持App推送,我又接了一段else if。每次加新渠道都要动这个中心方法,方法越变越长,判断条件越摞越多,很快就到了连自己都不想再看下去的地步。

真正让我意识到问题严重性的是一次排障。生产环境反馈说短信发送一直报错,我打开那个满是if/else的通知类排查,找了半天发现是之前加App推送时,不小心改动了公共的渠道路由变量,导致短信组件的配置被提前消费掉了。如果当初每个渠道是独立扩展的类,这个bug几乎不可能发生。这就是对修改关闭的意义——你需要一种机制,让已有的稳定逻辑不被新功能破坏。

开闭原则并不是要求你永远不修改任何代码,它说的是你要把那些"会发生变化的点"用抽象隔离起来,让变化通过新增代码来体现,而不是通过改写原有核心逻辑来体现。放在消息推送的场景里,解法就是定义一个通知渠道接口,邮件、短信、App推送各自实现这个接口。当第十个渠道出现时,你只需要新增一个类,注册进去,原来的路由方法一行都不用动。

2.2 开闭原则常见的误读

我把开闭原则的误读分成两种,这两种我都踩过。第一种是"完全不允许修改代码"。有朋友跟我争论说,产品改了需求,我必须在现有类里改逻辑,难道这违反开闭原则吗?其实开闭原则针对的是架构层面的扩展点,而不是禁止日常改bug、改文案、改计算规则。如果业务规则本身发生了变化,比如优惠门槛从100元改成200元,这是"内部实现调整",不属于扩展范畴,直接改没问题。真正需要守住的是接口和扩展点的稳定,不是业务数值的永恒不变。

第二种误读是"提前到处加接口,就以为满足了开闭原则"。我见过不少代码,每个类都配了一个接口,接口里方法跟实现类完全一致,但没有任何抽象价值。接口不是拿来凑数的装饰品,它必须服务一个真正的变化维度。判断标准很简单:你是否预见到这个模块会有多个版本或多种策略?如果答案是"未来可能、但不确定",不要急着抽象,先用具体类写着,等第二个真实变体出现时再抽接口。过早的接口抽象和没有抽象一样糟糕,它会增加跳转成本,让新人读代码时在一个接口和十几个实现类之间茫然徘徊。

真正实用的开闭落地套路,我总结下来就三件套:识别变化点、定义稳定接口、通过组装而不是改写来扩展。识别变化点是第一步,也是最难的一步,需要你对业务有一定的预判。定义稳定接口时,接口的语义要面向调用方,而非面向实现细节。组装方式可以是工厂方法、策略注册表,也可以是依赖注入容器,但核心思路一致:让调用方只依赖抽象,让新增实现类通过配置或注册进入系统。

2.3 策略模式、模板方法,怎么配合开闭原则落地

开闭原则是"道",策略模式、模板方法这些是"术"。比如前面提到的消息推送,用策略模式落地就很自然:定义一个Sender接口,里面有send(Message)方法,MailSender、SmsSender、AppPushSender分别实现。路由的地方维护一个Map,把渠道类型映射到对应的Sender实例。新增渠道时,写一个新类,往Map里加一个键值对,完事。这个Map你可以做成配置驱动,从数据库或者配置中心读取,这样连代码都不用动就能上线新渠道。

模板方法则适合那种"步骤固定、细节可变"的场景。比如一个对账流程,整体步骤都是拉取账单、比对差异、生成结果、发送报告,但不同渠道的账单格式不同、比对规则不同。这时可以设计一个抽象类,把拉取、比对、发送定义为抽象方法,把整个流程编排写死在模板方法里。新接入一个渠道,继承抽象类,填三个方法,原来的流程编排完全不动。这个过程就是"对扩展开放,对修改关闭"的直观体现。

我自己在项目里最常用的一种开闭配套实践是"用注册表代替if/else"。把传统的switch-case分发改成注册表查找之后,新增一个实现类的成本从"读懂整个分发逻辑"降为"写好一个独立组件并注册"。而且这种做法对测试特别友好,每个策略可以单独写单元测试,不再需要为了触发某个分支而构造一堆前置条件。

3. 里氏替换原则:继承的正确打开方式

3.1 为什么好的继承体系"替换无感"

里氏替换原则(LSP)可能是SOLID里最容易被忽略、出问题也最隐蔽的一条。它说的是:子类必须能够替换掉它的父类,并且程序的行为不会因此出现错误。说白了,如果你的代码里到处写着"如果是子类A就怎么怎么样,如果是子类B就怎么怎么样",那继承关系大概率是建错了。

我印象里最经典的违反案例就是正方形继承长方形。长方形有宽和高两个属性,设置宽度会影响面积;正方形继承长方形后,强制要求宽高相等,一旦调用方像操作长方形那样给正方形设置宽高,面积计算就全乱了。这个例子虽然简单,但在真实项目里每天都在以各种变体发生。比如我碰到过的一个订单系统,父类是Order,子类叫GroupOrder,代表拼团订单。各种业务方法里充斥着"if (order instanceof GroupOrder)"这样的判断,因为父类的实现默认走单人订单流程,到了子类这里出现了一堆例外逻辑。

好的继承体系应该满足"替换无感":调用方完全不需要知道当前处理的是哪个子类,父类定义的行为契约在子类里全部成立。要达到这个效果,关键不在于方法名是否相同,而在于行为语义是否一致。子类可以扩展父类没有的方法,也可以增强父类已有的能力,但不能削弱或改变父类对外承诺的语义。比如父类的save方法保证保存成功后会返回生成的id,子类就不能在某些情况下返回null;父类承诺getTotalPrice返回含税价格,子类就不能偷偷返回不含税的价格。

3.2 违反里氏替换的前置条件后遗症

违反里氏替换最常见的后遗症就是调用方代码里塞满了类型判断。每次新增一个子类,你都要回去翻所有使用父类的地方,看看那个if/else要不要再加一个分支。这种代码对扩展极不友好,新来的人根本不敢动,怕漏掉某个分支导致线上问题。另一个后遗症是方法契约被悄悄破坏,父类方法明确要求入参不能为空,子类重写时为了图省事没做校验,看起来功能正常,但当其他调用方按照父类契约传入null时,子类直接抛异常,最终出问题的是调用方——它明明没有做错任何事。

要诊断你的继承体系有没有违反里氏替换,可以用一个"契约测试"的思路:把父类的所有测试用例跑在子类上,看看有多少用例失败。如果子类复用父类测试套件时大面积失败,说明子类并没有真正满足"是一个"父类的关系。这时该做的不是改测试,而是反思继承是否成立。继承在面向对象里是最强的耦合关系,一旦建立就很难拆开,所以建立继承前,一定要问一句:这个子类是不是真的在任何父类能用的场景下都能用?如果答案里有任何犹豫,就考虑用组合代替继承。

我自己的团队里定过一条规矩:写继承必须同时写契约测试,至少要把父类最关键的行为约束用测试固化下来。这么做看起来增加了工作量,实际是省了大麻烦。契约测试会在子类破坏语义时第一时间报警,而不是等调用方在生产环境里被坑了才回头排查。

3.3 实战中怎么识别和收敛继承层次

识别继承问题的时机其实很容易:当你在代码里写出"instanceof"的时候,或者当你重写父类方法时犹豫要不要调用super的这个方法的时候。如果某个方法的重写逻辑跟父类完全没关系,甚至故意跳过了父类的关键校验,那就要警惕了,这个"继承"搞不好只是"方便复用代码"的借口。

收敛继承层的实操建议是"能组合就不要继承"。比如一个通知类,它既有邮件发送能力又有短信发送能力,与其让SmsNotification继承MailNotification来复用连接池逻辑,不如设计一个NotificationSender基类,让两个具体发送器都依赖它,再把连接池抽取成公共组件,通过构造参数传入。这个过程看起来是"多写了几个类",但每个类的行为边界更清楚,测试和替换都更容易。继承只应该发生在"语义上是同类,且行为契约完全一致"的场景,比如不同加密算法实现同一个加密算法接口,这才是合理的里氏替换关系。

还有一个策略是只在"层次叶子"上用继承:把抽象基类设计成不可再随意派生的稳定骨架,业务扩展尽量以组合、策略或者装饰器的方式完成。我见过有些项目的继承深度达到五六层,每一层都往子类传递几个字段,底层子类的构造函数光参数就有十几个。这种代码看似有设计,实际上已经失去了设计意义,排查问题时每一层都要跳进去看,重构成本极高。SOLID好就好在它给了你一个"否定"的借口:只要觉得继承关系可疑,你完全可以用里氏替换原则来反驳,然后理直气壮地改成组合。

4. 接口隔离原则:别让调用方为用不到的方法买单

4.1 胖接口带来的连锁反应

接口隔离原则(ISP)的核心是:客户端不应该被迫依赖它不使用的方法。这句话单独看有点抽象,举个例子你就懂了。我做过一个用户服务模块,早期为了省事设计了一个UserService接口,里面列了十几个方法,包括查询用户、更新资料、修改密码、绑定手机、注销账号、导出用户数据、统计用户活跃度等等。结果不同调用方各取所需,管理后台要用全部方法,用户端App只需要查询和改资料,内部运营平台只需要统计和导出。

问题很快暴露出来。有一次我调整了"导出用户数据"方法的返回结构,按理说只影响后台导出功能,但因为所有调用方都依赖同一个UserService接口,编译时所有引用这个接口的地方都要跟着检查,一些只需要查询用户信息的模块也被迫进行了代码评审和回归测试。接口越大,变更波及面越广,这就是胖接口的连锁反应。还有一种更恶心的场景:实现类不得不为空方法写空实现。某个类只需要查询方法,但因为实现了大接口,所有其他方法都得写上一行"throw new UnsupportedOperationException"。每次看到这种代码,我都觉得接口设计者是在给未来埋雷。

接口隔离原则的解法很直白:把大接口拆成小接口,每个小接口对应一组紧密相关的行为。比如把上面那个UserService拆成UserQueryService、UserProfileService、UserAuthService、UserExportService四个接口。根据调用方需求,实现类可以实现其中一个、多个或全部。这样用户端模块只需要依赖UserQueryService和UserProfileService,改导出服务的接口时,它们根本感知不到。重要的是,接口隔离不是把类拆开,而是把"对外的承诺"拆开,实现类仍然可以是一个完整的类,但它对外暴露的视角可以分块。

4.2 接口隔离和单一职责的区别

很多人容易混淆接口隔离原则和单一职责原则,这两个确实长得很像,但关注的层面完全不同。单一职责关注的是"实现类的内部设计",它追问的是一个类是否被多个无关的变化原因拉扯;接口隔离关注的是"调用方与提供方的契约边界",它追问的是一个客户端是否需要感知它用不到的契约。一个类可以有很多方法、承担一个大而完整的职责,但如果它把所有方法都放进一个接口里,让所有调用方都看到全量方法,接口隔离就被破坏了。

用一个生活化的类比:单一职责是要求一个厨师只负责做菜,不要又做菜又收银又洗碗;接口隔离则是说,顾客进餐厅时不应该看到后厨的所有细节,他只需要拿到一张菜单,上面列着他能点的菜。厨师可能确实同时负责切菜和炒菜,但顾客看到的"点单入口"(接口)不应该包含"磨刀""验收食材"这种内部操作。放在代码里,就是要把"实现者的能力"和"使用者的视角"分开建模。

实际工作中,接口隔离最常见的落地场景是"读写分离"。比如一个仓储接口,一开始是把save、findById、delete、findAll全部塞在一起。后来我发现很多只读场景根本不需要写方法,于是把接口拆成ReadRepository和WriteRepository,查询服务只依赖前者,管理工具依赖后者。改动的好处立刻体现出来:读服务的单元测试可以完全用内存实现的只读仓储,不再需要mock一大堆写方法;原来实现类为了满足接口而写的空方法也全部消失了。

4.3 接缝处的接口该怎么收

接口隔离原则还牵扯到一个工程判断:接口到底拆到多细才算合适。我的经验是,接口的粒度应当跟"变化的独立性"对齐,而不是跟"方法的数量"对齐。如果几个方法永远一起变、一起被调用,那是同一个角色,没必要拆开;如果某个方法或者某组方法只有特定场景才用,那就应该独立成接口。比如订单服务里,"查询订单详情"和"取消订单"虽然都围绕订单,但使用方和变化频率差异很大,拆成两个接口是合理的;而"查询订单详情"和"按状态查询订单列表"经常被同一个页面同时使用,留着不拆也完全没问题。

另一个需要注意的点是,接口隔离不一定是把接口拆小,有时候也是"给实现类换一个更合适的接口"。我曾经接手过一个Socket消息处理模块,原来的接口MessageHandler有十一个方法,每个实现类都只是零散地用其中三四个。后来我按处理阶段拆成MessageDecoder、MessageValidator、MessageProcessor三个接口,实现类根据自己关心的阶段实现对应接口,再把各个阶段在编排层串起来。这样每个实现类的代码量没有变少,但接口语义清晰了很多,新人看代码时能立刻明白一个类在整条链路里到底处于哪个环节。

说到底,接口隔离是给调用方提供"恰好够用"的依赖面。它减少了系统的耦合面,也减少了测试时要构造的mock数量。很多团队问我要不要引入微服务,我的回答通常是:先在单体里把接口隔离做好,如果你连一个模块内的接口边界都理不清,拆服务只会把耦合从方法级放大成网络级,问题只会更复杂。

5. 依赖倒置原则:高层模块的尊严

5.1 依赖倒置到底倒置了什么

依赖倒置原则(DIP)说的是两件事:高层模块不应该依赖底层模块,两者都应该依赖抽象;抽象不应该依赖细节,细节应该依赖抽象。我第一次读这句话的时候完全没懂,"依赖倒置"四个字到底倒置了什么?后来看了一个例子才明白:传统分层架构里,业务层直接调用数据访问层,业务层是高层,数据访问层是底层,高层依赖于底层。而好的设计应该让业务层定义"我需要什么样的数据访问能力"这个抽象,由数据访问层去实现这个抽象,这样依赖的方向变成了底层依赖高层的抽象,所以叫"倒置"。

举个例子,假设你写了一个报表业务类,里面直接new一个MySQLReportRepository。这个类本来应该只关心"取数、计算、渲染"这个业务流程,但现在它被迫知道了数据访问具体来自MySQL,未来想换成PostgreSQL或者从缓存里取数,都得改业务代码。如果让业务类依赖一个ReportRepository接口,接口定义了findReportData这个方法,MySQL版、PostgreSQL版、Redis版各自实现,业务类只认这个接口,依赖就被倒置了。这么做的好处是,业务逻辑不再受制于技术选型的波动,数据库迁移、缓存引入都变成了一件"换实现"的事。

依赖倒置原则之所以重要,是因为高层模块承载的是业务规则和业务流程,它们才是系统的核心价值。如果核心价值整天被底层的技术细节牵着走,系统就非常脆弱。数据库从一种换到另一种、消息队列从A厂商换成B厂商,这些事情不应该导致业务代码大面积改动。业务代码应该像一台稳定运转的机器,底层技术是里面的零件,零件坏了换个型号就行,机器整体不用重造。

5.2 依赖注入:落地依赖倒置最常用的载体

要说依赖倒置在工程上最常见的落地方式,那必须是依赖注入(DI)。依赖注入的核心思想是:对象不自己new依赖,而是通过构造函数、setter或者方法参数把依赖传进来。这样依赖的绑定关系被放到了对象外部,通常由容器或者装配根统一管理。我看到很多刚接触这个概念的人以为用了依赖注入框架就等于满足了依赖倒置原则,其实不对。依赖注入只是手段,真正要紧的是"注入的是抽象",如果构造函数注入的是一个具体类,那依赖倒置其实没发生。

我自己在项目里的习惯是:业务模块的构造函数一律接收接口类型,装配工作统一放到一个模块初始化方法里。比如一个订单处理器需要记录日志,我不会在里面new一个FileLogger,而是定义ILogger接口,构造函数接收ILogger,具体是FileLogger、DbLogger还是KafkaLogger,由装配层决定。这样的代码天然可测试,单元测试时传一个写内存的假日志器,就能验证业务逻辑而不产生任何副作用。

依赖注入还有一个隐藏的好处:它强迫你把依赖关系显式化。一个类的构造函数参数列表就是它的"依赖清单",新同事看代码时,不需要进到方法体里翻哪里new了什么,光看参数就知道这个类依赖哪些外部能力。反过来,如果类的方法里散落着一堆new,代码的依赖关系就被藏起来了,重构和测试都寸步难行。我现在做代码评审,第一眼就是看构造函数参数列表和类里有没有new,这个习惯帮我发现了大量隐式耦合。

5.3 过度依赖抽象的另一个坑

但是依赖倒置原则用过头了也有坑,最典型的就是"万物皆接口"。我曾经在一个项目里见过连一个只计算折扣的静态工具类都被套上了接口,还专门写了一个Impl类。调用方为了拿到这个折扣计算器,要先通过容器注入,传给三层,最后调一个方法。整个过程,抽象的价值为零,因为根本没有第二个实现,也不会出现第二个实现。这种过度设计不仅增加代码量,还让阅读成本成倍上升——每个接口都要跳一层到实现类,好几次我调试时都因为跳错实现类浪费了不少时间。

我现在的判断标准是"什么时候需要抽象?"当且仅当存在或者可以合理预见到多个实现时。如果只有一个实现,直接用具体类,把改动留到出现第二个实现时再做。这样的做法在代码里通常用一句话表达:"YAGNI,你不需要它"。依赖倒置不是让你把所有细节都抽象化,而是让你把"稳定的业务规则"与"容易变化的实现细节"分离开。稳定规则是高层的,细节是低层的,只有当高低层之间存在真实的独立变化需求时,倒置才有意义。

过度抽象的另一个危害是它会影响性能排查。依赖链变深之后,一个简单的业务请求要穿过五六个拦截器、代理、装饰器才能真正到达实现类。线上定位问题时,从入口到具体逻辑的距离被拉得很长,每层代理都像是在迷雾里加了一层纱。我处理过的性能问题里,不少都是因为框架代理层数太多,调用链在代理之间来回进出。所以能直连就直连,接口们只出现在真正需要面向变化的地方。实用主义在工程里永远是第一位的。

6. 六个原则一起用:一套真实的判重改造

6.1 改造前的代码长什么样

把SOLID五条原则单独聊完,正好用一套我实际经历过的判重需求改造来做个串联。这个需求是:用户注册时,系统要根据手机号、邮箱、身份证号、设备指纹等多个维度判断这个用户是否已经存在。最开始的代码把一个正则匹配、一个动态查询、两个第三方风控结果校验全部塞进了同一个方法里,整个类四百多行,方法内部if嵌套了五层,短信验证、数据库查询、外部接口调用直接硬编码在业务方法中,连依赖都是直接在方法里new的。

这套代码的问题非常典型。首先,判重规则如果要从"仅精确匹配手机号"升级成"手机号加设备指纹联合判断",必须直接改那个核心方法,明显违反开闭原则。其次,方法里同时处理了规则解析、数据查询、结果合并、异常包装,职责混乱,违反单一职责。第三,不同判重规则之间有一条隐式的父子关系,子规则重写了父规则的比对方式却在很多地方又绕回去调父类的私有方法,明显不符合里氏替换。第四,调用方无论需要哪个维度的判重,都只能依赖这个胖服务,接口隔离完全没有。最后,业务逻辑直接依赖数据库连接和第三方HTTP客户端,依赖倒置约等于零。

还有一个致命问题:测试几乎没法写。因为所有依赖都是new出来的,单元测试想验证"手机号重复的用户应该被拦截",必须先起一个真实数据库连接,再mock掉外部风控接口。跑一套测试的时间足够你去泡杯咖啡回来。需求方后来提出要支持企业用户按统一社会信用代码判重,我看着那坨代码,知道这已经是最后一次不打碎重构就无法接住的需求了。

6.2 分步改造中每个原则的落点

改造我分成了四步,每一步都对应SOLID中的若干原则。

第一步,把规则定义从执行逻辑里剥离出来。我定义了一个DuplicateRule接口,里面只有一个方法boolean check(DuplicateCheckContext context)。手机号规则、邮箱规则、身份证规则、设备指纹规则各写一个实现类。这一步同时落地了单一职责、开闭和里氏替换三个原则:每个类只负责一条规则的判定,新规则通过新增类加入,所有规则类都遵守同一个"输入上下文返回布尔值"的语义契约。原本那个四百行大方法的if/else被彻底消灭了。

第二步,把规则选择机制改成注册表。我用一个DuplicateRuleRegistry来维护规则列表,启动时从配置中心读取当前需要启用的规则顺序。调用方只是"给我所有启用的规则",而不需要关心具体有哪些规则。这就是依赖倒置的雏形:上层依赖的是"规则集合"这个抽象,而不是具体某一条规则。同时也让系统的扩展方式从"改代码"变成了"改配置",开闭原则在运维层面真正落了地。

第三步,把判重结果的处理和通知拆出去。原来判重方法里还夹着"如果发现重复就给运营发邮件"的代码,我把这个逻辑挪到了DuplicateResultHandler里,它接收判重结果、决定要不要通知、通过什么渠道通知。这样判重核心流程不再关心邮件还是短信,通知渠道的变化被隔离在一个独立接口后面。这也是接口隔离的体现:判重的调用方不需要知道结果会去哪、怎么通知,它只关心判重结果本身。

第四步,把数据库查询和外部风控调用都抽象成接口。DuplicateCheckContext需要的数据,不再由判重方法自己去查,而是通过一个DataProvider接口传入。具体是查MySQL、Redis还是调第三方风控API,都隐藏在实现类后面。判重规则本身只依赖上下文对象,对数据的来源一无所知。改造完成后,单元测试直接构造一个内存版DataProvider,传几个测试数据就能跑完整个判重流程,测试速度从分钟级降到了毫秒级。

6.3 重构后的效果和我的启发

改造完之后的代码结构发生了质的变化。核心判重类从四百多行瘦身到六十多行,主要工作是遍历启用规则、汇总结果、交给结果处理器。任何一条新判重规则,从需求提出到上线,只需要写一个新的规则类、在配置中心开启开关,连测试都不用碰其他规则。企业用户按统一社会信用代码判重的需求,我在两个小时内完成开发并提交测试,这在改造前是不可想象的——光是理清旧代码里那些交错的分支就要花半天。

测试的改善同样明显。每个规则类有独立的单元测试,注册表有注册顺序测试,结果处理有通知渠道测试。整个模块的测试覆盖率从原来的不到百分之二十提升到百分之八十五左右。代码评审也轻松了很多,新增规则类的评审只需要关注一个文件,而不需要理解整条链路。有一回一个新同事加入团队,看这套代码只花了半天就理清了判重的完整流程,放在以前,一个月可能都摸不清边界。

聊了这么多,如果说我对SOLID有什么新的体会,那就是:这五个原则从来不是独立的教条,而是一套互相咬合的设计观。单一职责帮你找到变化的边界,开闭原则帮你把边界变成扩展点,里氏替换保证继承不乱来,接口隔离让依赖关系清爽,依赖倒置让架构重心回到业务本身。如果你在实践中觉得某个原则不好用,多半不是原则错了,而是你还没找到那个真正让它发挥作用的切入点。代码写久了自然会有手感,但有了SOLID这套尺子,你的手感会更有底气。这大概就是设计模式原则对普通开发者最大的价值——它让你在说"这段代码设计得不好"的时候,能说出具体哪里不好、以及往哪个方向改。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询