Часть 3. Bun Framework – конфигурация приложения
2014-06-01 в 04:51 Bun Framework PHP PHPUnit
Продолжаем серию статей по разработке Bun Framework. После того, как мы подготовили рабочий "скелет" приложения нашего фреймворка, переходим к неотъемлемой части всех веб-приложений – конфигурации. Ранее я уже упоминал, что модуль Bun\Core\Config войдет в ядро фреймворка, и кроме того в этой статье я затрону базовые зависимости модуля конфигураций: абстракция работы с файловой системой или типизированные исключения. И на последок в данной статье для проверки работоспособности написанного модуля я покажу простой пример организации unit-тестов написанного компонента фреймворка.
Для начала, в написанный в предыдущей статье класс Bun\Core\Application мы добавляем новый метод getConfig()
:
/**
* @return ApplicationConfig
*/
public function getConfig()
{
if($this->config === null) {
$this->config = new ApplicationConfig($this);
}
return $this->config;
}
Теперь непросредственно переходим к созданию модуля конфигурации. Идея очень простая:
- Основные настройки разделены по пространствам имен, настройки представляются в виде php-классов (параметры храняться в виде массива в свойсве класса $config). Доступ к настройкам осуществляеться через метод get(), c использование dot-нотации. Т.е. запрос вида
$config->get('ns.param')
к классу, имеющему настройки видаarray('ns' => array('param' => 'value'))
– вернет нам значение "value".
В каждом модулей фреймворка и в каждом приложении предопределяется подпространство имен "Config", в котором и определяются соответсвующие классы - Также существуют файлы параметров, в формате json, которые хранятся в одной директории с классами настроек. В классах настроек можно использовать вместо скалярных значений ссылки на значения из параметров. Файлы параметров будут разделяться по окружению (dev, prod и т.д) а также будет один базовый файл: config.json
-
- При загрузке настройки переопределяют друг друга:
- Сначала грузяться настройки классов внутри фреймворка
- Затем настройки приложений внутри src (кроме текущего запущенного прилоежния, если такие есть)
- Затем подгружаются настройки уже активного запущенного приложения
- В конце в таком же порядке подгружаются файлы настроечных параметров и параметры подставляются в конфигурацию
- Таким образом, если в конфигурации фрейворка будет задан параметр
"db.driver": "mysqli"
, то указанный в настройках приложения такой-же параметр переопределить настройки фреймворка при инициализации конфига
- Все php-файлы внутри директории Config в модулях фреймворка и приложений будут считываться на предмет наличия конфигурационных классов, а для json-файлов параметров считываются только файлы config.json и config_env.json, где "env" – текущее окружение (prod, dev и т.д.).
Такая немного замудреная схема позволяет добиться довольно-таки гибкого управления настройками, в целях быстродействия, возможно придется предусмотреть механизм кэширования инициализированных настроек в продакшен-окружении.
Теперь определяем, как мы все это будем кодировать:
- Выделим пространство имен Exception внутри модуля Core, для создания типизированных исключений для обработки ошибок при работе компонента Config
- Для чтения директорий и файлов параметров придется создать небольшую абстракцию для удобной работы с файловой системой. Первоначально это будут классы Bun\Core\FileSystem\File и Bun\Core\FileSystem\Dir.
- Создадим абстактный класс, от которого будут наследоваться классы кофигураций
- Реализуем класс, который будет осуществлять загурзку всех конфигураций и компоновку их в один массив, а также организует доступ к ним с помощью описанного выше метода.
- Создадим несколько тестовых настроек и параметров и напишем небольшой тест-кейс для тестирования работоспособности компонента
Поехали!
Исключения — Bun\Core\Exception
Я выделил пространство имен и создал три типа исключений: BunException — базовый тип, от которого будем наследовать все остальные исключения (это поможет нам удобно отлавливать нужные исключения); FileSystemException — так как будем писать компоненты для работы с ФС, то понадобятся и исключения; и ConfigException — будем вываливать, когда что-то идет не так при загрузке конфигураций.
В исключениях нет особо никакого кода пока, просто болванка:
<?php
namespace Bun\Core\Exception;
/**
* Class BunException
*
* @package Bun\Core\Exception
*/
class BunException extends \Exception
{
}
<?php
namespace Bun\Core\Exception;
/**
* Class ConfigException
*
* @package Bun\Core\Exception
*/
class ConfigException extends BunException
{
}
//...
Bun\Core\FileSystem - немного абстрагируемся от файловой системы
При работе со встроенными функциями php для работы с файлами и директориями приходится довольно много кода писать для обработки возможных ошибок и предупреждений, да и вообще иногда лишний код для тривиальных операций приходится копипастить, поэтому очень часто разработчики используют собственные обертки над этими функциями в виде классов.
1. Bun\Core\FileSystem\AbstractFileSystemNode
Звучит страшно – но это всего лишь базовый абстрактный класс для будущих классов директории и файла. Файл и директория имеют много общего, поэтому дабы не дублировать код – используем наследование. Сюда можно вынести такие свойства ноды файловой системы, как полный путь, директория, имя без директории, время создания, изменения и доступа объекта и тому подобное, весь код писать не буду:
<?php
namespace Bun\Core\FileSystem;
/**
* Class AbstractFileSystemNode
*
* @package Bun\Core\FileSystem
*/
abstract class AbstractFileSystemNode
{
/** @var string */
protected $path;
/** @var string */
protected $name;
/** @var string */
protected $baseDir;
/**
* @return string
*/
public function getPath()
{
return $this->path;
}
/**
* @return string
*/
public function getBaseDir()
{
return $this->baseDir;
}
// ... etc
}
2. Bun\Core\FileSystem\Dir
Директория в основном нужна мне сейчас для удобного чтения директорий и проверки их на существование, поэтому представлю только актульаный на сейчас методы в теле класса:
<?php
namespace Bun\Core\FileSystem;
use Bun\Core\Exception\FileSystemException;
/**
* Class Dir
*
* @package Bun\Core\FileSystem
*/
class Dir extends AbstractFileSystemNode
{
/** @var resource */
protected $context;
/**
* @param $path
* @param bool $create
* @param int $mode
*/
public function __construct($path, $create = false, $mode = 0777)
{
$this->path = $path;
$this->baseDir = $path;
$this->name = basename($path);
if($create) {
$this->create($mode, true);
}
}
// ...
/**
* @param null $extension
*
* @return string
*/
public function readFile($extension = null)
{
$fileName = @readdir($this->getReadDirContext());
if(!$fileName) {
return $fileName;
}
$filePath = $this->baseDir .'/'. $fileName;
if(!is_file($filePath)) {
return $this->readFile($extension);
}
if($extension !== null) {
if(strtolower($extension) === strtolower(File::getFileExtension($fileName))) {
return $fileName;
}
return $this->readFile($extension);
}
if($fileName !== '.' && $fileName !== '..') {
return $fileName;
}
return $this->readFile($extension);
}
/**
* @return string
*/
public function readDir()
{
$dirName = @readdir($this->getReadDirContext());
if(!$dirName) {
return $dirName;
}
if(!is_dir($this->baseDir .'/'. $dirName)) {
return $this->readDir();
}
if($dirName === '.' || $dirName === '..') {
return $this->readDir();
}
return $dirName;
}
/**
* @return $this
*/
public function clearReadFileContext()
{
$this->context = null;
return $this;
}
/**
* @return resource
* @throws \Bun\Core\Exception\FileSystemException
*/
protected function getReadDirContext()
{
if($this->context === null) {
$this->context = @opendir($this->path);
if(!$this->context) {
throw new FileSystemException('Unable to read dir: '. $this->path);
}
}
return $this->context;
}
Я реализовал методы для чтения из директории только директорий и возможности считывать только файл с определенным расшинением. Просто чтобы исзбавиться от этого утилитного кода в других компонентах системы.
3. Bun\Core\FileSystem\File
Тут тоже все просто и тривиально, я представлю только небольшой кусочек класса:
<?php
namespace Bun\Core\FileSystem;
use Bun\Core\Exception\FileSystemException;
/**
* Class File
*
* @package Bun\Core\FileSystem
*/
class File extends AbstractFileSystemNode
{
/** @var string|null */
protected $extension;
/** @var string */
protected $content;
/**
* @param $path
* @param bool $create
* @param int $mode
*/
public function __construct($path, $create = false, $mode = 0777)
{
$this->path = $path;
$this->baseDir = dirname($path);
$this->name = basename($path);
$this->extension = self::getFileExtension($path);
if($create) {
$this->create($mode);
}
}
/**
* @param $mode
*
* @throws \Bun\Core\Exception\FileSystemException
*/
public function create($mode)
{
if(!Dir::exists($this->baseDir)) {
new Dir($this->baseDir, true, $mode);
}
if(!self::exists($this->path)) {
$ok = @touch($this->path);
if(!$ok) {
throw new FileSystemException('Unable to touch file: '. $this->path);
}
$ok = @chmod($this->path, $mode);
if(!$ok) {
throw new FileSystemException('Unable to chmod file: '. $this->path .' in '. $mode);
}
}
}
/**
* @param bool $reload
*
* @return null|string
*/
public function getContent($reload = false)
{
if($this->content === null || $reload) {
$this->readContent();
}
return $this->content;
}
/**
* @throws \Bun\Core\Exception\FileSystemException
*/
protected function readContent()
{
if(self::isReadable($this->path)) {
$this->content = @file_get_contents($this->path);
if($this->content === false) {
throw new FileSystemException('Unable to read file: '. $this->path);
}
return;
}
throw new FileSystemException('File: '. $this->path .' is not readable');
}
// ...
}
Как видите, чисто утилитный код, ничего особенного, но избавляет нас в дальнейшем от множется повторяющихся проверок на возможные ошибки при работе с файлами, плюс сразу дает возможности отлавливать типизированные исключния при возникших проблемах.
Bun\Core\Config
С зависимостями покончили, теперь переходим к непосредственно реализации самого компонента настроек. Согласно плану пишем первый класс:
<?php
namespace Bun\Core\Config;
/**
* Class AbstractConfig
*
* @package Bun\Core\Config
*/
abstract class AbstractConfig
{
protected $name = 'abstract';
protected $config = array();
/**
* @return array
*/
public function getConfig()
{
return $this->config;
}
/**
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* @param $param
*
* @return mixed|null
*/
public function get($param)
{
if ($param === null) {
return $this->config;
}
if (strpos($param, '.') !== false) {
$paramParts = explode('.', $param);
return $this->recursiveGet($paramParts, $this->config);
}
return (isset($this->config[$param])) ?
$this->config[$param] :
null;
}
/**
* @param array $paramParts
* @param array $config
*
* @return null|mixed
*/
protected function recursiveGet($paramParts = array(), $config = array())
{
$param = array_shift($paramParts);
if (isset($config[$param])) {
return (!$paramParts) ?
$config[$param] :
$this->recursiveGet($paramParts, $config[$param]);
}
return null;
}
}
Это тот самый абстрактрный класс, от которого будет необходимо наследовать остальные классы настроек. Свойство $name - определяет пространство имен настроек, $config – сами настройки. Также зедсь реализуем публичный метод get(), который и являет собой реализацию dot-нотации доступа к параметрам конфига. Все лаконично и просто – ничег лишнего (как говорится, лучший код тот, который не был написан)
Теперь основной класс. Я выбрал для него название Bun\Core\Config\ApplicationConfig.
Тут код буду сопровожать комментариями прямо в листинге и представлю весь класс, хоть он и довольно большой получился. НО для целостности картины не помешает:
<?php
namespace Bun\Core\Config;
use Bun\Core\Application;
use Bun\Core\Exception\ConfigException;
use Bun\Core\FileSystem\Dir;
use Bun\Core\FileSystem\File;
/**
* Loads and allows access to all configuration parameters in application
*
* Class ApplicationConfig
*
* @package Bun\Core\Config
*/
class ApplicationConfig extends AbstractConfig
{
/** @var Application */
protected $application;
/** @var array */
protected $configParams = array();
/**
* на входе принимаем объект запущенного приложения
* @param Application $application
*/
public function __construct(Application $application)
{
$this->application = $application;
$this->load(); // сразу запускаем загрузку настроек
}
/**
* loads application config
*/
protected function load()
{
$this->loadBunConfig(); // загружаем только настройки фреймворка
$this->loadApplicationsConfig(); // загружаем настройки приложений, в т.ч запущенного сейчас
$this->loadConfigParams(); // подставляем в настройки параметры из json-файлов
}
/**
* Loads config classes from bun modules
*/
protected function loadBunConfig()
{
$bunDir = new Dir(BUN_DIR); // открываем и читаем директорию кода фреймворка, получая названия модулей
while($moduleDir = $bunDir->readDir()) {
// вызываем загурзку настроек конкретного модуля
$this->loadModuleConfig($bunDir->getBaseDir() .'/'. $moduleDir, 'Bun\\' . $moduleDir);
}
}
/**
* Loading all applications config and current application config in the end
*/
protected function loadApplicationsConfig()
{
// открываем и читаем директорию приложений
$srcDir = new Dir(SRC_DIR);
$currentApplicationDir = '';
while($moduleDir = $srcDir->readDir()) {
if($moduleDir !== $this->application->getNameSpace()){
// если прилоежние на запущено, загружаем его настройки
// стоит обратить внимание, что настройки модуля и прилоежний грузяться одним методом
$this->loadModuleConfig($srcDir->getBaseDir() .'/'. $moduleDir, $moduleDir);
}
else {
// определяем запущенное прилоежние]
$currentApplicationDir = $moduleDir;
}
}
// в последнюю очередь подгружаем настройки активного прилоежния, т.к. они должны быть приоритетными и переопредить все остальные
$this->loadModuleConfig($srcDir->getBaseDir() .'/'. $currentApplicationDir, $currentApplicationDir);
}
/**
* @throws \Bun\Core\Exception\ConfigException
*/
protected function loadConfigParams()
{
// перезагужаем конфиг с применением считанных параметров из json-файлов
$reload = array();
foreach($this->config as $param => $value) {
if(is_array($value)) {
// массив читаем рекурсивно этой функцией
$reload[$param] = $this->loadRecursiveParam($value);
}
elseif(strpos($value, ':') === 0) {
// синтаксис использования параметров настроек имеет вид ":param"
// т.е. вмето этого выражения будет подставлено значение param из загруженных параметров
$paramKey = str_replace(':', '', $value);
if(isset($this->configParams[$paramKey])) {
$reload[$param] = $this->configParams[$paramKey];
}
else {
// если параметр не был определен, то стоит выбросить исключение
throw new ConfigException('Parameter '. $paramKey .' does not specified in config params');
}
}
else {
// скалярные параметры оставляем без изменений
$reload[$param] = $value;
}
}
// соханяем перезагруженный массив настроек
$this->config = $reload;
}
/**
* @param $config
*
* @return array
* @throws \Bun\Core\Exception\ConfigException
*/
protected function loadRecursiveParam($config)
{
// тут тоже самое, что в предыдущей функции, только вмето сохранения конфига, мы возвращаем его, т.к. энто рекурсия
$reload = array();
foreach($config as $param => $value) {
if(is_array($value)) {
$reload[$param] = $this->loadRecursiveParam($value);
}
elseif(strpos($value, ':') === 0) {
$paramKey = str_replace(':', '', $value);
if(isset($this->configParams[$paramKey])) {
$reload[$param] = $this->configParams[$paramKey];
}
else {
throw new ConfigException('Parameter '. $paramKey .' does not specified in config params');
}
}
else {
$reload[$param] = $value;
}
}
return $reload;
}
/**
* @param $modulePath
* @param $moduleNameSpace
*
* @throws \Bun\Core\Exception\ConfigException
*/
protected function loadModuleConfig($modulePath, $moduleNameSpace)
{
// тут универсальный загрузчик настроек модуля или приложения
// по сути парсер директории Config
$moduleConfigPath = $modulePath .'/Config';
// проверяем что директория есть
if(Dir::exists($moduleConfigPath)) {
// loading config classes
$configDir = new Dir($moduleConfigPath);
// читаем из нее php-файлы
while($configFile = $configDir->readFile('php')) {
$baseClassName = File::getFileNameWithoutExtension($configFile);
$configClass = $moduleNameSpace .'\\Config\\' . $baseClassName;
// исключаем несуществующие классы и абстрактные классы а также самого себя
if(
$baseClassName !== 'AbstractConfig' &&
$baseClassName !== 'ApplicationConfig' &&
class_exists($configClass)
) {
/** @var AbstractConfig $configObj */
$configObj = new $configClass();
// если класс не унаследован от базового, то бросим исключение
if($configObj instanceof AbstractConfig) {
// иначе даем мердж массивов с заменой загруженных ранее значений
$loaded = isset($this->config[$configObj->getName()]) ?
$this->config[$configObj->getName()] :
array();
$merged = array_replace_recursive($loaded, $configObj->getConfig());
$this->config[$configObj->getName()] = $merged;
}
else {
throw new ConfigException('Class '. $configClass .' not a config instance');
}
}
}
// loading params from json files
$configDir->clearReadFileContext(); // очищаем контекст чтения директории
$paramsFile = new File($configDir->getBaseDir() .'/config.json'); // считываем файл параметров
if(File::exists($paramsFile->getPath())) {
$this->loadConfigParamsFile($paramsFile); // загружаем из него настройки
}
// и тоже самое для файла параметров среды (ENV)
$paramsFile = new File($configDir->getBaseDir() .'/config_'. $this->application->getEnv() .'.json');
if(File::exists($paramsFile->getPath())) {
$this->loadConfigParamsFile($paramsFile);
}
}
}
/**
* @param File $paramsFile
*
* @throws \Bun\Core\Exception\ConfigException
*/
protected function loadConfigParamsFile(File $paramsFile)
{
// тут добавлена строгая проверка, что json-файлы параметров содежрать валидный json (каламбур, однако)
$configParams = json_decode($paramsFile->getContent(), true);
if($configParams !== null && is_array($configParams)) {
$this->configParams = array_replace_recursive($this->configParams, $configParams);
}
else {
throw new ConfigException('Config parameters file: '. $paramsFile->getPath() .
' does not contain valid json encoded parameters');
}
}
}
Готово. Можно содать пару тестовых файликов с конфигами и параметрами и писать unit-тесты.
Тестируем написанный код с помощью PHPUnit
Протестировать написанный компонент удобнее всего с помощью автоматизированных PHP unit-тестов. Даже для тех кто никогда эти не занимался, и сейчас я покажу как элементарно это делается, буквально за 10-15 минут я написал практически полноценный тест кейс для компонента конфигурации фреймворка Bun.
1. Устанавливаем PHPUnit
Я просто скачиваю phpunit.phar в корень проекта. Это довольно удобно, т.к. моя IDE автоматически подхватывает его и при написании тестов я могу пользоваться автодополнением и прочими плюшками
2. Настраиваем среду для тестирования
Чтобы тестировать классы, тесты должны знать как эти классы загрузить и запустить. Поэтому нам нужно в тесты включать те же автолодеры, что и в само приложение. Чтобы каждый раз в тесте не делать инклуды файлов, я выделил общий bootstrap-файл, который разместил в lib/bun/bun/src/Tests/bootstrap.php:
<?php
$configFile = __DIR__ .'/../../../../../config.php';
if(file_exists($configFile)) {
require_once $configFile;
}
else {
define('ENV', 'dev');
}
require_once __DIR__ . '/../../../../autoload.php';
Этот файл обеспечит нам автозагурзку всех нужных классов и констант приложения и фреймворка. Кроме того, тесты фреймворка должны быть независимы от прилоежний, а во фреймвроке у нас только абстрактный класс Bun\Core\Application. Поэтому для тестов я создал общий класс приложения Bun\Tests\TestApplication:
<?php
namespace Bun\Core\Tests;
use Bun\Core\Application;
/**
* Class TestApplication
*
* @package Bun\Core\Tests
*/
class TestApplication extends Application
{
/**
* @return string
*/
public function getNameSpace()
{
return __NAMESPACE__;
}
}
И последний шаг настройки среды тестирования: конфигурируем PHPUnit, чтобы нам удобно было запускать тесты. Я создал в директории lib/bun/bun/src файлик phpunit.xml, с такими настройками:
<phpunit
bootstrap="Tests/bootstrap.php"
backupGlobals="false"
backupStaticAttributes="false"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
syntaxCheck="false"
processIsolation="false"
colors="true">
<testsuites>
<testsuite name="Bun_Framework">
<directory>.</directory>
</testsuite>
</testsuites>
</phpunit>
Тут у нас основной момент – это автоматическое подключение бустрап файла для всех тестов и путь к директории тестов. Я просто указал все директорию кода фреймворка — PHPUnit сам поймет и найдет нужные ему тесты. В дальнешейм конечно же нужно будет разбить тесты на отдельный сьюты, но пока так вполне сойдет.
3. Пишем тест case
Для начала нужно будет предусмотреть тестовые данные. Я создал два класса настроек Bun\Core\Config\TestConfig1.php и Bun\Core\Config\TestConfig2.php. А также два файла с параметрами настроек: config.json и config_dev.json, т.к. мое приложение запускается в dev-окружении.
<?php
namespace Bun\Core\Config;
/**
* Class TestConfig2
*
* @package Bun\Core\Config
*/
class TestConfig2 extends AbstractConfig
{
protected $name = 'test1';
protected $config = array(
'ns2' => array(
'param22' => 'ok22',
),
'ns3' => array(
'params3' => 'ok3',
'params33' => array(
'param333' => 'ok333',
'parameter1' => ':test.parameter1', // тут определяем имена параметров
'parameter2' => ':test.parameter2',
),
),
);
}
И сами параметры:
{
"test.parameter1": "ok1",
"test.parameter2": "ok1"
}
Я решил разделить тесты по модулям фреймворка, т.е. в каждом модуле своя директория Tests со своими тестами. Итак создаю класс теста Bun\Core\Tests\ApplicationConfigTest.php
<?php
namespace Bun\Core\Tests;
use Bun\Core\Config\ApplicationConfig;
/**
* Class ApplicationConfigTest
*
* @package Bun\Core\Tests
*/
class ApplicationConfigTest extends \PHPUnit_Framework_TestCase
{
/**
* Создаем приложение и объект класса настроек
* @return ApplicationConfig
*/
protected function getConfig()
{
$app = new TestApplication(ENV);
$config = new ApplicationConfig($app);
return $config;
}
/**
* Тестим простой доступ к массиву параметров
*/
public function testGet()
{
$expect = array(
'param1' => 'ok1',
);
$actual = $this->getConfig()->get('test1.ns1');
$this->assertEquals($expect, $actual);
}
/**
* Тестим доступ через dot-нотацию
*/
public function testGetDot()
{
$expect = 'ok2';
$actual = $this->getConfig()->get('test1.ns2.param2');
$this->assertEquals($expect, $actual);
}
/**
* Тестим доступ к настройке, определенной в параметрах config.json
*/
public function testGetParam()
{
$expect = 'ok1';
$actual = $this->getConfig()->get('test1.ns3.params33.parameter1');
$this->assertEquals($expect, $actual);
}
/**
* Тестим доступ к настройке, определенной в параметрах среды config_dev.json
*/
public function testGetEnvParam()
{
$expect = 'ok2';
$actual = $this->getConfig()->get('test1.ns3.params33.parameter2');
$this->assertEquals($expect, $actual);
}
/**
* Тестим доступ к несуществующему параметру
*/
public function testGetNull()
{
$expect = null;
$actual = $this->getConfig()->get('some.very.unknown.parameter');
$this->assertEquals($expect, $actual);
}
}
А теперь запускаем PHPUnit с заданной конфигурацией и радуемся жизни:
Спасибо за внимание, буду рад замечаниям и вопросам. В следующих статьях буду рассказывать про создание связки Http-компонентов Request/Router/Response.
Автор Yakov Akulov
Комментарии (3) написать
Jacob Akulov
Наварил макарон, однако...
Ответить
Сергей Керимов
Доброго времени суток !
Скачал из репозитария проект, запустил тесты, все с ошибкой "Only variables should be passed by reference"
Ответить
Yakov Akulov
Сергей Керимов: вполне возможно, проект в разработке, в новой статье, скорее всего будет более менее стабильная сборка.
Ответить
Написать комментарий: