Как я уже писал, Yii поддерживает модульную архитектуру разработки приложения. Создать модуль можно используя консольную утилиту yiic, или руками. Для того что бы фреймворк “увидел” его – необходимо добавить строку в конфигурационных файл (документация по модулям) следующим образом:
'modules' => array('content','core','forum')
Рассмотрим систему как можно автоматизировать процесс нахождения модуля yii фреймворком.
Начнем с небольшого лирического отступления.
Последнее время я достаточно плотно работаю с движком создания интернет магазинов Magento. Эта система построена полностью на модульной архитектуре. Разработчики помещают собственный модули в каталог /app/code/local/CompanyName/ModuleName. Каждый модуль содержит файл /etc/config.xml – содержащий конфигурационные настройки модуля, папку /sql/modulename_setup/ – содержащую sql скрипты инсталяции и обновления базы. Дополнительно в директорию /app/etc/modules помещается xml файл с краткой информацией о модуле. Magento при обновлении кеша (или при любом действии в админке, если кеш отключен) производит проверку на наличие новых модулей или наличия обновления, и производит автоматическое выполнение sql скриптов. Я хочу показать реализацию подобной системы для Yii фреймворка
Сформулируем задачи которые необходимо выполнить:
- Необходимо производить изменение конфигурационного файла приложения при установке/удаления модуля
- Обеспечить хранение текущих версий установленных модулей в базе данных
- Дать возможность выполнять sql скрипты при установке, обновлении и удалении модуля
- Предоставить интерфейс для управления модулями
Структура файлов проекта
Каталог /protected/config содержит 3 конфигурационных файла:
- config.php – основной конфигурационный файл приложения. Настройки базы данных, названия приложения, используемых язык и т.п.
- modules.php – список установленных модулей
- main.php – файл производящий объединение данных в config.php и modules.php
Дополнительный каталог /protected/config/modules/ содержит xml файлы конфигурации каждого модуля установленного в веб-приложении. Например: content.xml, forum.xml, core.xml

Пример структуры конфигурационного файла модуля (на примере content.xml)
<?xml version="1.0"?> <config> <name>content</name> <version>1.0.0</version> <creationDate>2009-03-27</creationDate> <author>SpiRi7</author> <authorEmail>alex@spiri7.net</authorEmail> <authorUrl>http://spiri7.net</authorUrl> <copyright></copyright> <license></license> <description>Content module</description> </config>
Из данного файла мы будем использовать только ноды – name и version
Каждый модуль содержит директорию setup, в которой находятся файлы инсталляции, обновления, удаления модуля: install-1.0.0.sql, uninstall-1.0.0.sql, upgrade-0.9.0-1.0.0.sql. Каждый из этих файлов содержит набор sql инструкций для базы данных.

