понедельник, 1 декабря 2008 г.

Selenium и PHPUnit

Итак продолжая тему тестирования веб приложений, кратко опишу возможности Selenium RC в сочетании c PHPUnit.

Для написания тестов под selenium в PHPUnit существует расширение PHPUnit_Extensions_SeleniumTestCase. Это класс от которого следует наследоваться при написании тестовых случаев. При написании тестовых случаев следует учитывать, что каждый файл может содержать только один класс, т.е. например в файле FirstTest.php должен размещаться класс FirstTest.

Браузеры и платформы

При объявлении класса можно указать под какими браузерами и платформами должны выполняться тесты. Для этого служит свойство класса $browsers. Например так:


require_once 'PHPUnit/Extensions/SeleniumTestCase.php';

class FirstTest extends PHPUnit_Extensions_SeleniumTestCase
{
    public static $browsers = array
    (
        array
        (
            'name' => 'Firefox On Linux', // имя, будет отображаться в сообщениях
            'browser' => '*firefox', // браузер
            'host' => 'localhost', // хост где запущен selenium-server
            'port' => 4444, // порт
            'timeout' => 30000, // таймаут
        ),
        array
        (
            'name' => 'Konqueror On Linux',
            'browser' => '*konqueror',
            'host' => 'localhost',
            'port' => 4444,
            'timeout' => 30000,
        ),
        array
        (
            'name' => 'Safari On Windows',
            'browser' => '*custom C\Program Files\Safari\Safari.exe -url',
            'host' => 'windows.machine',
            'port' => 4444,
            timeout => 30000,
        )
        // ...
    );
}
Так же указать браузер можно в методе setUp используя метод setBrowser

require_once 'PHPUnit/Extensions/SeleniumTestCase.php';

class FirstTest extends PHPUnit_Extensions_SeleniumTestCase
{
    public function setUp()
    {
        $this->setBrowser('*firefox');
    }
}
Если в методе setUp устанавливается браузер, то это перекроет свойство $browsers. Если вы хотите чтобы использовался набор браузеров из свойства $browsers, то не используйте метод setBrowser.

В методе setUp следует указать начальный URL используя метод setBrowserUrl


public function setUp()
{
    $this->setBrowserUrl('http//myproject.sf');
}

Вообще метод setUp используется для конфигурирования сессии Selenium RC. Для этого существует набор методов


public function setUp()
{
    $this->setBrowser('*firefox'); // устанавливает браузер
    $this->setBrowserUrl('http//myproject.sf'); // базовый URL
    $this->setHost('localhost'); // хост где запущен selenium-server
    $this->setPort(4444); // порт на котором висит selenium-server
    $this->setTimeout(30000); // таймаут соединения с selenium-server
    // кол-во секунд между командами отправляемыми Selenium RС
    $this->setSleep(1); 
}

Названия тестовых методов должны начинаться с test. Например:


require_once 'PHPUnit/Extensions/SeleniumTestCase.php';

class FirstTest extends PHPUnit_Extensions_SeleniumTestCase
{
    // ...
    public function testHomepage()
    {
        // ...
    }
}

PHPUnit_Extensions_SeleniumTestCase реализует методы утверждений и действий. Например:


public function testHomepage()
{
    $this->open('/'); // открыть корневой URL
    $this->assertTitleEquals('My Project'); // утверждение title=My Project
}
Практически ко всем методам действий и утверждений доступных в Selenium RC можно обратиться как $this->methodName(). Список доступных методов см. ниже в тексте.

Так же используя PHPUnit_Extensions_SeleniumTestCase можно выполнять Selenese тесты. Это тесты написанные в HTML файлах, с расширением .htm. В таких файлах тесты пишутся в таблицах, из трех колонок:

  • команда
  • цель
  • значение
Так же можно записывать тесты используя Selenium IDE, которая позволяет писать тесты не только в HTML формате, но и на всех доступных в Selenium RC языках. Для выполнения Selenese тестов (тестов записанных в html формате) можно воспользоваться методом runSelenese($filename), либо при объявлении тестового класса указать свойство $seleneseDirectory в котором прописать путь к тестам в html формате.

require_once 'PHPUnit/Extensions/SeleniumTestCase.php';

class SeleneseTest extends PHPUnit_Extensions_SeleniumTestCase
{
    // в этой директории рекурсивно будут искаться все файлы с расширением .htm
    public static $seleneseDirectory = 'test/selenium/html';
}

Методы PHPUnit_Extensions_SeleniumTestCase

Таблица утверждений

Утверждение Значение
assertAlertPresent Уведомляет об ошибке если не представлен alert
assertNoAlertPresent() Уведомляет об ошибке если alert представлен.
assertChecked(string $locator) Уведомляет об ошибке, если элемент найденый по $locator не отмечен (checked) для checkbox и radiobutton.
assertNotChecked(string $locator) Противоположен предыдущему.
assertConfirmationPresent() Уведомляет об ошибке если подтверждение (confirm) не представлено.
assertNoConfirmationPresent() Противоположен предыдущему.
assertEditable(string $locator) Уведомляет об ошибке если элемент найденный по $locator не редактируем (editable).
assertNotEditable(string $locator) Противоположен предыдущему.
assertElementValueEquals(string $locator, string $text) Уведомляет об ошибке, если значение элемента найденного по $locator не эквивалентно переданному $text.
assertElementValueNotEquals(string $locator, string $text) Противоположен предыдущему.
assertElementContainsText(string $locator, string $text) Уведомляет об ошибке если элемент найденый по $locator не содержит текст переданный в $text.
assertElementNotContainsText(string $locator, string $text) Противоположен предыдущему.
assertElementPresent(string $locator) Уведомляет об ошибке если элемент найденный по $locator не представлен.
assertElementNotPresent(string $locator) Противоположен предыдущему.
assertLocationEquals(string $location) Уведомляет об ошибке если текущий "location" не эквивалентен переданному $location.
assertLocationNotEquals(string $location) Противоположен предыдущему.
assertPromptPresent() Уведомляет об ошибке если prompt не представлен.
assertNoPromptPresent() Противоположен предыдущему.
assertSelectHasOption(string $selectLocator, string $option) Уведомляет об ошибке если переданная опция не доступна.
assertSelectNotHasOption(string $selectLocator, string $option) Противоположен предыдущему.
assertSelected($selectLocator, $option) Уведомляет об ошибке если переданный элемент не выбран (select списки).
assertNotSelected($selectLocator, $option) Противоположен предыдущему.
assertIsSelected(string $selectLocator, string $value) Уведомляет об ошибке если в списке не выбран элемент с переданным значением (select списки).
assertIsNotSelected(string $selectLocator, string $value) Противоположен предыдущему.
assertSomethingSelected(string $selectLocator) Уведомляет об ошибке, если опция идентифицируемая как $selectLocator не выбрана.
assertNothingSelected(string $selectLocator) Противоположен предыдущему.
assertTextPresent(string $pattern) Уведомляет об ошибке, если не представлен текст соответствующий переданному $pattern.
assertTextNotPresent(string $pattern) Противоположен предыдущему.
assertTitleEquals(string $title) Уведомляет об ошибке если заголовок страницы (title) не соответствует переданному $title.
assertTitleNotEquals(string $title) Противоположен предыдущему.
assertVisible(string $locator) Уведомляет об ошибке если элемент найденный по $locator не виден (visible).
assertNotVisible(string $locator) Противоположен предыдущему.

