Пишем CLI модуль для Zend Framework 2

от автора

image
Приветствую, хабражители!

Это моя первая статья на хабре, но помойму я все правильно написал.

Недавно начал работать с 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, с использованием аналогичной системы роутинга, лишь немного отличающейся в связи со спецификой консольных параметров.

Роутер определяет какую команду нужно вызывать и вызывает нужный контроллер, передавая ему все данные.

Что характерно, для веб и консоли используются одни и теже контроллеры, различия пожалуй составляют только в использовании Zend\Console\Request вместо Zend\Http\Request и Zend\Console\Response вместо Zend\Http\Response, объект запроса и ответа соответственно.

Точкой взаимодействия с консольными командами является единая точка входа, та же что и отвечает за веб взаимодействие, т.е. обычно это /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' => 'ZendDbMigrations\Migrations',         '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' => 'ZendDbMigrations\Controller\Migrate',                             'action'     => 'version'                         )                     )                 ),                 'db_migrations_migrate' => array(                     'type'    => 'simple',                     'options' => array(                         'route'    => 'db_migrations_migrate [<version>] [--env=]',                         'defaults' => array(                             'controller' => 'ZendDbMigrations\Controller\Migrate',                             'action'     => 'migrate'                         )                     )                 ),                 'db_migrations_generate' => array(                     'type'    => 'simple',                     'options' => array(                         'route'    => 'db_migrations_generate [--env=]',                         'defaults' => array(                             'controller' => 'ZendDbMigrations\Controller\Migrate',                             'action'     => 'generateMigrationClass'                         )                     )                 )             )         )     ),     'controllers' => array(         'invokables' => array(             'ZendDbMigrations\Controller\Migrate' => 'ZendDbMigrations\Controller\MigrateController'         ),     ),     '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' => 'ZendDbMigrations\Controller\Migrate',                             '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 ZendDbMigrations\Controller;  use Zend\Mvc\Controller\AbstractActionController; use Zend\View\Model\ViewModel; use Zend\Console\Request as ConsoleRequest; use ZendDbMigrations\Library\Migration; use ZendDbMigrations\Library\MigrationException; use ZendDbMigrations\Library\GeneratorMigrationClass; use ZendDbMigrations\Library\OutputWriter;  /**  * Контроллер обеспечивает вызов команд миграций  */ class MigrateController extends AbstractActionController {     /**      * Создать объект класса миграций      * @return \Migrations\Library\Migration      */     protected function getMigration(){         $adapter = $this->getServiceLocator()->get('Zend\Db\Adapter\Adapter');         $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 %s\n", $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 "ZendDbMigrations\Library\MigrationException\n" . $e->getMessage() . "\n";         }     }          /**      * Сгенерировать каркасный класс для новой миграции      */     public function generateMigrationClassAction(){         $adapter = $this->getServiceLocator()->get('Zend\Db\Adapter\Adapter');         $config = $this->getServiceLocator()->get('Configuration');                  $generator = new GeneratorMigrationClass($config['migrations']['dir'], $config['migrations']['namespace']);         $className = $generator->generate();                  return sprintf("Generated class %s\n", $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 Zend\Mvc\ModuleRouteListener; use Zend\ModuleManager\Feature\AutoloaderProviderInterface; use Zend\ModuleManager\Feature\ConfigProviderInterface; use Zend\ModuleManager\Feature\ConsoleUsageProviderInterface; use Zend\Console\Adapter\AdapterInterface as Console; use Zend\ModuleManager\Feature\ConsoleBannerProviderInterface;  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(             'Zend\Loader\StandardAutoloader' => 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 Tests\ZendDbMigrations\Controller;  use ZendDbMigrations\Controller\MigrateController; use Zend\Console\Request as ConsoleRequest; use Zend\Console\Response; use Zend\Mvc\MvcEvent; use Zend\Mvc\Router\RouteMatch; use PHPUnit_Framework_TestCase; use \Bootstrap; use Zend\Db\Adapter\Adapter; use Zend\Db\Metadata\Metadata;  /**  * Тестирование контроллера 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 = \Zend\Mvc\Application::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('Zend\Db\Adapter\Adapter');                           $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('Zend\View\Model\ViewModel', $result, 'Method return object Zend\View\Model\ViewModel!');         $this->assertEquals("Current version 0\n", $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 12345678910\n", $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('Zend\View\Model\ViewModel', $result, 'Method return object Zend\View\Model\ViewModel!');         $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('Zend\View\Model\ViewModel', $result, 'Method return object Zend\View\Model\ViewModel!');         $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/?

ссылка на оригинал статьи http://habrahabr.ru/post/159155/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *