news 2026/10/14 23:37:50

设计原则与UML类图:用SOLID驱动类图设计,画清依赖与职责边界

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
设计原则与UML类图:用SOLID驱动类图设计,画清依赖与职责边界

设计原则与UML类图"——这两种东西放在一起讲,是因为我在实际项目里见过太多割裂的用法。有人把SOLID背得滚瓜烂熟,画出来的类图却细看全是味道不对。有人UML符号记得很清楚,问一句"为什么这样设计"立刻卡壳。其实它们本来就应该是同一个技能树上的两个分支,原则告诉你"该往哪个方向走",类图负责把"你打算怎么走"画清楚。这篇内容解决的问题就是这两件事的缝合,适合准备系统设计评审、在重构旧代码时做方案,或者面试前想把自己的设计表达能力补一补的开发者。

我自己的体会是,大部分项目的糟糕代码,根源不在于没用某种高级技巧,而在于设计决策散落在某个人的脑子里,没被表达出来。UML类图就是把脑子里的决策变成图纸,设计原则是这份图纸的好坏标准。你可以没有一张标准的类图,但不能没有依赖方向、职责边界、扩展点的思考,而这些思考恰恰都能用类图画出来检验。

1. 内容整体设计与思路拆解

1.1 设计原则与UML类图的关系澄清

先理顺一个基础认知。SOLID设计原则描述的是"软件组织方式的内在规律",UML类图是"把这种规律画出来的通用语言"。类图不负责告诉你代码好还是不好,它只负责如实反映代码结构。判断好坏的那把尺子,就是设计原则。

实际项目里最常见的两个误区。第一个误区是"为了画图而画图",团队要求写设计文档,于是有人把每个类的字段方法全部机械搬进图里,五六百个类画了四五十页PPT,评审时没人看得完。这种图的本质是代码的镜像,没有设计信息量。真正的类图应该是有选择的、有视角的,只画与当前设计决策相关的类、接口和关系。第二个误区是"为了原则而原则",一提到"开闭原则"就把所有类都抽象出接口,一提到"依赖倒置"就强行引入容器,结果代码层级翻倍,复杂度没降反升。原则是用来解释和指导设计的,不是用来表演的。

所以我建议的项目切入方式是把两者互为校验工具。先用职责分析确定"大致有哪些类、它们之间大概什么关系",画出第一版类图;再用SOLID逐条检查这张图;最后把违反原则的地方重新设计,迭代出第二版、第三版。设计文档评审和代码评审的对象,应该是最后一版类图。

1.2 如何用"变化点"驱动类图设计

有没有一种判断方法,能在画线之前就大概知道"什么该画成接口、什么该画成抽象类、什么关系该用聚合而不是继承"?

我常用的一个标准叫"变化点扫描"。拿到需求后不要急着抽象,先回答三个问题:未来的需求里,哪些部分是大概率会变的行为?哪些部分几乎是不会变的骨架流程?哪些数据对象只是被动传递,本身没有行为?

这三类答案对应三种设计倾向。会变的行为用接口或策略模式接住,让实现可以替换;骨架流程放进模板方法类,固定算法步骤;被动数据对象保持简单,不需要强行套层次。

举个例子,一个订单处理系统里,"计算金额"这个动作在未来极可能新增促销规则、会员折扣、平台补贴等玩法,这就是变化点。把它定义成DiscountStrategy接口,让每种玩法各实现一个类,类图上的体现就是OrderProcessor依赖接口,而不是依赖具体算法。反过来,"订单保存到数据库"这个动作虽然也可能换库,但变化频率远低于折扣规则,而且换库涉及的改动面更大,一般不值得为了"万一"而每层都套接口。

把变化点扫描的结果落成类图,有几个直接的画图规则:每个变化点对应一根"指向接口的虚线箭头"(依赖关系);每个稳定的骨架流程对应一根"指向抽象类的实线空心箭头"(继承关系);每份被传递的数据对象之间用简单的关联线即可。这样画出来的类图,天然具有面向扩展的结构,评审时也能顺着变化点一条条解释。

2. 核心细节解析与实操要点

2.1 单一职责原则的实际判断方法

S(Single Responsibility Principle)的官方定义是"一个类只有一个引起它变化的原因"。这句话字面很简单,但"什么算一个原因"在工程里非常容易被争论成玄学。我见过因为过度拆解单一职责,把一个原本清晰的业务模块拆成十几个微型类的项目,协作成本反而暴涨。

我本人的判断方法是给类问两个问题:它是不是同时承担了"业务规则"和"技术细节"两类责任?它是不是因为两个完全不同的角色在改动它?

第一类分裂最常见。同一个类里既写业务判断(比如积分规则、折扣计算),又写数据库存取、日志打印、格式转换,这种就要拆。第二类分裂稍微隐蔽一点。比如一个支付结果处理类,既因为"支付渠道增加"要改,又因为"对账报表格式调整"要改,两个改动的动机来源完全不同,这也是两个责任。

还有一种可以量化的观察角度,叫"方法内聚度"。一个类的所有方法应该几乎都用同一组字段。如果发现类里存在"用到字段A、B、C的方法集合"和"只用到字段D、E的方法集合"两个相对独立的小团体,说明这已经是一个类里住着两个人了,该拆就拆。

注意:拆分的粒度要参考团队规模和代码演化节奏。如果是两三个人的小项目、高速原型验证阶段,硬拆反而加重负担。单一职责的目标是让"改动点集中在少数类里",不是让类数量最大化。

2.2 开闭原则的落地尺度与判断标准

O(Open Closed Principle)讲的是"对扩展开放、对修改关闭"。它的难点在于:不可能在所有维度上同时满足开闭,否则每个类都要抽象成插件。实际项目中,开闭原则必须配合"变化点扫描"来使用,只在你预判的高频变化方向上做开放,其他方向保持简单。

以支付渠道对接为例。最初的代码可能是这样:

