Pull to refresh

А мы пойдем другим путем. Перемещаем модель в базу данных

Reading time 6 min
Views 5K
А мы пойдем другим путемВ последнее время веб-разработка из наколенного поделия превратилась в серьезную инженерную дисциплину. Все это стало возможным стараниями легиона специалистов, которые разработали общие практики, которые позволяют писать веб-проекты, с использованием некой архитектуры, а не подобно исследователю, сбрасывающему ящик типографского шрифта с крыши небоскреба, в надежде, что тот чудесным образом сложится в первый том «Войны и Мира». Самой распространенной парадигмой веб-программирования является, вне всякого сомнения, MVC — Model-View-Controller. Говоря примитивно, эта парадигма предусматривает разделение кода приложения на слой управления (Controller), слой представления (View) и слой управления данными (Model). При этом MVC предусматривает, что Controller и View могут (но не обязаны) зависеть от Model, в то время как Model ни при каких условиях не должен зависеть от них.
Есть много различных подходов, как отделить бизнес-логику приложения от логики отображения и управления. Все они предусматривают, что модель является частью приложения и взаимодействует с БД, использую последнюю лишь в качестве хранилища данных. Мы же попытаемся пойти иным путем и по возможности максимально вынести бизнес-логику приложения на уровень БД.
Предупреждение: лицам с тонкой душевной организацией лучше не видеть того, что будет твориться под катом.

Введение


Чтобы не быть похожим на шизофреника, скажу: в своей жизни мне довелось увидеть одно очень большое приложение, написанное похожим образом. Практически вся его бизнес-логика выполнялась на уровне БД. Было эффективно с точки зрения производительности приложения и ужасно с точки зрения производительности программиста. Правда написано оно было под ASP.NET + MS SQL Server с его, несравненно большими возможностями. Мы же будем в своих экспериментах использовать опостылевшую связку: PHP (5.3.1) + Zend Framework (1.11) + MySQL (5.1.4).
Итак, окинем пристальным взором наш инструментарий и не будем сильно унывать (хотя есть от чего). Средства для работы с хранимыми процедурами и функциями в MySQL находятся в зачаточном состоянии, встроенной поддержки работы с хранимыми процедурами и функциями в PHP фактически нет.

А что ваять-то будем?..


В качестве Hello-world проекта возьмем простенький бложик с записями, комментариями и тэгами. Функционал по минимуму — наша задача просто исследовать принцип переноса модели в БД.
В качестве программной платформы будем использовать Zend Framework.

На штурм!


Итак база данных у нас имеет самую простую структуру из 4 таблиц:image
Итак, наша задача — перенести всю (или хотя бы часть) бизнес-логику в слой БД. Для этого мы будем использовать хранимые процедуры и функции, хранящиеся и выполняющиеся непосредственно на стороне сервера БД.
Прежде всего инициализируем приложение Zend Framework из командной строки:
zf create project ./
zf create controller posts
zf create controller comments
zf create controller tags

zf create action add Posts
zf create action edit Posts
zf create action save Posts
zf create action delete Posts
zf create action addComment Posts
zf create action view Posts

zf create db-table Posts sb_posts
zf create db-table Comments sb_comments
zf create db-table Tags sb_tags
zf create db-table PostTags sb_post_tags

zf create form Post
zf create form Comment

zf enable layout

Для начала напишем процедуру по сохранению поста:
CREATE DEFINER = 'sqlblog'@'localhost' PROCEDURE `Posts_save`(
        IN title VARCHAR(100),
        IN text TEXT,
        INOUT post_id INTEGER(11)
    )
BEGIN
    IF ISNULL(`post_id`) OR `post_id` = 0 THEN
    	INSERT INTO `sb_posts` (`title`, `text`, `date`) 
    		VALUES (`title`, `text`, NOW());
        SET `post_id` = LAST_INSERT_ID();
    ELSE
    	UPDATE `sb_posts` AS p 
    		SET p.`title` = `title`, p.`text` = `text` 
        	WHERE p.`id` = `post_id` LIMIT 1;
    END IF;
END;

Первые два параметра, передаваемые в процедуру — это заголовок записи и непосредственно текст записи. Последний параметр — ID записи. Этот параметр неслучайно имеет тип INOUT. Внимательный читатель уже мог убедиться, что данная процедура имеет двойственную логику: создает запись, если post_id не передавался или передавался как 0, и обновляет запись в противном случае. После выполнения процедуры ID записи возвращается во все том же третьем параметре.
К сожалению, удобство работы с хранимыми процедурами из PHP оставляет желать лучшего. В частности, PDO позволяет при выполнении запроса указывать, что тип передаваемого параметра INOUT. В этом случае после выполнения запроса на вызов процедуры в переменной, указанной в качестве такого параметра будет возвращено значение, возвращаемое хранимой процедурой:
$post_id = 0;
$sth = $dbh->prepare('CALL Posts_save(?, ?, ?)');
$sth->bindParam(1, 'Post title', PDO::PARAM_STR);
$sth->bindParam(2, 'Post body', PDO::PARAM_STR);
$sth->bindParam(3, $post_id, PDO::PARAM_INT|PDO::PARAM_INPUT_OUTPUT, 11);
$sth->execute();
print("New post ID is: $post_id");

Однако сия замечательная функция не работает применительно к MySQL. Потому для нормальной работы с MySQL приходится извращаться и использовать переменные самого MySQL. Трюк прост: указываем в качестве третьего параметра переменную MySQL, а потом извлекаем ее значение при помощи SELECT:
SET @post_id = 12;
CALL Posts_save('Post title', 'Post body', @post_id);
SELECT @post_id AS post_id;
Первый запрос нужен только при обновлении существующей записи.
Для создания записи создадим следующую форму:
class Application_Form_Post extends Zend_Form
{
	public function init()
	{
		$this
			->addElement('hidden', 'id')
			->addElement('text', 'title', array('label' => 'Title:', 'required' => true))
			->addElement('textarea', 'text', array('label' => 'Text:', 'required' => true))
			->addElement('text', 'tags', array('label' => 'Tags:', 'required' => true))
			->addElement('submit', 'submit', array('label' => 'Add Post'));
	}

	public function prepareDecorators()
	{
		$this->setDecorators(array(
			'FormElements',
			'FormErrors',
			'Form'
		));
		
		return $this;
	}
}

Теперь в коде контроллера нам достаточно написать следующее:
class PostsController extends Zend_Controller_Action
{
	public function addAction()
	{
		$this->view->form = $form = new Application_Form_Post(array(
			'name' => 'postForm',
			'action' => '/posts/save/'
		));
		$form->prepareDecorators();
	}

	public function saveAction()
	{
		$post_id = $this->_getParam('id');
		$this->view->form = $form = new Application_Form_Post(array(
			'name' => 'postForm',
			'action' => '/posts/save/'
		));
		if ($this->getRequest()->isPost()) {
			if ($form->isValid($this->getRequest()->getPost())) {
				$result = $this->_helper->procedure()->Posts_save(
					$form->getValue('title'),
					$form->getValue('text'),
					$post_id
				);
				if ($post_id) {
					$tags = $form->getValue('tags');
					$tags = explode(',', $tags);
					$tags = array_map('trim', $tags);
					$this->_helper->procedure()->Post_clearTags($post_id);
					foreach ($tags as $tag) {
						$this->_helper->procedure()->Post_addTag($post_id, $tag);
					}
					$this->_redirect('/posts/view/id/' . $post_id);
				}
			}
			$this->view->form = $form->prepareDecorators();
		}
		else {
			$this->_redirect('/posts');
		}
	}
}
Расширим слегка наш функционал. Как и предполагалось, к каждому посту автор добавляет тэги, которые затем нужно выводить в виде облака. Для того, чтобы выполнить эту задачу нам необходимо на каком-то этапе вычислять вес каждого тэга. Есть несколько вариантов:
  1. Вычислять налету, запросом для каждого тэга
  2. Хранить вес каждого тэга в таблице и обновлять его при любых изменениях

Первый вариант легко решается запросом:
SELECT t.*, getTagWeight(t.id) AS weight FROM sb_tags AS t

Здесь getTagWeight — заранее созданная функция, вычисляющая вес тэга. Однако, ввиду того, что облако тэгов будет выводится очень часто (гораздо чаще, чем будет изменяться порядок и состав тэгов), то такой запрос нельзя считать эффективным решением проблемы. Неплохим выходом из сложившейся ситуации было бы кэширование облака тэгов целиком, но мы попробуем решить эту проблему другим путем.
В MySQL, начиная с версии 5.0 появился механизм триггеров — процедур, которые запускаются при выполнении некого условия. Навешивается такой триггер и срабатывать может, например, до вставки данных или после удаления. Таким образом, мы можем решить проблему вычисления весов тэгов путем навешивания двух триггеров на связующую таблицу — sb_post_tags:

CREATE DEFINER = 'sqlblog'@'localhost' TRIGGER `sb_post_tags_after_ins_tr` AFTER INSERT ON `sb_post_tags`
  FOR EACH ROW
BEGIN
	UPDATE `sb_tags` AS t
    	SET t.`weight` = `Tag_calculateWeight`(`Tag_getById`(NEW . tag_id))
	    WHERE t.`id` = NEW.tag_id;
END;

CREATE DEFINER = 'sqlblog'@'localhost' TRIGGER `sb_post_tags_after_del_tr` AFTER DELETE ON `sb_post_tags`
  FOR EACH ROW
BEGIN
	UPDATE `sb_tags` AS t
    	SET t.`weight` = `Tag_calculateWeight`(`Tag_getById`(OLD.`tag_id`))
		WHERE t.`id` = OLD.`tag_id`;
END;

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

Выводы


Итак, что же в сухом остатке? Простенькие проекты таким способом писать можно, но у крупных проектов при таком подходе расходы в человекочасах по моему мнению будут расти экспоненциально со сложностью проекта.
Есть ли плюсы? Есть: это быстро. Даже по сравнению с DDD это быстро. На написание 13 процедур и 7 функций я потратил 15-20 минут. И они пишутся реально легко и просто.
Минусы:
  • С отладкой все плохо. Я не знаю, возможна ли пошаговая отладка хранимых процедур
  • Связка PHP-Mysql применительно к хранимым процедурам работает отвратительно. Возможно, если использовать PgSQL или MSSql, то эффективность разработки можно повысить
  • В связи с некоторыми ограничениями языка MYSQL, некоторую функциональность все-таки пришлось реализовывать стандартными средствами. В частности, я говорю о постраничном выводе, так как LIMIT может принимать только константные значения, за исключением случаев, когда используется связка PREPARE/EXECUTE

В любом случае, данный топик следует рассматривать лишь как эксперимент. Обсуждение приветствуется.
Я не рассматривал здесь все процедуры, а лишь указал ключевые моменты разработки. Для тех, кому интересен проект в целом, могу предложить репозиторий проекта на github
Tags:
Hubs:
+36
Comments 112
Comments Comments 112

Articles