Приветствую!
Недавно начал работать с Zend Framework 2, и возникла потребность написать cli модуль работающий с миграциями базы данных.
В этой статье я опишу как создать модуль для Zend 2 для работы с ним из командной строки на примере модуля миграций, как написать тесты, как опубликовать модуль в packagist.org
Что такое миграции: Миграции базы данных — это система классов описывающая действия над базой данных и позволяющая выполнять эти действия.
Установка фрэймворка
Начнем с установки фрэймворка, в качестве каркаса возьмем ZendSkeletonApplication
Клонируем ZendSkeletonApplication, это скелет приложения.
cd projects_dir/
git clone git://github.com/zendframework/ZendSkeletonApplication.git
//переименуем в SampleZendModule
mv ZendSkeletonApplication SampleZendModule
//устанавливаем сам zendframework через композер
php composer.phar self-update
php composer.phar install
Подробнее о базовой установке и быстрый старт можно прочитать здесь
framework.zend.com/manual/2.0/en/index.html в разделе User Guide
Общее описание
Консольные задачи с Zend 2 пишутся по технологии MVC аналогично веб MVC, с использованием аналогичной системы роутинга, лишь немного отличающейся в связи со спецификой консольных параметров.
Роутер определяет какую команду нужно вызывать и вызывает нужный контроллер, передавая ему все данные.
Что характерно, для веб и консоли используются одни и теже контроллеры, различия пожалуй составляют только в использовании ZendConsoleRequest вместо ZendHttpRequest и ZendConsoleResponse вместо ZendHttpResponse, объект запроса и ответа соответственно.
Точкой взаимодействия с консольными командами является единая точка входа, та же что и отвечает за веб взаимодействие, т.е. обычно это /project/public/index.php
Создание каркаса модуля
Ввиду того что в Zend 2 все ещё нету консольных утилит для генерации кода, то создавать модуль придется руками.
Создаем следующую структуру каталогов от корня проекта
/project/
--/module/ — общая папка с модулями, по умолчанию там Application приложение которое должно быть обязательно
----/knyzev/ — название группы модулей или разработчика, вообще можно и не указывать но если публикуешь на packagist.org, то он хочет составное название вида group/package
------/zend-db-migrations/ — это сам каталог модуля
--------/config/ — папка для конфигов
--------/src/ — основная папка с классами
----------/ZendDbMigrations/ — каталог соответствующий пространству имен
------------/Controller/ — контроллеры
------------/Library/ — библиотека для работы миграций
------------Module.php — класс предоставляющий общую информацию о модуле
------------README.md — описание модуля
------------composer.json — описание модуля и зависимостей чтобы можно было опубликовать его на packagist.org
В Zend 2 приложение строится в виде модулей, каждый из которых может определять контроллеры, сервисы и т.д.
Конфигурация
Начнем с папки config, здесь нужно создать файл module.config.php содержащий конфиг, у меня получилось вот такое содержимое файла.
<?php
return array(
'migrations' => array(
'dir' => dirname(__FILE__) . '/../../../../migrations',
'namespace' => 'ZendDbMigrationsMigrations',
'show_log' => true
),
'console' => array(
'router' => array(
'routes' => array(
'db_migrations_version' => array(
'type' => 'simple',
'options' => array(
'route' => 'db_migrations_version [--env=]',
'defaults' => array(
'controller' => 'ZendDbMigrationsControllerMigrate',
'action' => 'version'
)
)
),
'db_migrations_migrate' => array(
'type' => 'simple',
'options' => array(
'route' => 'db_migrations_migrate [<version>] [--env=]',
'defaults' => array(
'controller' => 'ZendDbMigrationsControllerMigrate',
'action' => 'migrate'
)
)
),
'db_migrations_generate' => array(
'type' => 'simple',
'options' => array(
'route' => 'db_migrations_generate [--env=]',
'defaults' => array(
'controller' => 'ZendDbMigrationsControllerMigrate',
'action' => 'generateMigrationClass'
)
)
)
)
)
),
'controllers' => array(
'invokables' => array(
'ZendDbMigrationsControllerMigrate' => 'ZendDbMigrationsControllerMigrateController'
),
),
'view_manager' => array(
'template_path_stack' => array(
__DIR__ . '/../view',
),
),
);
В этом конфиге controllers и view_manager описывают где хранятся шаблоны и какие контроллеры будут вызываться, как я понял это сокращение, видимо можно обратится и напрямую, эти параметры являются стандартными для всех модулей.
Migrations — это настройки моего модуля задающие каталог хранения миграций, в моем случае это корневая директория проекта, namespace указанный в классах миграций и show_log определяющий вывод логов на консоль.
Console — это конфигурирование консольного роутинга, в Zend 2 определение параметров консоли происходит через систему роутинга аналогичную используемой в веб части
Подробнее о работе консольного роутинга можно прочитать тут
framework.zend.com/manual/2.0/en/modules/zend.console.routes.html
Про обычный http роутинг здесь
framework.zend.com/manual/2.0/en/modules/zend.mvc.routing.html
Итак, создаем роуты. В данном случае нам понадобится три роута
1. db_migrations_version — выводит инфу о текущей версии базы данных
2. db_migrations_migrate [] [--env=] — выполняет либо откатывает миграции базы данных
3. db_migrations_generate — генерирует заглушку для базы данных
Описание параметров роута:
'db_migrations_migrate' => array(
'type' => 'simple',
'options' => array(
'route' => 'db_migrations_migrate [<version>] [--env=]',
'defaults' => array(
'controller' => 'ZendDbMigrationsControllerMigrate',
'action' => 'migrate'
)
)
),
type — тип маршрута,
options/route — название консольной команды с параметрами и опциями, если параметр необязательный он берется в квадратные скобки, подробное описание по ссылке выше.
options/defaults/controller — контроллер обрабатывающий маршрут
options/defaults/action — действие в контроллере
Контроллер
<?php
/**
* Zend Framework (http://framework.zend.com/)
*
* @link http://github.com/zendframework/ZendSkeletonApplication for the canonical source repository
* @copyright Copyright (c) 2005-2012 Zend Technologies USA Inc. (http://www.zend.com)
* @license http://framework.zend.com/license/new-bsd New BSD License
*/
namespace ZendDbMigrationsController;
use ZendMvcControllerAbstractActionController;
use ZendViewModelViewModel;
use ZendConsoleRequest as ConsoleRequest;
use ZendDbMigrationsLibraryMigration;
use ZendDbMigrationsLibraryMigrationException;
use ZendDbMigrationsLibraryGeneratorMigrationClass;
use ZendDbMigrationsLibraryOutputWriter;
/**
* Контроллер обеспечивает вызов команд миграций
*/
class MigrateController extends AbstractActionController
{
/**
* Создать объект класса миграций
* @return MigrationsLibraryMigration
*/
protected function getMigration(){
$adapter = $this->getServiceLocator()->get('ZendDbAdapterAdapter');
$config = $this->getServiceLocator()->get('Configuration');
$console = $this->getServiceLocator()->get('console');
$output = null;
if($config['migrations']['show_log'])
{
$output = new OutputWriter(function($message) use($console) {
$console->write($message . "n");
});
}
return new Migration($adapter, $config['migrations']['dir'], $config['migrations']['namespace'], $output);
}
/**
* Получить текущую версию миграции
* @return integer
*/
public function versionAction(){
$migration = $this->getMigration();
return sprintf("Current version %sn", $migration->getCurrentVersion());
}
/**
* Мигрировать
*/
public function migrateAction(){
$migration = $this->getMigration();
$version = $this->getRequest()->getParam('version');
if(is_null($version) && $migration->getCurrentVersion() >= $migration->getMaxMigrationNumber($migration->getMigrationClasses()))
return "No migrations to execute.n";
try{
$migration->migrate($version);
return "Migrations executed!n";
}
catch (MigrationException $e) {
return "ZendDbMigrationsLibraryMigrationExceptionn" . $e->getMessage() . "n";
}
}
/**
* Сгенерировать каркасный класс для новой миграции
*/
public function generateMigrationClassAction(){
$adapter = $this->getServiceLocator()->get('ZendDbAdapterAdapter');
$config = $this->getServiceLocator()->get('Configuration');
$generator = new GeneratorMigrationClass($config['migrations']['dir'], $config['migrations']['namespace']);
$className = $generator->generate();
return sprintf("Generated class %sn", $className);
}
}
Вот пример типичного контроллера, действие (Action), к которому привязывается маршрут роутинга имеет название вида [name]Action, Action — обязательная часть, а name название команды.
Получение параметров запроса производится через классы Zend/Console/Request, через наследуемый базовый класс контроллера
$this->getRequest()->getParam('version') — так мы получили параметр version из роута db_migrations_migrate []
Все что возвращается из методов в виде plain text как в этом примере, будет обернуто в ViewModel и выведено прямо в консоль.
Для асинхронного вывода в консоль по мере работы приложения, нужно использовать Zend/Console/Response который доступен через сервис локатор $this->getServiceLocator()->get('console'), Поддерживает методы write, writeAt, writeLine. Подробное описание и параметры можно посмотреть в документации.
Module.php
<?php
namespace ZendDbMigrations;
use ZendMvcModuleRouteListener;
use ZendModuleManagerFeatureAutoloaderProviderInterface;
use ZendModuleManagerFeatureConfigProviderInterface;
use ZendModuleManagerFeatureConsoleUsageProviderInterface;
use ZendConsoleAdapterAdapterInterface as Console;
use ZendModuleManagerFeatureConsoleBannerProviderInterface;
class Module implements
AutoloaderProviderInterface,
ConfigProviderInterface,
ConsoleUsageProviderInterface,
ConsoleBannerProviderInterface
{
public function onBootstrap($e)
{
$e->getApplication()->getServiceManager()->get('translator');
$eventManager = $e->getApplication()->getEventManager();
$moduleRouteListener = new ModuleRouteListener();
$moduleRouteListener->attach($eventManager);
}
public function getConfig()
{
return include __DIR__ . '/config/module.config.php';
}
public function getAutoloaderConfig()
{
return array(
'ZendLoaderStandardAutoloader' => array(
'namespaces' => array(
__NAMESPACE__ => __DIR__ . '/src/' . __NAMESPACE__,
),
),
);
}
public function getConsoleBanner(Console $console){
return 'DB Migrations Module';
}
public function getConsoleUsage(Console $console){
//description command
return array(
'db_migrations_version' => 'Get current migration version',
'db_migrations_migrate [<version>]' => 'Execute migrate',
'db_migrations_generate' => 'Generate new migration class'
);
}
}
Файл Module.php предоставляет некоторую информацию о модуле, все файлы Module.php автоматически загружаются при каждом запуске с целью загрузки файлов конфигураций и других данных.
В данном случае класс Module будет выглядеть вот таким образом.
Стоит особо отметить, что для того, чтобы при вызове консольного скрипта без параметров он выводил список всех существующих команд, нужно добавить в модуль поддержку интерфейса ConsoleUsageProviderInterface и его реализацию, который вывод массива команд с описанием как в примере выше.
Так например при запуске команды
php public/index.php
будут выведены все команды которые возвращает метод getConsoleUsage нашего модуля.
Создание тестов PHPUnit
Тесты в MVC Zend 2 как правило размещаются в папке tests в корне проекта и полностью соответствуют структуре модуля.
Например
/project/
-/module/
--/knyzev/
---/zend-db-migrations/
----/src/
-----/ZendDbMigrations/
------/Controller/
-------/MigrateController.php
-/tests/
--/knyzev/
---/zend-db-migrations/
----/src/
-----/ZendDbMigrations/
------/Controller/
-------/MigrateControllerTest.php
И приведу пример тестов на класс MigrateController
<?php
namespace TestsZendDbMigrationsController;
use ZendDbMigrationsControllerMigrateController;
use ZendConsoleRequest as ConsoleRequest;
use ZendConsoleResponse;
use ZendMvcMvcEvent;
use ZendMvcRouterRouteMatch;
use PHPUnit_Framework_TestCase;
use Bootstrap;
use ZendDbAdapterAdapter;
use ZendDbMetadataMetadata;
/**
* Тестирование контроллера MigrateController
*/
class MigrateControllerTest extends PHPUnit_Framework_TestCase {
protected $controller;
protected $request;
protected $response;
protected $routeMatch;
protected $event;
protected $eventManager;
protected $serviceManager;
protected $dbAdapter;
protected $connection;
protected $metadata;
protected $folderMigrationFixtures;
/**
* Настройки
*/
protected function setUp() {
$bootstrap = ZendMvcApplication::init(Bootstrap::getAplicationConfiguration());
$this->request = new ConsoleRequest();
$this->routeMatch = new RouteMatch(array('controller' => 'migrate'));
$this->event = $bootstrap->getMvcEvent();
$this->event->setRouteMatch($this->routeMatch);
$this->eventManager = $bootstrap->getEventManager();
$this->serviceManager = $bootstrap->getServiceManager();
$this->dbAdapter = $bootstrap->getServiceManager()->get('ZendDbAdapterAdapter');
$this->connection = $this->dbAdapter->getDriver()->getConnection();
$this->metadata = new Metadata($this->dbAdapter);
$this->folderMigrationFixtures = dirname(__FILE__) . '/../MigrationsFixtures';
$this->initController();
$this->tearDown();
}
protected function tearDown(){
$this->dbAdapter->query('DROP TABLE IF EXISTS migration_version CASCADE;', Adapter::QUERY_MODE_EXECUTE);
$this->dbAdapter->query('DROP TABLE IF EXISTS test_migrations CASCADE;', Adapter::QUERY_MODE_EXECUTE);
$this->dbAdapter->query('DROP TABLE IF EXISTS test_migrations2 CASCADE;', Adapter::QUERY_MODE_EXECUTE);
$iterator = new GlobIterator($this->folderMigrationFixtures . '/tmp/*', FilesystemIterator::KEY_AS_FILENAME);
foreach ($iterator as $item) {
if($item->isFile())
{
unlink($item->getPath() . '/' . $item->getFilename());
}
}
chmod($this->folderMigrationFixtures . '/tmp', 0775);
}
protected function initController(){
$this->controller = new MigrateController();
$this->controller->setEvent($this->event);
$this->controller->setEventManager($this->eventManager);
$this->controller->setServiceLocator($this->serviceManager);
}
/**
* Тест метода возвращающего номер версии
*/
public function testVersion() {
$this->routeMatch->setParam('action', 'version');
$result = $this->controller->dispatch($this->request);
$response = $this->controller->getResponse();
$this->assertEquals(200, $response->getStatusCode(), 'Status code is 200 OK!');
$this->assertInstanceOf('ZendViewModelViewModel', $result, 'Method return object ZendViewModelViewModel!');
$this->assertEquals("Current version 0n", $result->getVariable('result'), 'Returt value is correctly!');
//добавляем информацию о версии
$this->connection->execute('INSERT INTO migration_version (version) VALUES (12345678910)');
//проверяем
$result = $this->controller->dispatch($this->request);
$response = $this->controller->getResponse();
$this->assertEquals("Current version 12345678910n", $result->getVariable('result'), 'Returt value is correctly!');
}
/**
* Тест запуска миграций если классов миграций нету
*/
public function testMigrateIfNotMigrations() {
$this->routeMatch->setParam('action', 'migrate');
$result = $this->controller->dispatch($this->request);
$response = $this->controller->getResponse();
$this->assertEquals(200, $response->getStatusCode(), 'Status code is 200 OK!');
$this->assertInstanceOf('ZendViewModelViewModel', $result, 'Method return object ZendViewModelViewModel!');
$this->assertEquals("No migrations to execute.n", $result->getVariable('result'), 'Return correct info if no exists not executable migations!');
}
/**
* Тест запуска миграций если есть миграция
*/
public function testMigrationIfExistsMigrations(){
//тестируем запуск миграции при наличии новой миграции
copy($this->folderMigrationFixtures . '/MigrationsGroup1/Version20121110210200.php',
$this->folderMigrationFixtures . '/tmp/Version20121110210200.php');
$this->routeMatch->setParam('action', 'migrate');
$result = $this->controller->dispatch($this->request);
$response = $this->controller->getResponse();
$this->assertEquals(200, $response->getStatusCode(), 'Status code is 200 OK!');
$this->assertEquals("Migrations executed!n", $result->getVariable('result'), 'Return correct info if executed migrations!');
//проверяем что миграция действительно выполнена
$this->assertTrue(in_array('test_migrations', $this->metadata->getTableNames()), 'Migration real executed!');
//тест запуска выполненной миграции и она является текущей версией
$this->initController();
$this->routeMatch->setParam('action', 'migrate');
$this->routeMatch->setParam('version', 20121110210200);
$result = $this->controller->dispatch($this->request);
$response = $this->controller->getResponse();
$this->assertEquals(200, $response->getStatusCode(), 'Status code is 200 OK!');
$this->assertContains("Migration version 20121110210200 is current version!n", $result->getVariable('result'), 'Starting the migration with a current version works correctly!');
}
/**
* Тест запуска миграций с указанием версии
*/
public function testMigrateWithVersion() {
copy($this->folderMigrationFixtures . '/MigrationsGroup2/Version20121111150900.php',
$this->folderMigrationFixtures . '/tmp/Version20121111150900.php');
copy($this->folderMigrationFixtures . '/MigrationsGroup2/Version20121111153700.php',
$this->folderMigrationFixtures . '/tmp/Version20121111153700.php');
$this->routeMatch->setParam('action', 'migrate');
$this->routeMatch->setParam('version', 20121111150900);
$result = $this->controller->dispatch($this->request);
$response = $this->controller->getResponse();
$this->assertEquals(200, $response->getStatusCode(), 'Status code is 200 OK!');
$this->assertTrue(in_array('test_migrations', $this->metadata->getTableNames()), 'Migration 20121111150900 execucte ok!');
$this->assertFalse(in_array('test_migrations2', $this->metadata->getTableNames()), 'Migration 20121111153700 not execucte ok!');
}
/**
* Тест генерации заглушки для миграций
*/
public function testGenerateMigrationClass() {
$this->routeMatch->setParam('action', 'generateMigrationClass');
$result = $this->controller->dispatch($this->request);
$response = $this->controller->getResponse();
$this->assertEquals(200, $response->getStatusCode(), 'Status code is 200 OK!');
$this->assertInstanceOf('ZendViewModelViewModel', $result, 'Method return object ZendViewModelViewModel!');
$this->assertContains("Generated class ",
$result->getVariable('result'), 'Return result info ok!');
$fileName = sprintf('Version%s.php', date('YmdHis', time()));
$this->assertFileExists($this->folderMigrationFixtures . '/tmp/' . $fileName, 'Generate command real generated class!');
}
}
Подробнее о структуре тестов можно почитать здесь
framework.zend.com/manual/2.0/en/user-guide/unit-testing.html
Тут есть нюанс, в зенд 2 не поддерживается работа с окружениями, поэтому нужно придумывать свой велосипед для работы с тестовой базой.
Composer.json и добавление модуля на packagist.org
Теперь нам осталось описать модуль в композер json и опубликовать его.
Создаем в корне модуля файл composer.json со следующей информацией
{
"name": "knyzev/zend-db-migrations",
"description": "Module for managment database migrations.",
"type": "library",
"license": "BSD-3-Clause",
"keywords": [
"database",
"db",
"migrations",
"zf2"
],
"homepage": "https://github.com/vadim-knyzev/ZendDbMigrations",
"authors": [
{
"name": "Vadim Knyzev",
"email": "vadim.knyzev@gmail.com",
"homepage": "http://vadim-knyzev.blogspot.com/"
}
],
"require": {
"php": ">=5.3.3",
"zendframework/zendframework": "2.*"
},
"autoload": {
"psr-0": {
"ZendDbMigrations": "src/"
},
"classmap": [
"./Module.php"
]
}
}
name — название модуля, оно же буде соответствовать названия папки модуля.
require — зависимости
Остальное можно скопировать и описать по подобию.
Далее регистрируем аккаунт на github.com, выбираем публичный репозиторий, вводим имя вида MyZendModule
На локальном компьютере инициируем гит репозиторий, и отправляем все на гитхаб
git init
git remote add origin github.com/knyzev/zend-db-migrations
git add -A
git commit -m «Init commit»
git push
На сайте packagist.org/ регистрируемся, выбираем submit package и добавляем ссылку на github, он автоматически проверит корректность composer.json и сообщит о проблемах если они есть.
Всё, теперь в новом проекте или кто-либо другой сможет в основном файле composer.json
просто добавить зависимость, например knyzev/zend-db-migrations
выполнить команды
php composer.phar self-update
php composer.phar update
И модуль будет автоматически установлен, останется только прописать его в config/application.config.php
Сравнение Symfony 2 + Doctrine 2 и Zend 2
Мне очень нравится Symfony 2 и Doctrine 2-й версии и после работы с аннотациями, полной поддержкой консоли (консольные команды на все случаи) и довольно удобным объявлением сервисов, ORM системой Doctrine, zend выглядит довольно мрачно и не уютно, ну это лично субъективное мнение, хотя возможно и работает местами быстрее и потребляет меньше памяти. Такое впечатление формируется в основном из-за недоделанности в сторону быстрого старта, т.е. все нужно конфигурировать и доделывать самому.
После того как немного поработал с Symfony стал подумывать о возможности перехода на Java Spring + Hibernate.
Сам модуль миграций описанный в этой статье можно посмотреть здесь
github.com/vadim-knyzev/ZendDbMigrations
Тесты не включены в модуль, т.к. по стандартам типовой структуры модуля zend 2, тесты размещаются в отдельной папке.
PS: Кто нибудь знает как добавить модуль на страницу информации о модулях на сайте зенда modules.zendframework.com/?
Автор: VadimKnyzev