class PaymentService { public void pay(String channel, double amount) { if ("alipay".equals(channel)) { // 支付宝逻辑 } else if ("wechat".equals(channel)) { // 微信逻辑 } } }

这段代码的问题在于:新增渠道时必须改PaymentService的if分支,违背了开闭原则。重构后的结构是:

interface PaymentChannel { void pay(double amount); } class AlipayChannel implements PaymentChannel { ... } class WechatChannel implements PaymentChannel { ... } class PaymentService { private PaymentChannel channel; public PaymentService(PaymentChannel channel) { this.channel = channel; } public void pay(double amount) { channel.pay(amount); } }

重构后,新增渠道只需要增加一个新类,PaymentService不再修改。类图上体现为PaymentService指向PaymentChannel的依赖关系,方向是从"抽象接口"指向"具体实现类"的反转。

但我要强调:这种重构不是免费的。它增加了类的数量,增加了调用方需要装配具体实现的成本,还需要依赖注入或简单的手工组装。如果整个项目的支付渠道可能十年就两个,这个抽象其实是过度设计。开闭原则应该服务于"确定会变"或"十分可能变"的点,而不是"万一变了怎么改都麻烦"的点。

判断一个设计是否满足开闭,有个快速的读图方法:找到类图里每个"分支判断"的地方,看看它是用if/switch在多个实现间做选择,还是用多态。只要看到接口或抽象类的实现数量在增长,同时调用者的代码不需要跟着改,这个方向的开闭就做得比较到位。

2.3 接口隔离与依赖倒置的配合使用

I(Interface Segregation Principle)和D(Dependency Inversion Principle)经常同时出现,放在一起说更清楚。

接口隔离的务实理解是:不要强迫实现类依赖它用不到的方法。一个经典的反例是万能接口:

interface Worker { void work(); void eat(); }

问题是:机器人实现类需要work(),但没有eat()的需求。强迫它实现eat()方法,要么抛异常,要么写空实现,都是坏味道。正确做法是拆成Workable和Eatable两个更小的接口,需要的类各取所需。

接口隔离的现实难度在于接口拆分的"度"。拆得太细会让类图变得碎片化,一个实现类要实现七八个接口;拆得太粗又回到万能接口的老路。我的参考标准是看"变更频率是否一致"。如果两个方法总是因为同一个原因改变,就放一起;如果改变原因完全不同,就拆开。比如订单服务里的createOrder()和exportReport(),前者因业务规则变化,后者因报表格式变化,这两组方法就不该待在同一个接口里。

依赖倒置的核心是"依赖抽象而不依赖具体类",这个原则和开闭原则几乎是一体两面。当PaymentService直接持有AlipayChannel的实例时,它依赖了一个具体实现,将来换渠道就要改PaymentService。改成依赖PaymentChannel接口后,依赖关系被倒置了:具体实现反过来依赖高层策略所定义的抽象。

我曾经带队重构过一个兼容多种文件格式的导入工具,最初的类图里ImportManager与每个具体Parser之间连着实线箭头,耦合明显。重构后,所有Parser实现Parser接口,ImportManager只依赖Parser接口,并引入SimpleParserFactory来根据文件后缀返回对应的Parser实例。这个重构的动作本身不大,但类图上的依赖方向完全反转,后续新增Excel、Parquet等格式的Parser时,导入主流程一行不动。这就是依赖倒置省下来的维护成本。

3. UML类图核心关系精讲

3.1 类图元素与记忆框架

进入UML类图本身,先建立整体框架。类图画的是"类型层面"的东西,不是"对象层面"的实例。一个类有三个格子:首格写类名(抽象类用斜体),次格写属性,末格写方法。可见性符号方面,+表示public,-表示private,#表示protected,无符号默认包级可见。

类图的核心不是这三个格子,而是格子之间的线。六种关系可以按"强弱程度"排列成一条光谱:依赖弱于关联,关联弱于聚合,聚合弱于组合,组合与继承各有各的强。理解了这条强弱光谱,图的语义就基本掌握了。

比较常见的一个现象是"线越粗关系越近"的直观直觉,这用在UML上方向反了。依赖是虚线,最弱;关联是实线;聚合是空心菱形;组合是实心菱形,一根比一根"重"。菱形所在的端指向整体方,这个方向感也是新人最容易画反的。

3.2 六种关系的判别与代码对照

逐个过一遍六种关系。

依赖(Dependency),虚线箭头,A方法里用到B,但A的属性里没有B。比如一个工具类方法接收了一个临时参数。哪怕调用关系再频繁,如果B只是作为方法参数传入、局部变量出现,就是依赖。

关联(Association),实线箭头,A持有B的引用,但两者生命周期互相独立。比如一个类里有一个Other类的字段,但创建时机不归A管。

聚合(Aggregation),空心菱形,整体与部分关系,但部分可以脱离整体独立存在。空菱形的典型例子是班级和学生:一个班级有若干学生,但学生被转走、毕业之后依然存在,班级解散了学生也照样在。代码上通常表现为整体持有部分的集合,部分在整体构造之前或之后独立创建。

组合(Composition),实心菱形,整体与部分同生共死,部分不能脱离整体单独存在。最典型的是订单与订单项:订单被删除,订单项没有独立存在的意义和价值。代码上表现为部分通常在整体的构造函数内部创建,整体销毁时部分也随之销毁。

泛化(Generalization),实线空心三角箭头,也就是继承,子类继承父类并可以替换父类出现的位置。实现(Realization),虚线空心三角箭头,类实现接口,实现接口中定义的方法契约。

符号本身不难,难在聚合与组合的区分,这是评审时被问得最频繁的地方。我提供一个实用的判别顺序:先问"有没有生命周期绑定",有就是组合,没有再看"是不是强调整体对部分的管理职责",是就是聚合。用生命周期作为第一判据,基本能避开大多数误判。

3.3 一张速查表与三条画图经验

关系箭头样式方向语义代码特征记忆锚点
依赖虚线箭头临时使用方法参数、局部变量用完即走
关联实线箭头长期持有引用成员属性互相认识即可
聚合空心菱形+实线整体包含部分,生命周期独立Set属性解散了各自还能活
组合实心菱形+实线整体包含部分,生命周期绑定构造中new同生共死
泛化实线+空心三角继承extendsis-a关系
实现虚线+空心三角接口实现implementslike-a关系

画类图还有三条实操经验值得分享。

第一条,箭头的方向一定要指向"被依赖方"。依赖、关联、聚合、组合的箭头都要从使用方指向被使用方,菱形在整体方,三角在父类或接口方。"人人都是被指向的那个"这个口诀能快速检查图中的箭头是不是画反。

第二条,画图时先画接口和抽象类,再画具体实现。这样会让依赖方向的视觉重心更清晰,一眼能看出"高层是否依赖抽象、低层是否依赖高层反过来的错误关系"。

第三条,纠结"是什么关系"时,把代码和生命周期对照着看,不要只凭直觉。直觉判断常常会被"看起来有联系"误导。比如订单和用户用关联就行,用户不会因为订单消失而消失;订单和订单项就是组合,这个不用犹豫。

4. 常见问题与排查技巧实录

4.1 泛化与实现、依赖与关联的混淆排查

所有类图问题里,最频繁出现的是两种混淆。

第一种,泛化与实现混淆。泛化是继承类,实现是实现接口,两者的视觉符号差在实线和虚线。泛化用实线三角,实现用虚线三角。记忆口诀是:父类的功能是"实打实"的,所以实线;接口只是"契约约定",所以虚线。还有一种情况是发现自己在基类和接口之间反复横跳,说明还没想清楚"共享代码"和"共享契约"两个目的。共享代码和默认行为用泛化(抽象类);需要定义能力形状、允许多实现用接口。一个类既继承抽象类又实现接口的情况也很正常,不冲突。

第二种,依赖与关联混淆。区分两关系的关键始终是"生命周期是否只是临时使用"。在某Service里调用另一个Service的临时方法,哪怕这两个Service在业务上关系非常紧密,只要没有成员属性长期持有,它就是依赖关系,不是关联。我审过一张图,把系统里几乎所有类都画上了关联线,就是因为作者把"业务上有接触"等同于"类图上的关联"。类图画的不是业务亲密度,而是代码结构上的持有关系,这一点要反复强调。

4.2 组合与聚合判错后的设计后果

选错组合或聚合,会直接影响对象的生命周期管理方式,在代码里埋下难以察觉的隐患。

一个典型场景:某系统把"配置"和"配置下的具体条目"画成聚合关系。代码中配置被删除时,相关人员手动把条目逐个清理。某天新增了一个异步批量删除逻辑,只删了配置,忘了删条目,数据库就留下了大量孤儿数据。如果当初画成组合关系,代码就会顺理成章地在删除配置的逻辑中一起处理条目删除,不会犯这种错。

反过来,把组合画成聚合的实例也有。比如某人把"用户"和"聊天消息"画成组合,代码在用户对象内部new出一堆消息。实际业务中,为了分页查询、异步推送、审计留存,消息根本不应该跟着用户一起销毁。这种设计会让消息的存取路径扭曲到极其别扭的程度,因为一个不该负责消息生命周期的类被强制捆绑了。

每当要画一对整体-部分的关系时,问一句"整体没了,部分有没有独立存在的意义和价值"。有就是聚合,没有就是组合。这句话能解决掉大半判定难题。

4.3 常见设计原则偏离图症状

顺着类图找设计问题,有几个高发信号可以直接看。

信号一:类图里某个具体类被很多其他类直接依赖。这些依赖大部分是合理的领域服务,但如果被依赖的类是个工具类、配置类、数据库访问类,说明大量业务逻辑可能都集中在这个"上帝类"上。它的入度越高,越要检查是不是违反了单一职责,是否应该按业务模块拆分。

信号二:接口旁边挂着一个实现类,且这个实现类几乎是唯一的。这种"只有一个实现的接口"常常是为了抽象而抽象。如果这个接口不代表未来的变化点,只是团队"规范要求",可以考虑先用具体类,等第二个不兼容实现真正出现再去抽象。过早抽象和过度设计,在类图上体现为大量孤立的、没有第二实现者的接口。

信号三:存在明显的循环依赖。图中A指向B、B又指向A,或者A→B→C→A,代码层面通常表现为两个类互相持有对方的引用,一旦出现,编译和运行时都可能报错或者出现难缠的初始化循环。消除循环依赖的核心手段是引入接口并反转依赖方向,用依赖倒置打破闭环。

信号四:抽象类的子类数量非常多,而且子类之间的差异只是某个方法的参数不同。这种结构可以考虑用策略模式或者组合来取代厚重的继承树,因为继承树越深,任何父亲改动带来的涟漪效应越难控制。

4.4 把设计模式落到类图上的一点点体会

这篇文章聊到设计原则和UML类图,难免会关联到设计模式。我的建议是,不要把设计模式当作"标准答案"来套,而是当作"类图上的常见连接方式"来理解。

策略模式就是"一个接口 + 多个实现 + 上下文依赖接口"的组合;模板方法就是"一个抽象类 + 固定骨架方法 + 子类实现钩子方法";工厂方法就是"创建者的抽象类或接口 + 具体创建者 + 具体产品"。这些模式的本质,都是在某种特定变化方向上做开闭原则的标准化实践。

我自己做技术评审时,看到一张类图,第一眼先找接口和抽象类的分布,再看依赖箭头是否都指向抽象,最后才看具体实现类是怎么落地的。用这套顺序基本不会漏掉严重的设计问题。设计原则不是一堆背来考试的名词,而是看图、画图时用来给自己挑毛病的那把尺子。

最后再多说一句:类图的价值不在图本身,而在画图过程中被逼着做清楚的判断。真正让人对"该怎么设计"有把握的时刻,从来不是看了一本教科书,而是自己动手画过、踩过、改过很多张类图之后的肌肉记忆。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 23:37:22

智能版本升级管理系统:从人工操作到自动化编排的关键能力与实践

版本升级这件事,很多团队都经历过“升级一时爽,回滚火葬场”的阵痛。平时手工部署一套流程跑下来,发版当晚熬夜盯监控,第二天还要处理零零散散的兼容性反馈。遇到跨版本升级、数据表结构变更、配置项不兼容这些场景,光…

作者头像 李华
网站建设 2026/10/14 23:35:31

AnyPS5主机游戏串流实战:从网络到编码的完整调优指南

1. 从"AnyPS5"这个标题说起:一个跨平台游戏串流方案的设计思路第一次看到"AnyPS5"这个命名,我脑子里蹦出来的第一反应是——这大概率是一个围绕主机游戏画面跨设备访问的项目。名字里的"Any"很直白,意思就是&q…

作者头像 李华
网站建设 2026/10/14 23:35:15

微信小程序+云开发:城市宠物规范管理系统的设计与实现

去年年底我接了一个挺有意思的需求:把一座城市的宠物登记、疫苗接种提醒、走失找回、违规投诉这些零散事统一起来,做成一个基于微信小程序的城市宠物规范管理系统。当时社区管理方最头疼的是,养宠信息全靠纸质登记表,工作人员上门…

作者头像 李华
网站建设 2026/10/14 23:34:46

从OCR工具到流程治理:文档识别转换工作台设计要点

文档识别转换工作台这两年很常见,但大多数团队一开始做的时候,都把它当"OCR工具的网页版"来做,结果做出来就是一个上传文件、返回文字、下载结果的页面,用起来跟本地跑一次脚本没区别。我最近在整理一份功能需求文档&am…

作者头像 李华
网站建设 2026/10/14 23:34:10

协作频谱感知中Pietra-Ricci指数检测器原理与Matlab实现

1. 为什么单节点频谱感知不可靠:从隐藏终端说起协作频谱感知这几年在认知无线电里被提的最多的一句话就是“单节点不可靠”。信号衰落、路径损耗、障碍物遮挡,任何一个因素都可能让某个感知节点对主用户的存在完全“失明”。你可能会想,直接在…

作者头像 李华
网站建设 2026/10/14 23:33:20

多功能计算器实现:前端状态机与表达式解析实战

1. 项目概述:为什么一个“多功能计算器”值得花时间重做一遍网页设计里,计算器看起来是个再基础不过的练手项目——加减乘除、括号优先级、清屏退格,十几行HTMLCSSJS就能跑起来。但真正在某高校数字媒体课程带学生实操、又在某外包团队接手过…

作者头像 李华