среда, 4 февраля 2009 г.

Работа с файловой системой

В symfony существует много полезных инструментов предзначенных для решения различных задач. И ковыряя "The API Documentation", либо исходники можно найти очень полезные инструменты. В частности в symfony существуют удобные инструменты для работы с файловой системой, о которых я кратенько и напишу.

sfFinder - ищейка

Первый инструмент - это sfFinder. Предзначен для нахождения файлов и директорий. Пожалуй первый метод с которого начинается работа - это type($type), который в общем задает тип искомых элементов и может принимать в качестве параметра:

  • file включает в разультат только файлы
  • dir включает в разультат только директории
  • any и файлы и директории

И последний метод - это пожалуй in($path[, $path2, ..]). Метод который запускает поиск и возвращает результат. Метод in() в параметрах принимает путь, где собственно искать и принимает он переменное кол-во параметров, т.е. можно задать ->in('.'), а можно ->in('apps', 'lib'). Метод этот последний не в том смысле, что больше нет методов, а в том что он как правило завершает цепочку вызовов методов.

Между вызовом ->type() и ->in() вызываются методы задающие параметры поиска. Полный список методов можно найти здесь.
Я же хочу рассмотреть лишь некоторые из них, наиболее часто используемые.

  • ignore_version_control() - игнорировать директории и файлы систем контроля версий, такие как .svn
  • maxdepth($level) - уровень вложенности элементов, глубже которого не искать
  • follow_link() - следовать по сим. линкам
  • relative() - возвращать относительный путь, если не указать, то для всех найденных элементов возвращается полный путь от корня
  • name($patter[, $pattern[, ..]]) - шаблон имени для поиска. Метод принимает переменное кол-во аргументов, т.е. можно задать несколько шаблонов
  • not_name($patter[, $pattern2[, patternN]]) - противоположен предыдущему. Исключает из результатов все элементы у которых имена совпадают с заданными шаблонами
  • mindepth($level) - задает минимальный уровень вложенности
  • size($size[, $size2[, ... $sizen]]) задает шаблон размера файла. Так же может принимать несколько шаблонов. Шаблоны можно задавать вида "< 10K" "> 10M"
  • discard($name[, $name2[, $nameN]]) - удаляет из результатов директорию совпадающую с указанными шаблонами. Принимает переменное кол-во аргументов
  • prune($name[, $name2[, $nameN]]) - удаляет из результатов вложенные элементы директорий совпадающих с задаными шаблонами
  • exec($callable[, $callable2[, $callableN]]) - вызвать функцию для каждого элемента. Например ->exec(array($object, 'methodName'))
Так же все методы принимающие переменное кол-во аргументов, можно вызывать несколько раз, задавая разные параметры, например: $finder->name('*.js', '*.css') то же самое, что и $finder->name('*.js')->name('*.css')

Ну и несколько примеров.


    // найти все классы таблиц моделей
    sfFinder::type('file')
        ->name('*Table.class.php')
        ->ignore_version_control()
        ->in('lib/model/doctrine');

    // найти все классы моделей
    sfFinder::type('file')
        ->name('*.class.php')
        ->not_name('*Table.class.php')
        ->prune('base')
        ->ignore_version_control()
        ->in('lib/model/doctrine');

    // найти все конфигурационные файлы модулей
    sfFinder::type('file')
        ->name('module.yml')
        ->ignore_version_control()
        ->in('apps');

    // найти все загруженные файлы размером больше 10Мб
    sfFinder::type('file')
        ->size('> 10M')
        ->ignore_version_control()
        ->follow_link()
        ->in('web/uploads');

    // найти все js и css файлы
    sfFinder::type('file')
        ->ignore_version_control()
        ->follow_link()
        ->name('*.js', '*.css')
        ->in('web');
    

sfFilesystem

Второй инструмент sfFilesystem представляет базовые методы для манипуляций с файловой системой. Подробнее с методами можно ознакомиться в The API Documentation.

Здесь я лишь коротко рассмотрю некоторые методы.

  • sh($cmd) - выполнить команду оболочки, возвращает результат выполненной команды. Например:
    
            $fs = new sfFilesystem;
            // вывести листинг содержимого текущей дирктории
            echo $fs->sh('ls');
            // выполнить svn update 
            $fs->sh(sprintf('svn update %s', sfConfig::get('sf_root_dir')));
            
  • copy($origin, $target, $options = array()) - копировать $origin в $target. В качестве $options принимает массив с опциями. Допустимая опция - override определяет перезаписывать ли файл, если он существует. Даже если override = false и в $target уже существует копируемый файл, но его дата последней модификации меньше, чем у оригинального, то файл будет перезаписан.
  • mirror($origin, $target, sfFinder $finder, $options = array()) - зеркалировать $origin в $target. При зеркалировании используется в том числе и метод copy($origin, $target, $options) и ему передается массив опций $options. Параметр $finder это экземпляр класса sfFinder, который осуществит поиск элементов в $origin.
  • touch($file) - создать пустой файл.
  • remove($file) - удалить файл или файлы. Принимает строку либо массив
  • rename($origin, $target) - переименовать
  • chmod($files, $mode, $umask = 0000) - изменить права. $files - либо строка путь к файлу/директории, либо массив.
  • symlink($origin, $target, $copyOnWindows = false) - создать символическую ссылку.
  • relativeSymlink($origin, $target, $copyOnWindows = false) - создать символическую ссылку с относительным путем.
  • replaceTokens($files, $beginToken, $endToken, $tokens) - производит замену в файлах. $files - файлы, в которых надо производить замену. $beginToken - открывающий обрамитель, $endToken - закрывающий обрамитель, $tokens - хеш массив замен. Пример:
    
            // во всех файлах .js и .css в директории web
            // заменить вставки "{revision}" на "137"
            $files = sfFinder::type('file')
                        ->ignore_revision_control()
                        ->follow_link()
                        ->name('*.css', '*.js')
                        ->in(sfConfig::get('sf_web_dir'));
            $fs = new sfFilesystem;
            $fs->replaceTokens($files, '{', '}', array('revision' => 137));
            

Использовать данные инструменты можно для различных задач, например: deploy, экспорт файлов (например массовая загрузка изображений), массовое переименнование файлов, создание превью для изображений, конвертация медиа файлов ну и т.д.

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

понедельник, 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 для редактирования объекта.

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