Цепочка обязанностей (Chain of Responsibility) — шаблон проектирования

28 января 2020
Шаблон предлагает схему для выполнения действия с помощью цепочки обработчиков. Если первый обработчик из цепочки не смог выполнить действие (обязанность), то выполнение переходит ко второму обработчику. Если второй не смог, то выполнение переходит к третьему. И так до конца цепочки или пока действие не будет успешно выполнено.

Пример задачи: сделать многофильтровое цензурирование текста.

Код реализации паттерна на языке PHP. Скачать исходники (ZIP, 2 Кб)

Общий смысл решения задачи сводится к тому, что у нас есть классы для проверки текста на недопустимые выражения. Каждый класс реализует собственный цензурный фильтр. Объекты этих классов связаны между собой в цепочку с помощью ссылок-указателей. Далее клиентский код отправляет текст на проверку в первый цензурный фильтр. Если фильтр не находит в тексте ничего запрещённого, то считается, что фильтр отработал неудачно и затем он передаёт текст на проверку следующему фильтру по цепочке. В случае, когда по ходу обработок фильтр что-то обнаружит, то он завершит дальнейшую обработку и вернёт в клиентский код соответствующий результат. В нашем примере фильтр вернёт сообщение о нахождении нецензурных элементов – мат, почтовые адреса, ссылки на сайты.



censorship_filters.classes.php

Содержит классы с цензурными фильтрами:
  • AbstractCensorshipFilter – абстрактный класс цензурного фильтра, используется как основа для конечных классов цензурных фильтров, состоит из следующих элементов:
    1. Свойство $nextFilter – содержит указатель на объект следующего цензурного фильтра, который стоит в цепочке фильтров;
    2. Метод setNextFilter($nextFilter) – устанавливает в свойство $nextFilter указатель на следующий фильтр;
    3. Абстрактный метод checkText($text) – выполняет цензурную проверку текста. В данном абстрактом классе метод объявляется, а в конечных классах он определяется.

      ВАЖНО! В методе конечных классов должна быть реализована логика, что в случае неуспешного результата, когда фильтр не смог найти недопустимые фразы, тогда он передаёт текст на проверку следующему фильтру, вызывая метод checkTextByNextFilter($text). А для случая успешного результата, когда проверка нашла недопустимые элементы, метод завершает дальнейшие обработки и возвращает символьный код, соответствующий типу недопустимого элемента.
    4. Метод checkTextByNextFilter($text) – выполняет цензурную проверку текста следующим фильтром, вызывая у того метод checkText($text). Если следующего фильтра нету, то есть свойство $nextFilter не содержит указателя на объект, значит это является признаком конца цепочки и метод возвращает значение valid. Значение valid соответствует тому, что текст прошёл все цензурные фильтры и является допустимым.
  • BadWordsCensorshipFilter – класс цензурного фильтра для поиска ругательных слов, наследник абстрактного класса AbstractCensorshipFilter. Классс содержит определение метода checkText($text);
  • EmailCensorshipFilter – класс цензурного фильтра для поиска email-адресов. Наследник абстрактного класса AbstractCensorshipFilter, содержит собственное определение метода checkText($text);
  • WebLinksCensorshipFilter – класс цензурного фильтра для поиска ссылок. Наследник абстрактного класса AbstractCensorshipFilter, содержит собственное определение метода checkText($text).
<?

/**
 * Абстрактный класс цензурного фильтра
 */
abstract class AbstractCensorshipFilter
{
    /**
     * Ссылка на следующий цензурный фильтр
     * 
     * @var AbstractCensorshipFilter
     */
    private $nextFilter = null;

    /**
     * Устанавливает указатель на следующий цензурный фильтр. Используется для организации
     * цепочки из нескольких фильтров.
     * 
     * @param AbstractCensorshipFilter $nextFilter - объект следующего фильтра.
     * @return AbstractCensorshipFilter - вернёт объект следующего фильтра. Возврат объекта испольузется 
     *                                    для возможности в одной строчке кода строить цепочку фильтров.
     */
    public function setNextFilter($nextFilter)
    {
        $this->nextFilter = $nextFilter;
        return $this->nextFilter;
    }

    /**
     * Выполняет цензурную проверку текста.
     * @param string $text - исходный текст
     * @return string - вернёт строку со статусом проверки. В случае, если текст проходит цензуру, 
     *                  метод вернёт значение 'valid', в противном случае вернёт символьный код ошибки.
     */
    abstract public function checkText($text);

    /**
     * Выполняет цензурную проверку текста следующим фильтром.
     * @return string - вернёт строку со статусом проверки. В случае, если текст проходит цензуру, 
     *                  метод вернёт значение 'valid', в противном случае вернёт символьный код ошибки.
     *                  Если следующий фильтр не установлен, то метод вернёт значение 'valid'
     */
    public function checkTextByNextFilter($text)
    {
        if ($this->nextFilter) {
            return $this->nextFilter->checkText($text);
        } else {
            return 'valid';
        }
    }
}