Доступные методы действий, более подробное описание:

  • addLocationStrategy
  • addSelection
  • allowNativeXpath
  • altKeyDown
  • altKeyUp
  • answerOnNextPrompt
  • assignId
  • break
  • captureEntirePageScreenshot
  • captureScreenshot
  • check
  • chooseCancelOnNextConfirmation
  • chooseOkOnNextConfirmation
  • click
  • clickAt
  • close
  • contextMenu
  • contextMenuAt
  • controlKeyDown
  • controlKeyUp
  • createCookie
  • deleteAllVisibleCookies
  • deleteCookie
  • doubleClick
  • doubleClickAt
  • dragAndDrop
  • dragAndDropToObject
  • dragDrop
  • echo
  • fireEvent
  • focus
  • goBack
  • highlight
  • ignoreAttributesWithoutValue
  • keyDown
  • keyPress
  • keyUp
  • metaKeyDown
  • metaKeyUp
  • mouseDown
  • mouseDownAt
  • mouseMove
  • mouseMoveAt
  • mouseOut
  • mouseOver
  • mouseUp
  • mouseUpAt
  • mouseUpRight
  • mouseUpRightAt
  • open
  • openWindow
  • pause
  • refresh
  • removeAllSelections
  • removeSelection
  • runScript
  • select
  • selectFrame
  • selectWindow
  • setBrowserLogLevel
  • setContext
  • setCursorPosition
  • setMouseSpeed
  • setSpeed
  • shiftKeyDown
  • shiftKeyUp
  • store
  • storeAlert
  • storeAlertPresent
  • storeAllButtons
  • storeAllFields
  • storeAllLinks
  • storeAllWindowIds
  • storeAllWindowNames
  • storeAllWindowTitles
  • storeAttribute
  • storeAttributeFromAllWindows
  • storeBodyText
  • storeChecked
  • storeConfirmation
  • storeConfirmationPresent
  • storeCookie
  • storeCookieByName
  • storeCookiePresent
  • storeCursorPosition
  • storeEditable
  • storeElementHeight
  • storeElementIndex
  • storeElementPositionLeft
  • storeElementPositionTop
  • storeElementPresent
  • storeElementWidth
  • storeEval
  • storeExpression
  • storeHtmlSource
  • storeLocation
  • storeMouseSpeed
  • storeOrdered
  • storePrompt
  • storePromptPresent
  • storeSelectOptions
  • storeSelectedId
  • storeSelectedIds
  • storeSelectedIndex
  • storeSelectedIndexes
  • storeSelectedLabel
  • storeSelectedLabels
  • storeSelectedValue
  • storeSelectedValues
  • storeSomethingSelected
  • storeSpeed
  • storeTable
  • storeText
  • storeTextPresent
  • storeTitle
  • storeValue
  • storeVisible
  • storeWhetherThisFrameMatchFrameExpression
  • storeWhetherThisWindowMatchWindowExpression
  • storeXpathCount
  • submit
  • type
  • typeKeys
  • uncheck
  • windowFocus
  • windowMaximize

[ читать дальше ]

четверг, 27 ноября 2008 г.

Selenium и symfony

Selenium

В symfony есть вполне удобные инструменты для создания тестов, как юнит тестов, так и функциональных тестов. Но в некоторых случаях инструментов symfony для функционального тестирования не достаточно. Например когда тестами надо покрыть функциональность частично реализованную на javascript. Или для тестирования определенной функциональности под разными браузерами. Для этих целей можно использовать Selenium.

Selenium - это набор инструментов для автоматизированного тестирования веб приложений под разными платформами. Со всеми инструментами тестирования можно ознакомиться на официальном сайте. Здесь я хочу рассмотреть Selenium Remote Control (RC) - инструмент для написания тестов для Selenium с использованием PHP Client Driver. Проще говоря - я хочу рассказать о написании тестов под Selenium используя PHP при разработке на базе symfony.

Интеграция Selenium и Symfony

В первую очередь, хотелось бы хоть как-то интегрировать selenium в symfony. В идеале конечно хотелось бы иметь удобный плагин, которым можно поделиться с сообществом. К сожалению на разработку полноценного плагина нет времени, поэтому сделаем просто небольшую заготовку, которая возможно в дальнейшем перерастет в полноценный плагин.

Итак, скачаем Selenium-Rc (на текущий момент лучше скачивать "latest nightly build", потому как у доступной сейчас версии "1.0 beta 1" есть некоторые проблемы с firefox 3). Собственно из всего архива нам потребуются selenium-server*/selenium-server.jar и selenium-php-client-driver*/PEAR/Testing. По мимо этого нам так же потребуется PHPUnit. Соответственно Testing и PHPUnit необходимо разместить так, что бы они были доступны при обращениях типа


require_once 'PHPUnit/Extensions/SeleniumTestCase.php';
Можно их разместить в директории lib/vendor и прописать данный путь в include_path.

Создадим в нашем проекте (предпологается, что у нас создан проект и приложение frontend) плагин, скажем spSeleniumTestPlugin со следующей структурой:


plugins
  spSeleniumTestPlugin
    config
      settings.yml
    lib
      task
Теперь, создадим команду позволяющую запускать "selenium" тесты примерно следующей командой:

$ ./symfony selenium:test frontend
Для этого выполним следующую команду

