ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

敏捷设计原则深度解析:从SRP到包设计六原则的落地实践

敏捷设计原则深度解析:从SRP到包设计六原则的落地实践

简介:这份PPT是北京大学研究生软件工程课程中关于敏捷软件开发方法的课堂讲义,面向软件工程专业研究生及希望系统理解敏捷思想的开发者与项目管理者。内容围绕敏捷开发的两类原则展开:12条开发原则侧重项目组织与团队协作,11条设计原则指导开发人员构建易维护、可扩展的软件结构,其中5条针对软件“腐化”问题,6条聚焦包的设计与集成技术。讲义还梳理了敏捷联盟宣言的四个核心价值观,并结合Jack Reeves关于“源代码即设计”的观点,讨论敏捷设计作为持续改进过程的目标与实现路径。资源包内含1个PPT文件,整体约446KB,以幻灯片形式呈现,便于课堂讲解与自学梳理。目前已有117人学习,适合需要快速把握敏捷开发原则体系、设计思想与宣言内涵的读者参考。

1. 从一份课程 PPT 说起:敏捷设计原则到底在解决什么问题

很多团队嘴上喊着敏捷,实际干的是“迭代式瀑布”——需求文档写三个月,评审两周,开发半年,测试再压一个月。上线那天才发现,用户真正要的功能只占交付清单的三成。这份来自某高校研究生软件工程课程的 PPT,把问题拉回到了更底层的地方:不是流程不够快,而是设计本身在腐化。它系统梳理了敏捷开发的两条主线——12 条开发原则和 11 条设计原则,前者管项目组织,后者管开发人员的行为。其中 5 条设计原则专门对付软件“腐化”,另外 6 条聚焦包的设计与集成技术。换句话说,它不是在教你“怎么站会”,而是在回答一个更硬核的问题:当需求每周都在变,怎么让代码结构不跟着烂掉。适合谁看?正在从传统开发往敏捷转型的团队技术负责人、被“改一处崩三处”折磨过的后端开发,以及需要给学生讲清楚设计原则落地路径的高校教师。

2. 敏捷设计原则的底层逻辑:为什么源代码才是唯一的设计文档

2.1 从 Jack Reeves 的观点说起:设计不是画图,是写代码

这份 PPT 里反复引用了一个 1992 年的观点:“满足工程设计标准的唯一文档是源代码清单。”这句话放在今天看依然扎心。传统工程领域,设计团队出图纸,制造团队按图施工,两者分离。但软件不一样——编译器和链接器让“构建”变得极其廉价,真正昂贵的是设计本身。PPT 里给了一个关键判断:编程就是一种设计活动,而且编码比高层设计图更能揭示疏漏和额外需求。你画十张 UML 类图,不如写两百行能跑的代码更能暴露接口不匹配的问题。

这引出一个很实际的推论:高层设计人员应该尽早参与详细设计,而不是把类图扔给开发就完事。PPT 里提到,软件开发过程模型的演化趋势就是支持在生命周期更早的阶段开始编码,让验证和测试更早介入。我见过不少团队把“设计阶段”和“编码阶段”切得干干净净,结果设计文档里没写到的边界条件,全在编码时爆发,然后回头改设计,再改文档,来回折腾。敏捷的做法是反过来的:先用代码把核心逻辑跑通,再补必要的辅助文档。

注意:这不是说不要文档,而是说文档的定位要变。PPT 里明确讲了辅助文档的三个用途——捕获问题空间中不能直接建模的信息(需求规约)、记录难以用注释表达的设计内容(图形最合适)、以及体系结构文档。但体系结构文档最好在源代码完成后或编写的同时编制,而不是之前。

2.2 拙劣设计的七种表征:你的代码“臭”在哪

PPT 里列了七种拙劣设计的表征,每一条都能对应到日常开发中的具体场景。我把它整理成一张表,方便对照排查:

表征核心表现典型场景
僵化性一个改动引发连锁改动改一个字段类型,五个模块跟着编译报错
脆弱性改动后问题出现在无关地方修了订单模块,库存模块开始丢数据
粘固性想复用但拆不出来想把支付逻辑抽成独立包,发现耦合了二十个类
粘滞性正确做法比错误做法更难想加单元测试,但环境配置要两天,干脆直接改代码
不必要的复杂性过度设计当前只有一个支付渠道,却写了策略工厂加反射加载
不必要的重复滥用复制粘贴同样的校验逻辑在六个文件里各写一遍
晦涩性模块难以理解一个方法三百行,变量名叫 a1、a2、tmp

