3、单一职责原则(SRP)下:实际案例——用户管理模块的拆分,过度细分的陷阱,SRP与类的内聚性

上一节我们聊了SRP的理论基础。说白了,就是一个类只该有一个被修改的理由。但理论归理论,落地的时候,坑比想象中多得多。

今天,我拿一个真实的用户管理模块来拆解。这个案例是我几年前参与的一个后台系统,当时代码写得那叫一个“爽”,后来维护起来,嗯,你懂的。

一个“万能”的用户类

先看一个典型的反面教材。很多新手(包括当年的我)都会写出这样的类:

class UserManager {
public:
    // 用户数据操作
    bool createUser(const UserInfo& info);
    UserInfo getUserById(int id);
    bool updateUserEmail(int id, const std::string& email);
    
    // 权限校验
    bool checkPermission(int userId, const std::string& resource);
    bool isAdmin(int userId);
    
    // 通知发送
    bool sendWelcomeEmail(const UserInfo& info);
    bool sendPasswordResetEmail(const std::string& email);
    
    // 日志记录
    void logUserAction(int userId, const std::string& action);
    
private:
    Database db_;
    EmailService emailSvc_;
    Logger logger_;
};

这个类看起来挺全的,对吧?但问题就出在“全”上。它同时承担了数据持久化、权限校验、通知发送、日志记录四个职责。任何一个职责的变化,都会导致这个类被修改。

我记得有一次,产品经理说:“用户注册后,欢迎邮件里要加个优惠券链接。” 就这一句话,我改了 UserManager 里的 sendWelcomeEmail 方法。结果呢?不小心把 createUser 里的一个事务提交逻辑给注释掉了,线上出了数据不一致的bug。这就是典型的“牵一发而动全身”。

按职责拆分:SRP的第一次实践

痛定思痛,我决定按单一职责原则重构。拆分的思路其实很清晰:

  • 数据操作UserRepository
  • 权限校验PermissionService
  • 通知发送NotificationService
  • 日志记录UserLogger

重构后的代码大概长这样:

class UserRepository {
public:
    bool createUser(const UserInfo& info);
    UserInfo getUserById(int id);
    bool updateUserEmail(int id, const std::string& email);
};

class PermissionService {
public:
    bool checkPermission(int userId, const std::string& resource);
    bool isAdmin(int userId);
};

class NotificationService {
public:
    bool sendWelcomeEmail(const UserInfo& info);
    bool sendPasswordResetEmail(const std::string& email);
};

class UserLogger {
public:
    void logUserAction(int userId, const std::string& action);
};

这样一来,每个类只有一个职责。邮件模板变了,只改 NotificationService;权限规则变了,只改 PermissionService。互不干扰。

核心要点: 拆分后的每个类,应该只有一个“被修改的理由”。如果你能说出“这个类因为A原因要改,因为B原因也要改”,那它就不符合SRP。

过度细分的陷阱:别把SRP当教条

但是,事情没那么简单。我见过一些团队,把SRP理解成了“一个类只能有一个方法”。结果呢?

class UserCreator {
public:
    bool execute(const UserInfo& info);
};

class UserGetter {
public:
    UserInfo execute(int id);
};

class EmailUpdater {
public:
    bool execute(int id, const std::string& email);
};

每个类只有一个方法,这确实“单一”了。但这是好的设计吗?我个人觉得,这完全是走火入魔。

为什么会这样?因为这些人把“职责”理解成了“操作”。一个职责可以包含多个紧密相关的操作。比如“用户数据持久化”这个职责,天然就包含增、删、改、查。强行拆成四个类,反而让调用方变得很痛苦——你得记住四个不同的类名,还得管理它们之间的依赖关系。

避坑指南: 我曾经在一个项目里看到过30多个“单方法类”,每个类就一个Execute方法。结果呢?业务逻辑散落在各个地方,想看懂一个完整的业务流程,得翻十几个文件。这比那个“万能类”还难维护。SRP不是让你把类拆得越碎越好,而是让你把“变化点”隔离开。

SRP与类的内聚性:硬币的两面

说到这儿,就不得不提内聚性。内聚性衡量的是类内部元素之间的关联程度。高内聚意味着类里的方法和数据都服务于同一个目的。

SRP和类内聚性,其实是同一个问题的两面:

维度 SRP视角 内聚性视角
关注点 变化的原因 内部元素的关联度
目标 减少变化影响范围 提高模块的独立性
判断标准 一个类是否只有一个被修改的理由 类的方法是否都操作了相同的成员变量
常见问题 职责过多(上帝类) 内聚性低(工具类)

你想想看,如果一个类里的方法操作的是完全不相关的数据,那它肯定违反了SRP。反过来,如果一个类只做一件事,那它的内聚性自然就高。

举个例子,UserRepository 里的所有方法都操作 UserInfoDatabase,这就是高内聚。而那个 UserManager,既有数据库操作,又有邮件发送,还有日志记录,这些方法之间没有共同的数据依赖,内聚性就很低。

我的个人习惯: 写类的时候,我会先问自己三个问题:

  1. 这个类里的方法,是不是都操作同一组数据?
  2. 如果需求变了,我改这个类的概率有多大?
  3. 这个类能不能用一句话说清楚它干什么?

如果三个问题的答案都是肯定的,那这个类的设计大概率没问题。

如何把握拆分的粒度?

说了这么多,你可能要问:到底拆到什么程度才算合适?

我个人的经验是,看“变化频率”和“变化原因”。如果两个操作经常因为不同的原因而修改,那就拆开。如果它们总是因为同一个原因而修改,那就放在一起。

比如,用户注册时,创建用户记录和发送欢迎邮件,这两个操作看起来是“一起发生的”。但它们变化的原因不同:

  • 创建用户记录:可能因为数据库表结构变化、字段校验规则变化而改。
  • 发送欢迎邮件:可能因为邮件模板变化、发送策略变化而改。

所以,它们应该拆开。

而创建用户记录和更新用户邮箱,虽然操作不同,但都是对用户数据的持久化操作,变化的原因也类似(都是因为数据模型变化),所以放在 UserRepository 里是合理的。

下面这张图,展示了用户管理模块按SRP拆分后的整体结构:

用户管理模块 · SRP拆分结构 调用方(Controller) UserRepository 用户数据持久化 增删改查操作 PermissionService 权限校验 角色判断 NotificationService 邮件/短信通知 模板管理 UserLogger 操作日志记录 审计追踪 底层基础设施(数据库、邮件服务器、文件系统) 每个服务类只负责一个职责,变化隔离,互不影响 调用方通过组合方式使用这些服务,完成完整的业务流程

你看,调用方(比如一个Controller)需要组合使用这四个服务,才能完成一个完整的用户注册流程。但每个服务内部的变化,都不会影响到其他服务。这就是SRP带来的好处。

总结一下: SRP不是让你把类拆得越细越好,而是让你把“变化点”隔离好。判断的标准不是“这个类有几个方法”,而是“这个类有几个被修改的理由”。一个类,一个理由,足矣。

嗯,关于用户管理模块的拆分,今天就聊到这儿。记住,好的设计不是一蹴而就的,而是在不断的重构中打磨出来的。下次你看到一个“万能类”的时候,不妨想想:它到底承担了几个职责?


公众号:蓝海资料掘金营,微信deep3321