PHP / Массивы моделей в MVC — вкусно и тяжело?

в 18:37, , рубрики: active record, memory usage, models, mvc, orm, метки: , , , ,

PHP / Массивы моделей в MVC — вкусно и тяжело?

Парадигма MVC во многом позволяет упростить поддержку кода за счет разделения логики и создания абстракций, однако часто, следуя принципу Thick Model & Thin Controller (он же Fat Model & Skinny Controller), разработчикам приходится упираться в краеугольный камень использования любого объекта-модели, а именно — в потребление памяти. Что особенно актуально при работе с моделями, которые реализуют ORM (или ActiveRecord паттерн).
В данной статье хочу вкратце продемонстрировать стандартные подходы к решению данной проблемы.
Для начала небольшое отступление для тех, кто не совсем понимает зачем нужно использовать модели если можно работать напрямую с данными из базы без лишних абстракций.
Собственно вот два варианта кода:
if ($row['status'] == 3) {
// do something ...
}

class Post {
const AUTH_ONLY = 3;
public $status;
public function isAuthOnly() {
return $this->status == self::AUTH_ONLY;
}
}

if ($post->isAuthOnly()) {
// do something ...
}

Если вам нужно решить задачу быстро и просто — первый вариант хорош. Однако если ваш код будет жить и после вас, читаться не только вами, а также развиваться и расширяться, то часто реализация второго варианта более предпочтительна.
Суть проблемы:
Сами по себе модели данных встречаются в большом количестве как правило именно при выборке записей из базы данных. Абстракция над записями в БД (ORM) требует реализации модели с собственной логикой, однако создавать модели на каждую выбираемую запись исключительно ресурсозатратно.
Чаще всего можно слышать следующую рекомендацию:
Если нужно мало записей, то можно и моделями, если же много, то уже обрабатывать отдельным запросом к БД с собственной логикой. Однако с отдельным запросом мы частично теряем слой абстракции к таблице и лишаемся возможности использовать логику модели.
Так как массив записей реляционной базы данных по своей сути представляет из себя лишь набор строк и полей, то самое первое и очевидное решение — это использовать только один объект модели, а его параметры заменять в цикле обхода выборки записей.
В результате получится нечто вроде этого:
$post = new Post;
$result = mysql_query('SELECT * FROM posts');
while ($row = mysql_fetch_array($db_result)) {
$post->setAttributes($row); // Устанавливаем значения полей модели

if ($post->isAuthOnly()) {
// do something ...
}
// do something ...
}

Таким образом ценой некоторой нагрузки мы получаем все преимущества модели не увеличивая потребление памяти для массовой выборки. Само собой при условии что нам действительно нужна логика модели.
Однако это решение хоть и прозрачно, но не достаточно элегантно, так как при его повторной реализации возникнет значительное дублирование кода. Призовем на помощь силу Iterator'a:
class PostIterator implements Iterator {

private $_model;
private $_result;
private $_row_num = 0;
private $_total_rows = 0;

public function __construct(Post $model) {
$this->_model = $model;
}

public function selectAll() { // Выбираем данные из базы
$this->_result = mysql_query('SELECT * FROM posts');
$this->_total_rows = mysql_num_rows($this->_result);
}

public function current () { // Получаем текущую модель с данными
mysql_data_seek($this->_result, $this->_row_num);
$data = mysql_fetch_array($this->_result);
$this->_model->setAttributes($data);
return $this->_model;
}

public function next () { // Переход к следующей строке
++$this->_row_num;
}

public function key () { // Номер текущей строки
return $this->_row_num;
}

public function valid () { // Не закончилась ли выборка
return ($this->_row_num _total_rows);
}

public function rewind () { // Переход в начало списка
mysql_data_seek($this->_result, 0);
$this->_row_num = 0;
}
}

class Post {
// ...
public function getIterator() {
return new PostIterator($this);
}
}

// Используем по назначению
$post_model = new Post;
$post_iterator = $post_model->getIterator();
$post_iterator->selectAll();
foreach ($post_iterator as $post) {

if ($post->isAuthOnly()) {
// do something ...
}
// do something ...
}