这七条里,粘滞性最容易被忽略。PPT 里特别区分了软件粘滞性和环境粘滞性——前者是“保持设计的方法比破坏设计的方法更难用”,后者是“编译时间长、环境迟钝”。很多团队代码烂不是因为开发不想写好,而是写好代码的代价太高。比如改一个接口要等十分钟编译,那谁都会选择在原有方法上打补丁。

2.3 防止腐化的三条基本途径

PPT 给出了防止软件腐化的基本途径,核心思想是“依赖变化来获取活力”。具体三条:

第一,团队几乎不进行预先设计,因此不需要一个成熟的初始设计。这句话容易被误读为“不做设计”,实际意思是不要试图在项目初期就定死所有结构。第二,使用大量单元测试和验收测试,让设计保持干净、简单。测试在这里的作用不只是验证功能,更是设计的“锚点”——有测试覆盖的代码,改动时才有底气。第三,灵活、持续地改进设计,使每次迭代结束时的系统具有满足那次迭代需求的设计。注意是“那次迭代”,不是“最终形态”。

我一般会这样落地:每个迭代预留 10% 到 15% 的容量做设计改进,不叫“重构”,叫“设计维护”。这样在排期时不会被当成额外工作砍掉。

3. 11 条设计原则的落地拆解:从 SRP 到包设计六原则

3.1 单一职责原则(SRP):一个类只有一个变化的理由

PPT 里对 SRP 的定义很精炼:“就一个类而言,应该仅有一个引起它变化的原因。”这条原则被称为内聚性原则,把内聚性和“引起模块改变的作用”联系了起来。什么意思?如果一个类同时承担业务逻辑和持久化,那么业务规则变化和数据库表结构变化都会导致这个类修改——它就有两个变化的理由,违反了 SRP。

实际编码中怎么判断?我常用一个简单方法:看这个类的 import 列表。如果它既 import 了业务领域的包,又 import 了数据库驱动或 HTTP 框架的包,大概率职责不单一。下面是一个反面示例和修正后的写法:

# 反面示例:一个类同时处理业务规则和数据库操作 class Order: def calculate_total(self): # 业务逻辑:计算总价 total = sum(item.price * item.quantity for item in self.items) if self.customer.is_vip: total *= 0.9 return total def save_to_database(self): # 持久化逻辑:直接拼 SQL sql = f"INSERT INTO orders VALUES ({self.id}, {self.total})" db.execute(sql) # 修正:拆成两个类,各自只有一个变化理由 class Order: """只负责业务规则,价格策略变化时才修改""" def calculate_total(self): total = sum(item.price * item.quantity for item in self.items) if self.customer.is_vip: total *= 0.9 return total class OrderRepository: """只负责持久化,数据库表结构变化时才修改""" def save(self, order): sql = "INSERT INTO orders VALUES (%s, %s)" db.execute(sql, (order.id, order.total))

逻辑说明:Order类只依赖业务概念(item、customer、price),OrderRepository只依赖数据库接口。参数上,Order不需要知道任何存储细节,OrderRepository不需要知道 VIP 折扣怎么算。这样改价格策略不会碰数据库代码,换数据库不会碰业务逻辑。

提示:SRP 不是要求每个类只有一个方法。一个类可以有多个方法,只要它们都服务于同一个变化原因。比如Order可以有calculate_total、apply_discount、validate_items,它们都随业务规则变化。

3.2 开闭原则与依赖倒置:让扩展不修改原有代码

PPT 里把开闭原则(OCP)和依赖倒置原则(DIP)放在一起讲,因为它们在实际落地时是配合使用的。OCP 说“对扩展开放,对修改关闭”,DIP 说“高层模块不应该依赖低层模块,两者都应该依赖抽象”。没有 DIP,OCP 很难做到——你总得修改某个地方来接入新功能。

常见做法是定义一个抽象接口,让高层模块依赖这个接口,低层模块实现它。下面用 Python 的抽象基类演示:

from abc import ABC, abstractmethod # 抽象层:高层模块只依赖这个接口 class NotificationSender(ABC): @abstractmethod def send(self, message: str) -> bool: pass # 低层实现:新增渠道不需要修改高层代码 class EmailSender(NotificationSender): def send(self, message: str) -> bool: # 邮件发送逻辑 return True class SmsSender(NotificationSender): def send(self, message: str) -> bool: # 短信发送逻辑 return True # 高层模块:依赖抽象,不依赖具体实现 class AlertService: def __init__(self, sender: NotificationSender): self.sender = sender def alert(self, message: str): if not self.sender.send(message): # 失败处理逻辑 pass

逻辑说明:AlertService不知道也不关心消息是通过邮件还是短信发的。新增一个WeChatSender只需要实现NotificationSender接口,AlertService一行不用改。参数上,sender通过构造函数注入,方便测试时替换成 mock 对象。

