当我们将职责划分为不同的 类 时试图理解 SRP

Trying to understand SRP when we seggregate responsibilities into different classes

我正在尝试了解 SRP 原理,但大多数 sof 线程都没有回答我遇到的这个特定查询,

用例

我正在尝试向用户的电子邮件地址发送一封电子邮件,以便在他尝试 register/create 网站中的用户帐户时验证自己。

没有 SRP

class UserRegistrationRequest {
    String name;
    String emailId;
}
class UserService {
    Email email;

    boolean registerUser(UserRegistrationRequest req) {
        //store req data in database
        sendVerificationEmail(req);
        return true;
    }

    //Assume UserService class also has other CRUD operation methods()    

    void sendVerificationEmail(UserRegistrationRequest req) {
        email.setToAddress(req.getEmailId());
        email.setContent("Hey User, this is your OTP + Random.newRandom(100000));
        email.send();
    }
}

上述 class 'UserService' 违反了 SRP 规则,因为我们将 'UserService' CRUD 操作和触发验证电子邮件代码合并为 1 个 class。

所以我这样做,

有 SRP

class UserService {
    EmailService emailService;

    boolean registerUser(UserRegistrationRequest req) {
        //store req data in database
        sendVerificationEmail(req);
        return true;
    }

    //Assume UserService class also has other CRUD operation methods()    

