Улучшаем архитектуру: Инверсия и внедрение зависимостей, наследование и композиция

    Всем привет. Очень часто, работая со старым (а иногда и не очень) кодом, или пытаясь применить какую-то библиотеку, сталкиваешься с ограничениями в расширении. Зачастую проблемы бы не было, будь код архитектурно грамотен. Есть множество архитектурных правил и паттернов, которые в конечном счете облегчают расширение кода, рефакторинг и переиспользование. В статье хочу затронуть некоторые из них в примерах.


    Давным давно в далеком далеком проекте появился сервис, отправляющий письмо с новым паролем пользователям. Примерно вот такой:

    <?php
    
    class ReminderPasswordService
    {
        protected function sendToUser($user, $message)
        {
            $this->getMailer()->send([
                'from' => 'admin@example.com',
                'to' => $user['email'],
                'message' => $message
            ]);
        }
    
        public function sendReminderPassword($user, $password)
        {
            $message = $this->prepareMessage($user, $password);
            $this->sendToUser($user, $message);
        }
    
        protected function prepareMessage($user, $password)
        {
            $userName = $this->escapeHtml($user['first_name']);
            $password = $this->escapeHtml($password);
            $message = "Привет {$userName}!
            Твой новый пароль {$password}";
    
            $message = $this->format($message);
            $message = $this->addHeaderAndFooter($message);
    
            return $message;
        }
    
        protected function format($message)
        {
            return nl2br($message);
        }
    
        protected function escapeHtml($string)
        {
            return htmlentities($string);
        }
    
        protected function addHeaderAndFooter($message)
        {
            $message = "<html><body>{$message}<br>С уважением, Админ!</body>";
    
            return $message;
        }
    
        protected function getMailer()
        {
            return new Mailer('user', 'password', 'smtp.example.com');
        }
    
    }

    В то время разработчик считал его очень гибким, т.к. можно спокойно расширить любую часть класса, поменять текст, заголовки, или что-то еще. И вот, приходит менеджер, и просит отправлять копию письма, но без пароля на адрес менеджера, а также с корпоративного почтового сервиса. И еще - только основной текст. Ну и в формате plainText, а не HTML. Программист обрадовался своей дальновидности и гибкому классу и написал вот такого наследника (он был слишком ленив, или у него было слишком мало времени, чтобы задуматься о некоторых вещах).

    <?php
    class ReminderPasswordCopyToManagerService extends ReminderPasswordService
    {
        protected function send($user, $message)
        {
            $this->getMailer()->send([
                'from' => 'admin@example.com',
                'to' => 'manager@example.com',
                'message' => $message
            ]);
        }
    
        protected function prepareMessage($user, $password)
        {
            $userName = $this->escapeHtml($user['first_name']);
            $message = "Привет {$userName}!
            Твой новый пароль ****";
    
            return $message;
        }
    
        protected function getMailer()
        {
            return new Mailer('user2', 'password2', 'smtp.corp.example.com');
        }
    }

    Со временем сервис обрастал наследниками, использующими частично его методы, частично новые. В один прекрасный солнечный день пришел менеджер с задачей переключиться с smtp на API популярного сервиса. Класс Mailer уже не подходит, а в коде уже целый зоопарк его упоминаний. Давайте посмотрим на этом этапе, что можно было сделать вначале, чтобы эта задача не превратилась в головную боль?

    Dependency Injection (Внедрение зависимостей, DI)

    DI - это паттерн, позволяющий не задумываться над созданием объектов, делегируя их куда-то наружу, и просто получать готовые сконфигурированные объекты внутри.

    В первую очередь давайте рассмотрим, что плохого в создании большинства объектов внутри класса. Начнем с того, что это мешает нам использовать расширения, например - наследников. Для того, чтобы использовать другой класс - нам приходится вмешиваться в код самого сервиса, либо переписывать это в его потомке. Такой код гораздо сложнее поддерживать в дальнейшем. Немаловажным фактом также оказывается осложнение или вообще отсутствие возможности написать Unit тесты к сервису. Давайте представим, что мы использовали какую-то реализацию DI, которая конфигурирует и передает объекты прямо в конструктор. Наш сервис тогда будет выглядеть таким образом:

    
    <?php
    class ReminderPasswordService
    {
        /**
         * @var Mailer
         */
        protected $mailer;
    
        public function __construct(Mailer $mailer)
        {
            $this->mailer = $mailer;
        }
    
        // удалили метод getMailer, заменив его protected свойством $mailer
    
        // ...
    }

    Также большинство популярных реализаций позволяет подменить объект на другой для определенного сервиса, и нашему потомку не требуется уже реализовывать свой getMailer():

    <?php
    class ReminderPasswordCopyToManagerService extends ReminderPasswordService
    {
        protected function send($to, $message)
        {
            $this->mailer->send([
                'from' => 'admin@example.com',
                'to' => 'manager@example.com',
                'message' => $message
            ]);
        }
    
        protected function prepareMessage($user, $password)
        {
            $userName = $this->escapeHtml($user['first_name']);
            $message = "Привет {$userName}!
            Твой новый пароль ****";
    
            return $message;
        }
    }

    Казалось бы, мы решили проблему, и добавили больше гибкости. Но чтобы решить задачу с переключением, нам придется написать наследника Mailer, берущего от родителя только собственно тип (ну или, возможно, пару методов) и полностью переписать отправку. Это не есть хорошо, и вот, что мы будем делать дальше.

    Принцип инверсии зависимостей (Dependency Inversion Principle, DIP)

    Согласно принципу - мы не должны зависеть от конкретных реализаций, а максимально абстрагироваться от кода. Говоря простым языком - мы должны зависеть не от классов а от интерфейсов.

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

    <?php
    interface MailerInterface
    {
        public function send($emailFrom, $emailTo, $message);
    }

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

    <?php
    interface MailMessageInterface
    {
        public function setFrom($from);
        public function getFrom();
    
        public function setTo($to);
        public function getTo();
    
        public function setMessage($message);
        public function getMessage();
    }

    и наш MailSenderInterface, соответственно, обретает вид

    <?php
    interface MailerInterface
    {
        public function send(MailMessageInterface $message);
    }

    Но в этом случае нам придется как-то создавать объект MailMessageInterface, и в этом нам поможет фабрика

    <?php
    interface MailMessageFactoryInterface
    {
        public function create(): MailMessageInterface;
    }

    Наш сервис, соответственно, обретает такой вид

    <?php
    class ReminderPasswordService
    {
        /**
         * @var MailerInterface
         */
        protected $mailer;
    
        /**
         * @var MailMessageFactoryInterface
         */
        protected $messageFactory;
    
        public function __construct(MailerInterface $mailer, MailMessageFactoryInterface $messageFactory)
        {
            $this->mailer = $mailer;
            $this->messageFactory = $messageFactory;
        }
    
        protected function send($user, $messageText)
        {
            $message = $this->messageFactory->create();
            $message->setFrom('admin@example.com');
            $message->setTo($user['email']);
            $message->setMessage($messageText);
    
            $this->mailer->send($message);
        }
    
        // далее ничего не менялось
    
        public function sendReminderPassword($user, $password)
        {
            $message = $this->prepareMessage($user, $password);
            $this->sendToUser($user, $message);
        }
    
        protected function prepareMessage($user, $password)
        {
            $userName = $this->escapeHtml($user['first_name']);
            $password = $this->escapeHtml($password);
            $message = "Привет {$userName}!
            Твой новый пароль {$password}";
    
            $message = $this->format($message);
            $message = $this->addHeaderAndFooter($message);
    
            return $message;
        }
    
        protected function format($message)
        {
            return nl2br($message);
        }
    
        protected function escapeHtml($string)
        {
            return htmlentities($string);
        }
    
        protected function addHeaderAndFooter($message)
        {
            $message = "<html><body>{$message}<br>С уважением, Админ!</body>";
    
            return $message;
        }
    }

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

    <?php
    class ReminderPasswordCopyToManagerService extends ReminderPasswordService
    {
        protected function send($to, $messageText)
        {
            $message = $this->messageFactory->create();
            $message->setFrom('admin@example.com');
            $message->setTo('manager@example.com');
            $message->setMessage($messageText);
    
            $this->mailer->send($message);
        }
    
        protected function prepareMessage($user, $password)
        {
            $userName = $this->escapeHtml($user['first_name']);
            $message = "Привет {$userName}!
            Твой новый пароль ****";
    
            return $message;
        }
    }

    Наследование VS композиция

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

    Плюсы:

    1. Мы можем спокойно использовать этот кусок кода где угодно там, где он нам потребуется еще.

    2. Легко покрыть тестами маленький кусок логики, а не большой класс с вызовом кучи protected/private методов

    3. Легко подменить этот класс другим, если вдруг нам где-то потребуется делать что-то иначе.

    Я давно для себя решил, что есть очень тонкая грань между местами, где наследование все-таки нужно, и местами, где все же лучше использовать композицию. В 90% случаев лучше использовать второе (я сейчас не говорю про ограничения вашей экосистемы, про места, где без наследования не обойтись), поэтому принимая решение в пользу композиции ошибиться сложно.

    Для начала хочу привести более понятный и очевидный пример, где композиция выигрывает. Мы имеем сервис, который дергает внешнее API, забирая какие-то данные

    <?php
    class SomeAPIService implements SomeAPIServiceInterface
    {
        public function getSomeData($someParam)
        {
            $someData = [];
            // ...
            return $someData;
        }
    }

    И в последнее время мы стали недовольны производительностью сервиса, и решили кешировать данные, чтобы иметь более быстрый доступ к ним. Неискушенный разработчик может написать:

    <?php
    class SomeApiServiceCached extends SomeAPIService
    {
        public function getSomeData($someParam)
        {
            $cachedData = $this->getCachedData($someParam);
            if ($cachedData === null) {
                $cachedData = parent::getSomeData($someParam);
                $this->saveToCache($someParam, $cachedData);
            }
    
            return $cachedData;
        }
    
        // ...
    }

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

    <?php
    
    class SomeApiServiceCached implements SomeAPIServiceInterface
    {
       private $someApiService;
    
        public function __construct(SomeApiServiceInterface $someApiService)
        {
            $this->someApiService = $someApiService;
        }
    
        public function getSomeData($someParam)
        {
            $cachedData = $this->getCachedData($someParam);
            if ($cachedData === null) {
                $cachedData = $this->someApiService->getSomeData($someParam);
                $this->saveToCache($someParam, $cachedData);
            }
    
            return $cachedData;
        }
    
        // ...
    }

    Согласитесь, тут гораздо больше гибкости, да и тесты написать гораздо проще.

    Вернемся к нашему старому коду и взглянем на ReminderPasswordCopyToManagerService и посмотрим, что можно вынести "за скобки". Первое, что бросается в глаза - класс наследует ненужные методы addHeaderAndFooter и format, а также метод prepareMessage сильно отличается от родителя (нарушая также принцип открытости-закрытости (Open-Closed Principe), модифицируя, а не расширяя родительский класс), и ему не нужен второй параметр

    Общее - тело сообщения, метод escapeHtml.

    Давайте попробуем вынести общее в отдельные классы.

    <?php
    
    class ReminderPasswordMessageTextBuilder
    {
        public function buildMessageText($userName, $password)
        {
            return "Привет {$userName}!
            Твой новый пароль {$password}";
        }
    }
    
    class Escaper
    {
        public function escapeHtml($string)
        {
            return htmlentities($string);
        }
    }

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

    <?php
    class ReminderPasswordService
    {
        // Обратите внимание, что свойства стали приватными
        private $mailer;
        private $messageFactory;
        private $escaper;
        private $messageTextBuilder;
    
        public function __construct(
            MailerInterface $mailer,
            MailMessageFactoryInterface $messageFactory,
            Escaper $escaper,
            ReminderPasswordMessageTextBuilder $messageTextBuilder
        ) {
            $this->mailer = $mailer;
            $this->messageFactory = $messageFactory;
            $this->escaper = $escaper;
            $this->messageTextBuilder = $messageTextBuilder;
        }
    
        public function sendReminderPassword($user, $password)
        {
            $messageText = $this->prepareMessage($user, $password);
    
            $message = $this->messageFactory->create();
            $message->setFrom('admin@example.com');
            $message->setTo($user['email']);
            $message->setMessage($messageText);
    
            $this->mailer->send($message);
        }
    
        private function prepareMessage($user, $password)
        {
            $userName = $this->escaper->escapeHtml($user['first_name']);
            $password = $this->escaper->escapeHtml($password);
            $message = $this->messageTextBuilder->buildMessageText($userName, $password);
            $message = $this->format($message);
            $message = $this->addHeaderAndFooter($message);
    
            return $message;
        }
    
        // методы ниже тоже будут вынесены в отдельные классы.
        private function addHeaderAndFooter($message)
        {
            $message = "<html><body>{$message}<br>С уважением, Админ!</body>";
    
            return $message;
        }
    
        private function format($message)
        {
            return nl2br($message);
        }
    }

    и бывший наследник

    <?php
    class ReminderPasswordCopyToManagerService
    {
        private $mailer;
        private $messageFactory;
        private $escaper;
        private $messageTextBuilder;
    
        public function __construct(
            MailerInterface $mailer,
            MailMessageFactoryInterface $messageFactory,
            Escaper $escaper,
            ReminderPasswordMessageTextBuilder $messageTextBuilder
        ) {
            $this->mailer = $mailer;
            $this->messageFactory = $messageFactory;
            $this->escaper = $escaper;
            $this->messageTextBuilder = $messageTextBuilder;
        }
    
        public function sendReminderPasswordCopyToManager($user)
        {
            $messageText = $this->prepareMessage($user);
    
            $message = $this->messageFactory->create();
            $message->setFrom('admin@example.com');
            $message->setTo($user['email']);
            $message->setMessage($messageText);
    
            $this->mailer->send($message);
        }
    
        private function prepareMessage($user)
        {
            $userName = $this->escaper->escapeHtml($user['first_name']);
            $message = $this->messageTextBuilder->buildMessageText($userName, '****');
    
            return $message;
        }
    }

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

    P.S. конечно же, данные классы еще далеки от идеала, но об этом в другой раз.

    Похожие публикации

    Средняя зарплата в IT

    113 000 ₽/мес.
    Средняя зарплата по всем IT-специализациям на основании 10 037 анкет, за 2-ое пол. 2020 года Узнать свою зарплату
    Реклама
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее

    Комментарии 4

      0

      Тогда уже


      public function __construct(
              MailerInterface $mailer,
              MailMessageFactoryInterface $messageFactory,
              EscaperInterface $escaper,
              ReminderPasswordMessageTextBuilderInterface $messageTextBuilder
          )
        0
        Само собой разумеется, просто решил не растягивать статью еще больше, и так получилось довольно много кода
        0
        Имхо, переборщили с количеством абстракций и интерфейсов
          0

          Но можно ведь и глубже

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

        Самое читаемое