$ ./symfony generate:task selenium:test --dir=plugins/spSeleniumTestPlugin/lib/task
Данная команда создаст класс-каркас для нашей команды в директории plugins/spSeleniumTestPlugin/lib/task. Так же разместим selenium-server.jar в дироектории plugins/spSeleniumTestPlugin/lib. И возьмемся за написание команды запускающей "selenium" тесты. Я не будет подробно останавливаться на создании команд для symfony, а просто выложу исходный код и немного его прокомментирую

require_once 'PHPUnit/Framework/TestSuite.php';
require_once 'PHPUnit/TextUI/TestRunner.php';

class seleniumTestTask extends sfBaseTask
{
    protected function configure()
    {
        $this->namespace        = 'selenium';
        $this->name             = 'test';
        $this->briefDescription = '';
        $this->detailedDescription = <<<EOF
The [selenium:test|INFO] task does things.
Call it with:

  [php symfony selenium:test|INFO]
EOF;
        $this->addArgument('application', sfCommandArgument::REQUIRED, 'The application name');
        $this->addArgument('directory', sfCommandArgument::OPTIONAL, 'Directory');
        $this->addOption('env', null, sfCommandOption::PARAMETER_REQUIRED, 'The environment', 'test');
    }

    protected function execute($arguments = array(), $options = array())
    {
        $dbManager = new sfDatabaseManager($this->configuration);
        $connection = Doctrine_Manager::connection();

        list($resource, $pipes) = $this->runSelenium(sfConfig::get('sf_selenium_port', 4444));

        $ds = DIRECTORY_SEPARATOR;
        $app = $arguments['application'];

        $testAppDir = sfConfig::get('sf_test_dir') . $ds . 'selenium' . $ds . $app;
        if ($arguments['directory'])
        {
            $testAppDir .=  $ds . $arguments['directory'];
        }

        $this->logSection('find tests in', $testAppDir);

        $files = sfFinder::type('file')
            ->ignore_version_control()
            ->follow_link()
            ->name('*Test.php')
            ->relative()
            ->in($testAppDir)
            ;

        $suite = new PHPUnit_Framework_TestSuite();

        foreach($files as $file)
        {
            $suite->addTestFile($testAppDir . $ds. $file);
        }

        $runner = new PHPUnit_TextUI_TestRunner;
        $runner->doRun($suite, array());

        proc_terminate($resource);
    }

    protected function runSelenium($port)
    {
        $ds = DIRECTORY_SEPARATOR;
        $pluginPath = realpath(dirname(__FILE__) . $ds . '..' . $ds . '..');

        $files = sfFinder::type('file')->ignore_version_control()->follow_link()->name('*selenium*server*.jar')->in($pluginPath);

        $seleniumJar = $files[0];
        $javaBin = sfConfig::get('sf_selenium_java_bin', 'java');

        $args = ' -port ' . $port;

        $cmd = escapeshellcmd($javaBin . ' -jar ' . $seleniumJar . $args);

        $dSpec = array
        (
            0 => array('pipe', 'r'),
            1 => array('pipe', 'w'),
            2 => array('pipe', 'r')
        );

        $pipes = array();
        $this->logSection('selenium', 'run');
        $resource = proc_open($cmd, $dSpec, $pipes);

        sleep(1);

        return array($resource, $pipes);
    }
}
В команде мы определяем два аргумента application и directory. Первый отвечает за то какое приложение будем тестировать, а второй не обязательный определяет в какой относительной директории искать тесты, это для того, что бы не запускать каждый раз все тесты, так как они могут выполняться достаточно долго.

Тесты по умолчанию ищутся в директории test/selenium/app_name - это для того, чтобы не смешивать их с остальными тестами (Примечание: по-хорошему надо бы реализовать интеграцию чтобы тесты могли располагаться вместе с остальными и запускаться общей для всех командой и этого добиться в общем не сложно, было бы время и желание). Так же в нашей команде определена одна не обязательная опция --env служащая для задания окружения.

Команда при выполнении пытается запуcтить selenium которого ищет в директории плагина. После запуска selenium команда собирает найденные тесты в тестовый набор и выполняет их средствами PHPUnit. Данный исходный код, можно взять как стартовую точку, для реализации под собственные нужды.

Пример теста

Для примера создадим файл test/selenium/GoogleTest.php со следующим содержанием:


setBrowser('*firefox');
        $this->setBrowserUrl('http://google.com/');
    }

    public function testGoogle()
    {
        $this->open("http://google.com/");
        $this->type("q", "hello world");
        $this->click("btnG");
        $this->waitForPageToLoad(10000);
        $this->assertRegExp("/helloworld\.ru/i", $this->getBodyText());

    }
}
Данный тест выполняет следующее, запускает браузер firefox, переходит на google.com, вводит в строку поиска (поле "q") "hello world", кликает на кнопку "btnG", ждет ответа и проверяет, что в тексте body содержится строка "helloworld.ru". А теперь запустим его

$ ./symfony selenium:test frontend
Если на выходе у вас, что-то вроде:

>> selenium  run
>> find tests in /var/workspace/sf11/example/test/selenium/backend
PHPUnit 3.2.21 by Sebastian Bergmann.

.

Time: 7 seconds


OK (1 tests)
значит все прошло успешно. Из выводимых сообщений можно понять, что тесты выполнялись 7 секунд, был выполнен 1 тест, который успешно прошел. Если у вас что-то не сработало, пишите.

На этом пока все, в следующей заметке я постараюсь описать основные возможности selenium и API PHPUnit.

[ читать дальше ]

четверг, 23 октября 2008 г.

Email рассылка средствами Swift

Email рассылка достаточно часто возникающая, перед разработчиком, задача. В простейшем случае это рассылка новостей сайта. На сайте symfony в The symfony Cookbook для отправки электронной почты предлагается использовать Swift Mailer. И действительно Swift Mailer - это мощная библиотека решающая практически все задачи связанные с отправкой электронных писем. Именно с ее помощью мы и создадим нашу email рассылку.

Установка Swift Mailer

Для начала надо установить Swift Mailer. Если проект находится под контролем Subversion, тогда добавим свойство svn:externals директории lib/vendor, если такой директории еще нет, то можно ее создать.

    $ mkdir -p lib/vendor
    $ svn propedit svn:externals lib/vendor
    
