Масштабирование веб-приложений с помощью HMVC

:

Последние десять лет мы наблюдаем второй цикл веб-дизайна – сайты превращаются в приложения и уже практически не появляется новых проектов, не обладающих некой долей интерактивности. Увеличение сложности ПО, разрабатываемого для интернета, вызвало необходимость в структурированном и взвешенном проектировании приложений.

На сегодняшний день наиболее часто используемым паттерном проектирования сайтов является Модель-Вид-Контроллер (MVC). Повсеместное его использование отчасти вызвано успехом и популярностью фреймворка Ruby on Rails. Сейчас MVC является практически синонимом веб-разработки среди всех платформ.

При выполнении задач, активно нагружающих процессор, современные сайты все больше полагаются на выделенные ресурсы. Этому, в частности, поспособствовало открытие компаниями Amazon и Google облачных сервисов, которые позволяют разработчикам существенно уменьшить нагрузку на процессоры их собственных серверов. Каждый сервис обычно проектируется в виде отдельного элемента ПО, который запускается внутри своего домена и использует свои собственные ресурсы.

Когда имеешь дело со скромными бюджетами, обычно довольно сложно убедить клиентов в преимуществах финансирования более чем одного завершенного фрагмента программного обеспечения. Как показывает мой опыт, множество из них придерживаются мнения, что масштабируемость не является актуальной задачей. Они «с нетерпением ждут того дня, когда придется этим обеспокоиться».

Для уменьшения первоначальных вложений обычно принимают решение о том, что приложение должно быть спроектировано в виде целостной программы, содержащей все требуемые функции. Если сайт быстро обретет популярность, это станет проблемой. У меня остались не очень приятные впечатления от рефакторинга плохо масштабируемых кодовых баз. К тому же, это может потребовать большого количества ресурсов и денег. В идеале приложения должны расти по мере необходимости и не требовать в процессе этого крупных финансовых затрат.

Паттерн Иерархические-Модель-Вид-Контроллер



Паттерн Иерархические-Модель-Вид-Контроллер (HMVC) является расширением MVC, которое позволяет решить множество уже упомянутых проблем масштабируемости. Впервые он был описан в июле 2000 года в статье блога JavaWorld под названием «HMVC: многослойный паттерн для разработки клиентских уровней»; хотя существует мнение, что авторы на самом деле переосмыслили другой паттерн – Представление-Абстракция-Управление (PAC) – описанный в 1987 году. Цель статьи состояла в том, чтобы продемонстрировать, как HMVC может использоваться для создания масштабируемых сайтов и при проектировании настольных приложений с графическими интерфейсами.

HMVC – это набор традиционных триад MVC, работающих как одно приложение. Каждая триада совершенно независима и может выполняться при отсутствии любой другой. Все запросы к триадам должны использовать интерфейс контроллера, никогда не подключайте модели или библиотеки вне их домена. Физическое местонахождение триады на сервере не имеет значения, главное, чтобы она была доступна из всех других частей системы. Характерными особенностями HMVC являются поощрение повторного использования кода, упрощение тестирования отдельных частей системы и гарантия того, что приложение может быть усложнено и расширено без особых сложностей.

Успешное проектирование приложения на основе паттерна HMVC невозможно без разбиения его функций на отдельные системы. Каждая такая система должна представлять собой одну триаду MVC, независимо управляющую методами хранения и представлением внутри более крупного HMVC приложения. В настоящее время существует несколько фреймворков, поддерживающих HMVC без дополнительных модулей. Один из них – Kohana PHP третьей версии, который изначально создавался с ориентацией на HMVC. Далее во всех примерах я буду использовать этот фреймворк.

Kohana 3 использует встроенный объект Request для вызова других контроллеров. Запросы могут быть как внутренние – к контроллерам приложения, так и внешние – к веб-сервисам. В обоих случаях используется все тот же класс Request. Если триада MVC выносится на другой сервер, требуется изменить лишь один параметр.

<?php
class Controller_Default extends Controller {
 
