Автоматический «текучий интерфейс» и ArrayIterator в PHP-моделях

в 14:57, , рубрики: fluent interface, php, модели данных, метки: , ,

Данный способ не претендует на оригинальность, но, как мне кажется, может быть полезен в понимании принципов работы подобных систем (см. например Varien_Object, написанный разработчиками Magento, идея была взята в первую очередь оттуда) и, возможно, будет полезен в проектах, куда не очень хочется подключать тяжелые фреймворки, но уже нужно как-то систематизировать код.

Сложно представить достаточно крупный проект, в котором не было бы работы с моделями данных. Скажу больше: по моему опыту около трех четвертых всего кода — это создание, загрузка, изменение, сохранение или удаление записей. Будь то регистрация пользователя, вывод десятка последних статей или работа с админкой — все это мелкая работа с базовыми операциями моделей. И, соответственно, такой код должен писаться и читаться быстро и не должен забивать голову программиста техническими деталями: он (программист) должен думать о логике работы приложения, а не об очередном UPDATE-запросе.

Вместо предисловия

На мой взгляд, чтобы код легко читался, он (помимо, разумеется, стандартов кодирования и понятности алгоритмов) должен быть максимально приближен к естественному языку. Загрузи этот товар, установи ему вот такое название, установи вот такую цену, сохрани. Кроме того, если в коде возможно избежать от повторения, будь то кусок кода или просто название переменной (при работе с одним объектом), то его следует избежать. В моем случае «текучий интерфейс» избавил меня от постоянного нудного копирования имени переменной.

Текучий интерфейс

Логично будет отделить мух от котлет и вынести «текучий интерфейс» в отдельный класс, на случай, если его потребуется использовать не только в моделях:

abstract class Core_Fluent extends ArrayObject {}

Перед тем, как начать писать, я определился, каким я хочу видеть конечный код, который я буду использовать. Получилось вот это:

$instance->load($entity_id)
	->setName('Foo')
	->setDescription('Bar')
	->setBasePrice(250)
->save();

При этом, я хотел, чтобы данные хранились с ключами вида «name», «description», «base_price» (это позволило бы гораздо проще реализовать взаимодействие с БД и этого требовал мой стандарт кодирования).

Для того, чтобы не писать в каждой модели однотипные методы, следует использовать «магические методы» (Magic Methods), в частности, метод __call(). Также можно было использовать методы __get() и __set(), но я пошел путем применения ArrayIterator.

Итак, метод __call, который будет определять, что именно было вызвано и что вообще дальше делать:

...
	// регулярное выражение для преобразования CamelCase в стиль_через_подчеркивания
	// Взял в свое время со StackOverflow, потому как в регулярках не силен
	const PREG_CAMEL_CASE = '/(?<=[A-Z])(?=[A-Z][a-z])|(?<=[^A-Z])(?=[A-Z])|(?<=[A-Za-z])(?=[^A-Za-z])/';

	// Массив для хранения самих данных
	protected $_data = array();

	public function __call($method_name, array $arguments = array()) {
		// Первым делом проверяем, подходит ли вообще то, что было вызвано под используемый 
		// шаблон и делим на действие и свойство (setBasePrice тут разделится на set и BasePrice)
		if(!preg_match('/^(get|set|isset|unset)([A-Za-z0-9]+)$/', $method_name, $data)) {
			// И если не подходит, то бросаем исключение
			throw new Core_Exception('Method '.get_called_class().'::'.$method_name.' was not found');
		}
		
		// Затем переводим имя свойства в стандартный вид (BasePrice => base_price) и помещаем в $property
		$property = strtolower(preg_replace(self::PREG_CAMEL_CASE, '_$0', $data[2]));
		
		// Теперь надо понять, что с этим вообще делать
		switch($data[1]) {
			case 'get': {
				// $object->getBasePrice(): возвращаем значение свойства
				return $this->get($property);
			} break;
			
			case 'set': {
				// $object->setBasePrice(): изменяем значение свойства
				return $this->set($property, $arguments[0]);
			} break;
			
			case 'unset': {
				// $object->getBasePrice(): удаляем свойство из объекта
				return $this->_unset($property);
			} break;
			
			case 'isset': {
				// $object->getBasePrice(): проверяем, есть ли это свойство у объекта
				return $this->_isset($property);
			} break;
			
			default: {
				
			}
		}
		// И, если мы сюда дошли, то возвращаем объект, реализуя таким образом "текучий интерфейс"
		return $this;
	}
...

Методы get, set, _isset и _unset

Реализация этих методов не представляет никакой сложности, их действие очевидно из названия:

...
	public function get($code) {
		if($this->_isset($code)) {
			return $this->_data[$code];
		}
		// Вот тут можно бросить исключение, но я предпочел просто вернуть NULL
		return NULL;
	}
	
	public function set($code, $value) {
		$this->_data[$code] = $value;
		return $this;
	}
	
	public function _unset($code) {
		unset($this->_data[$code]);
		return $this;
	}	

	public function _isset($code) {
		return isset($this->_data[$code]);
	}
...

ArrayIterator

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

...
	public function offsetExists($offset) {
		return $this->_isset($offset);
	}
	
	public function offsetUnset($offset) {
		return $this->_unset($offset);
	}
	
	public function offsetGet($offset) {
		return $this->get($offset);
	}
	
	public function offsetSet($offset, $value) {
		return $this->set($offset, $value);
	}
	
	public function getIterator() {
		return new Core_Fluent_Iterator($this->_data);
	}
...

И, соответственно, класс Core_Fluent_Iterator:

class Core_Fluent_Iterator extends ArrayIterator {}

Все. Теперь с любым классом, наследующимся от Core_Fluent доступны такие манипуляции:

