Принцип единой ответственности: рецепт отличного кода

Опубликовано: 2022-03-11

Независимо от того, что мы считаем отличным кодом, он всегда требует одного простого качества: код должен поддерживаться. Правильные отступы, аккуратные имена переменных, 100%-ное покрытие тестами и т. д. — далеко не все. Любой код, который не поддается сопровождению и не может относительно легко адаптироваться к изменяющимся требованиям, — это код, ожидающий устаревания. Нам может не понадобиться писать отличный код, когда мы пытаемся создать прототип, доказательство концепции или минимально жизнеспособный продукт, но во всех других случаях мы всегда должны писать код, который можно поддерживать. Это то, что следует считать фундаментальным качеством разработки и проектирования программного обеспечения.

Принцип единой ответственности: рецепт отличного кода

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

Модель - это все

Почти каждая книга о каком-нибудь новом MVC (MVP, MVVM или другом M**) фреймворке пестрит примерами плохого кода. Эти примеры пытаются показать, что может предложить фреймворк. Но они также заканчиваются тем, что дают плохие советы новичкам. Примеры вроде «скажем, у нас есть ORM X для наших моделей, механизм шаблонов Y для наших представлений, и у нас будут контроллеры для управления всем этим» не дают ничего, кроме огромных контроллеров.

Хотя в защиту этих книг, примеры предназначены для демонстрации простоты, с которой вы можете начать работу с их структурой. Они не предназначены для обучения разработке программного обеспечения. Но читатели, следящие за этими примерами, только спустя годы осознают, насколько контрпродуктивно иметь монолитные куски кода в своем проекте.

Модели — это сердце вашего приложения.

Модели — это сердце вашего приложения. Если у вас есть модели, отделенные от остальной логики вашего приложения, обслуживание будет намного проще, независимо от того, насколько сложным станет ваше приложение. Даже для сложных приложений хорошая реализация модели может привести к чрезвычайно выразительному коду. И чтобы достичь этого, начните с того, чтобы убедиться, что ваши модели делают только то, для чего они предназначены, и не заботятся о том, что делает приложение, построенное вокруг них. Кроме того, его не волнует, что представляет собой базовый уровень хранения данных: опирается ли ваше приложение на базу данных SQL или оно хранит все в текстовых файлах?

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

Принцип единой ответственности

Вы наверняка слышали о принципах SOLID: единая ответственность, открытие-закрытие, подстановка лисков, разделение интерфейсов и инверсия зависимостей. Первая буква, S, представляет принцип единой ответственности (SRP), и его важность невозможно переоценить. Я бы даже сказал, что это необходимое и достаточное условие для хорошего кода. На самом деле, в любом плохо написанном коде всегда можно найти класс, который несет более одной ответственности — form1.cs или index.php, содержащие несколько тысяч строк кода, встречаются не так уж редко, и все мы наверное видел или делал.