Конфигурационные файлы
Как я писал выше мы используем 3 конфигурационных файла: main.php, config.php, modules.php
Рассмотрим их содержимое:
config.php – обычный конфигурационный файл приложения
return array( 'basePath'=>dirname(__FILE__).DIRECTORY_SEPARATOR.'..', 'name'=>'Yii-Web', 'preload'=>array('log'), 'import'=>array( 'application.models.*', 'application.components.*', ), // application components 'components'=>array( 'user'=>array( // enable cookie-based authentication 'allowAutoLogin'=>true, 'loginUrl'=>array('admin/core/dashboard/login'), ), 'db'=>array( 'class'=>'system.db.CDbConnection', 'connectionString'=>'mysql:host=localhost;dbname=yii_test', 'username'=>'root', 'password'=>'', 'charset'=>'utf8', 'tablePrefix' => '' ), 'errorHandler'=>array( 'errorAction'=>'site/error', ), 'log'=>array( 'class'=>'CLogRouter', 'routes'=>array( array( 'class'=>'CFileLogRoute', 'levels'=>'error, warning', ), ), ), ), 'theme' => 'classic', 'language' => 'ru', 'params'=>array( // this is used in contact page 'adminEmail'=>'webmaster@example.com', ), );
modules.php – список установленных модулей, этот файл мы будем генерировать.
return array( 'modules' => array('content','core','invoice',) );
main.php – связка двух конфигурационных файлов в один
return CMap::mergeArray( require(dirname(__FILE__).'/config.php'), require(dirname(__FILE__).'/modules.php') );
Обратите внимание, что нет необходимости менять стартовый index.php. После создания нового приложения, он по умолчанию ссылается на конфигурационный файл main.php
Управление модулями
Перейдем непосредственно к коду. Для управления модулями я создал модуль core, в котором создал контроллер InstallController.php, модель Resource (ActiveRecord связана с таблицей core_resource).
Структура таблицы из БД
CREATE TABLE `yii_test`.`core_resource` ( `resource` varchar(50) NOT NULL, `version_installed` varchar(50) DEFAULT NULL, `version_available` varchar(50) DEFAULT NULL, `need_install` tinyint(2) NOT NULL DEFAULT '0', `need_upgrade` tinyint(2) NOT NULL DEFAULT '0', `need_uninstall` tinyint(2) NOT NULL DEFAULT '0', PRIMARY KEY (`resource`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC
resource – название ресурса (модуля)
version_installed – установленная версия (пустое значение если необходима установка)
version_available – доступная версия модуля
need_install/need_upgrade/need_uninstall флаги о необходимости установки, обновления, удаления
Контроллер
class InstallController extends AdminController { /** * @var CActiveRecord the currently loaded data model instance. */ private $_model; /** * @return array action filters */ public function filters() { return array( 'accessControl', // perform access control for CRUD operations ); } /** * Specifies the access control rules. * This method is used by the 'accessControl' filter. * @return array access control rules */ public function accessRules() { return array( array('allow', // allow authenticated user to perform all actions 'users'=>array('admin'), ), array('deny', // deny all users 'users'=>array('*'), ), ); } /** * Lists of all installed modules. */ public function actionIndex() { $model = new Resource(); // Refresh backend menu manager // Refreshing installing modules $model->refreshModuleList(); $dataProvider=new CActiveDataProvider('Resource', array()); $this->render('resources',array( 'dataProvider'=>$dataProvider )); } /** * Process install/upgrade/uninstall action */ public function actionProcess() { $resourceName = $_GET['resource']; $actionName = $_GET['action']; $model = new Resource(); $newVersion = $model->updateResource($resourceName, $actionName); Yii::app()->user->setFlash('success',"Module <b>".$resourceName."</b> success ".$actionName.". SQL script version: ".$newVersion); $this->redirect(Yii::app()->createUrl("admin/core/install/index")); } }
Разберем код подробнее.
actionIndex – производит обновление установленных модулей, и передачу данных в отображение списка записей в таблице
actionProcess – производит установку, удаление, обновления модуля (выполнение sql файлов) и передает управление в index (т.е. вывод списка на экран)
Отображение
<?php $this->widget('zii.widgets.grid.CGridView', array( 'id'=>'articles-grid', 'dataProvider'=>$dataProvider, 'columns'=>array( 'resource', 'version_installed', 'version_available', array( 'class'=>'CButtonColumn', 'buttons'=>array( 'install'=>array( 'label'=>'Install', 'visible'=>'($data->need_install == 1)?true:false', 'url'=> 'Yii::app()->createUrl("admin/core/install/process", array("resource"=>$data->resource,"action"=>"install"))', ), 'upgrade'=>array( 'label'=>'Upgrade', 'visible'=>'($data->need_upgrade == 1)?true:false', 'url'=> 'Yii::app()->createUrl("admin/core/install/process", array("resource"=>$data->resource,"action"=>"upgrade"))', ), 'uninstall'=>array( 'label'=>'Uninstall', 'visible'=>'($data->need_install == 0)?true:false', 'url'=> 'Yii::app()->createUrl("admin/core/install/process", array("resource"=>$data->resource,"action"=>"uninstall"))', ), ), 'template'=>'{install}{upgrade}{uninstall}', ), ), )); ?>
Отображение достаточно простое – просто производит вывод грида с кнопками для управления.
Модель
<?php class Resource extends CActiveRecord { const VERSION_COMPARE_EQUAL = 0; const VERSION_COMPARE_LOWER = -1; const VERSION_COMPARE_GREATER = 1; protected $isUpdated = false; /** * Returns the static model of the specified AR class. * @return CActiveRecord the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } /** * @return string the associated database table name */ public function tableName() { return 'core_resource'; } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels() { return array( 'resource' => 'Module', 'version_installed' => 'Installed', 'version_available' => 'Available', 'need_install' => 'Need install', 'need_upgrade' => 'Need upgrade', 'need_uninstall' => 'Need delete', ); } /** * Refreshing all installed modules list */ public function refreshModuleList() { $connection = Yii::app()->db; // Clear field that need to install (flag need_install=1) $command = $connection->createCommand("DELETE FROM {{core_resource}} WHERE need_install = 1"); $command->execute(); // Get all modules configuration files (configuration stored as xml file) $configFileList = glob(YiiBase::getPathOfAlias('application.config.modules')."/*.xml"); // Create config files array of items module=>version $installedFileModules = array(); foreach ($configFileList as $singleConfigFile) { $config = new SimpleXMLElement($singleConfigFile, NULL, true); $installedFileModules[(String)$config->name] = (String)$config->version; } // Get module config array from DB module=>version $installedDbModules = array(); $records = $this->findAll(); foreach ($records as $recordItem) { $installedDbModules[$recordItem->resource] = $recordItem['version_installed']; } // Determinate what modules need to be installed or upgraded $updateToDatabase = array(); foreach ($installedFileModules as $configModuleName => $configVersion) { $singleDbUpdate = array( 'resource' => $configModuleName, 'version_installed' => '', 'version_available' => $configVersion, 'need_install' => 0, 'need_upgrade' => 0, 'need_uninstall' => 0, ); if (isset($installedDbModules[$configModuleName])) { // Have such module installed into DB $status = version_compare($configVersion, $installedDbModules[$configModuleName] ); switch ($status) { case self::VERSION_COMPARE_LOWER: //$this->_rollbackResourceDb($configVer, $dbVer); break; case self::VERSION_COMPARE_GREATER: // Need update this module $singleDbUpdate['need_upgrade'] = 1 ; break; } $singleDbUpdate['version_installed'] = $installedDbModules[$configModuleName]; } else { $singleDbUpdate['need_install'] = 1 ; } $updateToDatabase[] = $singleDbUpdate; } // Determinate what modules need to be unistalled (no config file found) foreach ($installedDbModules as $dbModuleName => $dbVersion) { if (!isset($installedFileModules[$dbModuleName])) { $singleDbUpdate = array( 'resource' => $dbModuleName, 'version_installed' => $dbVersion, 'version_available' => '', 'need_install' => 0, 'need_upgrade' => 0, 'need_uninstall' => 1, ); $updateToDatabase[] = $singleDbUpdate; } } // Process update to DB // Clear all records $command = $connection->createCommand("DELETE FROM {{core_resource}}"); $command->execute(); $sql = "INSERT INTO {{core_resource}} (`resource`, `version_installed`, `version_available`, `need_install`, `need_upgrade`, `need_uninstall`) VALUES "; foreach ($updateToDatabase as $singleUpdate) { $sql.="('".$singleUpdate['resource']."','".$singleUpdate['version_installed']."','".$singleUpdate['version_available']."', ".$singleUpdate['need_install'].", ".$singleUpdate['need_upgrade'].", ".$singleUpdate['need_uninstall']."),"; } $sql = substr($sql,0, -1).';'; // Insert new list of modules $command = $connection->createCommand($sql)->execute(); // Update module list that installed to system // Create string with php code $installedModulesAsPhpArray = "<?php\n return array(\n 'modules' => array("; foreach ($updateToDatabase as $singleUpdate) { if ($singleUpdate['need_install'] == 0 && $singleUpdate['need_uninstall'] == 0) { // Write only modules that installed to system $installedModulesAsPhpArray.="'".$singleUpdate['resource']."',"; } } $installedModulesAsPhpArray.=")\n);"; // Need to have write permission to file config/modules.php $fh = fopen(YiiBase::getPathOfAlias('application.config')."/modules.php", "w"); fwrite($fh, $installedModulesAsPhpArray); fclose($fh); } /** * Make modifaction for module. Possible to install/upgrade/uninstall module. * * @param String $resourceName module name that need to perform $action * @param String $action action to perform install/upgrade/uninstall * @return real version that proccessed after update */ public function updateResource($resourceName, $action) { $resource = $this->findByPk($resourceName); $version = $this->_modifyResourceDb($resourceName, $action, $resource['version_installed'], $resource['version_available']); if ($action!="uninstall") { // After execute all sql file, update resource information for install/upgrade $sql = "UPDATE {{core_resource}} SET version_installed='".$resource['version_available']."' WHERE resource='".$resourceName."'"; Yii::app()->db->createCommand($sql)->execute(); } else { // for uninstall delete from table this resource Yii::app()->db->createCommand("DELETE FROM {{core_resource}} WHERE resource='".$resourceName."'")->execute(); } // Return real updated version return $version; } /** * Run module modification files. Return version of last applied upgrade (false if no upgrades applied) * * @param string $actionType install|upgrade|uninstall * @param string $fromVersion * @param string $toVersion * @return string | false */ protected function _modifyResourceDb($resourceName, $actionType, $fromVersion, $toVersion) { $sqlFilesDir = YiiBase::getPathOfAlias('application.modules.'.$resourceName.'.setup'); if (!is_dir($sqlFilesDir) || !is_readable($sqlFilesDir)) { return false; } // Read resource files $arrAvailableFiles = array(); $fileToDir = glob($sqlFilesDir."/".$actionType."-*.sql"); foreach($fileToDir as $sqlFile) { $matches = array(); if (preg_match('#.*'.$actionType.'-(.*)\.sql$#i', $sqlFile, $matches)) { $arrAvailableFiles[$matches[1]] = $sqlFile; } } if (empty($arrAvailableFiles)) { return false; } // Get SQL files name $arrModifyFiles = $this->_getModifySqlFiles($actionType, $fromVersion, $toVersion, $arrAvailableFiles); if (empty($arrModifyFiles)) { return false; } $modifyVersion = false; $connection = Yii::app()->db; // $connection->pdoInstance->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, true); foreach ($arrModifyFiles as $resourceFile) { try { ob_start(); include_once($resourceFile['fileName']); $sql = ob_get_contents(); @ob_end_clean(); if ($sql!='') { $command1 = $connection->createCommand($sql); $command1->execute(); // For some pdo adapter on next sql query show error $command1->getPdoStatement()->closeCursor(); } // Update Database module version $sql = "UPDATE {{core_resource}} SET version_installed='".$resourceFile['toVersion']."' WHERE resource='".$resourceName."'"; $connection->createCommand($sql)->execute(); } catch (Exception $e) { $message = 'Error in file: "'.$resourceFile['fileName'].'" - '.$e->getMessage(); throw new Exception($message); } $modifyVersion = $resourceFile['toVersion']; } // On final marked resource as not need install $sql = "UPDATE {{core_resource}} SET need_install = 0 WHERE resource='".$resourceName."'"; $connection->createCommand($sql)->execute(); return $modifyVersion; } /** * Get sql files for modifications * * @param $actionType * @return array */ protected function _getModifySqlFiles($actionType, $fromVersion, $toVersion, $arrFiles) { $arrRes = array(); switch ($actionType) { case 'install': uksort($arrFiles, 'version_compare'); foreach ($arrFiles as $version => $file) { if (version_compare($version, $toVersion) !== self::VERSION_COMPARE_GREATER) { $arrRes[0] = array('toVersion'=>$version, 'fileName'=>$file); } } break; case 'upgrade': uksort($arrFiles, 'version_compare'); foreach ($arrFiles as $version => $file) { $version_info = explode('-', $version); // In array must be 2 elements: 0 => version from, 1 => version to if (count($version_info)!=2) { break; } $infoFrom = $version_info[0]; $infoTo = $version_info[1]; if (version_compare($infoFrom, $fromVersion)!==self::VERSION_COMPARE_LOWER && version_compare($infoTo, $toVersion)!==self::VERSION_COMPARE_GREATER) { $arrRes[] = array('toVersion'=>$infoTo, 'fileName'=>$file); } } break; case 'uninstall': uksort($arrFiles, 'version_compare'); foreach ($arrFiles as $version => $file) { if (version_compare($version, $fromVersion) !== self::VERSION_COMPARE_GREATER) { $arrRes[0] = array('toVersion'=>$version, 'fileName'=>$file); } } break; case 'rollback': // @todo support rollback break; } return $arrRes; } }
Разбираем подробнее.
Метод refreshModuleList – основной метод запускаемый из контроллера когда мы переходит на просмотр грида. Производит удаление из базы данных всех модулей которые требуют установки. Производит считывание конфигурационных файлов модулей (директория /config/modules/*.xml) сохраняет данные в виде: имя модуля – версия. Получаем список модулей известных веб-приложению. В зависимости от результата сравнения данных (конфигурационные файлы – данные в базе) записывает в базу данных обновленные значения, с флагами информирующими о необходимости установки (данный модуль не найден в базе, но есть конфиг), обновления (модуль есть в базе и есть конфиг, но версия конфига более новая), необходимости удаления (нет конфигурационного файла, но имеется запись в базе). Записывает в конфигурационный файл приложения (/config/modules.php) список всех установленных и требующих обновления модулей.
Метод updateResource производит обновление указанного модуля согласно переданному действию. После успешного выполнения – обновляет таблицу core_resource (меняет версию или стирает модуль)
Метод _modifyResourceDb получает полный список sql файлов из директории модуля /modules/modulename/setup и сохраняет их в массив. Вызывает метод _getModifySqlFiles для получения списка файлов которых необходимо выполнить для обновления данных о ресурсе. После этого производит последовательное выполнение каждого файла требуемого для обновления/установки/удаления ресурса.
Обратите внимание на метод $command1->getPdoStatement()->closeCursor(). Sql файл обновления содержит несколько инструкций для модификации базы данных, некоторые pdo адаптеры не завершают корректно данный запрос, и при следующем запросе мы получим ошибку. Эта инструкция принудительно закрывает курсор (сбрасывает кеш).
Метод _getModifySqlFiles согласно передаваемого действия производит анализ списка файлов и возвращает только те которые необходимо использовать при обновлении до требуемой версии.
Результат работы нашего функционала будет следующий:
Недостатки
Такая система содержит ряд недостатков:
- Конфигурационные файлы модуля сделаны в xml формате. Для меня это удобнее, но не совсем Yii-style.
- Конфигурационный файл модуля находится в отдельной папке, для разработчика это может быть частично не удобно. Желательным будем размещение конфига в папке с модулем.
- При обновлении списка модулей выполняется полная очистка таблицы ресурсов. Это не очень красивый подход, возможна ситуация при которой таблица очиститься, а новые данные добавлены не будут.
- Нет защиты модуля от удаления. Например если мы произведем удаление модуля core — для возврата функционирования придется руками править базу данных
- При выполнении sql скриптов установки/обновления/удаления ошибки возвращаться если выполнение первого запроса было неудачным. При ошибке во втором, третьем и т.д. запросе исключение не генерируется, оставшиеся запросы пропускаются
- Возможна более грамотная генерация конфигурационного файла со списком установленных модулей.
Спасибо за внимание. Буду рад любым предложения, замечаниям и вариантам оптимизации.
Исходный код модуля core можно скачать по ссылке модуль core


Классная мысль, но, на мой взгляд, реализация некорректная.
Модель не предназначена для того, чтобы в ней конфигипарсить и со всей таблицей работать. В данном случае это решение не соответствует паттерну MVC. Я реализовал у себя такое, но избавленное от ваших недостатков. Отличительные особенности:
+ тоже XML, но для замены на Yii-style достаточно поменять класс, который парсит конфиг
+ в БД находятся только те модули, которые реально установлены в системе с теми версиями, которые были у них на момент установки. Таблица несколько другая, указаной вами возможной ошибки быть не может, потому что апдейтится таблица только построчно и только когда модуль реально ставится/удаляется. Наличие неустановленных модулей и обновлений устанавливается “пробежкой” по директориям модулей при каждом выводе списка модулей. Учитывая что это админка и не самая частоиспользуемая фишка, это не имеет большого значения.
+ Корректно реализована следуюя концепции MVC
+ Конфиг лежит в папке с модулем
+ Есть возможность указать дополнительные системные модули (например Gii)
+ Есть возможность указать дополнительные опции модуля (defaultController, basePath и т.п. через конфиг модуля)
- Мне не нужен был функционал обновления, поэтому я этого не реализовал.
- В моей реализации нет выполнения SQL-скриптов при установке/удалении модуля, это, конечно, надо будет доделать.
Своим модулем core я могу без проблем поделиться
PS. Каждый раз как оставляю здесь коммент, мне пишет чтобы я не спамил и включил куки. Я не спамлю. и куки включены. Браузер FF 3.5.9, ОС Ubuntu 9.10.
Я только начинаю работать с Yii, много действительно реализуется некорректно. Я рад что Вы указали на основные ошибки. Если есть возможность, поделитесь core модулем. Буду презнателен.
Кастательно несоответсвия концепции MVC размещать парсер в модели – насколько я понимаю лучше его поместить в компонент. Хотя я придерживаюсь концепции “толстые модели”. Т.е. большинство обработчиков перемещаю из контроллера в модели. Но этот подход больше подходит наверно для ZF, в Yii, насколько я понял, большую часть функционала надо переносить в компоненты.
С установкой SQL скриптов для меня остается так и не решенный вопрос отстутвия какой либо нотификации при возникновении ошибок.
P.S. Во всем виноват дурной плагин под вордперс, приходится ручками комментариям говорить – “Не спам” (отключил его потому что это не дело).
Konstantin
Не могли бы вы выслать ваш модуль core?
К тому же есть несколько вопросов по поводу организации модулей
Как сделать так, чтобы модели конфигурировались модулем, в котором они расположены (например,имена таблиц для моделей настраиваются самим модулем). При этом эти модели будут использоваться в других модулях (модуль node, модели которого отвечает в частности за иерархию объектов и за сохранение любой информации; модуль article, контроллеры которого заполняют таблицы информацией, используя модели из node). Предполагается наличие модулей user, node, message. На основе node будут строится article, forum, gallery и т.п.
Имеет ли смысл в создании админской части в каждом модуле, а отдельный модуль (admin) ссылается на эти компоненты?
Быть может есть информация по организации, которую стоит изучить?
Не могли бы вылодить на суд общественности ваш модуль core?
Я вот только начал разбираться с Yii и мне очень интересна тема модульности.
@Radik
Спасибо за предложение. Я добавил в конец статьи исходный код этого модуля.
А можно перевыложить модуль core? Потому как ссылка не рабочая
@Angrycat проверяйте, должно работать