Статьи

Открытый / закрытый принцип по реальному коду


В этой статье показан пример того, как применение принципа Open / Closed улучшило дизайн реального проекта, библиотеки с открытым исходным кодом
PHPUnit_Selenium . Эти концепции проектирования применимы к любому объектно-ориентированному языку, включая Java, Ruby или даже C ++.

Немного теории

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

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

Фон для этого примера

В PHPUnit_Selenium есть объект Session, представляющий браузер, открытый Selenium, который можно использовать для выполнения тестов. Есть много команд для поддержки, от заголовка, который извлекает <title> страницы, до URL, который может быть вызван с аргументами или без них (для доступа или изменения текущего местоположения)

Есть подробности, относящиеся к каждой команде: поскольку процесс PHP связывается с Selenium с помощью REST-подобного API, он может использовать запрос POST или GET, в зависимости от типа команды. И параметры могут быть обработаны по-разному:

  • иногда вам нужно передать сложный массив с опциями.
  • иногда один аргумент, но Selenium принимает его только как сложный массив. Например, URL-адрес должен быть указан как массив с одним элементом: array (‘url’ => …). Управлять набором символов еще сложнее, так как строка типа «Hi» должна быть представлена ​​в виде массива («H», «i»).

До применения OCP

Чтобы избежать написания полного метода для каждой новой поддерживаемой команды, они поддерживаются с помощью __call () как магические методы для объекта Session (это будет эквивалентно методу callCommand ($ commandName, …)):

    public function __call($command, $arguments)
    {
        if (count($arguments) == 1) {
            if (is_string($arguments[0])) {
                $jsonParameters = array('url' => $this->baseUrl->addCommand($arguments[0])->getValue());
            } else if (is_array($arguments[0])) {
                $jsonParameters = $arguments[0];
            } else {
                throw new Exception("The argument should be an associative array or a single string.");
            }
            $response = $this->curl('POST', $this->sessionUrl->addCommand($command), $jsonParameters);
        } else if (count($arguments) == 0) {
            $response = $this->curl($this->preferredHttpMethod($command),
                                    $this->sessionUrl->addCommand($command));
        } else {
            throw new Exception('You cannot call a command with multiple method arguments.');
        }
        return $response->getValue();
    }

Однако эта реализация беспорядок:

  • Есть несколько ветвей, которые зависят от количества аргументов: 0 означает команду GET, в то время как по крайней мере один аргумент (сложный массив) приводит к POST.
  • другие ветви зависят от того, что является командой: url является специальным и должен заключать свой единственный параметр в массив.

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

После применения OCP

Класс Session теперь перечисляет доступные команды в виде массива методов, которые могут создать объект Command:

    public function __construct(...)
    {
        $this->commandFactories = array(
            'acceptAlert' => $this->factoryMethod('PHPUnit_Extensions_Selenium2TestCase_SessionCommand_AcceptAlert'),
            'alertText' => $this->factoryMethod('PHPUnit_Extensions_Selenium2TestCase_SessionCommand_GenericAccessor'),
            'dismissAlert' => $this->factoryMethod('PHPUnit_Extensions_Selenium2TestCase_SessionCommand_DismissAlert'),
            'title' => $this->factoryMethod('PHPUnit_Extensions_Selenium2TestCase_SessionCommand_GenericAccessor'),
            'url' => function ($jsonParameters, $commandUrl) use ($baseUrl) {
                return new PHPUnit_Extensions_Selenium2TestCase_SessionCommand_Url($jsonParameters, $commandUrl, $baseUrl);
            }
        );
    }

    /**
     * @params string $commandClass     a class name, descending from
                                        PHPUnit_Extensions_Selenium2TestCase_Command
     * @return callable
     */
    private function factoryMethod($commandClass)
    {
        return function($jsonParameters, $url) use ($commandClass) {
            return new $commandClass($jsonParameters, $url);
        };
    }

    public function __call($commandName, $arguments)
    {
        $jsonParameters = $this->extractJsonParameters($arguments);
        $response = $this->driver->execute($this->newCommand($commandName, $jsonParameters));
        return $response->getValue();
    }

    /**
     * @return string
     */
    private function newCommand($commandName, $arguments)
    {
        if (isset($this->commandFactories[$commandName])) {
            $factoryMethod = $this->commandFactories[$commandName];
            $commandUrl = $this->sessionUrl->addCommand($commandName);
            $commandObject = $factoryMethod($arguments, $commandUrl);
            return $commandObject;
        }
        throw new BadMethodCallException("The command '$commandName' is not existent or not supported.");
    }