	public function action_index()
	{
		// Пример внутреннего запроса
		$internal_request = Request::factory('controller/action/param')
			->execute();
 
		// Пример внешнего запроса
		$external_request = Request::factory('http://www.ibuildings.com/controller/action/param')
			->execute();
	}
}

Внутренний запрос требует корректного пути с указанием контроллера и экшена, а создание запроса ко внешнему ресурсу сводится к предоставлению полного URL. Эта особенность делает внутренние и внешние запросы взаимозаменяемыми без каких-либо сложностей, поэтому вынесение триад за пределы основного сервера является довольно простой задачей.

Использование в Kohana класса Request для получения данных из внутренних контроллеров может напомнить вам перенаправление экшенов в других фреймворках, например, Zend Framework. На самом же деле, это два довольно разных метода. Объекты Request в Kohana могут работать в изоляции как уникальные запросы, а при перенаправлении экшенов каждый из них существует внутри создающегося запроса. Ниже пример для демонстрации этого.

Контроллер Default – /application/controllers/default.php

<?php
// Контроллер для первого запроса
class Controller_Default extends Controller
{
	public function action_index()
	{
		// Если метод запроса был GET
		if ($this->request->method === 'GET')
		{
			// Вывод POST, напечатает   array (0) { empty }
			var_dump($_POST);
 
			// Создание нового запроса к другому ресурсу
			$log = Request::factory('/log/access/'.$page_id);
 
			// Указание POST в качестве метода запроса
			$log->method = 'POST';
 
			// Занесение каких-то данных для отправки
			$log->post = array(
				'uid'      => $this->user->id,
				'ua'       => Request::user_agent('browser'),
				'protocol' => Request::$protocol,
			);
 
			// Выполнение запроса
			$log->execute();
 
			// Вывод POST, опять напечатает  array (0) { empty }
			var_dump($_POST);
		}
	}
}

Контроллер Log – /application/controllers/log.php
<?php
// Контроллер для второго запроса
class Controller_Log extends Controller
{
	public function action_access($page_id)
	{
		// Когда запрашивается из экшена index (который выше)
		// выводит отправленные переменные из второго запроса
		// array(3){string (3) 'uid' => int (1) 1, string (2) 'ua' => string(10) 'Mozilla...
		var_dump($_POST);
 
		// Создание новой модели Log
		$log = new Log_Model;
 
		// Присвоение значений и сохранение
		$log->set_values($_POST)
			->save();
	}
}

Пример выше показывает независимость, доступную объекту Request. Первоначально GET-запрос вызывает экшен index контроллера Default, в ответ на что создается POST-запрос к экшену access контроллера Log. Экшен index назначает значения трем переменным, которые не доступны глобальному массиву $_POST из первого контроллера. Когда выполняется второй запрос, в $_POST уже содержатся переменные, которые мы для него сделали доступными. Заметьте, что после завершения $log->execute() в контроллере Index данные из $_POST исчезают. В других фреймворках реализация такого взаимодействия требует создания нового запроса с помощью, например, Curl.

Gazouillement, сервис микроблогинга


Продемонстрировать мощь HMVC поможет вымышленный сервис коротких сообщений (статусов) под названием Gazouillement, который по сути является аналогом Twitter. Предположим, что он был спроектирован на основе сервис-ориентированной архитектуры (SOA), чтобы веб-интерфейс и компоненты для работы с сообщениями и отношениями были разделены.

В первое время трафик будет относительно небольшим, ведь сервис новый и люди о нем еще не знают. Поэтому ничего страшного не случится, если пока что вся логика приложения будет выполняться на одном сервере.

Давайте напишем контроллер для отображения домашней страницы пользователя. На ней будут его самые свежие статусы и список людей, которых пользователь зафолловил.

Контроллер Index – /application/controllers/index.php

<?php
// Обрабатывает запрос к http://gazouillement.com/samsoir/
class Controller_Index extends Controller {
 
