Этот пост будет целиком и полностью посвящен книге «Refactoring to collections», написанной Adam Wathan.

Начну с краткого рассказа об авторе. Если вы следите за тем, что происходит в php-сообществе, то об Adam Wathan (далее Адам) вы точно должны были слышать. Он является ведущим подкаста Full Stack Radio, ведет блог о веб-разработке. Адам тесно связан с сообществом фреймворка Laravel. Периодически он выступает на конференциях, и я советую посмотреть видео с его выступления на LaraconEU 2015.

А теперь перейдем к самой книге. Если коротко, то автор предлагает отказаться от классического императивного обхода массива с помощью конструкций for и foreach в пользу использования коллекций. Сразу скажу, пугаться не надо, на самом деле автор немного лукавит. Всеми нами любимые for и foreach инкапсулируются методами коллекции. Да, все просто, никакой магии! Что касается коллекций, Адам предпочитает использовать готовое решений в виде коллекций из фреймворка Laravel. На самом деле, коллекции могут быть самописными или компонентами других фреймворков. Заменяя стандартный обход массивов методами типа map или reduce, мы делаем код заметно более выразительным и читаемым. На протяжении всей книги с помощью простых примеров кода автор пытается донести эту идею. И вы знаете? Я ему верю. Вместо одного большого куска лапшекода после рефакторинга мы получаем стройную pipeline конструкцию, в которой каждое действие отчуждаемо и понятно.

Приведу пример из бесплатной главы книги. Предположим, что мы хотим оценить недавнюю активность пользователя GitHub в баллах. Действия пользователя можно получить, сделав GET-запрос по адресу https://api.github.com/users/{username}/events. Ниже приведен пример json-ответа для данного запроса.

[
    {
        "id": "3898913063",
        "type": "PushEvent",
        "public": true,
        "actor": "adamwathan",
        "repo": "tightenco/jigsaw",
        "payload": { /* ... */ }
    },
    // ...
]

Оцениваться активность будет следующим образом:

  1. PushEvent будет оцениваться в 5 баллов.
  2. CreateEvent будет оцениваться в 4 балла.
  3. IssuesEvent будет оцениваться в 3 балла.
  4. CommitCommentEvent будет оцениваться в 2 балла.
  5. Все остальные действия оцениваются в 1 балл.

Ниже представлен пример кода, написанный императивным стилем. Признаюсь, что решение очень близко к тому, которым я бы воспользовался до прочтения книги.

<?php
function githubScore($username)
{
    // Grab the events from the API, in the real world you'd probably use
    // Guzzle or similar here, but keeping it simple for the sake of brevity.
    $url = "https://api.github.com/users/{$username}/events";
    $events = json_decode(file_get_contents($url), true);
    // Get all of the event types
    $eventTypes = [];
    foreach ($events as $event) {
        $eventTypes[] = $event['type'];
    }
    // Loop over the event types and add up the corresponding scores
    $score = 0;
    foreach ($eventTypes as $eventType) {
        switch ($eventType) {
            case 'PushEvent':
                $score += 5;
                break;
            case 'CreateEvent':
                $score += 4;
                break;
            case 'IssuesEvent':
                $score += 3;
                break;
            case 'CommitCommentEvent':
                $score += 2;
                break;
            default:
                $score += 1;
                break;
        }
    }
    return $score;
}

Я опущу промежуточные стадии рефакторинга и сразу перейду к его результату.

<?php
function githubScore($username)
{
    $url = "https://api.github.com/users/{$username}/events";

    $events = collect(json_decode(file_get_contents($url), true));

    return $events->pluck('type')->map(function ($eventType) {
        return collect([
            'PushEvent' => 5,
            'CreateEvent' => 4,
            'IssuesEvent' => 3,
            'CommitCommentEvent' => 2,
        ])->get($eventType, 1);
    })->sum();
}

На мой взгляд, выглядит очень хорошо и аккуратно. Названия методов говорят сами за себя. Мне очень понравилось то, как автор избавляется от конструкции switch, заменив ее коллекцией. Читать такой код сначала немного непривычно, но чем дальше углубляешься книгу, тем больше видишь в таком коде смысла. Если подумать, все эти идеи не так уж и новы, они активно используются в других языках программирования, особенно в функциональных.