$ this-> commandFactories — это массив анонимных функций, проиндексированных по имени команды. Каждая из этих функций может создавать соответствующий объект команды с двумя параметрами: $ jsonParameters, содержащий конфигурацию для Selenium, и URL-адрес команды, который используется в качестве цели для выполнения (отправляя HTTP-запрос к / session / 123 / title).

Эти поля могут быть введены или заменены объектом CommandFactory для полного аутсорсинга проблемы списка команд.

Первоначально все эти анонимные фабричные методы были идентичны, только менялось имя класса. Однако класс SessionCommand_Url имеет дополнительный параметр (базовый URL-адрес веб-сайта, на котором мы работаем), и поэтому я решил, что более общее решение в этой статье будет более полным. Очевидно, что по мере добавления все большего количества команд становится все более вероятным, что некоторые из них требуют разных аргументов, и поэтому создание объекта Command не может быть одинаковым для всех случаев.

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

abstract class PHPUnit_Extensions_Selenium2TestCase_Command
{
    protected $jsonParameters;
    private $commandName;

    /**
     * @param array $jsonParameters     null in case of no parameters
     */
    public function __construct($jsonParameters,
                                PHPUnit_Extensions_Selenium2TestCase_URL $url)
    {
        $this->jsonParameters = $jsonParameters;
        $this->url = $url;
    }

    public function url()
    {
        return $this->url;
    }

    /**
     * @return string
     */
    abstract public function httpMethod();

    /**
     * @param array $jsonParameters     null in case of no parameters
     */
    public function jsonParameters()
    {
        return $this->jsonParameters;
    }
}

Обратите внимание, что, по крайней мере, изначально абстрактный класс более гибок, поскольку позволяет добавлять методы ко всем объектам Command в одном месте.

Вот несколько примеров классов Command: первая — это команда для принятия окна с предупреждением, нажав Ok.

class PHPUnit_Extensions_Selenium2TestCase_SessionCommand_AcceptAlert
    extends PHPUnit_Extensions_Selenium2TestCase_Command
{
    public function httpMethod()
    {
        return 'POST';
    }
}

Существует также команда для изменения текущего местоположения или получения его после перенаправления или отправки:

class PHPUnit_Extensions_Selenium2TestCase_SessionCommand_Url
    extends PHPUnit_Extensions_Selenium2TestCase_Command
{
    public function __construct($relativeUrl, $commandUrl, $baseUrl)
    {
        if ($relativeUrl !== NULL) {
            $absoluteLocation = $baseUrl->addCommand($relativeUrl)->getValue();
            $jsonParameters = array('url' => $absoluteLocation);
        } else {
            $jsonParameters = NULL;
        }
        parent::__construct($jsonParameters, $commandUrl);
    }

    public function httpMethod()
    {
        if ($this->jsonParameters) {
            return 'POST';
        }
        return 'GET';
    }
}

Вывод

Код больше не беспорядок: добавление команды означает написание отдельного нового класса и добавление одной строки в Session в списке фабричных методов.

Некоторые условия все еще существуют, например, чтобы решить, когда команде следует использовать POST или GET; однако они заключены в среде одной команды, и это значительно упрощает их (требуется только одна ветвь).

Наконец, помните, что, применяя эту версию шаблона Command, вы обычно можете начать с создания списка имен классов для создания экземпляров; через некоторое время вы можете добавить косвенность, чтобы обеспечить их чистое создание (в моем случае команда Url передает дополнительный параметр вместо извлечения его из какого-то синглтона).

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