Попытка понять SRP, когда мы распределяем обязанности по разным классам

Я пытаюсь понять принцип 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» и инициируем код проверки электронной почты в 1 единственный класс.

Следовательно, я делаю,

С СРП

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(), хотя на этот раз он не содержал всей логики отправки электронной почты.

Разве мы снова не объединяем грубую операцию и sendVerificationEmail() в один класс даже после применения SRP?


person Manikandan Kbk DIP    schedule 29.06.2019    source источник
comment
Разница заключается в использовании другого класса, который объединяет все (некоторые) операции для электронной почты. Это на стороне дизайна и с SRP, в котором говорится, что один класс должен нести только одну ответственность (перевод означает, что UserService использует делегирование EmailService). Также вы можете добавить метод registerUser в EmailService и снова использовать делегирование в UserService (это касается только стороны дизайна)   -  person Traian GEICU    schedule 29.06.2019


Ответы (2)


Ваше ощущение абсолютно правильное. Я с тобой согласен.

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

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

Рекомендуемая розничная цена:

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

Вы можете начать с переименования 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 не смешиваются с реальными операциями сохранения.

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

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

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

person BionicCode    schedule 29.06.2019

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

SRP может быть сложным.

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

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

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

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

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

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

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

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

Вот пример.

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

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

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

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

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

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

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

SRP — это здорово, но учитывайте детализацию и выбирайте ее с умом, исходя из размера кода, размера команды и т. д.

person expandable    schedule 29.06.2019
comment
Большая часть того, что вы написали, является мнением. Некоторые вещи не соответствуют действительности или перепутаны. Во-первых, мне нравится эта тема, и я люблю обсуждать. Вот почему я комментирую ваш пост. Я стараюсь быть кратким. Размер команды не имеет значения, поскольку мы используем инструменты рефакторинга, которые извлекают методы в классы и интерфейсы из классов или классы из интерфейсов. SRP не является дополнительным кодом. Это код, который нужно написать в любом случае, просто лучше организовать. Да, время может быть убийцей чистого кода. Но счет придет, когда нам придется добавлять функции, исправлять ошибки, изменять функции, чтобы соответствовать ожиданиям клиентов. - person BionicCode; 29.06.2019
comment
Я согласен с тем, что вы говорите. Маленькие объекты и методы легче использовать и компоновать. Добавление большего количества вещей также имеет стоимость. Люди начинают с работы в течение 3 месяцев, добавляя воздержание, прежде чем они что-то выпустят. За использование IDatabaseReader, IUserDatabaseReader и IUserProvider вместо одного IUserRepository взимается плата. Я начинаю с хорошего дизайна с нужной степенью детализации, а затем рефакторинг по мере необходимости. Я думаю, что SRP действительно помогает с контролем версий. Больше интерфейсов и классов с отдельными обязанностями —> меньше точек соперничества. - person expandable; 29.06.2019
comment
Контроль версий - это не причина для SRP, это следствие того, что с ним проще работать, если вы используете SRP. Я использовал интерфейсы, чтобы донести мысль. Возможно, это действительно сбивало с толку и звучало как путаница. Реализация 5 интерфейсов из одного класса, вероятно, возложит на него больше обязанностей и вызовет конфликтную ситуацию. По моему опыту, лучше иметь более мелкие классы и использовать композицию. Небольшие приложения легко понять и модифицировать. Большие сложнее. Если приложение меньше и его легко понять, я не вижу необходимости ломать что-то дальше. - person expandable; 29.06.2019
comment
Также важна экономия времени. Трудно получить хороший код, когда нужно сэкономить время, но иногда (конечно, не всегда) это нужно делать. Если мы не вывезем вещи вовремя и останемся без работы, потому что компания, на которую мы работаем, обанкротилась (или, что еще хуже, это может быть ваша собственная компания), это не круто. Если вы работаете в организации, у которой много денег и свободного времени, хорошо. Если вам нужно что-то сделать, чтобы заработать деньги, это другая история. Хорошо ли получить хорошие юнит-тесты и SRP, которые обанкротят компанию? Теперь домен, такой как здравоохранение, — это другая история. - person expandable; 29.06.2019