Подводя итоги, хочу сказать, что идея книги мне очень нравится и хочется ее опробовать в своей работе как можно быстрее. Единственная мысль, которая не дает мне покоя - а как будут вести себя эти коллекции на гигантских объемах данных? Хотелось бы увидеть бенчмарки для таких случаев.

Вердикт - книга обязательна к прочтению! Купить ее или скачать бесплатный семпл можно по вот этой ссылке.

Рекомендую обратиться к статье в википедии, если слышите о приложении Wallet(Passbook) впервые.

Существует документация для разработчиков, желающих реализовать генерацию пассов.

Обычно добавление пассов в приложение реализуют через мобильное приложение, в котором есть функционал генерации пассов, либо через отправку на почту.

Я же расскажу о более редком виде распространения пассов. Предположим, что есть задача дать возможность пользователям iPhone добавить себе в приложение Wallet(Passbook) сгенерированный пасс с определенными данными по нажатию кнопки на веб-сайте. Не буду описывать логику на клиенте. В примере будет код обычного скрипта, при вызове которого будет происходить генерация пасса.

Самым большим препятствием для реализации подобной задачи является потребность в валидном сертификате разработчика под ios. Сертификат можно получить только при наличии учетной записи разработчика, которая доступна по годовой платной подписке.

Если кратко, то пасс - это упакованные в один файл изображения и несколько конфигов, подписанный сертификатами.

Писать свой генератор пассов - занятие, на которое у рядового разработчика просто нет времени. Правильным решением будет воспользоваться готовым решением, если таковое имеется. Я рекомендую eo/passbook, отличный php-пакет, который поможет решить задачу быстро и эффективно.

Устанавливаем eo/passbook

$ composer require "eo/passbook" "1.*"

Подключаем автозагрузку Composer.

require 'vendor/autoload.php';

Согласно документации существует несколько типов пассов. Различия между этими типами заключаются в дизайне и наборе полей, доступных на лицевой стороне пасса. Допустим, что типом пасса будет “Билет”.

// каждый пасс должен иметь уникальный идентификатор
$date = new \DateTime();
$uniqueId = 'best_pass_ever_' . $date->format('Y_m_d_h_i_s');
$pass = new EventTicket($uniqueId, 'Wallet example');
// устанавливаем цвет бэкграунда
$pass->setBackgroundColor('rgb(255, 255, 255)');
// устанавливаем цвет шрифта
$pass->setForegroundColor('rgb(0, 0, 0)');
// устанавливаем цвет шрифта лейблов
$pass->setLabelColor('rgb(0, 0, 0)');

Далее создаем структуру полей пасса. Начнем с поля заголовка и вторичного поля.

$structure = new Structure();

// поле заголовка
// конструктор поля должен состоять из уникального ключа и значения поля
$headerField = new Field('header', 'Поле заголовка');
// также можно добавить лейбл
$headerField->setLabel('Лейбл поля заголовка');
$structure->addHeaderField($headerField);

// вторичное поле
$secondaryField = new Field('secondary', 'Вторичное поле');
$secondaryField->setLabel('Лейбл вторичного поля');
$structure->addSecondaryField($secondaryField);

Продолжим с дополнительным полем и полями задней стороны пасса.

// дополнительное поле
$auxiliaryField = new Field('auxiliary', 'Дополнительное поле');
$auxiliaryField->setLabel('Лейбл дополнительного поля');
$structure->addAuxiliaryField($auxiliaryField);

// первое поле задней стороны пасса
$backField = new Field('back_field_one', '+7 (999) 999-99-99');
$backField->setLabel('Пример с номером телефона:');
$structure->addBackField($backField);

// второе поле задней стороны пасса
$backField = new Field('back_field_two', '<a href="https://google.com">Ссылка</a>');
$backField->setLabel('Пример ссылки:');
$structure->addBackField($backField);

// можно не ограничиваться одним или двумя!

Пришло время добавить изображения. Важно отметить, что изображения должны быть разных размеров для обычных и ретина дисплеев.

// логотип
$logoImage = new Image('/full/path/to/logo/', 'logo');
$pass->addImage($logoImage);
$logoImage = new Image('/full/path/to/retina/logo/', 'logo');
$logoImage->setIsRetina(true);
$pass->addImage($logoImage);

// иконка, видна на экране блокировки
$iconImage = new Image('/full/path/to/icon/', 'icon');
$pass->addImage($iconImage);
$iconImage = new Image('/full/path/to/retina/icon/', 'icon');
$iconImage->setIsRetina(true);
$pass->addImage($iconImage);

