关键词:设计原则、设计模式、单一职责原则、面向对象、SOLID、设计、编码、Java
1. 原则介绍-what
单一职责在不同书籍中的理解:
|
单一职责原则的英文是 Single Responsibility Principle,缩写为 SRP。这个原则的英文描述是:A class or module should have a single responsibility。翻译成中文就是:一个类或模块只负责完成一个职责。
名词理解: 职责(responsibility)可以理解为功能、变化的原因(A reason for change)、一类行为者(actor)。 模块(module)可以理解为是比类更加抽象的概念,由一个或多个类组成,是一类职责的聚合。 类和模块的道理是相通的,为了简便描述,下面统一以“类”的角度讲解单一职责原则。 |
单一职责原则备受争议却又极其重要,反映到编码实践上,就是要求不要设计大而全的类,要设计粒度小、职责单一的类。但职责划分界限并不是如马路上的行车道那么清晰,掌握最佳的尺度极其重要。
这里有几个关键问题:
|
2. 使用价值-why
面向对象将职责(需求)用类一个个隔开,这样可以将需求的变化局限在部分类中,避免因逻辑变更引起其他部分的不稳定,从而引发较多新BUG,陷入焦头烂额。 |
单一职责原则是为了实现代码高内聚、低耦合,提高代码的可读性、可维护性、复用性。
场景案例: 当在各类视频网站看电影、电视剧时,网站针对不同的用户类型,会在用户观看时给出不同的服务反馈: 访客用户:可以观看480P视频,有广告。 普通会员:可以观看720P超清视频,有广告。 VIP 会员:可以观看 1080P 蓝光视频,有广告。 |
简单的代码:
public class VideoUserService(){
public void serveGrade(String userType){
if("VIP会员".equals(userType)){
System.out.println("VIP会员,视频1080P蓝光");
System.out.println("VIP会员,视频有广告");
}else if("普通会员".equals(userType)){
System.out.println("普通会员,视频720P超清");
System.out.println("普通会员,视频有广告");
}else if("访客会员".equals(userType)){
System.out.println("访客会员,视频480P高清");
System.out.println("访客会员,视频有广告");
}
}
}
如上,实现业务功能逻辑的方式非常简单,虽然违反了单一职责原则,但不失为一种项目早期快速实现的选择。我们也完全没必要在业务方向还未完全定形时,去选择一些比较重的设计方法,反而对快速迭代带来了一定的困扰。但是如果这个模型是我们熟悉的领域,且业务的反战方向可以提前预见,那么在早期就需要进行良好设计。
经过一段时间,因为业务发展的需要,提出了新的需求: 访客用户:可以观看480P视频,有广告。 普通会员:可以观看720P超清视频,有广告。 VIP 会员:可以观看 1080P 蓝光视频,有广告,可以关闭或跳过。 |
简单代码继续迭代:
public class VideoUserService(){
public void serveGrade(String userType){
if("VIP会员".equals(userType)){
System.out.println("VIP会员,视频1080P蓝光");
System.out.println("VIP会员,视频有广告,可关闭或跳过");
}else if("普通会员".equals(userType)){
System.out.println("普通会员,视频720P超清");
System.out.println("普通会员,视频有广告");
}else if("访客会员".equals(userType)){
System.out.println("访客会员,视频480P高清");
System.out.println("访客会员,视频有广告");
}
}
}
Java |
迭代需要修改 VideoUserService 类,影响范围是所有用户功能,并且随着连续的版本迭代,新的需求逻辑越来越多的加入,这个方法就会变得非常臃肿。这里用 System.out.println() 示意,但实际上逻辑的实现代码可能会非常复杂。
为了应对不断迭代的需求,就不能使用一个类把所有职责行为混为一谈,而是需要提供一个上层的接口类,对不同的差异化用户给出单独的实现类,拆分各自的职责边界。 |
使用原则改善的编码:
上层接口类:
public interface IVideoUserService(){
// 视频清晰级别:1080P 720P 480P
void definition();
// 广告播放方式:无广告、有广告
void advertisement();
}
Java |
具体实现类:
// 访客会员 实现类
public class GuestVideoUserService implements IVideoUserService(){
public void definition(){
System.out.println("访客会员,视频480P高清");
}
public void advertisement(){
System.out.println("访客会员,视频有广告");
}
}
Java |
// 普通会员 实现类
public class MemberVideoUserService implements IVideoUserService(){
public void definition(){
System.out.println("普通会员,视频720P超清");
}
public void advertisement(){
System.out.println("普通会员,视频有广告");
}
}
Java |
// VIP会员 实现类
public class VipVideoUserService implements IVideoUserService(){
public void definition(){
System.out.println("VIP会员,视频1080P蓝光");
}
public void advertisement(){
System.out.println("VIP会员,视频有广告");
}
}
Java |
按照用户级别和行为拆分成了不同的类和方法。这样做的好处就是,如果要添加或者修改逻辑,那么只需要改动一个类的一个方法就可以了。比如对Vip用户的广告行为进行修改,只需要修改 VipVideoUserService.advertisement 就可以了。
重构为单一职责后,实现需求,只需要修改 VipVideoUserService 类,仅影响 VIP 用户的功能。
public class VipVideoUserService implements VideoUserService(){
public void definition(){
System.out.println("VIP会员,视频1080P蓝光");
}
public void advertisement(){
System.out.println("VIP会员,视频有广告,可关闭或跳过");
}
}
Java |
可以看到,如果每种服务职责都有对应的类实现,当某一类用户需要添加新的运营规则(例如,后续增加需求xx用户可以发弹幕、点播)时,逻辑简单清晰,开发和维护非常方便。
单一职责的实现价值:
这其实某种程度上是面向对象对比面向过程的优势之一。 |
3. 情境识别-when&where
单一职责原则的实践过程中,最难得地方就是识别“职责”。
在原则介绍章节,我们知道,职责的含义是功能、变化的原因、一类行为者。
那么功能、变化、行为到底是什么,拆分的颗粒度如何把握呢?
3.1 基于领域的职责拆分
一种识别职责的方式,其实就是“领域建模”。
领域模型是一种问题域模型,目标是将某个现实领域进行抽象和分解,将复杂问题拆解成一个个抽象,代表了特定的一块密集而内聚的信息。这样我们就可以将不同领域的对象,交由不同模块和类实现。
比如,在移动应用领域,我们可以识别出如下领域模型:
这一般是由架构师在系统业务及需求分析阶段,基于对业务的深入理解,高度抽象而来。
领域建模有一些通用方法,如用例分析法、DDD建模法、四色建模法等,这里不作为介绍重点。由于领域建模完成后并不是一成不变的,随着业务发展,模型会动态改变。这就要求我们编码人员一定要具备一定的领域“鉴赏”能力,对不合理的领域拆分要有一定的感知和识别能力。
举一个随着业务发展,领域发生动态改变的例子:
为了方便大家快速理解,例子相对通用化、简单化,实际业务一般会更加复杂,梳理和分析就已经很耗时了。 |
public class UserInfo {
private long userId;
private String username;
private String email;
private String telephone;
private long createTime;
private long lastLoginTime;
private String avatarUrl;
private String provinceOfAddress; // 省
private String cityOfAddress; // 市
private String regionOfAddress; // 区
private String detailedAddress; // 详细地址
// ...省略其他属性和方法...
}
Java |
识别:在UserInfo 类中,有较多的 Address 信息,是否需要拆分呢?
实际上,要从中做出选择,我们不能脱离具体的应用场景。
如果在这个社交产品中,用户的地址信息跟其他信息一样,只是单纯地用来展示,那 UserInfo 现在的设计就是合理的。但是,如果这个社交产品发展得比较好,之后又在产品中添加了电商的模块,用户的地址信息还会被用在电商物流中,那我们最好将地址信息从 UserInfo 中拆分出来,独立成用户物流信息(或者叫地址信息、收货信息等)。
public class UserInfo {
private long userId;
private String username;
private String email;
private String telephone;
private long createTime;
private long lastLoginTime;
private String avatarUrl;
// ...省略其他属性和方法...
}
Java |
public class UserAdress{
private long userId;
private String provinceOfAddress; // 省
private String cityOfAddress; // 市
private String regionOfAddress; // 区
private String detailedAddress; // 详细地址
// ...
}
Java |
再进一步延伸一下。
如果做这个社交产品的公司发展得越来越好,公司内部又开发出了很多其他产品(可以理解为其他 App)。公司希望支持统一账号系统,也就是用户一个账号可以在公司内部的所有产品中登录。这个时候,我们就需要继续对 UserInfo 进行拆分,将跟身份认证相关的信息(比如 email、telephone 等)抽取成独立的类。
public class UserInfo {
private long userId;
private long createTime;
private long lastLoginTime;
private String avatarUrl;
// ...省略其他属性和方法...
}
Java |
public class UserAuth {
private long userId;
private String username;
private String email;
private String telephone;
// ...
}
Java |
public class UserAdress{
private long userId;
private String provinceOfAddress; // 省
private String cityOfAddress; // 市
private String regionOfAddress; // 区
private String detailedAddress; // 详细地址
// ...
}
Java |
我们可以总结出,不同的应用场景、不同阶段的需求背景下,对同一个类的职责是否单一的判定,可能都是不一样的。在某种应用场景或者当下的需求背景下,一个类的设计可能已经满足单一职责原则了,但如果换个应用场景或着在未来的某个需求背景下,可能就不满足了,需要继续拆分成粒度更细的类。
3.2 基于行为的职责拆分
最常见的单一职责的应用——分层拆分:将接口、行为、持久化等职责拆分到不同类中:
// 用户数据
public class User{
}
// ------------------------------------------------------------------
// 对外接口定义
public class UserController {
void registerUser(User user){}
void deleteUser(){}
}
// ------------------------------------------------------------------
// 用户行为
public interface IUserService {
void registerUser(User user);
void deleteUser();
}
// ------------------------------------------------------------------
// 用户持久化
public interface UserRepository {
void saveUser(User user);
}
Java |
关于行为的拆分,是不是我们拆分到最细,一个类单独一个方法,是不是就万事大吉了呢?答案显而易见,也不是的,因为这样会让类的内聚性不足,反而对维护带来的障碍。
这里简单举个反例:
Serialization 类实现了一个简单协议的序列化和反序列功能,具体代码如下:
/**
* Protocol format: identifier-string;{gson string}
* For example: UEUEUE;{"a":"A","b":"B"}
*/
public class Serialization {
private static final String IDENTIFIER_STRING = "UEUEUE;";
private Gson gson;
public Serialization() {
this.gson = new Gson();
}
public String serialize(Map<String, String> object) {
StringBuilder textBuilder = new StringBuilder();
textBuilder.append(IDENTIFIER_STRING);
textBuilder.append(gson.toJson(object));
return textBuilder.toString();
}
public Map<String, String> deserialize(String text) {
if (!text.startsWith(IDENTIFIER_STRING)) {
return Collections.emptyMap();
}
String gsonStr = text.substring(IDENTIFIER_STRING.length());
return gson.fromJson(gsonStr, Map.class);
}
}
Java |
如果我们想让类的职责更加单一,我们对 Serialization 类进一步拆分,拆分成一个只负责序列化工作的 Serializer 类和另一个只负责反序列化工作的 Deserializer 类。拆分后的具体代码如下所示:
public class Serializer {
private static final String IDENTIFIER_STRING = "UEUEUE;";
private Gson gson;
public Serializer() {
this.gson = new Gson();
}
public String serialize(Map<String, String> object) {
StringBuilder textBuilder = new StringBuilder();
textBuilder.append(IDENTIFIER_STRING);
textBuilder.append(gson.toJson(object));
return textBuilder.toString();
}
}
public class Deserializer {
private static final String IDENTIFIER_STRING = "UEUEUE;";
private Gson gson;
public Deserializer() {
this.gson = new Gson();
}
public Map<String, String> deserialize(String text) {
if (!text.startsWith(IDENTIFIER_STRING)) {
return Collections.emptyMap();
}
String gsonStr = text.substring(IDENTIFIER_STRING.length());
return gson.fromJson(gsonStr, Map.class);
}
}
虽然经过拆分之后,Serializer 类和 Deserializer 类的职责更加单一了,但也随之带来了新的问题。
- 如果我们修改了协议的格式,数据标识从UEUEUE改为 DFDFDF,或者序列化方式从 JSON 改为了 XML,那 Serializer 类和 Deserializer 类都需要做相应的修改,代码的内聚性显然没有原来高了。
- 如果我们仅仅对 Serializer 类做了协议修改,而忘记了修改 Deserializer 类的代码,那就会导致序列化、反序列化不匹配,程序运行出错,也就是说,拆分之后,代码的可维护性变差了。
有的同学会说,那可以再定义一个公共属性,共同使用。确实是可以的,但复杂性相比原来大大提高了,这真的值得或者有必要吗?如果没有充足的理由,那这其实就是一种过度设计了。
好的设计要在高内聚和低耦合间取舍平衡的,也就是说高内聚和低耦合是冲突的。 |
关于如何平衡取舍,需要一定的理论知识和实践经验,这里不作为介绍重点。
3.3 基于经验的职责拆分
在实际编码过程中,对于何时何处需要考量单一职责拆分,有一些主观感受的经验技巧,可供参考:
- 类代码超过200行,方法或属性超过10个。内容过多严重影响代码可读性和可维护性,需要拆分审视。
- 类依赖的其他类过多,或者依赖类的其他类过多。
- 私有方法过多。我们要考虑能否将私有方法独立到新的类中,设置为 public 方法,供更多的类使用,从而提高代码的复用性。例如,很多同学事把工具类的方法写在业务类中,或者没有提取可复用的业务处理方法。
- 类名与实际不符。很难用一个业务名词概括类名,或者只能用一些笼统的 Manager、Context 之类的词语来命名,这就说明类的职责定义得可能不够清晰。
- 类中大量的方法都是集中操作某几个属性。比如,在 UserInfo 例子中,如果一半的方法都是在操作 address 信息,那就可以考虑将这几个属性和对应的方法拆分出来。
实际上,200行,10个,这些数字都不是一成不变的,它们只是一个宽泛的、强行量化的标准。需要我们不断在实际项目中使用和打磨,逐步形成一种感觉,一种对美的鉴赏力。
4. 实践技巧-how
4.1 使用方法
康威定律:一个组织设计出的系统,其结构受限于其组织的沟通结构。 Robert Martin说,单一职责原则是基于康威定律的一个推论:一个软件系统的最佳结构高度依赖于使用这个软件的组织的内部结构。 |
单一职责原则这个看起来最简单的原则,实际上也蕴含着很多值得挖掘的内容。
- 我们要深入理解需求,才能准确的进行功能拆解和职责定义。
- 我们需要理解封装,知道要把什么样的内容放到一起。
- 我们需要理解分离关注点,将明确不同的内容拆分开来。
- 我们需要理解变化的来源,预测业务扩展的方向,把不同行为者负责进行代码拆分。
4.2 结合设计模设
对于单一原则的实践,有一些固定套路,即设计模式:
- 工厂模式:将对象创建的责任从业务逻辑中分离出来,使得类只负责业务逻辑。
- 策略模式:将不同的算法封装到不同的策略类中,使得算法的变化不影响使用算法的客户端代码。
- 观察者模式:将一个对象的状态变化通知到多个观察者对象,从而分离了对象之间的依赖关系。
- 装饰者模式:在不改变对象接口的情况下,动态增加对象的功能,符合单一职责原则中的“职责分离”思想。
4.3 实践要点
持续重构:
实际的业务场景会更加复杂,评价一个类的职责是否足够单一,并没有一个非常清晰的、明确的、可以量化的标准。可以说,这是件非常主观、仁者见仁智者见智的事情。
在真正的软件开发中,我们也没必要过于未雨绸缪,过度设计。
所以,我们可以先写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构。
不管是应用设计原则还是设计模式,最终的目的还是提高代码的可读性、可扩展性、复用性、可维护性等。我们在考虑应用某一个设计原则是否合理的时候,也可以以此作为最终的考量标准。
注意事项:
- 职责的划分:在复杂系统中,如何界定一个“职责”因项目和环境而异,较难度量,可能会具有挑战性。
- 过度拆分:过度遵循SRP可能导致类的数量激增,进而增加系统复杂度。
- 持续重构:职责的划分往往不能一蹴而就,在业务发展过程中,需要不断的审视和调整,找到最佳维度。
4.4 结合开闭原则
SRP与开闭原则(Open/Closed Principle)密切相关。开闭原则要求软件实体(类、模块、函数等)应对扩展开放,对修改封闭。通过遵循SRP,可以更容易地遵循开闭原则。
关于开闭原则,可以参考