Статьи

Написание асинхронных библиотек — давайте конвертируем HTML в PDF

Эта статья была рецензирована Томасом Пунтом . Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!


Я едва помню конференцию, на которой тема асинхронного PHP не обсуждалась. Я рад, что об этом так часто говорят в эти дни. Есть секрет, о котором эти ораторы не говорят, хотя…

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

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

Причина, по которой эти простые вещи просты, заключается в том, что они стали доказательством концепции — сделать асинхронный PHP конкурентоспособным с NodeJS . Вы можете видеть это в том, насколько похожи их ранние интерфейсы:

var http = require("http"); var server = http.createServer(); server.on("request", function(request, response) { response.writeHead(200, { "Content-Type": "text/plain" }); response.end("Hello World"); }); server.listen(3000, "127.0.0.1"); 

Этот код был протестирован с Node 7.3.0

 require "vendor/autoload.php"; $loop = React\EventLoop\Factory::create(); $socket = new React\Socket\Server($loop); $server = new React\Http\Server($socket); $server->on("request", function($request, $response) { $response->writeHead(200, [ "Content-Type" => "text/plain" ]); $response->end("Hello world"); }); $socket->listen(3000, "127.0.0.1"); $loop->run(); 

Этот код был протестирован с PHP 7.1 и react/http:0.4.2

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

Вы можете найти код для этого урока на Github . Я протестировал его с PHP 7.1 и самыми последними версиями ReactPHP и Amp.

Перспективная теория

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

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

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

Вторая распространенная абстракция — это обещания. Там, где обратные вызовы — это хуки, ожидающие будущих событий, обещания — это ссылки на будущие значения. Они выглядят примерно так:

 readFile() ->then(function(string $content) { print "content: " . $content; }) ->catch(function(Exception $e) { print "error: " . $e->getMessage(); }); 

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

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

 $promise = readFile(); $promise->then(...)->catch(...); // ...let's add logging to existing code $promise->then(function(string $content) use ($logger) { $logger->info("file was read"); }); 

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

Давайте возьмем некоторый код приложения и сделаем его асинхронным, используя обещания …

Создание файлов PDF

Обычно приложения генерируют какой-либо сводный документ — будь то счет или список акций. Представьте, что у вас есть приложение электронной коммерции, которое обрабатывает платежи через Stripe. Когда клиенты что-то покупают, вы хотите, чтобы они могли загрузить квитанцию ​​об этой транзакции в формате PDF.

Есть много способов, которыми вы могли бы сделать это, но действительно простой подход заключается в создании документа с использованием HTML и CSS. Вы можете преобразовать это в документ PDF и позволить клиенту загрузить его.

Мне нужно было сделать что-то подобное в последнее время. Я обнаружил, что не так много хороших библиотек, поддерживающих этот вид операций. Я не смог найти ни одной абстракции, которая позволила бы мне переключаться между различными механизмами HTML → PDF. Поэтому я начал строить свой собственный.

Я начал думать о том, для чего мне нужна абстракция. Я остановился на интерфейсе совсем как:

 interface Driver { public function html($html = null); public function size($size = null); public function orientation($orientation = null); public function dpi($dpi = null); public function render(); } 

Для простоты я хотел, чтобы все, кроме метода render работали как получатели и установщики. Учитывая этот набор ожидаемых методов, следующее, что нужно сделать, — это создать реализацию, используя один возможный механизм. Я добавил domPDF в свой проект и приступил к его использованию:

 class DomDriver extends BaseDriver implements Driver { private $options; public function __construct(array $options = []) { $this->options = $options; } public function render() { $data = $this->data(); $custom = $this->options; return $this->parallel( function() use ($data, $custom) { $options = new Options(); $options->set( "isJavascriptEnabled", true ); $options->set( "isHtml5ParserEnabled", true ); $options->set("dpi", $data["dpi"]); foreach ($custom as $key => $value) { $options->set($key, $value); } $engine = new Dompdf($options); $engine->setPaper( $data["size"], $data["orientation"] ); $engine->loadHtml($data["html"]); $engine->render(); return $engine->output(); } ); } } 

Я не собираюсь вдаваться в специфику использования domPDF. Я думаю, что документы справляются с этой задачей достаточно хорошо, что позволяет мне сосредоточиться на асинхронных битах этой реализации.

Мы немного рассмотрим data и parallel методы. Что важно в реализации этого Driver это то, что он собирает данные (если они были заданы, в противном случае по умолчанию) и пользовательские параметры вместе. Он передает их обратному вызову, который мы хотели бы запустить асинхронно.

domPDF не является асинхронной библиотекой, и преобразование HTML → PDF является заведомо медленным процессом. Итак, как мы можем сделать это асинхронным? Ну, мы могли бы написать полностью асинхронный преобразователь или использовать существующий синхронный преобразователь; но запустить его в параллельном потоке или процессе.