	public function action_index()
	{
		// Загрузка пользователя (samsoir) в модель
		$user = new Model_User($this->request->param('user'));
 
		// Если пользователь не загружен, выкидывает исключение 404
		if ( ! $user->loaded)
			throw new Controller_Exception_404('Unable to load user :user', array(':user' => $this->request->param('user')));
 
		// Загрузка пользовательских сообщений в формате xhtml
		$messages = Request::factory('messages/find/'.$user->name.'.xhtml')
			->execute()
			->response;
 
		// Загрузка пользовательских отношений в формате xhtml
		$relations = Request::factory($user->name.'/following.xhtml')
			->execute()
			->response;
 
		// Вывод в браузер вида домашней страницы пользователя
		// и привязка к нему пользователя, сообщений и отношений
		$this->request->response = View::factory('user/home', array(
			'user'      => $user,
			'messages'  => $messages,
			'relations' => $relations,
		));
	}
}

Теперь рассмотрим, что же делает Controller_Index::action_index(). Вначале экшен пытается загрузить пользователя на основе параметра user из URL. Если это не удается, выводится страница 404. Затем, с целью получения сообщений в формате xhtml, создается новый запрос, который в URI запроса использует имя пользователя из соответствующего свойства класса. Аналогичным образом и в том же формате запрашиваются пользовательские отношения. В итоге создается объект класса View с привязкой к нему пользователя, его сообщений и отношений.

При загрузке существующих сообщений и отношений через новый запрос, вся логика приложения для каждого сервиса остается абстрагированной от веб-сайта. Такая архитектура дает два значительных преимущества по сравнению с привычным выполнением контроллеров:

  1. Контроллера не касается никакая часть логики работы сервиса сообщений. Единственное требование – результат должен быть в формате xhtml. Во время выполнения контроллера ни одна дополнительная библиотека или расширение не загружались.
  2. Каждый контроллер отвечает лишь за одно конкретное задание, заметно упрощая таким образом написание юнит-тестов для них.

Из-за только что продемонстрированного уровня абстракции невозможно увидеть, чем занимаются сервисы. Поэтому давайте обратим внимание на контроллер сервиса сообщений. Начнем с роута в загрузочном файле, который будет отвечать за внутреннее управление запросами сообщений. Класс Route в Kohana занимается парсингом внутренних URL, связывая переданные элементы URI с контроллерами, экшенами и параметрами.

Настройка роута – /application/bootstrap.php

Route::set('messages', 'messages/<action>/<user>(<format>)', array('format' => '\.\w+'))
->defaults(array(
	'format'     => '.json',
	'controller' => 'messages',
));

Это создает роут для сервиса сообщений, который пока что находится внутри домена основного приложения. Запрос через адрес messages/find/samsoir.xhtml будет перенаправлен контроллеру messages, экшену find() которого передадутся в виде параметров 'user' => 'samsoir' и 'format => '.json'.

Контроллер Messages – /application/controllers/messages.php

<?php
class Controller_Messages extends Controller {
 
	// Форматы вывода, поддерживаемые этим контроллером
	protected $supported_formats = array(
		'.xhtml',
		'.json',
		'.xml',
		'.rss',
	);
 
	// Контекст пользователя в этом запросе
	protected $user;
 
	// Код ниже выполнится перед экшеном
	// Мы проверяем корректность формата и пользователя
	public function before()
	{
		// Проверка на наличие формата в списке поддерживаемых
		if ( ! in_array($this->request->param('format'), $this->supported_formats))
			throw new Controller_Exception_404('File not found');
 
		// Проверка корректности имени пользователя
		$this->user = new Model_User($this->request->param('user'));
 
		if ( ! $this->user->loaded())
			throw new Controller_Exception_404('File not found');
 
		return parent::before();
	}
 
	// Этот метод ищет сообщения пользователя
	public function find()
	{
		// Загрузка сообщений со связью пользователя и сообщений как 1:M
		$messages = $this->user->messages;
 
		// Назначаем ответ на запрос, используя метод prepare для корректного вывода
		$this->request->response = $this->_prepare_response($messages);
	}
 
