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

в 13:45, , рубрики: cli, php, phpunit, Zend Framework, 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, с использованием аналогичной системы роутинга, лишь немного отличающейся в связи со спецификой консольных параметров.

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

Что характерно, для веб и консоли используются одни и теже контроллеры, различия пожалуй составляют только в использовании 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

Источник

Поделиться

* - обязательные к заполнению поля