Попытка понять SRP, когда мы разделяем обязанности на разные классы - PullRequest
3 голосов
/ 29 июня 2019

Я пытаюсь понять принцип SRP, и большинство программных потоков не ответили на мой конкретный запрос,

Использование регистра

Я пытаюсь отправить электронное письмо на адрес электронной почты пользователя, чтобы проверить себя всякий раз, когда он пытается зарегистрироваться / создать учетную запись пользователя на веб-сайте.

Без 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();
    }
}

Приведенный выше класс 'UserService' нарушает правило SRP, поскольку мы объединяем операции CRUD 'UserService' и запускаем код подтверждения по электронной почте в один отдельный класс.

Отсюда и я,

С 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();
    }

Но даже «с SRP», UserService как класс снова имеет поведение sendVerificationEmail (), хотя на этот раз он не содержал всю логику отправки электронной почты.

Разве не опять мы объединяем операции crud и sendVerificationEmail () в один отдельный класс даже после применения SRP?

Ответы [ 2 ]

3 голосов
/ 29 июня 2019

Ваше чувство абсолютно верно. Я согласен с вами.

Я думаю, что ваша проблема начинается с вашего стиля именования, так как вы, кажется, прекрасно понимаете, что означает SRP . Имена классов, такие как «... Service» или «... Manager», имеют очень смутное значение или семантику. Они описывают более обобщенный контекст или концепцию. Другими словами, класс '... Manager' предлагает вам поместить все внутрь, и он все еще чувствует себя хорошо, потому что это manager .

Когда вы станете более конкретными, пытаясь сосредоточиться на истинных концепциях ваших классов или их обязанностях, вы автоматически найдете более крупные имена с более сильным значением или семантикой. Это действительно поможет вам разделить классы и определить обязанности.

SRP:

Не должно быть более одной причины для изменения определенного модуля.

Вы можете начать с переименования UserService в UserDatabaseContext. Теперь это автоматически вынудит вас помещать в этот класс только операции, связанные с базой данных (например, операции CRUD).

Вы даже можете получить более конкретный здесь. Что вы делаете с базой данных? Вы читаете из и пишите в него. Очевидно две обязанности, что означает два класса: один для операций чтения и другой, ответственный за операции записи. Это могут быть очень общие классы, которые могут просто читать или писать что угодно . Давайте назовем их DatabaseReader и DatabaseWriter, и поскольку мы пытаемся отделить все, мы будем использовать интерфейсы везде. Таким образом, мы получаем два интерфейса IDatabaseReader и IDatabaseWriter. Эти типы очень низкого уровня, так как они знают базу данных (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. Эти интерфейсы не знают, как подключиться к базе данных или какой тип базы данных используется. Эти интерфейсы знают только, какая информация требуется для чтения или записи пользовательских данных (например, с использованием объекта Query, который преобразуется в реальный запрос с низким уровнем IDatabaseReader или IDatabaseWriter):

// Knows only about structure of the database (e.g. there is a table called 'user') 
// Implementation will use IDatabaseWriter to access the database
interface IDatabaseWriter {
  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 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. Идея состоит в том, чтобы отделить доступ к базе данных от остальной части нашего приложения. Другими словами, мы объединяем операции запроса данных, связанных с пользователем, в этот класс. Таким образом, IUserProvider будет единственным типом, который имеет прямой доступ к уровню данных. Он формирует интерфейс для уровня сохраняемости приложения:

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

Реализация IUserProvider. Единственный класс во всем приложении, который имеет прямой доступ к уровню данных, ссылаясь на IUserDatabaseReader и IUserDatabaseWriter. Он оборачивает чтение и запись данных, чтобы сделать обработку данных более удобной. Ответственность этого типа заключается в предоставлении пользовательских данных приложению:

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);
  }
}

Теперь, когда мы взялись за операции с базой данных, мы можем сосредоточиться на процессе аутентификации и продолжить извлекать логику аутентификации из UserService, добавив новый интерфейс IAuthentication:

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());    
  }
}

