Доброго вечера, Хабрахабр.
Сегодня я расскажу о небольшом компоненте формы, который мне довелось написать для замечательного 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 "<?phpnreturn " . $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 "<?phpnreturn " . $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;
}
}
Об ошибках и опечатках просьба сообщать посредством личных сообщений. Приношу извинения за опечатки заранее — давно ничего кроме кода в таких объёмах не писал
Автор: RUgaleFF