Полагаю дополнительные комментарии тут излишни. В результате мы получили объект, который позволяет обрабатывать достаточно большие объекты записей с использованием логики модели, при этом сохраняя малый объем потребляемой памяти и достаточную «прозрачность».
Однако приведенный код имеет ряд недостатков и узких мест:
1. Мы имеем только одну модель (объект) которую и передаем по псевдоссылке внутрь цикла, в результате ее изменение внутри цикла отразится на всем последующем цикле.
2. Операция перехода по результату запроса из базы данных (mysql_data_seek) потребляет свою часть ресурсов, и если нам не нужна «исключительно правильная» реализация итератора, то можно ее немного «облегчить». Так как интерфейс Traversable появится в mysqli_result только в версии PHP 5.4, то мы не может прямо переложить ответственность за его реализацию в Iterator'е на mysqli_result. Однако в самом ресурсе результата этот интерфейс неявно реализован, чем можно воспользоваться.
3. Дополнительно нам не обязательно каждый раз создавать модель (Post) предварительно, чтобы получить итератор. Это можно реализовать и через статический метод.
4. Обычно в совокупности с итератором иногда бывает удобно реализовать и интерфейс ArrayAccess, однако этот момент вы уже с легкостью реализуете при необходимости сами (mysql_data_seek вам в помощь).
По части указанных выше замечаний мы можем получить следующий, слегка оптимизированный, код:
class PostIterator implements Iterator {

private $_model;
private $_result;
private $_row_num = 0;
private $_total_rows = 0;
private $_current_data = null; // Добавим временное хранение данных строки

public function __construct($model) {
$this->_model = $model;
}

public function selectAll() {
$this->_result = mysql_query('SELECT * FROM posts');
$this->_total_rows = mysql_num_rows($this->_result);
$this->_current_data = mysql_fetch_array($this->_result); // тут
return $this; // для MethodChaining
}

public function current () {
$model = clone $this->_model; // тут переделаем
$model->setAttributes($this->_current_data);
return $model;
}

public function next () {
++$this->_row_num;
$this->_current_data = mysql_fetch_array($this->_result); // тут
}

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

public function valid () {
return ($this->_row_num _total_rows);
}

public function rewind () {
mysql_data_seek($this->_result, 0);
$this->_row_num = 0;
$this->_current_data = mysql_fetch_array($this->_result); // тут
}
}

class Post {
// ...
public static function getIterator() {
$class_name = __CLASS__;
return new PostIterator(new $class_name);
}
}

// Используем по назначению
$post_iterator = Post::getIterator()->selectAll();

foreach ($post_iterator as $post) {

if ($post->isAuthOnly()) {
// do something ...
}
// do something ...
}

В результате мы получили довольно простой «велосипед», который при должной «доработке напильником» позволит использовать модели более гибко и при больших объемах выборки.
В нем мы избавились от указанных выше недостатков, однако тут скрыта проблема использования внутреннего итератора результата запроса, которая не всплывает при стандартном использовании только в цикле foreach, однако использовать интерфейс Iterator в более сложных конструкциях уже не получится (выбрать этот подход или нет в целях быстродействия — решать вам, всё зависит от задачи).
Серьезные минусы данного подхода:
1. Более значительная нагрузка на вычислительные ресурсы машины. Потому за подобной реализацией правильно всегда иметь слой кеша, собственно как и везде.
2. Не каждую логику модели можно использовать за подобной абстракцией, например реляционные отношения модели. Если таковые имеются, то их придется или «зашивать» внутрь итератора, или же искать какое-то более оптимальное решение внутри самой модели.
3. Дополнительные сложности могут возникнуть если будет требоваться использовать модель с состояниями, что также потребует отдельной реализации применительно к итератору.
P.S.:
Весь приведенный код достаточно абстрактен от какой-либо конкретной задачи. Он приведен лишь для получения общего представления о принципе, который можно самостоятельно реализовать на практике. И, конечно, нет предела совершенству.


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


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