Чтобы отделить EmailService от класса EmailAuthentication, мы можем удалить зависимость от UserRegistrationRequest, позволив sendVerificationEmail() взять объект параметра Email` вместо этого:

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), но без изменения существующего кода. Это также будет работать с IDatabaseReader и IDatabaseWriter, когда вы решите переключиться на другую базу данных (например, Sqlite). Реализации IUserDatabaseReader и IUserDatabaseWriter будут работать без изменений.

С этим дизайном класса у вас теперь есть ровно одна причина для изменения каждого существующего типа:

  • EmailService, когда вам нужно изменить реализацию (например, использовать другой почтовый API)
  • IUserDatabaseReader или IUserDatabaseWriter, когда вы хотите добавить дополнительные операции чтения или записи, связанные с пользователем (например, для обработки пользователяроль)
  • предоставляют новые реализации IDatabaseReader или IDatabaseWriter, когда вы хотите переключить базовую базу данных или вам нужно изменить доступ к базе данных
  • реализации IAuthentication при изменении процедуры (например,используя встроенную аутентификацию ОС)

Теперь все чисто разделено.Аутентификация не смешивается с операциями CRUD.У нас есть дополнительный уровень между приложением и постоянным уровнем, чтобы добавить гибкость в отношении базовой персистентной системы.Таким образом, операции CRUD не сочетаются с фактическими операциями сохранения.

В качестве подсказки: в будущем вам лучше начать сначала с размышления (разработки): что должно делать мое приложение?

  • обрабатывать аутентификацию
  • обрабатывать пользователей
  • обрабатывать базу данных
  • обрабатывать электронную почту
  • создавать ответы пользователей
  • показывать страницы просмотра пользователю
  • и т. Д.

Как видите, вы можете приступить к реализации каждого шага или требования отдельно.Но это не значит, что каждое требование реализуется одним классом.Как вы помните, мы разделили доступ к базе данных на четыре обязанности или класса: чтение и запись в реальную базу данных (низкий уровень), чтение и запись в слой абстракции базы данных, чтобы отразить конкретные варианты использования (высокий уровень).Использование интерфейсов повышает гибкость и тестируемость приложения.

1 голос
/ 29 июня 2019

@BionicCode уже дал отличный ответ на этот вопрос.Я просто не хочу добавлять краткое резюме и некоторые мои мысли по этому вопросу.

SRP может быть хитрым.

По моему опыту гранулярность обязанностей и количество абстиненций , которые вы помещаете в свою систему,влияет на его простоту использования и его размер.

Вы можете добавить множество абстракций и разбить все на очень маленькие компоненты.Это действительно то, к чему мы должны стремиться.

Теперь вопрос: Когда остановиться?

Это будет зависеть от:

  • Размервашего приложения
  • Какие его части будут меняться чаще, чем другие
  • Вам нужно составлять объекты вместе, или большую часть времени ваши модули независимы друг от друга, а вы нет?наберите много объектов.
  • Сколько времени у вас есть
  • Каков размер вашей команды
  • Много других вещей ...

Давайте начнем с того, насколько велика команда.

Одна из причин, по которой мы разбиваем наш код на отдельные модули и классы на отдельные файлы, заключается в том, что мы можем работать в команде и избегать слишком большого количества слияний в нашей любимой системе контроля версий.,Если вам нужно изменить файл, который содержит компонент вашей системы, и кто-то другой должен изменить его тоже, это может быть довольно быстро.Теперь, если вы делаете отдельные модули с использованием SRP, вы получаете больше, но меньшие модули, которые в большинстве случаев будут меняться независимо друг от друга.

Что если команда не такая большая, а наши модули тоже не такие большие?Вам нужно сгенерировать их больше?

Вот пример.

Допустим, у вас есть мобильное приложение с настройками.Мы можем сказать, что связавшись с этими установками, несем ответственность и добавим ее к одному интерфейсу IApplicationSettings, чтобы удержать их все.

В случае, если у нас 30 настроек, этот интерфейс будет огромным, и это плохо.Это также означает, что мы, вероятно, снова нарушаем SRP, поскольку этот интерфейс, вероятно, будет содержать настройки для нескольких различных категорий.

Поэтому мы решили применить Принцип сегрегации интерфейса и SRP и разделите настройки на несколько интерфейсов ISomeCategorySettings, IAnotherCategorySettings и т. д.

Теперь давайте скажем, что наши приложения не слишком велики (пока), и у нас есть 5 настроек.Даже если они принадлежат к разным категориям, разве плохо хранить эти настройки в одном интерфейсе?

Я бы сказал, что хорошо иметь все настройки в одном интерфейсе, если это не начинает замедлять насили начать становиться уродливым (30 или более установок!).

Разве это плохо - создавать электронную почту и отправлять ее из вашего service объекта?Это действительно то, что может стать довольно уродливым довольно быстро, поэтому вам лучше перенести эту ответственность с объекта service на EmailSender fast.

Если у вас есть объект service, который содержит 5 методов, выполнитеВам действительно нужно разбить это на 5 различных объектов для каждой операции?Если эти методы большие, да.Если они маленькие, держать их в одном объекте - это большая проблема.

SRP - это здорово, но нужно учитывать гранулярность и выбирать ее разумно, исходя из размера кода, размера команды и т. Д.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...