Шаблоны проектирования в PHP : Цепочка обязанностей

Назначение паттерна?

  • Паттерн Chain of Responsibility (Цепочка обязянностей) позволяет избежать жесткой зависимости отправителя запроса от его получателя (т.е. обеспечивается слабая связанность компонентов системы), при этом запрос может быть обработан несколькими объектами.
  • Объекты-получатели связываются в цепочку. Запрос передается по этой цепочке, пока не будет обработан.
  • Вводит конвейерную обработку для запроса с множеством возможных обработчиков.

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

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

Реализация

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

<?php
abstract class DocParser
{
    public function parse($fileName)
    {
        // тут мы определяем является ли документ допустимым
        // для текущего обработчика
    }
}

От этого класса будут наследоваться обработчики документов. Всего в цепочке будет четыре обработчика для документов: xml, json, csv, txt. Под каждый обработчик мы создам свои классы: XmlDocParser, JsonDocParser, CsvDocParser, TxtDocParser у каждого класса будет свой собственный способ обработки документа (метод parse).

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

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

Реализуем эту логику:

<?php
/**
 * Class DocParser
 */
abstract class DocParser
{
    /**
     * @var DocParser
     */
    protected $successor;
    /**
     * @param DocParser $successor
     */
    public function __construct(DocParser $successor = null)
    {
        $this->successor = $successor;
    }
    /**
     * @param $fileName
     * @return mixed
     */
    public function parse($fileName)
    {
        $successor = $this->getSuccessor();
        if (!is_null($successor)) {
            $successor->parse($fileName);
        } else {
            print("Unable to find the correct parser for the file: {$fileName}\n");
        }
    }
    /**
     * @return DocParser
     */
    public function getSuccessor()
    {
        return $this->successor;
    }
    /**
     * @param $fileName
     * @param $format
     * @return bool
     */
    public function canHandleFile($fileName, $format)
    {
        if ($fileName) {
            $extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
            if ($extension == $format) {
                return true;
            }
        }
        return false;
    }
}

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

Метод canHandleFile проверяет является ли текущий файл допустимым для обработки. Соответственно изменился метод parse, теперь он проверяет, что если есть хоть какой-то обработчик, то необходимо передать ему документ на обработку . Если же нету обработчика, то необходимо вывести сообщение (или бросить исключение), о том что корректный обработчик не найден.

Теперь нам надо реализовать метод parse в каждом из классов обрабочиков:

<?php
/**
 * Class CsvDocParser
 */
class CsvDocParser extends DocParser
{
    /**
     * @param $fileName
     * @return mixed|void
     */
    public function parse($fileName)
    {
        if ($this->canHandleFile($fileName, 'csv')) {
            print("A CSV parser is handling the file: {$fileName}\n");
        } else {
            parent::parse($fileName);
        }
    }
}
<?php
/**
 * Class JsonDocParser
 */
class JsonDocParser extends DocParser
{
    /**
     * @param $fileName
     * @return mixed|void
     */
    public function parse($fileName)
    {
        if ($this->canHandleFile($fileName, 'json')) {
            print("A JSON parser is handling the file: {$fileName}\n");
        } else {
            parent::parse($fileName);
        }
    }
}
<?php
/**
 * Class TextDocParser
 */
class TextDocParser extends DocParser
{
    /**
     * @param $fileName
     * @return mixed|void
     */
    public function parse($fileName)
    {
        if ($this->canHandleFile($fileName, 'txt')) {
            print("A TEXT parser is handling the file: {$fileName}\n");
        } else {
            parent::parse($fileName);
        }
    }
}
<?php
/**
 * Class XmlDocParser
 */
class XmlDocParser extends DocParser
{
    /**
     * @param $fileName
     * @return mixed|void
     */
    public function parse($fileName)
    {
        if ($this->canHandleFile($fileName, 'xml')) {
            print("A XML parser is handling the file: {$fileName}\n");
        } else {
            parent::parse($fileName);
        }
    }
}

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

Вроде всё сделали, теперь каждый обработчик может определить есть ли следующий обработчик в цепочке. Тут вы меня спросите, а где же эта цепочка и куда передавать название файла (запрос)? И я отвечу, можно вызывать напрямую соотествующий класс обработчик, но так как мы используем шаблон проектирования Цепочка обязанностей, нам нужно ослабить связь и абстрагироваться от конкретного обработчика. Для этого создадим класс, в который и будем передавать запрос и строить цепочку обработчиков:

<?php
require_once('DocParser.php');
require_once('XmlDocParser.php');
require_once('JsonDocParser.php');
require_once('TextDocParser.php');
require_once('CsvDocParser.php');
/**
 * ChainOfResponsibility
 *
 * Class DocParserProcessor
 */
class DocParserProcessor
{
    /**
     * @param $files
     */
    public static function run($files)
    {
        // Это и есть цепочка обязанностей
        $xmlParser  = new XmlDocParser();
        $jsonParser = new JsonDocParser($xmlParser);
        $csvParser  = new CsvDocParser($jsonParser);
        $textParser = new TextDocParser($csvParser);
        if (is_array($files)) {
            foreach ($files as $file) {
                $textParser->parse($file);
            }
        } else {
            $textParser->parse($files);
        }
    }
}

В данном примере я вручную подключил файлы (через require), в реальном приложении они будут автоматически загружаться по неймспайсам.

Итак, в этом классе и таится вся магия, в метод run мы передаём ЗАПРОС! А запрос в данном случае это набор документов. В этом методе создаём цепочку из объектов обработчиков, в конструктор метода run, можно передать как набор названий документов, так и просто название одного документа и он будет обработан.

А теперь посмотрим, как использовать этот шаблон на практике:

<?php
require_once('DocParserProcessor.php');
$files = [
    'file.txt',
    'file.json',
    'file.xml',
    'file.csv',
    'file.doc'
];
DocParserProcessor::run($files);
DocParserProcessor::run('somefile.xml');

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

Вот вывод вышеприведённого листинга:

A TEXT parser is handling the file: file.txt
A JSON parser is handling the file: file.json
A XML parser is handling the file: file.xml
A CSV parser is handling the file: file.csv
Unable to find the correct parser for the file: file.doc
A XML parser is handling the file: somefile.xml

Как видите, каждый парсер определил свой тип документа и обработал его! На этом всё, если нашли ошибку или у вас другое мнение по поводу этого шаблона проектирования, то пишите в комментариях.