/**
 * Цензурный фильтр на матерные/ругательные слова
 */
class BadWordsCensorshipFilter extends AbstractCensorshipFilter
{
    /**
     * Выполняет цензурную проверку текста.
     * @param string $text - исходный текст
     * @return string - вернёт строку со статусом проверки. В случае, если текст проходит цензуру, 
     *                  метод вернёт значение 'valid', в противном случае вернёт символьный код ошибки.
     */
    public function checkText($text)
    {
        $badWords = array(
            'бля',
            'сука',
            'пидор',
            'пидр',
            'пизди',
            'пезде',
            'пизда',
            'хуй',
            'хуел',
            'ёбла',
            'ебла',
            'ебать',
            'ебан'
        );
        foreach($badWords as $z => $v) {
            $badWords[$z] = '[\w\-\_]*'.preg_quote($v).'[\w\-\_]*';
        }

        /*
        Ищем матерные слова.
        Метасимвол \b обозначает границы слова
        */
        $existBadWords = false;
        $pattern = '/\b('.implode('|', $badWords).')\b/iu';
        if (preg_match($pattern, $text)) {
            $existBadWords = true;
        }

        if ($existBadWords == true) {
            return 'error_bad_words_exist';
        } else {
            return $this->checkTextByNextFilter($text);
        }
    }
}

/**
 * Цензурный фильтр на email-адреса
 */
class EmailCensorshipFilter extends AbstractCensorshipFilter
{
    /**
     * Выполняет цензурную проверку текста.
     * @param string $text - исходный текст
     * @return string - вернёт строку со статусом проверки. В случае, если текст проходит цензуру, 
     *                  метод вернёт значение 'valid', в противном случае вернёт символьный код ошибки.
     */
    public function checkText($text)
    {
        $badWords = array();

        //Ищем email-адреса
        $existEmails = false;
        $pattern = '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/iu';
        if (preg_match($pattern, $text)) {
            $existEmails = true;
        }

        if ($existEmails == true) {
            return 'error_emails_exist';
        } else {
            return $this->checkTextByNextFilter($text);
        }
    }
}

/**
 * Цензурный фильтр на интернет-ссылки
 */
class WebLinksCensorshipFilter extends AbstractCensorshipFilter
{
    /**
     * Выполняет цензурную проверку текста.
     * @param string $text - исходный текст
     * @return string - вернёт строку со статусом проверки. В случае, если текст проходит цензуру, 
     *                  метод вернёт значение 'valid', в противном случае вернёт символьный код ошибки.
     */
    public function checkText($text)
    {
        $badWords = array();

        //Ищем ссылки
        $existLinks = false;
        $pattern = '/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*)/iu';
        if (preg_match($pattern, $text)) {
            $existLinks = true;
        }

        if ($existLinks == true) {
            return 'error_web_links_exist';
        } else {
            return $this->checkTextByNextFilter($text);
        }
    }
}

?>

test.php

Содержит тестовый скрипт, в котором создаются объекты трёх цензурных фильтров:
  • $badWordsFilter – фильтр ругательных слов;
  • $emailFilter – фильтр email-адресов;
  • $webLinksFilter – фильтр ссылок.

Далее с помощью метода setNextFilter($nextFilter) фильтры устанавливаются в цепочку. Затем, используя метод checkText($text), по нашей цензурной цепочке прогоняется несколько текстов и результат проверки выводится на экран. 
<?
//Подключаем классы для работы с цензурными фильтрами
require_once(__DIR__.'/censorship_filters.classes.php');

//Создаём объекты фильтров
$badWordsFilter = new BadWordsCensorshipFilter(); //Фильтр плохих слов
$emailFilter = new EmailCensorshipFilter(); //Фильтр email-адресов
$webLinksFilter = new WebLinksCensorshipFilter(); //Фильтр веб-ссылок

//Устанавливаем цепочку фильтров
$badWordsFilter->setNextFilter($emailFilter)->setNextFilter($webLinksFilter);

//Выполняем проверку на мат
$text = 'Он спиздил идею';
echo 'Результат проверки на мат: '.$badWordsFilter->checkText($text).'<br><br>';

//Выполняем проверку на email
$text = 'Напишите мне на mail-01@test-site.ru, пообщаемся';
echo 'Результат проверки на наличие email-адресов: '.$badWordsFilter->checkText($text).'<br><br>';

//Выполняем проверку на ссылки
$text = 'На test-site.ru можете увидеть что-то интересное';
echo 'Результат проверки на наличие ссылок: '.$badWordsFilter->checkText($text).'<br><br>';

//Выполняем проверку цензурного выражения
$text = 'Съешь ещё этих мягких французских булок, да выпей чаю';
echo 'Результат проверки цензурного выражения: '.$badWordsFilter->checkText($text).'<br><br>';


?>

Результат работы скрипта test.php
Результат проверки на мат: error_bad_words_exist

Результат проверки на наличие email-адресов: error_emails_exist

Результат проверки на наличие ссылок: error_web_links_exist

Результат проверки цензурного выражения: valid