добавим следующую строку

    swift http://swiftmailer.svn.sourceforge.net/svnroot/swiftmailer/tags/php5/3.3.3/lib/
    
и обновим

    $ svn update
    
Если проект не использует Subversion, то просто разместим Swift Mailer в директории lib/vendor, для этого можно выполнить следующие команды:

    $ mkdir -p lib/vendor
    $ svn co http://swiftmailer.svn.sourceforge.net/svnroot/swiftmailer/tags/php5/3.3.3/lib/ lib/vendor/swift
    
На этом установка завершена.

Создание моделей

Теперь определим модели данных, которые нам потребуются. В простейшем случае - это подписчик (Subscriber) и сообщение (Message). Модели могут быть примерно следующими:

    --- config/doctrine/subscribe.yml
    Subscriber:
      columns:
        first_name: string(100)
        middle_name: string(100)
        last_name: string(100)
        email: string(200)
        email_key: string(40)

    Message:
      columns:
        subject: string(255)
        body: string
        sended: timestamp
    
Несколько комментариев. В модели Subscriber пояснить пожалуй стоит только поле email_key. Это хеш, сгенерированный для каждого подписчика и используемый для возможности отписаться от рассыкли. В моделе Message поле sended содержит отметку времени об отправке письма, если это поле null значит письмо еще не было отправлено и его следует разослать подписчикам. Предназначение остальных полей кажется вполне понятным исходя из их названий. Вопрос о создании интерфейса управления сообщениями, подписчиками и т.д. оставим за кадром.

Команда рассылки писем

Для рассылки писем создадим команду (task), для этого воспользуемся командой:

    $ ./symfony generate:task mailer:send 
    
Это создаст команду mailer:send определенную в файле lib/task/mailerSendTask.class.php. Метод configure этой команды может выглядеть примерно так:

    protected function configure()
    {
        $this->namespace        = 'mailer';
        $this->name             = 'send';
        $this->briefDescription = 'Рассылает письма';
        $this->detailedDescription = <<<EOF
[mailer:send|INFO] Отправляет письмо подписчикам.
Используется так:

  [./symfony mailer:send appliaction --env=environment|INFO]

Простой пример:

  [./symfony mailer:send frontend --env=prod|INFO]
EOF;
        $this->addArgument('application', sfCommandArgument::REQUIRED, 'Приложение');
        $this->addOption('env', null, sfCommandOption::PARAMETER_REQUIRED, 'Окружение', 'dev');
    }
    
Добавление аргумента application в данном случае обязательно, так как мы будем использовать базу данных. В дальнейшем команду надо будет запускать примерно так:

    $ ./symfony mailer:send frontend
    

Подготовка к рассылке email

Для начала необходимо получить письмо, которое следует разослать подписчикам. Для этого определим метод getWaitingMessage() в классе MessageTable, он может выглядеть примерно так:

    # lib/model/doctrine/MessageTable.class.php
    public function getWaitingMessage()
    {
        return $this->createQuery('m')
                ->where('m.sended IS NULL')
                ->limit(1)
                ->execute()
                ->getFirst();
    }
    
Также необходимо получить подписчиков, но для этого нам пока достаточно простого:

    Doctrine::getTable('Subscriber')->findAll();
    

Подготовка сообщения для email рассылки

Прежде, чем отправлять письмо, его необходимо подготовить. Например, если мы создаем сообщение через wysiwyg редактор, то нам как минимум необходимо вложить все изображения и изменить на них ссылки, а так же надо проставить email_key в ссылку по которой можно отписаться от рассылки. Для этого определим метод getMessage() в классе нашей команды mailerSendTask, в простейшем случае он может выглядеть примерно так:

    protected $message;
    public function getMessage()
    {
        // получаем сообщение для отправки
        $this->message = Doctrine::getTable('Message')->getWaitingMessage();
        if (!$message)
        {
            throw new sfException('Не найдено сообщений для отправки');
        }

        // создаем email сообщение используя Swift
        $mimeMessage = new Swift_Message($this->message['subject']);
        $mimeMessage->setCharset('UTF-8');

        // находим все изображения в сообщении.
        $images = array();
        if (preg_match_all('/<img[^>]*?src=(?:\'|")(.*?)(?:\'|")/umsi', $this->message['body'], $matches))
        {
            foreach($matches[1] as $match)
            {
                $images[$match] = new Swift_Message_Image
                (
                    new Swift_File($webDir . DIRECTORY_SEPARATOR . $match)
                );
            }
        }

        // приаттачиваем найденные изображения и меняем ссылки в сообщении
        if (!empty($images))
        {
            foreach($images as $src => $image)
            {
                $attach = $mimeMessage->attach($image);
                $this->message['body'] = str_replace($src, $attach, $this->message['body']);
            }
        }

        // наконец прикрепляем собственно сообщение.
        $mimeMessage->attach
        (
            new Swift_Message_Part
            (
                $this->message['body'], 
                'text/html', 
                'base64'
            )
        );

        return $mimeMessage;

    }
    
Если с заменой изображений все понятно, то вот со ссылкой позволяющей отписаться мы пока подождем, так как она должна быть своей для каждого подписчика.

Составление списка получателей

Прежде чем отправлять письмо необходимо определить список получателей. Для этого воспользуемся классом Swift_RecipientList и определим метод getRecipients в классе нашей команды, код может выглядеть примерно так:

    // lib/task/mailerSendTask.class.php
    protected $subscribers;
    // ...
    public function getRecipients()
    {
        $this->subscribers = Doctrine::getTable('Subscriber')->findAll();

        $recipients = new Swift_RecipientList;
        $encoder = new Swift_Message_Encoder;

        foreach($subscribers as $subscriber)
        {
            $recipients->addTo
            (
                $subscriber['email'],
                sprintf
                (
                    '=?utf-8?B?%s=',
                    $encoder->base64Encode
                    (
                        sprintf("%s %s", $subsciber['first_name'], $subscriber['last_name'])
                    )
                )
            );
        }

        return $recipients;
    }
    
Здесь используется Swift_Message_Encoder для кодирования получателей. Это необходимо, так как если в имени получателей содержатся например кириллические символы, то могут быть проблемы. Во избежание этих проблем и для адекватного отображения почтовыми клиентами мы и используем кодирование имен.

Отправка email сообщения