class Some_Class extends Core_Fluent {}

$instance = new Some_Class();

$instance->set('name', 'Foo')->setDescription('Bar')->setBasePrice(32.95);

echo $instance->getDescription(), PHP_EOL; // Bar
echo $instance['base_price'], PHP_EOL; // 32.95
echo $instance->get('name'), PHP_EOL; // Foo


// name => Foo
// description => Bar
// base_price => 32.95
foreach($instance as $key => $value) {
	echo $key, ' => ', $value, PHP_EOL;
}

var_dump($instance->issetBasePrice()); // true
var_dump($instance->issetFinalPrice()); // false
var_dump($instance->unsetBasePrice()->issetBasePrice()); // false

Модель

Теперь сама модель, частный случай применения вышеописанного механизма.

abstract class Core_Model_Abstract extends Core_Fluent {}

Для начала необходимо добавить основу для CRUD (создание, загрузка, изменение и удаление). Логика (работа с БД, файлами и чем угодно еще) будет ниже по иерархии, здесь нужно сделать только самое основное:

...
	// Массив измененных свойств, понадобится чуть позже
	protected $_changed_properties = array();

	// Создание. При реализации save() ние по иерархии можно добавить проверку на
	// существование и вызывать этот метод автоматически, в случае если идентификатор
	// не найден в базе (или где угодно еще)
	public function create() {
		return $this;
	}
	
	// Загрузка
	public function load($id) {
		$this->_changed_properties = array();
		return $this;
	}
	
	// Загрузка из массива
	public function loadFromArray(array $array = array()) {
		$this->_data = $array;
		return $this;
	}

	// Сохранение
	public function save() {
		$this->_changed_properties = array();
		return $this;
	}
	
	// Удаление
	public function remove() {
		return $this->unload();
	}

	// Выгрузка из памяти
	public function unload() {
		$this->_changed_properties = array();
		$this->_data = array();
		return $this;
	}

	// Конвертация объекта в массив
	public function toArray() {
		return $this->_data;
	}
...

И, наконец, переопределим set(), добавив массив измененных свойств

...
	public function set($code, $value) {
		$this->_changed_properties[] = $code;
		return parent::set($code, $value);
	}
...

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

Полный код всех трех файлов под спойлером.

Полный код всех трех файлов

Core/Fluent.php

<?php
abstract class Core_Fluent extends ArrayObject {
	const PREG_CAMEL_CASE = '/(?<=[A-Z])(?=[A-Z][a-z])|(?<=[^A-Z])(?=[A-Z])|(?<=[A-Za-z])(?=[^A-Za-z])/';
	
	protected $_data = array();
	
	public function __call($method_name, array $arguments = array()) {
		if(!preg_match('/^(get|set|isset|unset)([A-Za-z0-9]+)$/', $method_name, $data)) {
			throw new Core_Exception('Method '.get_called_class().'::'.$method_name.' was not found');
		}
	
		$property = strtolower(preg_replace(self::PREG_CAMEL_CASE, '_$0', $data[2]));
	
		switch($data[1]) {
			case 'get': {
				return $this->get($property);
			} break;
				
			case 'set': {
				return $this->set($property, $arguments[0]);
			} break;
				
			case 'unset': {
				return $this->_unset($property);
			} break;
				
			case 'isset': {
				return $this->_isset($property);
			} break;
				
			default: {
	
			}
		}
		return $this;
	}
	
	public function get($code) {
		if($this->_isset($code)) {
			return $this->_data[$code];
		}
		return NULL;
	}
	
	public function set($code, $value) {
		$this->_data[$code] = $value;
		return $this;
	}
	
	public function _unset($code) {
		unset($this->_data[$code]);
		return $this;
	}
	
	public function _isset($code) {
		return isset($this->_data[$code]);
	}
	
	/**
	 * Implementation of ArrayIterator
	 */
	public function offsetExists($offset) {
		return $this->_isset($offset);
	}
	
	public function offsetUnset($offset) {
		return $this->_unset($offset);
	}
	
	public function offsetGet($offset) {
		return $this->get($offset);
	}
	
	public function offsetSet($offset, $value) {
		return $this->set($offset, $value);
	}
	
	public function getIterator() {
		return new Core_Fluent_Iterator($this->_data);
	}
}
?>

Core/Fluent/Iterator.php

<?php
class Core_Fluent_Iterator extends ArrayIterator {}
?>

Core/Model/Abstract.php

<?php
abstract class Core_Model_Abstract extends Core_Fluent {
	protected $_changed_properties = array();
	
	public function set($code, $value) {
		$this->_changed_properties[] = $code;
		return parent::set($code, $value);
	}
	
	public function create() {
		return $this;
	}
	
	public function load($id) {
		$this->_changed_properties = array();
		return $this;
	}
	
	public function loadFromArray(array $array = array()) {
		$this->_data = $array;
		return $this;
	}
	
	public function save() {
		$this->_changed_properties = array();
		return $this;
	}
	
	public function remove() {
		return $this->unload();
	}
	
	public function unload() {
		$this->_changed_properties = array();
		$this->_data = array();
		return $this;
	}
	
	public function toArray() {
		return $this->_data;
	}
}
?>

Вместо заключения

Получилось достаточно объемно, но, в основном, из-за кода. Если эта тема интересна, то я могу описать реализацию коллекции (некое подобие массива записей с возможностью загрузки с фильтрацией и коллективных (batch) действий) на этом же механизме. И коллекции, и эти модели взяты из разрабатываемого мной фреймворка, поэтому их правильнее рассматривать в комплексе, но я не стал перегружать и без того объемную статью.
Разумеется, буду рад услышать ваше мнение или аргументированную критику.

Автор: Jack_Taylor

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js