这里有个容易翻车的地方:PPT 里特别注明“无条件地遵循这些原则是错误的做法,过分地遵循将导致不必要的复杂性”。我见过一个项目,为了“开闭”,给每个 if-else 分支都建了类,结果二十个类里有一半只被调用一次。原则是工具,不是教条。

3.3 包设计的六条原则:REP、CCP、CRP 与 ADP、SDP、SAP

PPT 里把 11 条设计原则分了两组:5 条解决腐化病症,6 条用于包的设计(集成技术)。包设计这六条可以进一步分成三个维度:

复用维度——REP(复用发布等价原则)、CCP(共同闭包原则)、CRP(共同复用原则)。REP 说包的粒度应该和发布粒度一致,不能复用一个包却要跟着它一起发布一堆不相关的东西。CCP 说一起变化的类应该放在同一个包里,这其实是 SRP 在包层面的应用。CRP 说不要强迫一个包的使用者依赖他们不需要的东西。

耦合维度——ADP(无环依赖原则)、SDP(稳定依赖原则)、SAP(稳定抽象原则)。ADP 要求包依赖关系不能有环,否则测试和发布都会变成噩梦。SDP 说依赖应该指向更稳定的方向。SAP 说越稳定的包应该越抽象。

这三条耦合原则在微服务拆分时特别有用。我一般会先画一张包依赖图,用工具检测有没有环,然后把稳定的基础包(比如工具类、领域模型)和易变的业务包分开。如果基础包依赖了业务包,那就是方向反了,得用依赖倒置拉回来。

4. 避坑与排查:敏捷设计原则落地时的五个常见翻车现场

4.1 把 SRP 用成“一个类一个方法”

现象:团队里有人把 SRP 理解成“每个类只能做一件事”,结果一个简单的用户注册流程拆出了UserValidator、UserCreator、UserNotifier、UserLogger四个类,每个类只有一个方法,调用链长得没法看。

原因:混淆了“职责”和“操作”。SRP 的“职责”是“引起变化的原因”,不是“一个方法”。用户注册的业务规则变化时,验证逻辑、创建逻辑、通知逻辑可能同时要改,它们其实属于同一个职责。

解决:先识别变化原因,再决定拆分粒度。如果两个方法总是因为同一个需求变更而一起修改,它们就应该在同一个类里。我一般会看版本控制历史——如果两个文件的修改记录高度重合,说明它们可能不该分开。

4.2 过度设计:为“未来可能的需求”提前抽象

现象:项目初期只有一个数据源,但架构里已经写了DataSourceFactory、DataSourceStrategy、DataSourceAdapter三层抽象。三个月后需求变了,这些抽象全要重写。

原因:PPT 里说的“不必要的复杂性”——设计中包含了当前没有用的成分。敏捷设计原则本身也警告过“过分遵循将导致不必要的复杂性”。

解决:遵循“三次法则”——第一次直接写,第二次复制并稍作修改,第三次才抽象。PPT 里也强调敏捷设计是“不断应用原则、模式和实践来改进软件结构”的过程,改进的前提是有实际代码可改,不是凭空设计。

4.3 单元测试写成了“实现细节测试”

现象:每次重构,测试全红。开发开始害怕改代码,因为改任何实现细节都要同步改一堆测试。

原因:测试耦合了实现细节,而不是行为契约。比如测试一个排序方法时,断言了内部用了哪种排序算法。

解决:测试只断言输入和输出的关系,不断言内部调用。用 mock 时只 mock 外部依赖(数据库、HTTP),不 mock 同一个包内的协作类。PPT 里说“使用许多单元测试和验收测试,支持系统的设计尽可能干净、简单”,测试本身也要保持简单。

4.4 包依赖成环,编译顺序变成玄学

现象:项目编译时偶尔报“找不到符号”,清理后重新编译又好了。或者改一个底层包,上层三个包跟着报错。

原因:包之间形成了循环依赖。A 包依赖 B 包,B 包依赖 C 包,C 包又依赖 A 包。PPT 里 ADP 原则明确要求“包依赖关系不能有环”。

解决:用依赖分析工具(比如 Java 的 JDepend、Python 的 pydeps)生成依赖图,找到环后,用依赖倒置或提取新包来打破。常见做法是把环中共享的部分抽成一个更基础的包,让原来的包都依赖它。

4.5 把“不写文档”当成敏捷

现象:团队说“敏捷宣言说了,可工作的软件胜过面面俱到的文档”,于是什么文档都不写。新人入职两周还在问“这个接口是干嘛的”。