    void sendVerificationEmail(UserRegistrationRequest req) {
        emailService.sendVerificationEmail(req);
    }
}

class EmailService {
    void sendVerificationEmail(UserRegistrationRequest req) {
        email.setToAddress(req.getEmailId());
        email.setContent("Hey User, this is your OTP + Random.newRandom(100000));
        email.send();
    }

但即使 'with SRP',作为 class 的 UserService 再次持有 sendVerificationEmail() 的行为,尽管这次它没有持有发送电子邮件的全部逻辑。

即使在应用 SRP 之后,我们是否又将 crud 操作和 sendVerificationEmail() 合并为一个 class?

你的感觉完全正确。我同意你的看法。

我认为您的问题始于您的命名风格,因为您似乎很清楚 SRP 的含义。 Class 诸如“...Service”或“...Manager”之类的名称具有非常模糊的含义或语义。他们描述了一个更 概括化 的上下文或概念。换句话说,一个“...经理”class 邀请你把所有东西都放在里面,但它仍然感觉不错,因为它是一个 经理

当您通过尝试关注 classes 的真正概念或他们的职责变得更加具体时,您会自动找到具有更强含义或语义的更大名称。这将真正帮助您拆分 classes 并确定责任。

建议零售价:

There should never be more than one reason to change a certain module.

您可以先将 UserService 重命名为 UserDatabaseContext。现在这将自动强制您只将数据库相关操作放入此 class(例如 CRUD 操作)。

您甚至可以在此处获得更具体的信息。你用数据库做什么?您从 读取并向 写入。很明显是两个职责,也就是两个classes:一个负责读操作,一个负责写操作。这可能是非常通用的 classes,可以读取或写入 任何东西。我们称它们为 DatabaseReaderDatabaseWriter 并且因为我们试图解耦所有我们将在任何地方使用接口的东西。这样我们就得到了IDatabaseReaderIDatabaseWriter两个接口。这种类型的级别非常低,因为它们知道数据库(Microsoft SQL 或 MySql)、如何连接到它以及查询它的确切语言(例如使用 SQL 或 MySql):

// Knows how to connect to the database
interface IDatabaseWriter {
  void create(Query query);
  void insert(Query query);
  ...
}

// Knows how to connect to the database
interface IDatabaseReader {
  QueryResult readTable(string tableName);
  QueryResult read(Query query);
  ...
}

最重要的是,您可以实现更专业的读写操作层,例如用户相关数据。我们将引入一个 IUserDatabaseReader 和一个 IUserDatabaseWriter 接口。该接口不知道如何连接到数据库或使用什么类型的数据库。该接口只知道读取或写入用户详细信息所需的信息(例如,使用由低级别 IDatabaseReaderIDatabaseWriter 转换为真实查询的 Query 对象):

// Knows only about structure of the database (e.g. there is a table called 'user') 
// Implementation will internally use IDatabaseWriter to access the database
interface IUserDatabaseWriter {
  void createUser(User newUser);
  void updateUser(User user);
  void updateUserEmail(long userKey, Email emailInfo); 
  void updateUserCredentials(long userKey, Credential userCredentials); 
  ...
}

// Knows only about structure of the database (e.g. there is a table called 'user') 
// Implementation will internally use IDatabaseReader to access the database
interface IUserDatabaseReader {
  User readUser(long userKey);
  User readUser(string userName);
  Email readUserEmail(string userName);
  Credential readUserCredentials(long userKey);
  ...
}

我们还没有完成持久层。我们可以引入另一个接口IUserProvider。这个想法是将数据库访问与我们应用程序的其余部分分离。也就是说我们将用户相关的数据查询操作合并到这个class中。因此,IUserProvider 将是唯一可以直接访问数据层的类型。它形成应用程序持久层的接口:

interface IUserProvider {
  User getUser(string userName);
  void saveUser(User user);
  User createUser(string userName, Email email);
  Email getUserEmail(string userName);
}

执行IUserProvider。整个应用中唯一通过引用IUserDatabaseReaderIUserDatabaseWriter直接访问数据层的class。对数据的读写进行封装,使数据处理更加方便。该类型的职责是向应用程序提供用户数据:

class UserProvider {
  IUserDatabaseReader userReader;
  IUserDatabaseWriter userWriter;
    
    // Constructor
    public UserProvider (IUserDatabaseReader userReader, 
          IUserDatabaseWriter userWriter) {
      this.userReader = userReader;
      this.userWriter = userWriter;
    }

  public User getUser(string userName) {
    return this.userReader.readUser(username);
  }

  public void saveUser(User user) {
    return this.userWriter.updateUser(user);
  }

  public User createUser(string userName, Email email) {
    User newUser = new User(userName, email);
    this.userWriter.createUser(newUser);
    return newUser;
  }

  public Email getUserEmail(string userName) {
    return this.userReader.readUserEmail(userName);
  }
}

现在我们已经解决了数据库操作,我们可以专注于身份验证过程并通过添加新接口 IAuthentication:

继续从 UserService 中提取身份验证逻辑
interface IAuthentication {
  void logIn(User user)
  void logOut(User);
  void registerUser(UserRegistrationRequest registrationData);
} 

IAuthentication的实现实现了特殊的认证过程:

class EmailAuthentication implements IAuthentication {
  EmailService emailService;
  IUserProvider userProvider;

// Constructor
  public EmailAuthentication (IUserProvider userProvider, 
      EmailService emailService) {
    this.userProvider = userProvider;
    this.emailService = emailService;
  }

  public void logIn(string userName) {
    Email userEmail = this.userProvider.getUserEmail(userName);
    this.emailService.sendVerificationEmail(userEmail);
  }

  public void logOut(User user) {
    // logout
  }

  public void registerUser(UserRegistrationRequest registrationData) {
    this.userProvider.createNewUser(registrationData.getUserName, registrationData.getEmail());

    this.emailService.sendVerificationEmail(registrationData.getEmail());    
  }
}

为了将 EmailServiceEmailAuthentication class 分离,我们可以通过让 sendVerificationEmail() 接受一个 Email 参数对象来移除对 UserRegistrationRequest 的依赖相反:

class EmailService {
  void sendVerificationEmail(Email userEmail) {
    email.setToAddress(userEmail.getEmailId());
    email.setContent("Hey User, this is your OTP + Random.newRandom(100000));
    email.send();
}

由于身份验证是由接口 IAuthentication 定义的,因此您可以在决定使用不同的过程(例如 WindowsAuthentication)时随时创建新的实现,而无需修改现有代码.一旦您决定切换到不同的数据库(例如 Sqlite),这也适用于 IDatabaseReaderIDatabaseWriterIUserDatabaseReaderIUserDatabaseWriter 实现将在没有任何修改的情况下继续工作。

有了这个 class 设计,您现在有一个理由来修改每个现有类型:

  • EmailService 当您需要更改实现时(例如使用 不同的电子邮件 API)
  • IUserDatabaseReaderIUserDatabaseWriter 当您想要添加其他与用户相关的读取或写入操作时(例如处理用户角色)
  • 当您想要切换底层数据库或需要修改数据库访问时,提供 IDatabaseReaderIDatabaseWriter 的新实现
  • 程序更改时 IAuthentication 的实现(例如使用内置 OS 身份验证)

现在一切都干净利落地分开了。身份验证不与 CRUD 操作混合。我们在应用程序层和持久层之间有一个附加层,以增加有关底层持久性系统的灵活性。所以 CRUD 操作不会与实际的持久性操作混合。

提示:以后你最好先从思考(设计)部分开始:我的应用程序必须做什么?

  • 处理身份验证
  • 处理用户
  • 处理数据库
  • 处理电子邮件
  • 创建用户响应
  • 向用户显示查看页面

如您所见,您可以开始分别实施每个步骤或要求。但这并不意味着每个需求都由一个 class 实现。您还记得,我们将数据库访问分为四个职责或 classes:读取和写入真实数据库(低级别),读取和写入数据库抽象层,以反映具体的用例(高级)。使用接口增加了应用程序的灵活性和可测试性。

@BionicCode 已经很好地回答了这个问题。我只是不想添加一个简短的总结和我对此事的一些想法。

SRP 可能很棘手。

根据我的经验,责任的粒度的弃权[=您在系统中放置的 68=] 会影响它的易用性和大小。

您可以添加 a-lot 抽象并将所有内容分解为非常小的组件。这确实是我们应该努力的事情。

现在的问题是:什么时候停止?

这将取决于:

  • 您的应用程序的大小
  • 它的哪些部分会比其他部分更频繁地更改
  • 您是否需要将对象组合在一起,或者大多数时候您的模块彼此独立并且您不会重复使用很多对象。
  • 你有什么时间
  • 你的团队有多少人
  • 很多其他的东西...

先从团队规模说起。

我们将代码分解成单独的模块并将 类 分解成单独的文件的一个原因是这样我们就可以在团队中工作并避免在我们最喜欢的源代码控制系统中进行太多合并。如果您需要更改包含系统组件的文件,而其他人也需要更改它,那么这可能会很快变得难看。现在,如果您使用 SRP 分离模块,您会得到更多但更小的模块,这些模块在大多数情况下会相互独立地更改。

如果团队没有那么大,我们的模块也没有那么大怎么办?您需要生成更多吗?

这是一个例子。

假设您有一个具有设置的移动应用程序。我们可以说将这些设置包含在一个职责中,并将其添加到一个接口 IApplicationSettings 以容纳所有这些设置。

在我们有 30 个设置的情况下,这个界面会很大而且很糟糕。这也意味着我们可能再次违反了 SRP,因为该界面可能会保存多个不同类别的设置。

所以我们决定应用接口隔离原则SRP并将设置划分到多个接口ISomeCategorySettings,IAnotherCategorySettings

现在假设我们的应用程序(还)不是太大并且我们有 5 个设置。即使它们来自不同的类别,将这些设置保留在一个界面中是否不好?

我会说将所有设置放在一个界面中是很好的,只要它不会开始减慢我们的速度或开始变得丑陋(30 个或更多设置!)。

构建电子邮件并从您的 service 对象发送它有那么糟糕吗?这确实会很快变得丑陋,因此您最好将此责任从 service 对象快速转移到 EmailSender

如果您有一个包含 5 个方法的 service 对象,您真的需要为每个操作将其分成 5 个不同的对象吗?如果这些方法很大,是的。如果它们很小,将它们放在一个对象中,那就是一个大问题。

SRP 很棒,但要考虑粒度并根据代码大小、团队规模等明智地选择它。