// strip
$stripImage = new Image('/full/path/to/strip/', 'strip');
$pass->addImage($stripImage);
$stripImage = new Image('/full/path/to/retina/strip/', 'strip');
$stripImage->setIsRetina(true);
$pass->addImage($stripImage);

Есть возможность добавить отдельную ссылку на какое-либо приложение в AppStore. Для примера добавим приложение Youtube. Для добавления требуется идентификатор приложения. Узнать его можно из ссылки на приложение.

$pass->addAssociatedStoreIdentifier(544007664);

Добавление bar/qr-кода опционально. Добавим qr-код, ведущий на главную страницу поисковика Google.

$barcode = new Barcode(Barcode::TYPE_QR, 'http://google.com');
$pass->setBarcode($barcode);

Заканчиваем генерацию структуры и создаем пасс. Параметры, передаваемые в конструктор PassFactory в большинстве своем можно узнать из аккаунта разработчика под ios.

$pass->setStructure($structure);

$factory = new PassFactory(
    // Идентификатор типа пасса (bundle id)
    'TYPE_IDENTIFIER',
    // Идентификатор команды разработчика
    'TEAM_ID',
    // Название компании разработчика
    'ORGANIZATION_NAME',
    // Путь до сертификата разработчика
    '/full/path/to/developer/certificate/',
    // Пароль к сертификату разработчика
    'DEVELOPER_CERTIFICATE_PASSWORD',
    // Путь до WWDR сертификата
    '/full/path/to/wwdr/certificate/',
);

$factory->setOverwrite(true);
// Путь до папки, в которую будут сохраняться готовые пассы
$factory->setOutputPath('/path/to/generated/passes/');

$file = $factory->package($pass);

Имеется возможность провалидировать готовый пасс. Воспользуемся этой возможностью.

$validator = new PassValidator();
$validator->validate($pass);
if ($validator->getErrors()) {
    $errorsString = implode(' | ', $validator->getErrors());
    throw new \Exception("Ошибки валидации passbook: $errorsString");
}

Теперь, когда пасс готов, его еще нужно отдать клиенту с определенными заголовками.

$passBookFullPath = $file->getPath() . "/{$uniqueId}.pkpass";

header("Pragma: no-cache");
header("Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0");
header("Content-Type: application/vnd.apple.pkpass");
header('Content-Disposition: attachment; filename="pass.pkpass"');
clearstatcache();
$fileSize = filesize($passBookFullPath);
if ($fileSize) {
    header("Content-Length: " . $fileSize);
}
header('Content-Transfer-Encoding: binary');
if (filemtime($passBookFullPath)) {
    date_default_timezone_set("UTC");
    header(
        'Last-Modified: '
        . date("D, d M Y H:i:s", filemtime($passBookFullPath))
        . ' GMT'
    );
}
flush();
readfile($passBookFullPath);
exit();

Собираем куски кода воедино:

<?php
require 'vendor/autoload.php';

use Passbook\Pass\Barcode;
use Passbook\Pass\Field;
use Passbook\Pass\Image;
use Passbook\Pass\Structure;
use Passbook\PassFactory;
use Passbook\PassValidator;
use Passbook\Type\EventTicket;

// каждый пасс должен иметь уникальный идентификатор
$date = new \DateTime();
$uniqueId = 'best_pass_ever_' . $date->format('Y_m_d_h_i_s');
$pass = new EventTicket($uniqueId, 'Wallet example');
// устанавливаем цвет бэкграунда
$pass->setBackgroundColor('rgb(255, 255, 255)');
// устанавливаем цвет шрифта
$pass->setForegroundColor('rgb(0, 0, 0)');
// устанавливаем цвет шрифта лейблов
$pass->setLabelColor('rgb(0, 0, 0)');

$structure = new Structure();

// поле заголовка
// конструктор поля должен состоять из уникального ключа и значения поля
$headerField = new Field('header', 'Поле заголовка');
// также можно добавить лейбл
$headerField->setLabel('Лейбл поля заголовка');
$structure->addHeaderField($headerField);

// вторичное поле
$secondaryField = new Field('secondary', 'Вторичное поле');
$secondaryField->setLabel('Лейбл вторичного поля');
$structure->addSecondaryField($secondaryField);