	// Метод для подготовки выводимых данных
	protected function _prepare_response(Model_Iterator $messages)
	{
		// Возвращает сообщения, отформатированные в соответствии с форматом
		switch ($this->request->param('format') {
			case '.json' : {
				$this->request->headers['Content-Type'] = 'application/json';
				$messages = $messages->as_array();
				return json_encode($messages);
			}
			case '.xhtml' : {
				return View::factory('messages/xhtml', $messages);
			}
			default : {
				throw new Controller_Exception_404('File not found!');
			}
		}
	}
}

Процесс получения сообщений пользователя в деталях показан в контроллере Controller_Messages. Все методы и свойства привязаны только к контексту сообщений, включая пользовательские отношения. Давайте рассмотрим код этого контроллера, чтобы понять происходящее.

Объект Request всегда перед вызовом указанного экшена обращается к методу before(). Это позволяет выполнять перед основной работой различные рутинные операции. Метод before() вначале проверяет запрашиваемый формат на предмет поддерживаемости, а затем имя пользователя на корректность. Если before() завершается без выкинутых исключений, дальше объект Request вызовет экшен find(). Он загружает сообщения пользователя в виде объекта Model_Iterator. Необходимо заметить, что итератор будет пуст, если в пользовательском отношении не будут найдены сообщения. Завершается все передачей итератора сообщений в метод _prepare_response(), который корректно форматирует данные для вывода и выставляет все нужные заголовки.

Контроллеры Controller_Index и Controller_Messages были оба выполнены единым запросом к приложению. О существовании друг друга они, тем не менее, не знали. Каждый разработчик способен прочитать текущую кодовую базу и понять, что и где выполняется. Это еще одна отличная особенность HMVC – легкость в обслуживании.

После окончания работы над кодом остальных сервисов руководители компании выразили удовлетворение первым этапом разработки и разрешили развертывание на открытом сервере для ограниченного бета-тестирования. После пары недель приложение готово к массовому использованию. В течение следующих месяцев аудитория будет постоянно расти, а вся архитектура сайта будет улучшаться и оптимизироваться.

Эффект Стивена Фрая


Стивен Фрай ( @stephenfry) – один из самых известных пользователей Twitter с более чем 2 миллионами фолловеров. Он способен отключать сайты одной лишь публикацией URL в своем микроблоге, создавая DDOS-атаку на сервер.

Gazouillement последние несколько месяцев активно набирал пользователей. Время отклика в силу этого возросло, но оно все еще в рамках допустимого. Вдруг некий пользователь Twitter с огромным количеством фолловеров, вроде Стивена Фрая, размещает у себя сообщение со ссылкой на Gazouillement. И тут у сервиса начинаются настоящие проблемы.

Первой станет большой объем трафика. Приложению вместо обычных нескольких сотен запросов в секунду придется обрабатывать тысячи. При таком сценарии Gazouillement начнет испытывать перегрузку и возможно “положит” сервер. Станет очевидным, что приложению требуется некоторая оптимизация и улучшение для того чтобы справляться с заметно увеличивающимся трафиком.

Анализ кода с целью улучшения производительности – занятие не из простых. В предыдущих статьях на TechPortal рассматривались утилиты вроде XHProf, предназначенные для глубокого анализа памяти и процессора во время выполнения кода. Kohana PHP имеет встроенную в ядро программу для анализа производительности под названием Profiler. Но она является дополнением, а не заменой XHProf и подобных ей утилит. Profiler позволяет разработчикам обнаружить медленные места в выполняющемся коде, которые затем могут быть в деталях исследованы с помощью XHProf или еще одного встроенного во фреймворк модуля – Codebench.

Включить Profiler можно изменением лишь одного параметра в загрузчике.

Загрузчик – /application/bootstrap.php

//-- Environment setup --------------------------------------------------------
Kohana::$profiling = TRUE;

И наконец, добавьте вид Profiler’а в конец выводимого контроллером ответа. Это лучше всего сделать через метод after(), тогда профайлер будет показываться для всех экшенов.

Контроллер Index – /application/controllers/index.php

public function after()
{
	// Добавление в контроллер статистики от профайлера
	$this->request->response .= View::factory('profiler/stats');
}

После включения профайлера его информация начинает появляться внизу каждого экшена из этого контроллера. Лучше всего создать класс, наследующий Controller, и вставить вид профайлера в метод after() этого класса. Теперь все системные контроллеры выводят отладочную информацию профайлера во время разработки.

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

Масштабируем Gazouillement


Глобальный анализ производительности Gazouillement показал, что большие трудности возникают при получении сообщений. Хотя команда разработчиков произвела рефакторинг и оптимизацию соответствующей триады MVC, нужные показатели производительности так и не были получены. После не особо результативного улучшения процессоров и памяти на сервере, руководители компании соглашаются на горизонтальное масштабирование приложения, начиная с системы сообщений.

При архитектуре MVC новый сервис обычно должен пройти стадии проектирования, разработки, приемочного тестирования и внедрения. Этот процесс может занять месяцы и потребовать существенных вложений. Но в Gazouillement, по сути, будет интегрироваться новый фрагмент программного обеспечения, а не новая программа.

В приложениях на базе HMVC новые сервисы на основе существующей кодовой базы можно создавать в кратчайшие промежутки времени. Поскольку все взаимодействие с сервисом сообщений осуществлялось через главный контроллер, изменить нужно лишь запросы сообщений.

Сервис сообщений был перенесен на другой сервер и оптимизирован для работы с базой данных. Этот новый сервер выполняет только действия, связанные с сообщениями, таким образом заметно повышая скорость всех подобных операций.

Контроллер Index – /application/controllers/index.php

<?php
// Обрабатывает запрос к http://gazouillement.com/samsoir/
class Controller_Index extends Controller {
 