Для отправки нам так же необходимы некоторые настройки, которые лучше вынести в файл настроек приложения app.yml Выглядеть он может примерно так:

    mail:
      subscribe:
        host: localhost
        port: 25
        from: robot@example.com
    
Сам код отправки email сообщения может выглядеть примерно так:

    public function execute($arguments=array(), $options=array())
    {
        $dbManager = new sfDatabaseManager($this->configuration);

        // получаем настройки
        $config = sfConfig::get('app_mail_subscribe');

        // создаем экземпляр мейлера
        $mailer = new Swift(new Swift_Connection_SMTP($config['host'], $config['port']));

        // получаем список получателей
        $recipients = $this->getRecipients();

        try
        {
            // создаем объект пакетной рассылки и отправляем письмо
            // по списку получателей
            $batch = Swift_BatchMailer($mailer);
            $batch->send($this->getMessage(), $recipients, $config['from']);

            // после отправки письма, ставим отметку у сообщения
            $message = Doctrine::getTable('Message')->find($this->message['id']);
            $message->sended = date('Y-m-d H:i');
            $message->save();
        }
        catch (Exception $e)
        {
            new throw sfException($e->getMessage());
        }
    }
    
Мы используем Swift_BatchMailer для отправки email сообщения по списку получателей.

Персонализация сообщения в email рассылке

У нас уже отправляются письма по списку подписчиков. Но мы еще не решили одной важной проблемы - вставка ссылки для возможности отписаться от рассылки. Конечно можно было бы пойти простым путем и сделать ссылку для всех одинаковой, а уже на этой странице запрашивать email по которому отправлять письмо с нужной ссылкой. Но это слишком не удобно. Более того, хотелось бы хоть как-то персонализировать сообщения и начинать их например с приветствия по имени каждого подписчика. Для этого мы используем плагин для Swift - Swift_Plugin_Decorator. Который позволяет производить замены в тексте сообщения для каждого получателя. Нам для этого необходимо создать массив замен по следующему принципу:

    $replacements = array
    (
        'email@address.com' => array('{key}' => 'value', '{key2}' => 'value 2'),
        // ...
    );
    
Все ключи (они не обязательно должны быть обрамлены {}, можно использовать что угодно, например %%key%%) заменяются на значения. Для использования плагина необходимо для начала создать массив замен. Для простоты будем создавать массив замен в момент получения списка получателей. Для этого добавим свойство классу команды mailerSendTask - $replacements и изменим код получения списка получателей.

    // lib/task/mailerSendTask.class.php
    protected $subscribers;
    protected $replacements = array();
    // ...
    public function getRecipients()
    {
        $this->subscribers = Doctrine::getTable('Subscriber')->findAll();

        $recipients = new Swift_RecipientList;
        $encoder = new Swift_Message_Encoder;

        foreach($subscribers as $subscriber)
        {
            $recipients->addTo
            (
                $subscriber['email'],
                sprintf
                (
                    '=?utf-8?B?%s=',
                    $encoder->base64Encode
                    (
                        sprintf("%s %s", $subsciber['first_name'], $subscriber['last_name'])
                    )
                )
            );

            $this->replacements[$subscriber['email']] = array
            (
                '{first_name}' => $subscriber['first_name'],
                '{middle_name}' => $subscriber['middle_name'],
                '{last_name}' => $subscriber['last_name'],
                '{email_key}' => $subscriber['email_key']
            );
        }

        return $recipients;
    }
    
Теперь немного модифицируем код отправки сообщения, указав, что мы хотим использовать плагин:

    public function execute($arguments=array(), $options=array())
    {
        $dbManager = new sfDatabaseManager($this->configuration);

        // получаем настройки
        $config = sfConfig::get('app_mail_subscribe');

        // создаем экземпляр мейлера
        $mailer = new Swift(new Swift_Connection_SMTP($config['host'], $config['port']));

        // получаем список получателей
        $recipients = $this->getRecipients();

        // подключаем плагин 
        $mailer->attachPlugin(new Swift_Plugin_Decorator($this->replacements), 'decorator');

        try
        {
            // создаем объект пакетной рассылки и отправляем письмо
            // по списку получателей
            $batch = Swift_BatchMailer($mailer);
            $batch->send($this->getMessage(), $recipients, $config['from']);

            // после отправки письма, ставим отметку у сообщения
            $message = Doctrine::getTable('Message')->find($this->message['id']);
            $message->sended = date('Y-m-d H:i');
            $message->save();

        }
        catch (Exception $e)
        {
            new throw sfException($e->getMessage());
        }
    }
    
Теперь в теле сообщения все вставки вида {first_name}, {middle_name}, {last_name}, {email_key} будут заменяться на соответствующие значения. Т.е. письмо может выглядеть примерно так:

    Здравствуйте, {first_name} {last_name}!
    ...

    ---
    Если вы хотите отписаться от данной рассылки, перейдите по <a href="http://example.com/unsubscribe/{email_key}">этой ссылке</a>
    
На этом все. За кадром остались многие вопросы создания хорошей email рассылки, например управление подписчиками, собщениями, составление писем, создание нескольких рассылок, запланированная рассылка и т.д. Но главной целью данного поста было показать как персонализировать письма при отправке по списку получателей.

[ читать дальше ]

вторник, 14 октября 2008 г.

Новое в symfony 1.2, часть 3

Request

Настройки path_info_array, path_info_key и relative_url_root перенесены из settings.yml в factories.yml (в секцию param). Это изменение убирает зависимость sfRequest и sfConfig. Эти три опции передаются в конструктор четвертым параметром. Форматы так же передаются как опция.

Константы класса sfRequest теперь имеют текстовое значение:

Константа Старое значение Новое значение
GET 2 GET
POST 4 POST
PUT 5 PUT
DELETE 6 DELETE
HEAD 7 HEAD
NONE 1 -
Методы getMethod() и getMethodName() теперь возвращают одинаковые значения. Метод getMethodName() - устарел. sfAction::getMethodNames() и соответствующий код в sfValidationExecutionFilter из sfCompat10Plugin удалены.

Response

Добавилась новый параметр для request в factory - send_http_headers. По умолчанию эта настройка установлена в true, исключая test окружение. Это изменение удаляет зависимость sfResponse и sfConfig.

Ответ (response) имеет новый метод getCharset(), который возвращает текущую кодировку ответа. Возвращаемая кодировка, меняется если изменить кодировку через установку "content-type".