// дополнительное поле
$auxiliaryField = new Field('auxiliary', 'Дополнительное поле');
$auxiliaryField->setLabel('Лейбл дополнительного поля');
$structure->addAuxiliaryField($auxiliaryField);

// первое поле задней стороны пасса
$backField = new Field('back_field_one', '+7 (999) 999-99-99');
$backField->setLabel('Пример с номером телефона:');
$structure->addBackField($backField);

// второе поле задней стороны пасса
$backField = new Field('back_field_two', '<a href="https://google.com">Ссылка</a>');
$backField->setLabel('Пример ссылки:');
$structure->addBackField($backField);

// можно не ограничиваться одним или двумя!

// логотип
$logoImage = new Image('/full/path/to/logo/', 'logo');
$pass->addImage($logoImage);
$logoImage = new Image('/full/path/to/retina/logo/', 'logo');
$logoImage->setIsRetina(true);
$pass->addImage($logoImage);

// иконка, видна на экране блокировки
$iconImage = new Image('/full/path/to/icon/', 'icon');
$pass->addImage($iconImage);
$iconImage = new Image('/full/path/to/retina/icon/', 'icon');
$iconImage->setIsRetina(true);
$pass->addImage($iconImage);

// strip
$stripImage = new Image('/full/path/to/strip/', 'strip');
$pass->addImage($stripImage);
$stripImage = new Image('/full/path/to/retina/strip/', 'strip');
$stripImage->setIsRetina(true);
$pass->addImage($stripImage);

$pass->addAssociatedStoreIdentifier(544007664);

$pass->setStructure($structure);

$barcode = new Barcode(Barcode::TYPE_QR, 'http://google.com');
$pass->setBarcode($barcode);

$factory = new PassFactory(
    // Идентификатор типа пасса (bundle id)
    'TYPE_IDENTIFIER',
    // Идентификатор команды разработчика
    'TEAM_ID',
    // Название компании разработчика
    'ORGANIZATION_NAME',
    // Путь до сертификата разработчика
    '/full/path/to/developer/certificate/',
    // Пароль к сертификату разработчика
    'DEVELOPER_CERTIFICATE_PASSWORD',
    // Путь до WWDR сертификата
    '/full/path/to/wwdr/certificate/',
);

$factory->setOverwrite(true);
// Путь до папки, в которую будут сохраняться готовые пассы
$factory->setOutputPath('/path/to/generated/passes/');

$file = $factory->package($pass);

$validator = new PassValidator();
$validator->validate($pass);
if ($validator->getErrors()) {
    $errorsString = implode(' | ', $validator->getErrors());
    throw new \Exception("Ошибки валидации passbook: $errorsString");
}

$passBookFullPath = $file->getPath() . "/{$uniqueId}.pkpass";

header("Pragma: no-cache");
header("Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0");
header("Content-Type: application/vnd.apple.pkpass");
header('Content-Disposition: attachment; filename="pass.pkpass"');
clearstatcache();
$fileSize = filesize($passBookFullPath);
if ($fileSize) {
    header("Content-Length: " . $fileSize);
}
header('Content-Transfer-Encoding: binary');
if (filemtime($passBookFullPath)) {
    date_default_timezone_set("UTC");
    header(
        'Last-Modified: '
        . date("D, d M Y H:i:s", filemtime($passBookFullPath))
        . ' GMT'
    );
}
flush();
readfile($passBookFullPath);
exit();

В результате получим пасс примерно следующего содержания:

wallet example

А вот его задняя сторона:

wallet back example

Перечислять достоинства этой технологии я не буду, т.к. на мой взгляд они достаточно субъективны.

А вот по недостаткам и подводным камням стоит пройтись:

  • Ужасная документация и это не только мое мнение.
  • Слишком много места отводится qr/bar кодам, остальной контент не всегда возможно уместить на лицевой стороне.
  • Ограничения на количество символов для лейблов как на лицевой так и на задней сторонах.
  • Если не указать какое-либо значение для полей на лицевой или задней стороне, то пасс будет считаться невалидным.
  • В целом, очень мало возможностей для кастомизации дизайна.

Интересным моментом, незатронутым мной в этой статье, я считаю возможность обновлять пасс с помощью веб-сервиса. Об этом можно прочитать в документации.