Давайте рассмотрим пример на C# (ASP.NET MVC и Entity framework). Даже если вы не являетесь разработчиком C#, с некоторым опытом ООП вы сможете легко следовать этому курсу.

 public class OrderController { ... public ActionResult CreateForm() { /* * View data preparations */ return View(); } [HttpPost] public ActionResult Create(OrderCreateRequest request) { if (!ModelState.IsValid) { /* * View data preparations */ return View(); } using (var context = new DataContext()) { var order = new Order(); // Create order from request context.Orders.Add(order); // Reserve ordered goods …(Huge logic here)... context.SaveChanges(); //Send email with order details for customer } return RedirectToAction("Index"); } ... (many more methods like Create here) }

Это обычный класс OrderController, показан его метод Create. В подобных контроллерах я часто вижу случаи, когда в качестве параметра запроса используется сам класс Order. Но я предпочитаю использовать специальные классы запросов. Опять СРП!

Слишком много заданий для одного контроллера

Обратите внимание на фрагмент кода выше, как контроллер знает слишком много о «размещении заказа», включая, помимо прочего, хранение объекта «Заказ», отправку электронных писем и т. д. Это просто слишком много заданий для одного класса. Для каждого небольшого изменения разработчик должен изменить весь код контроллера. И на тот случай, если другому контролеру тоже нужно создавать заказы, чаще всего разработчики прибегают к копипасту кода. Контроллеры должны контролировать только весь процесс, а не всю его логику.

Но сегодня тот день, когда мы перестанем писать эти огромные контроллеры!

Давайте сначала извлечем всю бизнес-логику из контроллера и переместим ее в класс OrderService:

 public class OrderService { public void Create(OrderCreateRequest request) { // all actions for order creating here } } public class OrderController { public OrderController() { this.service = new OrderService(); } [HttpPost] public ActionResult Create(OrderCreateRequest request) { if (!ModelState.IsValid) { /* * View data preparations */ return View(); } this.service.Create(request); return RedirectToAction("Index"); }

Сделав это, контроллер теперь делает только то, для чего он предназначен: управляет процессом. Он знает только о представлениях, классах OrderService и OrderRequest — наименьшем наборе информации, необходимом для выполнения его работы, то есть управления запросами и отправки ответов.

Таким образом, вы редко будете менять код контроллера. Другие компоненты, такие как представления, объекты запросов и службы, могут изменяться, поскольку они связаны с бизнес-требованиями, но не с контроллерами.

Это то, что касается SRP, и существует множество техник написания кода, отвечающего этому принципу. Одним из примеров этого является внедрение зависимостей (то, что также полезно для написания тестируемого кода).

Внедрение зависимости

Трудно представить большой проект, основанный на принципе единой ответственности, без внедрения зависимостей. Давайте еще раз взглянем на наш класс OrderService:

 public class OrderService { public void Create(...) { // Creating the order(and let's forget about reserving here, it's not important for following examples) // Sending an email to client with order details var smtp = new SMTP(); // Setting smtp.Host, UserName, Password and other parameters smtp.Send(); } }

Этот код работает, но не совсем идеален. Чтобы понять, как работает метод создания класса OrderService, они вынуждены разбираться в тонкостях SMTP. И, опять же, копирование-вставка — единственный способ воспроизвести это использование SMTP везде, где это необходимо. Но с небольшим рефакторингом это может измениться:

 public class OrderService { private SmtpMailer mailer; public OrderService() { this.mailer = new SmtpMailer(); } public void Create(...) { // Creating the order // Sending an email to client with order details this.mailer.Send(...); } } public class SmtpMailer { public void Send(string to, string subject, string body) { // SMTP stuff will be only here } }

Уже намного лучше! Но класс OrderService по-прежнему много знает об отправке электронной почты. Для отправки электронной почты нужен именно класс SmtpMailer. Что, если мы захотим изменить его в будущем? Что, если мы хотим распечатать содержимое отправляемого электронного письма в специальный файл журнала, а не отправлять его в нашу среду разработки? Что, если мы хотим протестировать наш класс OrderService? Продолжим рефакторинг, создав интерфейс IMailer:

 public interface IMailer { void Send(string to, string subject, string body); }

SmtpMailer реализует этот интерфейс. Кроме того, наше приложение будет использовать IoC-контейнер, и мы можем настроить его так, чтобы IMailer был реализован классом SmtpMailer. Затем OrderService можно изменить следующим образом:

 public sealed class OrderService: IOrderService { private IOrderRepository repository; private IMailer mailer; public OrderService(IOrderRepository repository, IMailer mailer) { this.repository = repository; this.mailer = mailer; } public void Create(...) { var order = new Order(); // fill the Order entity using the full power of our Business Logic(discounts, promotions, etc.) this.repository.Save(order); this.mailer.Send(<orders user email>, <subject>, <body with order details>); } }

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

Таким образом, если тестировщик обнаружит, что что-то неправильно работает с отправкой писем, разработчик точно знает, где искать: в классе SmtpMailer. Если со скидками что-то не так, разработчик, опять же, знает, где искать: код класса OrderService (или, если вы заучили SRP наизусть, то это может быть DiscountService).

Архитектура, управляемая событиями

Однако мне все еще не нравится метод OrderService.Create:

 public void Create(...) { var order = new Order(); ... this.repository.Save(order); this.mailer.Send(<orders user email>, <subject>, <body with order details>); }

Отправка электронного письма не является частью основного процесса создания заказа. Даже если приложению не удается отправить электронное письмо, заказ все равно создается правильно. Кроме того, представьте ситуацию, когда вам нужно добавить новую опцию в область настроек пользователя, которая позволяет им отказаться от получения электронного письма после успешного размещения заказа. Чтобы включить это в наш класс OrderService, нам нужно ввести зависимость IUserParametersService. Добавьте к этому локализацию, и вы получите еще одну зависимость, ITranslator (для создания правильных сообщений электронной почты на выбранном пользователем языке). Некоторые из этих действий не нужны, особенно идея добавления этих многочисленных зависимостей и получения конструктора, который не помещается на экране. Я нашел отличный пример этого в кодовой базе Magento (популярная CMS электронной коммерции, написанная на PHP) в классе, который имеет 32 зависимости!

Конструктор, который не помещается на экране

Иногда просто сложно понять, как отделить эту логику, и класс Magento, вероятно, является жертвой одного из таких случаев. Вот почему мне нравится способ, управляемый событиями:

 namespace <base namespace>.Events { [Serializable] public class OrderCreated { private readonly Order order; public OrderCreated(Order order) { this.order = order; } public Order GetOrder() { return this.order; } } }

Всякий раз, когда создается заказ, вместо отправки электронного письма непосредственно из класса OrderService создается специальный класс событий OrderCreated и генерируется событие. Где-то в приложении будут настроены обработчики событий. Один из них отправит электронное письмо клиенту.

 namespace <base namespace>.EventHandlers { public class OrderCreatedEmailSender : IEventHandler<OrderCreated> { public OrderCreatedEmailSender(IMailer, IUserParametersService, ITranslator) { // this class depend on all stuff which it need to send an email. } public void Handle(OrderCreated event) { this.mailer.Send(...); } } }

Класс OrderCreated специально помечен как Serializable. Мы можем обработать это событие немедленно или сохранить его сериализованным в очереди (Redis, ActiveMQ или что-то еще) и обработать его в процессе/потоке, отдельном от того, который обрабатывает веб-запросы. В этой статье автор подробно объясняет, что такое событийно-ориентированная архитектура (пожалуйста, не обращайте внимания на бизнес-логику внутри OrderController).

Кто-то может возразить, что теперь сложно понять, что происходит при создании заказа. Но это не может быть дальше от истины. Если вы так считаете, просто воспользуйтесь функциональностью вашей IDE. Найдя все случаи использования класса OrderCreated в IDE, мы можем увидеть все действия, связанные с событием.

Но когда мне следует использовать внедрение зависимостей, а когда — подход, управляемый событиями? Не всегда легко ответить на этот вопрос, но одно простое правило, которое может вам помочь, — использовать внедрение зависимостей для всех ваших основных действий в приложении и подход, управляемый событиями, для всех второстепенных действий. Например, используйте Dependecy Injection с такими вещами, как создание заказа в классе OrderService с помощью IOrderRepository, и делегируйте отправку электронной почты, что не является важной частью основного потока создания заказа, какому-либо обработчику событий.

Заключение

Мы начали с очень тяжелого контроллера, всего одного класса, а закончили сложной коллекцией классов. Преимущества этих изменений вполне очевидны из примеров. Тем не менее, есть еще много способов улучшить эти примеры. Например, метод OrderService.Create можно перенести в отдельный класс: OrderCreator. Поскольку создание заказа является независимой единицей бизнес-логики в соответствии с принципом единой ответственности, для него вполне естественно иметь собственный класс со своим набором зависимостей. Аналогичным образом удаление и отмена заказа могут быть реализованы в своих собственных классах.

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

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