Статьи

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

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

Одно из самых печально известных следствий этого рационального ассоциативного процесса заключается в том, что в какой-то момент мы фактически заканчиваем тем, что создаем классы, которые делают слишком много. Так называемый «класс Бога», возможно, является наиболее экстремальным и грубым примером структуры, которая буквально объединяет кучу несвязанных операций за ограждением одного и того же API, но существуют и другие тонкие, более скрытые ситуации, когда назначение нескольких ролей один и тот же класс сложнее выследить. Хорошим примером этого является Синглтон.

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

Легко держаться подальше от одиноких, не чувствуя ни малейшего чувства вины. Но как можно быть прагматичным в более общих, повседневных ситуациях и классах дизайна, чьи заботы достаточно приличны? Даже когда нет прямого ответа на вопрос, можно в целом придерживаться правил принципа единой ответственности , формальное определение которого гласит следующее:

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

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

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

Типичное нарушение принципа единой ответственности

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

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

<?php
namespace Model;

interface UserInterface
{
    public function setId($id);
    public function getId();

    public function setName($name);
    public function getName();

    public function setEmail($email);
    public function getEmail();
    public function getGravatar();

    public function findById($id);
    public function insert();
    public function update();
    public function delete();
}
 <?php
namespace Model;
use LibraryDatabaseDatabaseAdapterInterface;

class User implements UserInterface
{
    private $id;
    private $name;
    private $email;
    private $db;
    private $table = "users";

    public function __construct(DatabaseAdapterInterface $db) {
        $this->db = $db;
    }

    public function setId($id) {
        if ($this->id !== null) {
            throw new BadMethodCallException(
                "The user ID has been set already.");
        }
        if (!is_int($id) || $id < 1) {
            throw new InvalidArgumentException(
                "The user ID is invalid.");
        }
        $this->id = $id;
        return $this;
    }
    
    public function getId() {
        return $this->id;
    }
    
    public function setName($name) {
        if (strlen($name) < 2 || strlen($name) > 30) {
            throw new InvalidArgumentException(
                "The user name is invalid.");
        }
        $this->name = $name;
        return $this;
    }
    
    public function getName() {
        if ($this->name === null) {
            throw new UnexpectedValueException(
                "The user name has not been set.");
        }
        return $this->name;
    }

    public function setEmail($email) {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException(
                "The user email is invalid.");
        }
        $this->email = $email;
        return $this;
    }
    
    public function getEmail() {
        if ($this->email === null) {
            throw new UnexpectedValueException(
                "The user email has not been set.");
        }
        return $this->email;
    }
    
    public function getGravatar($size = 70, $default = "monsterid") {
        return "http://www.gravatar.com/avatar/" .
            md5(strtolower($this->getEmail())) .
            "?s=" . (integer) $size .
            "&d=" . urlencode($default) .
            "&r=G";
    }
    
    public function findById($id) {
        $this->db->select($this->table,
            ["id" => $id]);
        if (!$row = $this->db->fetch()) {
            return null;
        }
        $user = new User($this->db);
        $user->setId($row["id"])
             ->setName($row["name"])
             ->setEmail($row["email"]);
        return $user;
    }
    
    public function insert() {
        $this->db->insert($this->table, [
            "name"  => $this->getName(), 
            "email" => $this->getEmail()
        ]);
    }
    
    public function update() {
        $this->db->update($this->table, [
                "name"  => $this->getName(), 
                "email" => $this->getEmail()], 
            "id = {$this->id}");
    }

    public function delete() {
        $this->db->delete($this->table,
            "id = {$this->id}");
    }
}

Как и следовало ожидать от типичной реализации Active Record, класс UserGravatars на лету с доступом к данным. , Является ли это нарушением принципа единой ответственности? Что ж, несомненно, это так, поскольку класс подвергает внешний мир двум различным обязанностям, которые ни в коем случае не имеют истинных семантических отношений друг с другом.

Даже не пройдя реализацию класса и просто просканировав его интерфейс, ясно, что методы CRUD следует размещать на уровне доступа к данным, полностью изолированном от того, где живут и дышат мутаторы / средства доступа. В этом случае, например, результат выполнения findById() Это означает, что здесь сосуществуют две перекрывающиеся обязанности, что приводит к изменению класса в ответ на различные требования.

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

Помещение логики доступа к данным в Data Mapper

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

 <?php
namespace Mapper;
use ModelUserInterface;