原因:误读了敏捷宣言。PPT 里写得很清楚:“虽然右边各项也有价值,但左边的各项具有更大的价值。”文档有价值,只是不如可工作的软件价值大。而且 PPT 里也说了,辅助文档有三个明确用途——需求规约、图形化设计记录、体系结构文档。

解决:区分“面面俱到的文档”和“必要的辅助文档”。我一般要求每个包有一个 README 说明职责和依赖关系,每个对外接口有参数说明,体系结构用几幅图和十几页文字描述关键类和关系。PPT 里也提到“一个好的 OO 体系结构通常可以使用几幅图和十几页文字描述出来”。

5. 从原则到实践:用依赖倒置和包设计原则做一次真实的结构改进

前面讲了原则和坑,这一章落到一个具体技巧:怎么用依赖倒置原则和包设计六原则,把一个“僵化”的模块改造成可扩展的结构。我拿一个模拟项目举例——某图像处理 Demo,最初所有逻辑写在一个ImageProcessor类里,支持 JPEG 和 PNG 两种格式,后来要加 WebP,发现改一处崩三处。

先看改造前的结构:

# 改造前:所有逻辑耦合在一个类里 class ImageProcessor: def process(self, file_path, output_format): # 读取文件 if file_path.endswith('.jpg'): image = self._read_jpeg(file_path) elif file_path.endswith('.png'): image = self._read_png(file_path) else: raise ValueError("不支持的格式") # 处理逻辑 image = self._resize(image, 800, 600) image = self._sharpen(image) # 输出 if output_format == 'jpg': self._write_jpeg(image) elif output_format == 'png': self._write_png(image) else: raise ValueError("不支持的输出格式")

问题很明显:新增 WebP 要改process方法里的两个 if-else,违反 OCP;读取、处理、输出三个职责混在一起,违反 SRP;process方法依赖具体的_read_jpeg、_write_png,违反 DIP。

改造思路分三步。第一步,把读取和输出抽象成接口,让ImageProcessor依赖抽象。第二步,把处理逻辑抽成独立的Pipeline,每个处理步骤是一个可组合的单元。第三步,用包设计原则组织目录结构——codec包放编解码器,pipeline包放处理步骤,core包放主流程。

from abc import ABC, abstractmethod # codec 包:编解码器接口和实现 class ImageCodec(ABC): @abstractmethod def read(self, file_path: str): pass @abstractmethod def write(self, image, output_path: str): pass class JpegCodec(ImageCodec): def read(self, file_path: str): # JPEG 读取实现 return image def write(self, image, output_path: str): # JPEG 写入实现 pass class WebpCodec(ImageCodec): def read(self, file_path: str): # WebP 读取实现 return image def write(self, image, output_path: str): # WebP 写入实现 pass # pipeline 包:处理步骤接口 class ProcessStep(ABC): @abstractmethod def apply(self, image): pass class ResizeStep(ProcessStep): def __init__(self, width: int, height: int): self.width = width self.height = height def apply(self, image): # 缩放逻辑 return resized_image class SharpenStep(ProcessStep): def apply(self, image): # 锐化逻辑 return sharpened_image # core 包:主流程,只依赖抽象 class ImageProcessor: def __init__(self, codec: ImageCodec, steps: list): self.codec = codec self.steps = steps def process(self, input_path: str, output_path: str): image = self.codec.read(input_path) for step in self.steps: image = step.apply(image) self.codec.write(image, output_path)

逻辑说明:ImageProcessor现在只依赖ImageCodec和ProcessStep两个抽象。新增 WebP 格式只需要写一个WebpCodec类,在组装时传入即可,ImageProcessor一行不改。新增处理步骤(比如裁剪、滤镜)只需要实现ProcessStep接口,加到steps列表里。参数上,ResizeStep的宽高通过构造函数传入,方便不同场景配置不同尺寸。

包依赖关系也理顺了:core依赖codec和pipeline的抽象,codec和pipeline的具体实现依赖各自的抽象。没有环,稳定方向正确——core是最稳定的(业务主流程很少变),codec和pipeline的具体实现是易变的(新增格式、新增步骤)。

验证方法:改造完成后,我一般会做三件事。第一,用依赖分析工具检查包依赖图,确认没有环。第二,写一个测试,只 mockImageCodec和ProcessStep,验证ImageProcessor的调用顺序正确。第三,新增一个假的TestCodec,确认不需要修改core包任何代码就能跑通。

从那以后我每次做结构改进,都强制走一遍“抽象依赖检查 → 包依赖图 → 新增一个实现验证扩展性”这三步。看起来多花二十分钟,但省下的是后面无数次的“改一处崩三处”。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进