7

Yii: инсталяция модулей

Posted апреля 9, 2010 in Yii and tagged , by SpiRi7

Как я уже писал, 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 фреймворка

Сформулируем задачи которые необходимо выполнить:

  1. Необходимо производить изменение конфигурационного файла приложения при установке/удаления модуля
  2. Обеспечить хранение текущих версий установленных модулей в базе данных
  3. Дать возможность выполнять sql скрипты при установке, обновлении и удалении модуля
  4. Предоставить интерфейс для управления модулями

Структура файлов проекта

Каталог /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

Похожие посты:

7 Responses so far.

  1. Konstantin пишет:

    Классная мысль, но, на мой взгляд, реализация некорректная.
    Модель не предназначена для того, чтобы в ней конфигипарсить и со всей таблицей работать. В данном случае это решение не соответствует паттерну MVC. Я реализовал у себя такое, но избавленное от ваших недостатков. Отличительные особенности:
    + тоже XML, но для замены на Yii-style достаточно поменять класс, который парсит конфиг
    + в БД находятся только те модули, которые реально установлены в системе с теми версиями, которые были у них на момент установки. Таблица несколько другая, указаной вами возможной ошибки быть не может, потому что апдейтится таблица только построчно и только когда модуль реально ставится/удаляется. Наличие неустановленных модулей и обновлений устанавливается “пробежкой” по директориям модулей при каждом выводе списка модулей. Учитывая что это админка и не самая частоиспользуемая фишка, это не имеет большого значения.
    + Корректно реализована следуюя концепции MVC
    + Конфиг лежит в папке с модулем
    + Есть возможность указать дополнительные системные модули (например Gii)
    + Есть возможность указать дополнительные опции модуля (defaultController, basePath и т.п. через конфиг модуля)
    - Мне не нужен был функционал обновления, поэтому я этого не реализовал.
    - В моей реализации нет выполнения SQL-скриптов при установке/удалении модуля, это, конечно, надо будет доделать.

    Своим модулем core я могу без проблем поделиться :)

    PS. Каждый раз как оставляю здесь коммент, мне пишет чтобы я не спамил и включил куки. Я не спамлю. и куки включены. Браузер FF 3.5.9, ОС Ubuntu 9.10.

  2. SpiRi7 пишет:

    Я только начинаю работать с Yii, много действительно реализуется некорректно. Я рад что Вы указали на основные ошибки. Если есть возможность, поделитесь core модулем. Буду презнателен.

    Кастательно несоответсвия концепции MVC размещать парсер в модели – насколько я понимаю лучше его поместить в компонент. Хотя я придерживаюсь концепции “толстые модели”. Т.е. большинство обработчиков перемещаю из контроллера в модели. Но этот подход больше подходит наверно для ZF, в Yii, насколько я понял, большую часть функционала надо переносить в компоненты.

    С установкой SQL скриптов для меня остается так и не решенный вопрос отстутвия какой либо нотификации при возникновении ошибок.

    P.S. Во всем виноват дурной плагин под вордперс, приходится ручками комментариям говорить – “Не спам” (отключил его потому что это не дело).

  3. Alex пишет:

    Konstantin
    Не могли бы вы выслать ваш модуль core?
    К тому же есть несколько вопросов по поводу организации модулей
    Как сделать так, чтобы модели конфигурировались модулем, в котором они расположены (например,имена таблиц для моделей настраиваются самим модулем). При этом эти модели будут использоваться в других модулях (модуль node, модели которого отвечает в частности за иерархию объектов и за сохранение любой информации; модуль article, контроллеры которого заполняют таблицы информацией, используя модели из node). Предполагается наличие модулей user, node, message. На основе node будут строится article, forum, gallery и т.п.

    Имеет ли смысл в создании админской части в каждом модуле, а отдельный модуль (admin) ссылается на эти компоненты?
    Быть может есть информация по организации, которую стоит изучить?

  4. Radik пишет:

    Не могли бы вылодить на суд общественности ваш модуль core?
    Я вот только начал разбираться с Yii и мне очень интересна тема модульности.

  5. SpiRi7 пишет:

    @Radik
    Спасибо за предложение. Я добавил в конец статьи исходный код этого модуля.

  6. Angrycat пишет:

    А можно перевыложить модуль core? Потому как ссылка не рабочая

  7. SpiRi7 пишет:

    @Angrycat проверяйте, должно работать

Leave a Reply