interface UserMapperInterface
{
    public function findById($id);
    public function insert(UserInterface $user);
    public function update(UserInterface $user);
    public function delete($id);
}
 <?php
namespace Mapper;
use LibraryDatabaseDatabaseAdapterInterface,
    ModelUserInterface,
    ModelUser;

class UserMapper implements UserMapperInterface
{
    private $db;
    private $table = "users";
    
    public function __construct(DatabaseAdapterInterface $db) {
        $this->db = $db;
    }
    
    public function findById($id) {
        $this->db->select($this->table, ["id" => $id]);
        if (!$row = $this->db->fetch()) {
            return null;
        }
        return $this->loadUser($row);
    }
    
    public function insert(UserInterface $user) {
        return $this->db->insert($this->table, [
            "name"  => $user->getName(), 
            "email" => $user->getEmail()
        ]);
    }
    
    public function update(UserInterface $user) {
        return $this->db->update($this->table, [
            "name"  => $user->getName(), 
            "email" => $user->getEmail()
        ], 
        "id = {$user->getId()}");
    }
    
    public function delete($id) {
        if ($id instanceof UserInterface) {
            $id = $id->getId();
        }
        return $this->db->delete($this->table, "id = $id");
    }
    
    private function loadUser(array $row) {
        $user = new User($row["name"], $row["email"]);
        $user->setId($row["id"]);
        return $user;
    }
}

Глядя на контракт маппера, легко увидеть, насколько хорошо CRUD-операции, которые раньше загрязняли экосистему класса User Эта единственная модификация должна позволить нам реорганизовать класс домена и превратить его в более чистую, более дистиллированную структуру, которая соответствует принципу:

 <?php
namespace Model;

interface UserInterface
{
    public function setId($id);
    public function getId();

    public function setName($name);
    public function getName();

    public function setEmail($email);
    public function getEmail();
    public function getGravatar();
}
 <?php
namespace Model;

class User implements UserInterface
{
    private $id;
    private $name;
    private $email;

    public function __construct($name, $email) {
        $this->setName($name);
        $this->setEmail($email);
    }
    
    public function setId($id) {
        if ($this->id !== null) {
            throw new BadMethodCallException(
                "The user ID has been set already.");
        }
        if (!is_int($id) || $id < 1) {
            throw new InvalidArgumentException(
                "The user ID is invalid.");
        }
        $this->id = $id;
        return $this;
    }
    
    public function getId() {
        return $this->id;
    }
    
    public function setName($name) {
        if (strlen($name) < 2 || strlen($name) > 30) {
            throw new InvalidArgumentException(
                "The user name is invalid.");
        }
        $this->name = $name;
        return $this;
    }
    
    public function getName() {
        return $this->name;
    }

    public function setEmail($email) {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException(
                "The user email is invalid.");
        }
        $this->email = $email;
        return $this;
    }
    
    public function getEmail() {
        return $this->email;
    }
     
    public function getGravatar($size = 70, $default = "monsterid") {
        return "http://www.gravatar.com/avatar/" .
            md5(strtolower($this->email)) .
            "?s=" . (integer) $size .
            "&d=" . urlencode($default) .
            "&r=G";
    }
}

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

Конечно, пример выглядел бы наполовину подкрепленным, если бы я не показал вам, как заставить маппер выполнять все функции доступа к данным, сохраняя независимость от постоянства класса User

 <?php
$db = new PdoAdapter("mysql:dbname=test", "myusername",
    "mypassword");
$userMapper = new UserMapper($db);

// Display user data
$user = $userMapper->findById(1);
echo $user->getName() . ' ' . $user->getEmail() .
    '<img src="' . $user->getGravatar() . '">';

// Insert a new user
$user = new User("John Doe", "[email protected]");
$userMapper->insert($user);

// Update a user
$user = $userMapper->findById(2);
$user->setName("Jack");
$userMapper->update($user);

// Delete a user    
$userMapper->delete(3);

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

Заключительные замечания

Возможно, просто предвзятое мнение, основанное на моем собственном опыте разработчика (так что воспринимайте это как личность), я бы осмелился сказать, что худшее проклятие принципа единой ответственности и, безусловно, причина, по которой он так явно игнорируется на практике, — это прагматизм реальности. Очевидно, что намного легче «выполнить работу» и бороться с жесткими сроками, слепо назначая кучу ролей классу, не думая, семантически ли они связаны друг с другом.

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

Изображение через Fotolia