Генерация pdf-документов является повседневной задачей в веб-разработке. В перечень таких документов входят счета, накладные, полисы и прочие. Существует множество готовых библиотек для решения этой задачи, в том числе и для php. Например, mpdf,tcpdf, и многие другие. Файл можно собрать с помощью api этих библиотек, но это довольно долгое занятие. А времени на реализацию задачи много не бывает, не так ли? Поэтому чаще всего pdf-файл создается из html-представления, что довольно удобно. Но, к сожалению, не все так просто. У такого подхода есть множество подводных камней, способных вывести из себя кого угодно.

Например:

  • Стили нельзя подключить отдельно, следовательно они должны быть включены в html-документ отдельным блоком, либо инлайново для каждого элемента. В этом нет ничего страшного, небольшое неудобство.
  • К сожалению, в таких библиотеках некоторые стили могут работать не так как этого от них ожидаешь, либо не работать в принципе. Это самый главный недостаток.
  • Из предыдущего пункта следует, что создать документ максимально соответствующий требованиям очень трудоемко, а порой просто невозможно.
  • А в случае разработки под Битрикс есть еще одна проблемка. Все знают, что для работы платформы требуется в php.ini установить параметр mbstring.func_overload в значение 2. А для создания pdf-файла, содержащего кириллицу потребуется значение 0. Обычно эта проблема решается с помощью настройки веб-сервера, но все равно неприятно.

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

Пришло время перейти к главной части этой статьи. Помимо вариантов предложенных выше, существует альтернативный - PhantomJS.

PhantomJS — это сборка движка WebKit без графического интерфейса, позволяющая в режиме консоли загружать веб-страницу, выполнять JavaScript, полноценно работать с DOM, Canvas и SVG.

Конечно, помимо перечисленных выше возможностей, он дает возможность создавать pdf-файлы.

Каким образом? Говоря простым языком, он загружает требуемую веб-страницу и дает возможность сохранить результат как pdf-файл.

Процесс установки PhantomJS достаточно подробно описан в документации, поэтому я не буду останавливаться на этом вопросе.

Важным моментом в работе PhantomJS является js-файл (далее config.js), который своим содержанием определяет то, что именно мы хотим сделать. На официальном сайте есть множество готовых примеров таких файлов, с помощью которых решаются самые разные задачи, например, unit-тестирование. В числе примеров есть сохранение веб-страницы в формате pdf. Это именно то, что нам нужно.

Пара небольших правок позволит использовать этот пример в наших целях.

var page = require('webpage').create(),
    system = require('system'),
    address, output, size;

//если аргументов мало или слишком много, выводится сообщение с помощью по использованию
if (system.args.length < 3 || system.args.length > 5) {
    console.log('Usage: config.js URL filename [paperwidth*paperheight|paperformat] [zoom]');
    console.log('  paper (pdf output) examples: "5in*7.5in", "10cm*20cm", "A4", "Letter"');
    console.log('  image (png/jpg output) examples: "1920px" entire page, window width 1920px');
    console.log('                                   "800px*600px" window, clipped to 800x600');
    phantom.exit(1);
} else {
    // обработка аргументов
    address = system.args[1];
    output = system.args[2];
    page.viewportSize = { width: 800, height: 800 };
    if (system.args.length > 3 && system.args[2].substr(-4) === ".pdf") {
        size = system.args[3].split('*');
        page.paperSize = (size.length === 2)
            ? { width: size[0], height: size[1], margin: '0px' }
            : { format: system.args[3], orientation: 'portrait', margin: '1cm' };
    } else {
        console.log('Invalid path to pdf!');
        phantom.exit(1);
    }
    if (system.args.length > 4) {
        page.zoomFactor = system.args[4];
    }
    // открытие страницы и сохранение результата
    page.open(address, function (status) {
        if (status !== 'success') {
            console.log('Unable to load the address!');
            phantom.exit(1);
        } else {
            window.setTimeout(function () {
                page.render(output);
                phantom.exit();
            }, 200);
        }
    });
}

Далее, запустив из консоли следующую команду (делаю допущение, что PhantomJS доступен глобально):

$ phantomjs path/to/config.js "url" path/to/pdf/file "A4"

Разберем аргументы по порядку:

  1. Путь до config.js.
  2. Адрес веб-страницы, которую надо преобразовать в pfd-файл.
  3. Путь до pdf-файла, в который произойдет сохранение результата.
  4. Формат pdf-файла.

Получаем искомый pdf-файл.

Возвращаясь к php, это решение довольно просто интегрировать в код.

В простейшем случае это выглядит вот так:

