В этой статье показан пример того, как применение принципа 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, пока класс не взорвется, или вы можете извлечь некоторый интерфейс или абстрактный класс для управления новыми функциями с небольшими, совершенно новыми объектами.