Методы getStylesheets, getJavascripts могут возвращать все файлы упорядочено, если передать sfWebResponse::ALL как первый аргумент.


    $response = new sfWebResponse(new sfEventDispatcher());
    $response->addStylesheet('foo.css');
    $response->addStylesheet('bar.css', 'first');

    var_export($response->getStylesheets());

    // outputs
    array(
      'bar.css' => array(),
      'foo.css' => array(),
    )
    
sfWebResponse::ALL - это теперь значение по умолчанию. Добиться поведения как в symfony 1.1 можно передав в качестве параметра sfResponse::RAW

    var_export($response->getStylesheets(sfWebResponse::RAW));

    // outputs
    array(
      'first' =>
        array(
          'bar.css' => array (),
        ),
      '' =>
        array(
        'foo.css' => array(),
        ),
      'last' => array(),
    )
    
Все позиции (first, '', last) теперь также доступны как константы

    sfWebResponse::FIRST  === 'first'
    sfWebResponse::MIDDLE === ''
    sfWebResponse::LAST   === 'last'
    
Методы removeStylesheet() и removeJavascript() теперь принимают только один аргумент, имя файла, который надо удалить из ответа. Файл будет удален из всех доступных позиций.

[ читать дальше ]

суббота, 11 октября 2008 г.

Шаблоны ошибок и форматы запроса

В symonfy 1.1 была введена поддержка различных форматов запроса. Но там не доставало одного важного фрагмента: поддержки ошибок. Благодаря огромной работе проделаной Kris Wallsmith в symfony 1.2 этот маленький недостаток уже исправлен. Крис создал плагин реализующий поддержку ошибок в разных форматах. Позднее его плагин был включен в ядро symfony 1.2.

Что бы проще было объяснить как это работает, рассмотрим небольшой пример. Допустим есть приложение с модулем API возвращающим HTML, XML или JSON представление статьи (модель Article). Определим правила маршрутизации:


    // apps/frontend/config/routing.yml
    api_article:
      url: /api/article/:id.:sf_format
      param: { module: api, action: atricle }
      requirements:
      sf_format: (?:html|xml|json)

И соответствующее действие (action) в нашем модуле:

    class apiActions extends sfActions
    {
        public function executeArticle($request)
        {
            $this->article = Doctrine::getTable('Article')->find((int)$request->getParameter('id'));
            $this->forward404Unless($this->article);
        }
    }