$command = sprintf(
    "phantomjs %s %s %s %s",
    $fullPathToConfigJS,
    $url,
    $fullPathToSave,
    $format
);

exec($command);

Итак, подведу итоги. На мой взгляд, такое решение имеет следующие достоинства:

  • Сверстать макет для такого документа намного проще. Это очень важно, т.к. не только упрощает разработку, но и не превращает в кошмар последующие правки документа.
  • Избавляет от нужды извращаться с mbstring.func_overload. Но это, несомненно, проблема характерная в основном для Битрикс.

Конечно, есть и недостатки:

  • Не 100% поддержка всех css-стилей. Но в сравнении с библиотеками, перечисленными в начале стати, все очень хорошо.
  • Такое решение может отпугнуть начинающего разработчика.

Чтобы быть востребованным в IT-индустрии, надо постоянно учиться. Технологии развиваются с невероятными скоростями. Те инструменты, с помощью которых сегодня выполняешь поставленные перед тобой задачи, могут безнадежно устареть через год-два. Если посмотреть на тенденцию появления новых js-фреймворков, то впору за голову хвататься.

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

Вот что говорит википедия о подкастах:

Подкастинг (англ. podcasting, от iPod и англ. broadcasting — повсеместное вещание, широковещание) — процесс создания и распространения звуковых или видеофайлов (подкастов) в стиле радио- и телепередач в Интернете (вещание в Интернете). Как правило, подкасты имеют определенную тематику и периодичность издания.

Популярность подкастов в России, на мой взгляд, заметно ниже, чем допустим в Штатах или Европе. На это есть пара очевидных причин. Первая заключается в том, что качественных подкастов о разработке на русском языке очень мало. А вторая, более очевидная, связана с тем, что все хорошие подкасты на английском языке. И если с чтением на английском языке у большинства разработчиков в нашей стране все более или менее хорошо, то с восприятием английского на слух проблем намного больше.

Мое знакомство с подкастами произошло случайно - наткнулся на приложение подкастов в AppStore телефона, и мне стало интересно, что это такое. С тех пор подкасты постепенно заменили музыку в наушниках.

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

А теперь перечислю свои любимые каналы:

Пятиминутка PHP

Пятиминутка PHP http://5minphp.ru/

На текущий момент это единственный русскоязычный подкаст в моем списке.

Тот факт, что подкаст на русском языке, не является его единственным достоинством. В подкасте поднимаются очень интересные и актуальные темы в контексте современной php-разработки.

The Changelog

The Changelog https://changelog.com/

Это очень крутой подкаст, количество выпусков которого приближается к двум сотням. Такие долгожители - большая редкость в сфере подкастинга.

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

Full stack radio

Full stack radio http://www.fullstackradio.com/

Подкаст, на мой взгляд, похож на The Changelog, но тематика более ограничена. Основной упор делается на веб-разработку.

Ведущий подкаста Adam Wathan является php-разработчиком. А, следовательно, множество выпусков посвящено нашему любимому php.

The Laravel Podcast

The Laravel Podcast http://www.laravelpodcast.com/

Как видно из названия, этот подкаст о php-фреймворке Laravel. Мне жутко нравится этот фреймворк, поэтому добавить подкаст в список было более чем логичным поворотом событий.

В рамках подкаста обсуждается все, что связано с данным фреймворком.

The PHP Roundtable

The PHP Roundtable https://www.phproundtable.com/

Подкаст обо всем, что связано с php.

Формат подкаста предполагает несколько гостей из мира php.

Помимо php обсуждаются близкие к разработке темы на этом языке темы (например, конференции).

PHP Town Hall

PHP Town Hall http://phptownhall.com/

Если вы хотите послушать подкаст с неформальной обстановкой - это именно то, что вам нужно. Phil Sturgeon - один из ведущих подкаста и этого уже достаточно, чтобы добавить подкаст в свой список.

Тематика подкаста - веб-разработка.

The Five-Minute Geek Show

The Five-Minute Geek Show http://www.fiveminutegeekshow.com/

Пятиминутный подкаст о веб-разработке и всем, что с ней связано. Благодаря своему формату он отлично помогает скоротать свободные пять минут с пользой.

В первой части я сделал краткий обзор теории. А теперь переходим к практике.

