Простой редактор конфигурационных файлов для Yii

    Доброго вечера, Хабрахабр.

    Сегодня я расскажу о небольшом компоненте формы, который мне довелось написать для замечательного PHP фреймворка Yii. Этот компонент (а точнее, модель формы) позволяет редактировать config-файлы прямо из веба. Статья навеяна недавним постом о подобной функциональности, но та реализация основана на БД. Это не совсем нэйтивно для конфигурационных файлов Yii. К тому же за такое решение придётся заплатить лишними запросами к базе/кэшу, а их в проектах с высокой посещаемостью нужно экономить.

    В статье будет много кода, но я постараюсь разделить его на логичные куски.

    Идея


    Конфигурационный файл в Yii — обычный php скрипт, который возвращает массив.
    Например:
    return array(
        'name' => 'My Awesome Web Site',
        'lang' => 'ru',
        'sourceLang' => 'en',
    );
    


    В конфигурации порой указываются некие статичные параметры сайта, которые изменяются раз в год или не изменяются вовсе. Для примера возьмём E-mail администратора или номер телефона, который выводится рядом с логотипом. Эти параметры определённо нужно позволить редактировать администратору сайта, но не давать же ему лезть в код, верно? (:

    Реализация


    Реализация сама по себе довольно проста, но в то же время запутана. Я постараюсь разложить всё по полочкам.

    Модель

    Модель — это данные. А какие данные есть у конфиг-файлов? Правильно, массив конфигурации. Вот для него-то нам и надо создать модель.

    class ConfigForm extends CFormModel
    {
    	/** @var array Массив, содержащий в себе всю конфигурацию */
    	private $_config = array();
    
    	/**
    	 * Инициализация модели
    	 * @param array $config Массив из конфига
    	 * @param string $scenario Сценарий валидации
    	 */
    	public function __construct($config = array(), $scenario = '')
    	{
    		parent::__construct($scenario);
    		$this->setConfig($config);
    	}
    
    	public function setConfig($config)
    	{
    		$this->_config = $config;
    	}
    
    	public function getConfig()
    	{
    		return $this->_config;
    	}
    
    }
    

    Пока всё просто, не так ли?
    На самом деле, нет никакой нужды в приватности переменной $_config, но это не будет лишним если Вы вдруг захотите изменить правила игры

    Далее нам нужно установить правила, по которым будут формироваться имена атрибутов. Вы ведь не хотите каждый раз добавлять новое поле в модель (хотя, всё же, Вам придётся кое-что добавлять, но об этом позже). Итак, допустим у нас есть такой конфигурационный массив:
    array(
    	'name' => 'My Awesome Site', // на самом деле, изменять имя сайта - плохая идея
    	'params' => array(
    		'adminEmail' => 'admin@example.com',
    		'phoneNumber' => '555-555-555',
    		'motto' => 'the best of the most awesome',
    	),
    );
    


    Из этого массива нужно получить следующие атрибуты: name, params[adminEmail], params[phoneNumber], params[motto]. Соответственно, делать это нужно рекурсивно, и вот моё решение:

    Моё решение
    	/**
    	 * Возвращает все атрибуты с их значениями
    	 * 
    	 * @return array
    	 */
    	public function getAttributes()
    	{
    		$this->attributesRecursive($this->_config, $output);
    		return $output;
    	}
    	/**
    	 * Возвращает имена всех атрибутов
    	 * 
    	 * @return array
    	 */
    	public function attributeNames()
    	{
    		$this->attributesRecursive($this->_config, $output);
    		return array_keys($output);
    	}
    
    	/**
    	 * Рекурсивно собирает атрибуты из конфига
    	 *
    	 * @param array $config
    	 * @param array $output
    	 * @param string $name
    	 */
    	public function attributesRecursive($config, &$output = array(), $name = '')
    	{
    		foreach ($config as $key => $attribute) {
    			if ($name == '')
    				$paramName = $key;
    			else
    				$paramName = $name . "[{$key}]";
    			if (is_array($attribute))
    				$this->attributesRecursive($attribute, $output, $paramName);
    			else
    				$output[$paramName] = $attribute;
    		}
    	}
    


    На выходе получаем искомый массив, к которому неплохо бы создать правила валидации:
    	public function rules()
    	{
    		$rules = array();
    		$attributes = array_keys($this->_config);
    		$rules[] = array(implode(', ', $attributes), 'safe');
    		return $rules;
    	}
    

    Тут всё просто. Для того, чтобы атрибуты вида params[motto] считались безопасными, достаточно сделать безопасным лишь родительский атрибут.
    Стоит понимать, что фактически в массив params можно будет записать что угодно, но добавить дополнительный корневой атрибут в конфиг не выйдет. Могу попробовать объяснить этот момент в комментариях если возникнут вопросы.

    Чтобы иметь прямой доступ к этим атрибутам через выражение $model->$attribute расширим методы __set() и __get():
    	public function __get($name)
    	{
    		// Если атрибут есть в конфиге - возвращаем его. Если нет - передаём эстафетную палочку родительскому классу
    		if (isset($this->_config[$name]))
    			return $this->_config[$name];
    		else
    			return parent::__get($name);
    	}
    
    	public function __set($name, $value)
    	{
    		// Если атрибут есть в конфиге - пишем в него
    		if (isset($this->_config[$name]))
    			$this->_config[$name] = $value;
    		else
    			parent::__set($name, $value);
    	}
    

    Что может быть проще?

    Итак, каркас модели готов. Теперь она умеет говорить какие атрибуты у неё есть и кушать атрибуты из формы. Для проверки этой модели можно написать обычный action для обработки POST запроса и построения формы:
    	public function run()
    	{
    		$path = YiiBase::getPathOfAlias('application.config') . '/params.php';
    		$model = new ConfigForm(require($path));
    		if (isset($_POST['ConfigForm'])) {
    			$model->setAttributes($_POST['ConfigForm']);
    			if($model->save($path))
    			{
    				Yii::app()->user->setFlash('success config', 'Конфигурация сохранена');
    				$this->controller->refresh();
    			}
    		}
    		$this->controller->render('config', compact('model'));
    	}
    

    Этот action приведён для примера, не стоит полностью его копировать, достаточно лишь уловить суть

    Что-то? Метода save() нет у CFormModel? Верно, но мы его напишем здесь для сохранения результата в файл. Так же как и в построении атрибутов здесь нам понадобится рекурсия:

    Сохранение в файл
    	public function save($path)
    	{
    		$config = $this->generateConfigFile();
    		// Предупредим программиста о том, что в файл не получится записать
    		if(!is_writable($path))
    			throw new CException("Cannot write to config file!");
    		file_put_contents($path, $config, FILE_TEXT);
    		return true;
    	}
    
    	public function generateConfigFile()
    	{
    		$this->generateConfigFileRecursive($this->_config, $output);
    		$output = preg_replace('#,$\n#s', '', $output); // Регулярка делает красиво
    		return "<?php\nreturn " . $output . ";\n";
    	}
    
    	public function generateConfigFileRecursive($attributes, &$output = "", $depth = 1)
    	{
    		$output .= "array(\n";
    		foreach ($attributes as $attribute => $value) {
    			if (!is_array($value))
    				$output .= str_repeat("\t", $depth) . "'" . $this->escape($attribute) . "' => '" . $this->escape($value) . "',\n";
    			else {
    				$output .= str_repeat("\t", $depth) . "'" . $this->escape($attribute) . "' => ";
    				$this->generateConfigFileRecursive($value, $output, $depth + 1);
    			}
    		}
    		$output .= str_repeat("\t", $depth - 1) . "),\n"; // Глубина нужна, чтобы определить длину отступа
    	}
    
    	private function escape($value)
    	{
    		/**
    		 * Это для того, чтобы с кавычкой не сломался синтаксис (php-injection).
    		 * Не исключаю, что в php есть какой-нибудь специальный метод, 
    		 * зато я знаю, что ничего лишнего заэкранировано не будет
    		*/
    		return str_replace("'", "\'", $value);
    	}
    


    Добрый KeepYourMind подсказал в комментарии, что для генерации можно использовать php функцию var_export(), о которой я не знал перед написанием этого генератора велосипеда

    Так же нам понадобится View файл, в котором форма сама сгенерируется по существующим атрибутам.
    <?php
    $form = $this->beginWidget('CActiveForm', array(
    	'id' => 'config-form',
    	'enableAjaxValidation' => false, // Ajax- и Client- валидацию я не предусматривал, т.к. это не имеет смысла
    	'enableClientValidation' => false,
    ));
    foreach ($model->attributeNames() as $attribute) {
    	echo CHtml::openTag('div', array('class' => 'row'));
    	{
    		echo $form->labelEx($model, $attribute);
    		echo $form->textField($model, $attribute);
    	}
    	echo CHtml::closeTag('div');
    }
    echo CHtml::submitButton('Сохранить');
    $this->endWidget();
    

    Для того, чтобы у нас были красивые подписи у атрибутов, нам придётся жёстко определить их в модели.
    	public function attributeLabels()
    	{
    		return array(
    			'name' => 'Название сайта',
    			'params[adminEmail]' => 'Email администратора',
    			'params[phoneNumber]' => 'Номер телефона',
    			'params[motto]' => 'Девиз сайта',
    		);
    	}
    

    Это некрасиво и грубо, однако, я не нашёл другого нормального способа это сделать. Можно их вынести в дополнительный файл, но сути это не поменяет — всё равно для добавления опции придётся редактировать 2 файла.

    Вот, в принципе, и всё. Полный код модели привожу без подробных комментариев:

    Полный код модели
    class ConfigForm extends CFormModel
    {
    	private $_config = array();
    
    	/**
    	 * Инициализация модели
    	 * @param array $config Массив из конфига
    	 * @param string $scenario Сценарий валидации
    	 */
    	public function __construct($config = array(), $scenario = '')
    	{
    		parent::__construct($scenario);
    		$this->setConfig($config);
    	}
    
    	public function setConfig($config)
    	{
    		$this->_config = $config;
    	}
    
    	public function getConfig()
    	{
    		return $this->_config;
    	}
    
    	public function __get($name)
    	{
    		if (isset($this->_config[$name]))
    			return $this->_config[$name];
    		else
    			return parent::__get($name);
    	}
    
    	public function __set($name, $value)
    	{
    		if (isset($this->_config[$name]))
    			$this->_config[$name] = $value;
    		else
    			parent::__set($name, $value);
    	}
    
    	public function save($path)
    	{
    		$config = $this->generateConfigFile();
    		if(!is_writable($path))
    			throw new CException("Cannot write to config file!");
    		file_put_contents($path, $config, FILE_TEXT);
    		return true;
    	}
    
    	public function generateConfigFile()
    	{
    		$this->generateConfigFileRecursive($this->_config, $output);
    		$output = preg_replace('#,$\n#s', '', $output);
    		return "<?php\nreturn " . $output . ";\n";
    	}
    
    	public function generateConfigFileRecursive($attributes, &$output = "", $depth = 1)
    	{
    		$output .= "array(\n";
    		foreach ($attributes as $attribute => $value) {
    			if (!is_array($value))
    				$output .= str_repeat("\t", $depth) . "'" . $this->escape($attribute) . "' => '" . $this->escape($value) . "',\n";
    			else {
    				$output .= str_repeat("\t", $depth) . "'" . $this->escape($attribute) . "' => ";
    				$this->generateConfigFileRecursive($value, $output, $depth + 1);
    			}
    		}
    		$output .= str_repeat("\t", $depth - 1) . "),\n";
    	}
    
    	private function escape($value)
    	{
    		return str_replace("'", "\'", $value);
    	}
    
    	/**
    	 * Возвращает все атрибуты с их значениями
    	 *
    	 * @return array
    	 */
    	public function getAttributes()
    	{
    		$this->attributesRecursive($this->_config, $output);
    		return $output;
    	}
    
    	/**
    	 * Возвращает имена всех атрибутов
    	 *
    	 * @return array
    	 */
    	public function attributeNames()
    	{
    		$this->attributesRecursive($this->_config, $output);
    		return array_keys($output);
    	}
    
    	/**
    	 * Рекурсивно собирает атрибуты из конфига
    	 *
    	 * @param array $config
    	 * @param array $output
    	 * @param string $name
    	 */
    	public function attributesRecursive($config, &$output = array(), $name = '')
    	{
    		foreach ($config as $key => $attribute) {
    			if ($name == '')
    				$paramName = $key;
    			else
    				$paramName = $name . "[{$key}]";
    			if (is_array($attribute))
    				$this->attributesRecursive($attribute, $output, $paramName);
    			else
    				$output[$paramName] = $attribute;
    		}
    	}
    
    	public function attributeLabels()
    	{
    		return array(
    			'name' => 'Название сайта',
    			'params[adminEmail]' => 'Email администратора',
    			'params[phoneNumber]' => 'Номер телефона',
    			'params[motto]' => 'Девиз сайта',
    		);
    	}
    
    	public function rules()
    	{
    		$rules = array();
    		$attributes = array_keys($this->_config);
    		$rules[] = array(implode(', ', $attributes), 'safe');
    		return $rules;
    	}
    
    }
    


    Об ошибках и опечатках просьба сообщать посредством личных сообщений. Приношу извинения за опечатки заранее — давно ничего кроме кода в таких объёмах не писал

    Only registered users can participate in poll. Log in, please.

    Используете ли Вы подобный конфигуратор в админке?

    • 14.8%Да23
    • 60.6%Нет94
    • 24.5%Теперь буду38
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 12

      +2
        +6
        Спасибо, не знал об этой функции. Нарекаю свой генератор велосипедом!
        0
        Не совсем понятно зачем так все усложнять. Как для личного опыта это конечно хорошо, но вот я бы это реализовал намного проще, легче и быстрее.
        Я бы добавил к основному контроллеру статическую функцию которая занималась бы как раз тем что вам нужно. Например она возвращала бы этот самый массив Controller::getSiteSettings(); далее, только что набросанный код:

        public static function getSiteSettings()
        {
        $arr = Yii::app()->cache->get('siteSettings');
        if($arr === false)
        {
        $arr = array();// идем или в базу или читаем с файла то что нужно, + я бы все это сериализировал

        Yii::app()->cache->set('siteSettings', $arr);
        }

        return $arr;
        }

          0
          Хорошо, рассмотрим этот пример.

          Допустим, настройки лежат в БД. А что Вы будете делать если администратору нужно отредактировать настройки подключения к базе?
          А если здесь подключается файл, то смысла от этого нет, ибо изначально можно все параметры захардкодить в конфиге.

          Суть моего подхода в том, что мы получим конфигурационный файл самым родным для Yii путём до инициализации приложения. Единственное ограничение: одна форма — один файл конфигурации.

          Приведу пример. Такая структура:
          — config
          |-- main.php
          |-- cli.php
          |-- web.php
          |-- params.php

          А в main.php находится:
          return CMap::mergeArray(array(
          // здесь core-конфигурация
          ), require(dirname(__FILE__) . '/params.php')); // подключаем конфигурацию, которая может изменяться из админки
          


          Но вообще, да, можно считать это небольшой разминкой для мозгов
            0
            «А что Вы будете делать если администратору нужно отредактировать настройки подключения к базе?»
            Не знаю зачем администратору это нужно, как по мне то настройки подключения к БД нужны только разработчику. Если же вы измените уже существующие настройки подключения то у вас упадет сайт. В вашем случаи все параметры «params» типа телефон, мыло и прочее вы тащите по всему сайту, а нужно вам это только в нескольких случаях.
              0
              Не знаю зачем администратору это нужно, как по мне то настройки подключения к БД нужны только разработчику
              Неудачный пример, но суть я раскрыл
              В вашем случаи все параметры «params» типа телефон, мыло и прочее вы тащите по всему сайту, а нужно вам это только в нескольких случаях.
              Согласен, это минус. Однако, иногда они используются для вывода в шаблоне, так что в некоторых случаях Yii::app()->params нужен при каждом рендере
          0
          p.s. подскажите неуку почему код не подсветился и потерял формат? использовал тэг source
            0
            Это называется — окончательно попрощайтесь с безопасностью проекта.
              0
              Отнюдь. Добавить корневой атрибут невозможно. Так же можно прикрутить валидацию элементов массива, чтобы нельзя было создать дополнительные директивы (не сложно). Я не стал этого делать, ибо администратор — сам владелец сайта. Степень доверия высокая, фигню инжектить не будет.
              0
              Параметры измененные через админку — сохраняю в отдельный файл, в каталоге runtime. При инициализации приложение идёт array_merge основного конфигурационного и данного под админку файлов.
                0
                Да, эта модель облегчает работу с такими файлами. Как Вы производите редактирование конфигурации?
                  0
                  По сути аналогично, только использую var_export и не затираю оригинальную конфигурацию.

              Only users with full accounts can post comments. Log in, please.