Вот для чего я сделал parallel метод:

 abstract class BaseDriver implements Driver { protected $html = ""; protected $size = "A4"; protected $orientation = "portrait"; protected $dpi = 300; public function html($body = null) { return $this->access("html", $html); } private function access($key, $value = null) { if (is_null($value)) { return $this->$key; } $this->$key = $value; return $this; } public function size($size = null) { return $this->access("size", $size); } public function orientation($orientation = null) { return $this->access("orientation", $orientation); } public function dpi($dpi = null) { return $this->access("dpi", $dpi); } protected function data() { return [ "html" => $html, "size" => $this->size, "orientation" => $this->orientation, "dpi" => $this->dpi, ]; } protected function parallel(Closure $deferred) { // TODO } } 

Здесь я реализовал методы getter-setter, полагая, что смогу использовать их для следующей реализации. Метод data действует как ярлык для сбора различных свойств документа в массив, что упрощает их передачу анонимным функциям.

parallel метод начал становиться интересным:

 use Amp\Parallel\Forking\Fork; use Amp\Parallel\Threading\Thread; // ... protected function parallel(Closure $deferred) { if (Fork::supported()) { return Fork::spawn($deferred)->join(); } if (Thread::supported()) { return Thread::spawn($deferred)->join(); } return null; } 

Я большой поклонник проекта Amp . Это коллекция библиотек, поддерживающих асинхронную архитектуру, и они являются ключевыми сторонниками проекта async-interop .

Одна из их библиотек называется amphp/parallel и поддерживает многопоточный и многопроцессорный код (через расширения Pthreads и Process Control). Эти методы spawn возвращают выполнение обещаний Amp. Это означает, что метод render можно использовать как любой другой метод, возвращающий обещание:

 $promise = $driver ->html("<h1>hello world</h1>") ->size("A4")->orientation("portrait")->dpi(300) ->render(); $results = yield $promise; на $promise = $driver ->html("<h1>hello world</h1>") ->size("A4")->orientation("portrait")->dpi(300) ->render(); $results = yield $promise; 

Этот код немного загружен. Amp также предоставляет реализацию цикла событий и весь вспомогательный код, чтобы иметь возможность преобразовывать обычные генераторы PHP в сопрограммы и обещания. Вы можете прочитать о том, как это вообще возможно, и как это связано с генераторами PHP, в другом посте, который я написал .

Возвращенные обещания также становятся стандартизированными. Amp возвращает реализации спецификации Promise . Он немного отличается от кода, который я показал выше, но все еще выполняет ту же функцию.

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

На практике это выглядит так:

 use AsyncInterop\Loop; Loop::execute( Amp\wrap(function() { $result = yield funcReturnsPromise(); }) ); 

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

Давать обещания — это та абстракция, о которой я говорил. Это дает нам структуру, с помощью которой мы можем создавать функции, которые возвращают обещания. Код может взаимодействовать с этими обещаниями предсказуемым и понятным образом.

Посмотрите, как будет выглядеть PDF-документ с помощью нашего драйвера:

 use AsyncInterop\Loop; Loop::execute(Amp\wrap(function() { $driver = new DomDriver(); // this is an AsyncInterop\Promise... $promise = $driver ->body("<h1>hello world</h1>") ->size("A4")->orientation("portrait")->dpi(300) ->render(); $results = yield $promise; // write $results to an empty PDF file })); на use AsyncInterop\Loop; Loop::execute(Amp\wrap(function() { $driver = new DomDriver(); // this is an AsyncInterop\Promise... $promise = $driver ->body("<h1>hello world</h1>") ->size("A4")->orientation("portrait")->dpi(300) ->render(); $results = yield $promise; // write $results to an empty PDF file })); 

Это менее полезно, чем, скажем, создание PDF-файлов на асинхронном HTTP-сервере. Существует библиотека Amp под названием Aerys, которая упрощает создание серверов такого типа. Используя Aerys, вы можете создать следующий код HTTP-сервера:

 $router = new Aerys\Router(); $router->get("/", function($request, $response) { $response->end("<h1>Hello World!</h1>"); }); $router->get("/convert", function($request, $response) { $driver = new DomDriver(); // this is an AsyncInterop\Promise... $promise = $driver ->body("<h1>hello world</h1>") ->size("A4")->orientation("portrait")->dpi(300) ->render(); $results = yield $promise; $response ->setHeader("Content-type", "application/pdf") ->end($results); }); (new Aerys\Host()) ->expose("127.0.0.1", 3000) ->use($router); на $router = new Aerys\Router(); $router->get("/", function($request, $response) { $response->end("<h1>Hello World!</h1>"); }); $router->get("/convert", function($request, $response) { $driver = new DomDriver(); // this is an AsyncInterop\Promise... $promise = $driver ->body("<h1>hello world</h1>") ->size("A4")->orientation("portrait")->dpi(300) ->render(); $results = yield $promise; $response ->setHeader("Content-type", "application/pdf") ->end($results); }); (new Aerys\Host()) ->expose("127.0.0.1", 3000) ->use($router); 

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

Мой босс говорит: «Нет асинхронного!»

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

Чтобы использовать этот код в синхронном приложении, все, что нам нужно сделать, это переместить часть асинхронного кода внутрь:

 use AsyncInterop\Loop; class SyncDriver implements Driver { private $decorated; public function __construct(Driver $decorated) { $this->decorated = $decorated; } // ...proxy getters/setters to $decorated public function render() { $result = null; Loop::execute( Amp\wrap(function() use (&$result) { $result = yield $this->decorated ->render(); }) ); return $result; } } 

Используя этот декоратор, мы можем написать то, что кажется синхронным кодом:

 $driver = new DomDriver(); // this is a string... $results = $driver ->body("<h1>hello world</h1>") ->size("A4")->orientation("portrait")->dpi(300) ->render(); // write $results to an empty PDF file на $driver = new DomDriver(); // this is a string... $results = $driver ->body("<h1>hello world</h1>") ->size("A4")->orientation("portrait")->dpi(300) ->render(); // write $results to an empty PDF file 

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

Поддержка других фреймворков

Amp имеет особый набор требований, которые делают его непригодным для всех сред. Например, базовая библиотека Amp (цикл обработки событий) требует PHP 7.0 . Для параллельной библиотеки требуется расширение Pthreads или расширение Process Control.

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

 interface Runner { public function run(Closure $deferred); } 

Я мог бы реализовать это для Amp, а также для (менее ограничивающего, хотя и гораздо более старого) ReactPHP:

 use React\ChildProcess\Process; use SuperClosure\Serializer; class ReactRunner implements Runner { public function run(Closure $deferred) { $autoload = $this->autoload(); $serializer = new Serializer(); $serialized = base64_encode( $serializer->serialize($deferred) ); $raw = " require_once '{$autoload}'; \$serializer = new SuperClosure\Serializer(); \$serialized = base64_decode('{$serialized}'); return call_user_func( \$serializer->unserialize(\$serialized) ); "; $encoded = addslashes(base64_encode($raw)); $code = sprintf( "print eval(base64_decode('%s'));", $encoded ); return new Process(sprintf( "exec php -r '%s'", addslashes($code) )); } private function autoload() { $dir = __DIR__; $suffix = "vendor/autoload.php"; $path1 = "{$dir}/../../{$suffix}"; $path2 = "{$dir}/../../../../{$suffix}"; if (file_exists($path1)) { return realpath($path1); } if (file_exists($path2)) { return realpath($path2); } } } 

Я привык передавать замыкания многопоточным и многопроцессорным рабочим, потому что так работают Pthreads и Process Control. Использование объектов процессов ReactPHP совершенно иное, поскольку они полагаются на exec для многопроцессного выполнения. Я решил реализовать ту же функциональность замыкания, к которой привык. Это не обязательно для асинхронного кода — это просто выражение вкуса.

Библиотека SuperClosure сериализует замыкания и их связанные переменные. Большая часть кода здесь — это то, что вы ожидаете найти внутри рабочего скрипта. Фактически, единственный способ (кроме сериализации замыканий) использовать дочернюю библиотеку процессов ReactPHP — это посылать задачи рабочему сценарию.

Теперь вместо загрузки наших драйверов с $this->parallel и Amp-специфичным кодом, мы можем обойти реализации бегуна. Как асинхронный код, это похоже на:

 use React\EventLoop\Factory; $driver = new DomDriver(); $runner = new ReactRunner(); // this is a React\ChildProcess\Process... $process = $driver ->body("<h1>hello world</h1>") ->size("A4")->orientation("portrait")->dpi(300) ->render($runner); $loop = Factory::create(); $process->on("exit", function() use ($loop) { $loop->stop(); }); $loop->addTimer(0.001, function($timer) use ($process) { $process->start($timer->getLoop()); $process->stdout->on("data", function($results) { // write $results to an empty PDF file }); }); $loop->run(); на use React\EventLoop\Factory; $driver = new DomDriver(); $runner = new ReactRunner(); // this is a React\ChildProcess\Process... $process = $driver ->body("<h1>hello world</h1>") ->size("A4")->orientation("portrait")->dpi(300) ->render($runner); $loop = Factory::create(); $process->on("exit", function() use ($loop) { $loop->stop(); }); $loop->addTimer(0.001, function($timer) use ($process) { $process->start($timer->getLoop()); $process->stdout->on("data", function($results) { // write $results to an empty PDF file }); }); $loop->run(); 

Не пугайтесь того, насколько этот код ReactPHP отличается от кода Amp. ReactPHP не реализует ту же основу сопрограммы, что и Amp. Вместо этого ReactPHP предпочитает обратные вызовы для большинства вещей. Этот код все еще просто выполняет преобразование PDF параллельно и возвращает полученные PDF-данные.

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

Могу ли я использовать это?

То, что начиналось как эксперимент, стало библиотекой HTML → PDF с несколькими драйверами; называется бумага . Это похоже на HTML → PDF эквивалент Flysystem , но это также хороший пример того, как писать асинхронные библиотеки.

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

Вы недавно создали интересное асинхронное PHP-приложение или библиотеку? Дайте нам знать об этом в комментариях.