Теперь, если в запросе передать идентификатор не существующей статьи, запрос будет перенаправлен на страницу с 404 ошибкой. Если при этом используется HTML формат (http://localhost/frontend_dev.php/api/article/1.html) в development окружении вы получите сообщение об ошибке sfError404Exception.

В production окружении будет показана страница с 404 ошибкой "Oops! Page Not Found".

Если изменить формат запроса на XML, то в development окружении получим ответ:

и в production окружении просто:

Т.е. ошибки теперь возвращаются в том же формате, что и запрос.

И это еще не все. Сообщения об ошибках можно настраивать для каждого формата, добавляя шаблоны в директорию проекта config/error/ или конкретного приложения apps/frontend/config/error/. Например, для того чтобы настроить сообщения об ошибках для XML запросов можно создать шаблон config/error/error.xml.php примерно следующего содержания:


    <?xml version="1.0" encoding="<?php echo sfConfig::get('sf_charset', 'UTF-8') ?>"?>
    <error>
      <code><?php echo $code ?></code>
      <message><?php echo $text ?></message>
    </error>

Что даст примерно следующий вывод:

В шаблонах ошибок доступны следующие переменные:

  • $code - код статуса ответа
  • $text - текст статуса ответа
  • $name - имя класса исключения
  • $message - сообщение исключения
  • $traces - массив содержащий полный PHP trace
  • $format - формат запроса

Но например в development окружении вместо страниц типа 404 ошибки отображаются страницы с выброшенным исключением. Как например при запросе не существующей статьи, отображается страница с исключением sfError404Exception.
Эти страницы можно настраивать за счет создания шаблонов исключений, например config/error/exception.xml.php.
Шаблоны исключений используемые по умолчанию хранятся в директории lib/exception/data/ в дистрибутиве symfony.
Для создания своего шаблона для какого-либо формата запроса, необходимо создать шаблон вида config/error/exception.FORMAT_NAME.php, где FORMAT_NAME - название формата, для которого создается шаблон. В своих шаблонах, можно подгружать шаблоны symfony используя следующую конструкцию:


    <?php include sfException::getTemplatePathForError('xml', true) ?>

навеяно: New in symfony 1.2: Error Templates and Request Formats

[ читать дальше ]

четверг, 9 октября 2008 г.

Новое в symfony 1.2, часть 2

Действия (Action)

В действиях теперь можно генерировать URL используя объект маршрутизатора (routing object) благодаря новому методу generateUrl

        public function executeIndex()
        {
            $this->redirect($this->generateUrl('homepage'));
        }
    
Метод generateUrl в параметрах принимает имя маршрута, массив параметров и флаг генерировать ли абсолютный URL.

Формы (Forms)

Добавлены два новых метода облегчающих работу с формами в шаблонах. Первый - hasErrors возвращает true если форма содержит какие-либо ошибки и false если ошибок нет. Этот метод так же возвращает false если форма не была привязана к данным. Это используется в шаблонах когда нужно вывести сообщение о том, что форма содержит ошибки.

    <?php if ($form->hasError()): ?>
      Форма содержит ошибки, исправть пожалуйста.
    <?php endif; ?>
    
Второй метод renderFormTag генерирует открывающий тег для формы. Он так же добавляет enctype атрибут, если это необходимо и скрытый тег, если метод не POST и не GET

    <?php echo $form->renderFormTag('@article_update', array('method' => 'PUT')) ?>
    
Если форма связана с Propel объектом метод renderFormTag() автоматически изменяет HTTP метод на POST для создания объекта и на PUT для редактирования объекта.

[ читать дальше ]

среда, 8 октября 2008 г.

Новое в symfony 1.2, часть 1

Имя приложения в CLI командах

Некоторые команды требуют в аргументах имя приложения, так как они используют соединение с базой данных. Но в некоторых из этих команд требовать имя приложения не логично. В symfony1.2 этот аргумент был заменен на опцию --application. Если не указать эту опцию, то symfony возьмет настройки для соединения с базой данных из файла config/databases.yml Примеры таких команд

        #propel
        ./symfony propel:build-all-load 
        ./symfony propel:build-all-load --application=frontend

        #doctrine
        ./symfony doctrine:build-all-load
        ./symfony doctrine:build-all-load --application=frontend
        ./symfony doctrine:migrate
        ./symfony doctrine:migrate --application=frontend
    

Поддержка методов PUT и DELETE в браузере

Теперь можно имитировать поддержку методов PUT и DELETE браузером используя метод POST и добавив специальный скыртый (hidden) параметр sf_method

        <form action='#' method='POST'>
            <input type='hidden' name='sf_method' value='PUT' />

            <!-- // ... -->
        </form>
    
используя эту форму вызвав sfRequest::getMethod получим PUT

Улучшения в "response"

Иногда требуется получить файлы таблиц стилей (stylesheets) и файлы с javascript кодом текущего ответа. Но в версии 1.1 методы getJavascripts и getStylesheets возрващают внутрее представление данных, что в общем оказывается не тем что ожидалось. В symfony 1.2 данные возвращаются в упорядоченом, готовом к использованию виде.

        array
        (
            'bar.css' => array(),
            'foo.css' => array()
        );
    

sfValidatorSchemaCompare валидатор

sfValidatorSchemaCompare стал немного удобнее в использовании

        // symfony 1.1 and 1.2
        $v = new sfValidatorSchemaCompare('left', sfValidatorSchemaCompare::EQUAL, 'right');

        // symfony 1.2 only
        $v = new sfValidatorSchemaCompare('left', '==', 'right');
    

[ читать дальше ]

четверг, 31 июля 2008 г.

Команды (Tasks). Создание

Начиная с версии 1.1 в symfony каждая команда представляет из себя отдельный класс, в отличие от предыдущих версий, где каждая команда была представлена двумя функциями (описание и сама команда). Команды разделены на пространства имен, что позволяет избегать конфликтов в именовании команд. Команды можно наследовать и переопределять. При совпадении названий внутри одного пространства имен, команды перекрываются, такми образом команды установленных плагинов переопределяют команды ядра симфони, а команды проекта переопределяют команды плагинов и ядра симфони.

Для создания своей команды, можно воспользоваться генерацией каркаса команды. Например создадим команду, которая выводит "Привет, Мир!" и назовем ее "hello". Определим ее в пространстве имен "example"


       ./symfony generate:task example:hello

Теперь для того чтобы убедиться, что каркас был создан введем команду:


       ./symfony list example
   
и если мы на выходе получим примерно следующее:

       Available tasks for the "example" namespace:
         :hello
   
значит каркас был успешно создан и в директории "lib/task" можно найти файл "exampleHelloTask.class.php".

В этом файле и создан класс-каркас нашей команды. В котором определены два необходимых метода "configure" и "execute", которые конфигурируют и запускают команду соответственно.


   class exampleHelloTask extends sfBaseTask
   {
       protected function configure()
       {
           // ...
       }

       protected function execute($arguments = array(), $options = array())
       {
           // ...
       }
   }
   

По умолчанию, команда "generate:task" создаст каркас в директории "lib/task", но можно указать путь, где создать каркас, например при создании своей команды в плагине:


     ./symfony generate:task --dir=plugins/spExamplePlugin/lib/task example:hello
   

В классе команды должны быть определены два метода, метод описания и конфигурации команды - "configure" и метод в котором выполняются действия команды - "execute". Метод configure для нашей команды может выглядеть примерно так:


   protected function configure()
    {
        $this->namespace = 'example'; // пространство имен
        $this->name      = 'hello'; // название команды 
        // короткое описание 
        // выводимое по команде ./symfony list
        $this->briefDescription = ''; 
        // подробное описание 
        // выводимое по команде help, в этом описании 
        // форматируя особым образом текст можно добиться 
        // необходимой подсветки.
        // например [text|INFO] выведет text зеленым цветом, а
        // [text|COMMENT] выведет text коричневым цветом
        $this->detailedDescription = '
        Команда [example:hello|INFO] просто привествует мир.
        Запускайте ее так:

          [./symfony example:hello|INFO]

        ';
        $this->aliases = array(); // массив синонимов
    }

   
Теперь можно выполнить команду:

     ./symfony help example:hello
   
и убедиться, что справочная информация выводится так как и было задумано.

Теперь добавим действий команде, дополнив код метода "execute":


   protected function execute($arguments = array(), $options = array())
   {
       $this->logSection("example", "Привет, Мир!");
   }
   
Метод "logSection" печатает отформатированное сообщение на стандартный вывод. Запускаем:

     ./symfony example:hello
   
и получаем:

   >>example   Привет, Мир!
   

Зачастую команды должны быть конфигурируемы, например выполнять действия в указанном окружении, или для определенного приложения. А так же должны уметь принимать аргументы как имя автора в команде "configure:author" или название приложения в команде "generate:app".
Эти задачи реализуются за счет описания опций и аргументов в методе "configure".
В качестве примера добавим нашей команде способность привествовать не только мир, но и разработчика вызвавшего ее. Для этого добавим аргумент "name", в котором будем принимать имя разработчика и немного изменим подробное описание команды, продемонстрировав еще одну возможность расскраски выводимого текста:


    protected function configure()
    {
        $this->namespace = 'example'; // пространство имен
        $this->name      = 'hello'; // название команды 
        // короткое описание 
        // выводимое по команде ./symfony list
        $this->briefDescription = ''; 
        // подробное описание 
        // выводимое по команде help, в этом описании 
        // форматируя особым образом текст можно добиться 
        // необходимой подсветки.
        // например [text|INFO] выведет text зеленым цветом, а
        // [text|COMMENT] выведет text коричневым цветом
        $this->detailedDescription = '
        Команда [example:hello|INFO] просто привествует мир.
        Запускайте ее так:

          [./symfony example:hello|INFO]

        Команда может привествовать не только мир, но и вас лично,
        для этого используйте аргумент [name|COMMENT]:

          [./symfony example:hello name|INFO]

        ';
        $this->aliases = array(); // массив синонимов
        $this->addArgument
        (
            'name', // название аргумента
            sfCommandArgument::OPTIONAL, // тип аргумента
            'Ваше имя, о величайший создатель', // справка
            'Мир' // значение по умолчанию
        )
    }
   
Если теперь выполнить команду:

     ./symfony help example:hello
   
то можно увидеть, что описание этого аргумента добавилось в вывод справки нашей команды. Теперь изменим код команды:

   protected function execute($arguments = array(), $options = array())
   {
       $this->logSection('example', 'Привет, '.$arguments['name'].'!');
   }
   
и запустим в двух вариантах:

     ./symfony example:hello
     >>example    Привет, Мир!
     ./symfony example:hello Сергей
     >>example    Привет, Сергей!
   

Рассмотрим подробнее описание аргумента.
Для описания используется метод:


   /**
    * name - имя аргумента
    * mode - свойства аргумента, возможные значения
    *         sfCommandArgument::OPTIONAL,
    *         sfCommandArgument::REQUIRED,
    *         sfCommandArgument::IS_ARRAY
    * help - справка
    * default - значение по умолчанию, нельзя указать при REQUIRED
    */
   addArgument($name, $mode = null, $help = '', $default = null);
   
Предназначение параметров вроде понятно из их названий, а вот о "mode" немного дополню.
sfCommandArgument::OPTIONAL - говорит о том, что аргумент является не обязательным
sfCommandArgument::REQUIRED - напртив, говорит о том, что аргумент является обязательным. При указании sfCommandArgument::REQUIRED нельзя задать значение по умолчанию для аргумента.
sfCommandArgument::IS_ARRAY - говорит о том, что аргумент является массивом. В этом случае в методе "execute" он будет доступен именно как массив и в значении по умолчанию его тоже надо задавать в виде массива. При вызове команды из консоли массивы передаются так:

     ./symfony example:hello {Сергей,Мир}
     // преобразуется в массив array('Сергей', 'Мир');
     ./symfony example:hello Сергей Мир
     // преобразуется в массив, если у аргумента указан IS_ARRAY
   
"mode" можно задать и так, объединив свойства аргумента:

   $this->addArgument
   (
       'hello',
       sfCommandArgument::OPTIONAL | sfCommandArgument::IS_ARRAY
   );
   
Для добавления нескольких аргументов за раз, можно воспользоваться методом "addArguments":

   $this->addArguments
   (
       new sfCommandArgument
       (
           'hello',
           sfCommandArgument::OPTIONAL,
           'help',
           'default'
       ),
       new sfCommandArgument
       (
           'bye',
           sfCommandArgument::OPTIONAL,
           'help',
           'default'
       )
       //, ...
   );
   

Опции команде можно добавить используя методы "addOption" или "addOptions".


   /**
    * Описывает принимаемую опцию
    *
    * name - имя опции
    * shortname - короткое имя опции
    * mode - свойства параметра опции
    * help - справка
    * default - значение по умолчанию
    */
   addOption($name, $shortname = null, $mode = null, $help = '', $default = null);
   

Добавим нашей команде возможность указания способа привествия, для этого опишем опцию "greeting" в методе "configure":


   protected function configure()
   {
       // ... пропускаю то, что сделали выше

       // обязателен только первый параметр
       $this->addOption
       (
           'greeting', // имя опции
           'g', // короткое имя опции
           sfCommandOption::PARAMETER_OPTIONAL, // mode
           'Как попривествовать', // справка
           'Привет', // значение по умолчанию
       )
   }

   protected function execute($arguments = array(), $options = array())
   {
       $this->logSection('example', $options['greeting'].', '.$arguments['name'].'!');
   }
   
И запускаем в двух вариантах:

     ./symfony example:hello -g Здравствуй
     >> example    Здравствуй, Мир!
     ./symfony example:hello --greeting=Здравствуй
     >> example    Здравствуй, Мир!
   
Предназначение параметров понятны из названия и комментариев.
"mode" может иметь следующие значения или их комбинации:
sfCommandOption::PARAMETER_NONE - если опция не нуждается в параметре, т.е. например:

     ./symfony task_name --debug
   
в метод "execute" в "$options['debug']" будет передано либо "true" если опция указана, либо "false" если опция не указана.
sfCommandOption::PARAMETER_REQUIRED - параметр у опции обязателен
sfCommandOption::PARAMETER_OPTIONAL - параметр у опции не обязателен
sfCommandOption::IS_ARRAY - параметр у опции массив, можно указать несколько раз:

     ./symfony task_name --option='param1' --option='param2'
     // преобразуется в array('param1', 'param2')
   
Ну и вариант для добавления нескольких опций за раз:

   protected function configure()
   {
       $this->addOptions
       (
           new sfCommandOption
           (
               'greeting',
               'g',
               sfCommandOption::PARAMETER_REQUIRED,
               'help',
               'Привет'
           ),
           new sfCommandOption
           (
               'env'
           )
           //, ...
       );
   }
   

Несколько полезных советов

Вызов команды внутри другой команды.


   protected function execute($arguments = array(), $options = array())
   {
       $task = new myOtherTask($this->dispatcher, $this->formatter);
       $task->run
       (
           $arguments = array
           (
               'foo' => 'bar'
           ),
           $options = array
           (
               'bar' => 'foo'
           )
       );
   }
   

Использование базы данных Если в команде используется подключение к базе данных, то необходимо в методе "configure" добавить аргумент "appliction" и опцию "--env" (опция не обязательна) для указания какие данные использовать для подключения к базе данных. После этого необходимо в методе "execute" выполнить инициализацию соединения с базой данных.


   protected function configure()
   {
       $this->addArgument('application', sfCommandArgument::REQUIRED, 'Имя приложения');
       $this->addOption('env', null, sfCommandOption::PARAMETER_REQUIRED, 'Окружение', 'dev');
       $this->addOption('connection', null, sfCommandOption::PARAMETER_REQUIRED, 'Имя соединения', 'doctrine')
       // ...
   }
   protected function execute($arguments = array(), $options = array())
   {
       $dbManager = new sfDatabaseManager($this->configuration);
       $connection = Doctrine_Manager::connection();
       // ...
   }
   
Можно и при создании каркаса команды указать опцию "--use-database", но потом поправить некоторые значения если используется "Doctrine", а в частности:
В методе "configure" при описании опции "connection" изменить имя по умолчанию.
В метод "execute":

   // заменить
   $connection = Propel::getConnection($options['connection'] ? $options['connection'] : '');
   // на
   $connection = Doctrine_Manager::connection();
   

[ читать дальше ]