Для начала скажу о том, что должно быть установлено на сервере перед началом написания тестов. Версия Битрикс должна быть 14 или выше. Должен быть установлен Composer локально или глобально. Версия php желательно 5.6 иначе все перечисленные ниже инструменты надо подбирать индивидуально для имеющейся версии php.

Для создания тестов я использую следующие инструменты:

  • phpUnit - фреймворк, который не нуждается в представлении

  • Mockery - фреймворк для создания заглушек (stubs, mocks), с этой задачей может справиться и phpUnit, но Mockery делает этот процесс проще

  • Faker - библиотека, позволяющая создавать фейковые данные, область применения не ограничивается только юнит-тестами, важно заметить, что у библиотеки есть провайдеры для создания данных на русском языке

Используем Composer для того, чтобы подтянуть последние стабильные версии. Не буду описывать этот процесс, т.к. считаю что это очевидно и любой php-разработчик должен знать как использовать Composer.

Первый файл, о котором я расскажу - конфигурационный файл phpUnit phpunit.xml.dist. Он не является обязательным, но позволяет упростить запуск phpUnit, т.к. нужные параметры будут указаны в конфиге. Файл следует поместить в корень проекта. Посмотреть все настройки конфига можно в документации. В данном случае конфиг нужен в первую очередь для указания пути до bootstrap-файла и, собственно, тестов.

<!-- Путь до bootstrap файла -->
<phpunit bootstrap="tests/bootstrap.php">
    <testsuites>
        <testsuite name="Test suit">
            <!-- Путь до папки, где будут храниться тесты -->
            <directory>tests/unit/</directory>
        </testsuite>
    </testsuites>
</phpunit>

Bootstrap-файл запускается перед тем как phpUnit запустит тест. Этим он позволяет инициализировать все, что потребуется для тестирования. В нашем случае это ядро Битрикс, автозагрузкчик Composer, подключение класса, расширяющего стандартный тест-класс phpUnit’а.

<?php
// Подключение ядра 1С-Битрикс
define ('NOT_CHECK_PERMISSIONS', true);
define ('NO_AGENT_CHECK', true);
$GLOBALS['DBType'] = 'mysql';
$_SERVER['DOCUMENT_ROOT'] = realpath(__DIR__ . '/..' );

