вторник, 14 июня 2016 г.

Принцип открытости/закрытости (Open-closed principle)

Принцип открытости/закрытости (OCP), второй из принципов SOLID

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

Как и принцип единственной ответственности применим не только к классам, но и к модулям, методам и т.д.

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

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

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

Применительно к ООП этот принцип можно реализовать двумя способами:

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

Первый как правило использует паттерн Startegy (Стратегия) для внедрения зависимостей, второй - Template method (Шаблонный метод). Оба способа правильные, каждый нужно использовать исходя из свойств соответствующих отношений.

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

class FirstLogger
{
    /**
     * @return FirstLogger
     */
    public static function me() { /* реализуем синглетон */ }

    public function log($message) { /* логируем */ }
}

class SecondLogger
{
    public function log($message) { /* логируем */ }
}

class Foo
{
    public function doSomething()
    {
        // ...
        FirstLogger::me()->log('Some log info');
        // ...
        (new SecondLogger())->log('Some log info');
        // ...
    }
}

// Использование
(new Foo())->doSomething();

Недостатки такой реализации очевидны: замена, например, классов *Logger на другой класс влечет за собой значительные изменения. Так же проблематично писать юнит-тесты для подобного кода.

Тот же код с применением принципа открытости/закрытости:

class FirstLogger
{
    /**
     * @return FirstLogger
     */
    public static function me() { /* реализуем синглетон */ }

    public function log($message) { /* логируем */ }
}

class SecondLogger
{
    public function log($message) { /* логируем */ }
}


class Foo
{
    /** @var FirstLogger */
    public $firstLogger;

    /** @var SecondLogger */
    public $secondLogger;

    public function __construct(FirstLogger $firstLogger, SecondLogger $secondLogger)
    {
        $this->firstLogger = $firstLogger;
        $this->secondLogger = $secondLogger;
    }

    public function doSomething()
    {
        // ...
        $this->firstLogger->log(('Some log info'));
        // ...
        $this->secondLogger->log('Some log info');
        // ...
    }
}

// Использование
(new Foo(FirstLogger::me(), new SecondLogger()))->doSomething();

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

Еще пример. Допустим в системе есть класс, записывающий лог в файл first_log.log:

class FirstLogger
{
    public function log($message)
    {
        $handler = fopen('first_log.log', 'w+');
        fwrite($handler, sprintf('[%s]%s%s', date('c'), $message, PHP_EOL));
        fclose($handler);
    }
}

// Использование
(new FirstLogger())->log('Some message');

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

class FirstLogger
{
    public function log($file, $message)
    {
        $handler = fopen($file, 'w+');
        fwrite($handler, sprintf('[%s]%s%s', date('d-m-Y H:i:s'), $message, PHP_EOL . PHP_EOL));
        fclose($handler);
    }
}

class SecondLogger
{
    public function log($message)
    {
        $handler = fopen('second_log.log', 'w+');
        fwrite($handler, sprintf('[%s]%s%s', date('d-m-Y H:i:s'), $message, PHP_EOL . PHP_EOL));
        fclose($handler);
    }
}

// Использование
(new FirstLogger())->log('second_log.log', 'Some message');
(new SecondLogger())->log('Some message');

Тот же код с применением принципа открытости/закрытости, опять через внедрение зависимостей:

class FileLogger
{
    private $logFile;

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

    public function log($message)
    {
        $handler = fopen($this->logFile, 'w+');
        fwrite($handler, sprintf('[%s]%s%s', date('c'), $message, PHP_EOL));
        fclose($handler);
    }
}

// Использование
(new FileLogger('first_log.log'))->log('Some message');
(new FileLogger('second_log.log'))->log('Some message');

Но класс по прежнему не полностью открыт для расширения. Точнее открыт исключительно настолько, насколько требуется для логирования в разные файлы. Теперь представим, что нужно будет писать логи еще и в удаленную систему (Graylog, Elastic, etc). Да еще и в разных форматах для каждой. Уже не обойтись без абстракции:

abstract class AbstractLogger
{
    public function log($message)
    {
        $this->connect();
        $this->write(
            $this->formatMessage(
                $message
            )
        );
        $this->disconnect();
    }

    /**
     * @return void
     */
    abstract protected function connect();

    /**
     * @param string $message
     * @return void
     */
    abstract protected function write($message);

    /**
     * @return void
     */
    abstract protected function disconnect();

    /**
     * @param string $message
     * @return string
     */
    abstract protected function formatMessage($message);
}

class FileLogger extends AbstractLogger
{
    private $logFile;

    private $handler;

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

    protected function connect()
    {
        $this->handler = fopen($this->logFile, 'w+');
    }

    protected function write($message)
    {
        fwrite($this->handler, $message);
    }

    protected function disconnect()
    {
        fclose($this->handler);
    }

    protected function formatMessage($message)
    {
        return sprintf('[%s]%s%s', date('c'), $message, PHP_EOL);
    }
}

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

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

Комментариев нет:

Отправить комментарий