	protected $_messages_uri = 'http://messages.gazouillement.com';
 
	public function action_index()
	{
		// Загрузка пользователя (samsoir) в модель
		$user = new Model_User($this->request->param('user'));
 
		// Если пользователь не загружен, выкидывается исключение 404
		if ( ! $user->loaded)
			throw new Controller_Exception_404('Unable to load user :user', array(':user' => $this->request->param('user')));
 
		// -- НАЧАЛО ИСПРАВЛЕННОГО КОДА --
 
		// Новый URI сообщений ко внешнему серверу
		$messages_uri = $this->_messages_uri.'/find/'.$user->name'.xhtml';
 
		// Загрузка сообщений для пользователя в формате xhtml
		$messages = Request::factory($messages_uri)
			->execute()
			->response;
 
		// -- КОНЕЦ ИСПРАВЛЕННОГО КОДА --
 
		// Загрузка пользовательских отношений в формате xhtml
		$relations = Request::factory($user->name.'/following.xhtml')
			->execute()
			->response;
 
		// Вывод в браузер вида домашней страницы пользователя
		// и привязка к нему пользователя, сообщений и отношений
		$this->request->response = View::factory('user/home', array(
			'user'      => $user,
			'messages'  => $messages,
			'relations' => $relations,
		));
	}
}

В коде выделено крохотное изменение в контроллере Controller_Index::action_index(). Запрос сообщений теперь идет не к экшену внутреннего контроллера, а к сервису сообщений, который работает на поддомене messages.gazouillement.com. То, что обычно привело бы к пересмотру всей архитектуры проекта, стало небольшим ее дополнением. И поскольку изменилось так мало кода, ассоциативное и приемочное тестирования будут гораздо короче.

Это лишь один пример того, как паттерн Иерархические-Модель-Вид-Контроллер позволяет разработчикам проектировать веб-приложения, которые с самого начала могут расширяться и вертикально, и горизонтально. Мы увидели, как объект Request позволяет контроллерам возвращать корректные данные для каждого контекста, используя простой интерфейс. Видели, как он же делает вынесение части приложения за пределы сервера весьма легкой задачей. И стоимость масштабирования была относительно мала в силу небольшого количества изменений, чему руководители компании были очень рады.

Больше информации о Kohana вы можете найти на сайте фреймворка, а файлы Kohana PHP 3 можно скачать с Github. Еще есть документация по api: http://v3.kohanaphp.com/guide.

Класс для запросов, использованный в этом примере, в данный момент доступен в ветке разработки ядра Kohana на моем личном аккаунте в github – http://github.com/samsoir/core. Если используется официальный дистрибутив Kohana PHP 3.0, необходимо видоизмененное расширение класса для запросов.