require($_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php');

// Искуственная авторизация в роли админа
$_SESSION['SESS_AUTH']['USER_ID'] = 1;
// Подключение автозаргрузки Composer
require_once $_SERVER['DOCUMENT_ROOT'] . '/vendor/autoload.php';

require_once 'BitrixTestCase.php';

Ниже представлен класс, расширяющий стандартный класс phpUnit для тестов. Все тесты должны наследоваться от этого класса. Очень важный момент заключается в том, что если не переопределить свойство $backupGlobals и присвоить ему значение false, то подключение ядра Битрикс будет приводить к фатальной ошибке.

<?php
/**
* Class BitrixTestCase
*/
class BitrixTestCase extends \PHPUnit_Framework_TestCase
{
    /**
     * @var bool
     */
    protected $backupGlobals = false;
    
    /**
     * @var \Generator
     */
    protected $faker;
    
    /**
     * этот метод phpUnit вызывает перед запуском текущего теста
     * @inheritdoc
     */
    public function setUp()
    {
        // создание экземпляра Faker, который будет создавать рандомные данные
        $this->faker = \Faker\Factory::create();
    }
    
    /**
     * этот метод phpUnit вызывает после исполнения текущего теста
     * @inheritdoc
     */
    public function tearDown()
    {
        // без этого вызова Mockery не будет работать
        \Mockery::close();
    }
}

Ниже я представил код упрощенного класса, который подвергнется тестированию. Это класс-репозиторий для получения данных о сущности книг, которые в свою очередь хранятся в инфоблоках Битрикс. Зависимость в виде экземпляра класса CIBlockElement передается через конструктор (Dependency Injection, о которой говорилось ранее). Для того, чтобы phpUnit распознал метод как тест, название метода должно начинаться с test. Тестировать будем метод getByAuthorId. Все максимально просто.

<?php
/**
* Class BooksRepository
*/
class BooksRepository
{
    /**
     * @var CIBlockElement
     */
    private $iblockElement;

    /**
     * @param CIBlockElement $iblockElement
     */
    public function __construct(\CIBlockElement $iblockElement)
    {
        $this->iblockElement = $iblockElement;
    }

    /**
     * @param $authorId
     * @return array
     */
    public function getByAuthorId($authorId)
    {
        if (! $authorId) {
            throw new \InvalidArgumentException('Author Id must be specified');
        }
        $selection = $this->iblockElement->GetList(
            [],
            [
                'PROPERTY_AUTHOR_ID' => $authorId,
                'IBLOCK_ID' => 1, // тут должен быть валидный идентификатор инфоблока
                'ACTIVE' => 'Y'
            ],
            false,
            false,
            ['NAME']
        );
        $books = [];
        while ($book = $selection->Fetch()) {
            $books[] = $book;
        }
        return $books;
    }
}

Пришло время рассмотреть пример теста. Тестирование произойдет для трех случаев: - Метод успешно отрабатывает и возвращает массив с данными одной записи. - Метод выбрасывает исключение из-за некорректного аргумента. - Метод успешно отрабатывает и возвращает пустой массив.

Все важные моменты я пометил комментариями в коде.

<?php
/**
* Class BooksRepositoryTest
*/
class BooksRepositoryTest extends BitrixTestCase
{
    /**
     * @var Mockery\MockInterface
     */
    protected $iblockElementMock;
    
    /**
     * @var Mockery\MockInterface
     */
    protected $iblockResultMock;
    
    /**
     * @var BooksRepository
     */
    protected $bookRepository;

    /**
     * @inheritdoc
     */
    public function setUp()
    {
        parent::setUp();
        // создание заглушки для класса CIBlockElement
        $this->iblockElementMock = \Mockery::mock('CIBlockElement');
        // создание заглушки для класса CIBlockResult
        // используется для имитации результата работы метода CIBlockElement::GetList
        $this->iblockResultMock = \Mockery::mock('CIBlockResult');
        $this->bookRepository = new \BooksRepository($this->iblockElementMock);
    }

    public function testGetByAuthorId()
    {
        // создание рандомных данных с помощью Faker
        $authorId = $this->faker->randomNumber;
        $bookData = ['NAME' => $this->faker->word];
        // описание поведение заглушки для CIBlockElement
        // должен быть вызван метод GetList
        // вызов производится только один раз
        // метод должен вернуть заглушку для CIBlockResult
        $this->iblockElementMock->shouldReceive('GetList')->once()
            ->andReturn($this->iblockResultMock);
            
        // описание поведение заглушки для CIBlockResult
        // должен быть вызван метод Fetch
        // вызов производится один раз или более
        // метод должен вернуть $bookData при первом вызове
        // метод должен вернуть пустой массив при втором вызове
        $this->iblockResultMock->shouldReceive('Fetch')->atLeast(1)
            ->andReturn($bookData, []);
        $expectedResult = [$bookData];
        $result = $this->bookRepository->getByAuthorId($authorId);
        
        // делаем предположение, что результат вызова BookRepository::getByAuthorId
        // соответствует ожидаемому
        $this->assertEquals($expectedResult, $result);
    }

    public function testGetByAuthorIdThrowsException()
    {
        // установка ожидаемого исключения и сообщения исключения
        $this->setExpectedException(
            'InvalidArgumentException',
            'Author Id must be specified'
        );
        // намеренно передается аргумент, который спровоцирует исключительную ситуацию
        $this->bookRepository->getByAuthorId(null);
    }

    public function testGetByAuthorIdReturnsEmptyArray()
    {
        // в этом тесте практически все аналогично первому
        $authorId = $this->faker->randomNumber;
        $this->iblockElementMock->shouldReceive('GetList')->once()
            ->andReturn($this->iblockResultMock);
        $this->iblockResultMock->shouldReceive('Fetch')->once()
            ->andReturn([]);
        $result = $this->bookRepository->getByAuthorId($authorId);
        // предположение, что результат вызова BookRepository::getByAuthorId
        // будет пустым
        $this->assertEmpty($result);
    }
}

А теперь запуск тестов. В простейшем случае следует запустить из корня проекта следующую команду:

php vendor/bin/phpunit

Если все сделано правильно, то не будет сообщений о каких-либо ошибках. phpUnit выведет информацию о количестве тестов и предположений (asserts), время исполнения и количестве использованной памяти.

Итоги

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

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

  • Мэт Зандстра. PHP. Объекты, шаблоны и методики программирования.
  • Roy Osherove. The Art of Unit Testing: with examples in C#.
  • Jeffrey Way. Laravel Testing Decoded.
  • Laracasts
  • tuts+