Данный способ не претендует на оригинальность, но, как мне кажется, может быть полезен в понимании принципов работы подобных систем (см. например 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, от которых, в свою очередь, наследовать уже конечные модели данных.
Полный код всех трех файлов под спойлером.
<?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