このページの本文へ

PHPで非同期処理ライブラリーを書く:HTMLをPDFに変換する

2017年03月29日 14時00分更新

文●Christopher Pitt

  • この記事をはてなブックマークに追加
本文印刷

PHPの非同期処理に関するトピックが議論に上がらなかったカンファレンスを、私はほとんど覚えていません。最近はとても頻繁に話題になっていて、うれしく思っています。しかし、講演者が伝えていない秘密があります。

非同期処理サーバーの構築や、ドメイン名の名前解決、ファイルシステムとのやりとりは簡単です。独自の非同期処理ライブラリーを作ることが難しく、ここに作業時間の大部分が費やされるのです!

簡単だと述べたのは、PHPの非同期処理をNodeJSに対抗できる、というコンセプトが実証されたので簡単なのも当然です。初期におけるインターフェイスがどれほど似ていたかは以下で確認できます。

下はNode 7.3.0でテストしたコードです。

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");

次のコードはPHP 7.1react/http:0.4.2でテストしました。

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();

記事では、アプリケーションのコードを非同期処理のアーキテクチャーでうまく動かすための手法を説明します。普段使用しているコードは同期処理のアーキテクチャーでも引き続き動作するので心配ありません。つまり、新たな技術を学ぶために別のことを諦める必要ないということです。多少の時間はかかりますが…

記事のコードはGithubにあります。PHP 7.1と最新バージョンのReactPHP、Ampでテスト済みです。

Promiseの理論

非同期処理のコードにおいて一般的な抽象化処理がいくつかあります。その1つはコールバックです。コールバックはその名のとおり、遅い処理やほかの動作をブロックする処理を扱う方法です。同期処理のコードには待ち時間が伴います。ある要求をしたあと、その要求が実際に動作するのを待つからです。

そこで非同期処理のフレームワークやライブラリーでは、代わりにコールバックを使用します。要求を出しておき、実際に動作したらフレームワークやライブラリーがコードを呼び戻します。

HTTPサーバーの場合、すべてのリクエストを事前に処理することも、リクエストが発生するのをただ待つこともしません。単にリクエスト発生時に呼ばれるコードを書き、あとはイベントループに任せます。

もう1つの一般的な抽象化処理がpromiseです。コールバックが未来のイベントを待つフックなのに対し、promiseは未来の値への参照です。promiseは以下のようになります。

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

コールバックよりも少しコードが多いですが、興味深い手法です。イベントの発生を待ち、発生後(then)に別の処理をします。例外が発生した場合は、エラーを捕捉(catch)し適切に対処します。これだと単純そうに見えますが、十分ではありません。

ここでもコールバックを使用していますが、コールバックを抽象化した処理にラップしています。これにはメリットがあります。その1つが複数のコールバックを可能にすることですが…。

$promise = readFile();
$promise->then(...)->catch(...);

// ...let's add logging to existing code

$promise->then(function(string $content) use ($logger) {
    $logger->info("file was read");
});

注目すべき点は別にあります。同期処理のコードをどのようにして非同期処理のコードにするか考える際にpromiseが一般的な言語、つまり一般的な抽象化を提供してくれるという点です。

promiseを使用してアプリケーションのコードを非同期処理します。

PDFファイルの作成

納品書や在庫一覧表など、何らかの情報をまとめたドキュメントをアプリケーションで生成することはよくあります。Stripe経由で支払い処理するeコマースアプリケーションを想像してください。カスタマーが商品を購入したときに領収書を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メソッド以外の関数すべてにgetterとsetterの両方の機能を持たせたいと考えたのです。使用予定メソッド一覧を用意して、利用可能なエンジンを使ってメソッドを実装しました。このプロジェクトでは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();
            }
        );
    }
}

domPHPの使用方法についてはドキュメントが良くできているので、ここでは詳しく説明しません。メソッドの実装における非同期処理に注目します。

最初にdataメソッドとparallelメソッドを確認します。重要な点はDriverインターフェイスの実装がデータ(セット済みの場合で、なければデフォルト)とカスタムオプションを収集することです。これらをコールバックに渡し非同期処理します。

domPDFは非同期処理ライブラリーではありません。さらに、HTML→PDF変換の処理は時間がかかることで有名です。では、どのように非同期処理を作れば良いのでしょうか。完全に非同期のコンバーターを書くことも1つの方法ですが、既存の同期処理コンバーターを使用しスレッドまたはプロセスを並列に実行する方法もあります。

この方法を実現するために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プロジェクトの大ファンです。Ampは非同期処理のアーキテクチャーをサポートするライブラリーの集まりで、async-interopプロジェクトの主要なサポーターです。

ライブラリーの1つにamphp/parallelがあります。このライブラリーはマルチスレッドやマルチプロセスのコードを、Pthreads拡張やProcess Control拡張経由でサポートします。ライブラリーのspawnメソッドはAmpにおけるpromiseの実装を返します。つまり、promiseを返すほかのメソッドと同じようにrenderメソッドを使えるようになります。

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

$results = yield $promise;

このコードは少し複雑です。Ampはイベントループの実装や、通常のPHPジェネレーターをコルーチンとpromiseに変換するヘルパーのコードも提供します。私が書いた別の記事で、どのような方法で変換を可能としているか、またPHPジェネレーターとどのように関係しているか説明しています。

返されるpromiseも標準化されています。Ampはpromiseの仕様の実装を返します。上のコードと少し違っていますが、同じ機能を持っています。

ジェネレーターはほかの言語におけるコルーチンに似た動作をします。コルーチンは割り込み可能な関数です。つまり、短時間に集中して動作したあとにポーズし、新たな指示を待つという使い方が可能です。ポーズ中はほかの機能がシステムのリソースを使用できます。

実際には、以下のようになります。

use AsyncInterop\Loop;

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

単に同期処理のコードを書くよりもかなり複雑です。しかしfuncReturnsPromiseが完了するまでの時間をただ待機するのではなく、ほかの処理に使えるようになります。

promiseをyieldすることは先に説明した抽象化に該当します。これによりフレームワークが得られ、promiseを返す関数を作成できるようになります。コードは予測可能かつ理解可能な方法でpromiseとやりとりできるようになります。

以下のように、ドライバー(driver)を使用して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
}));

より便利にするには非同期処理のHTTPサーバーでPDFを生成するようにします。Aerysと呼ばれるAmpライブラリーがあり、非同期処理サーバーをより簡単に作成できます。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);

Aerysの説明も省略します。専用の記事を書くに値するすばらしいソフトウェアです。コンバーターのコードが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

これでも、まだ非同期処理のコードとして、少なくともバックグラウンドでは実行されていますが、コンシューマーにはなにも分かりません。内部で起きていることを秘密にしつつ、同期処理のアプリケーションで利用できます。

ほかのフレームワークのサポート

Ampには特定の要件があり、どのような環境でも使えるわけではありません。たとえば、ベースとなるAmp(イベントループ)ライブラリーはPHP 7.0が必要です。parallelライブラリーはPthreads拡張かProcess Control拡張が必要です。

こうした制約は課したくはないので、より幅広いシステムをサポートするにはどうすれば良いか検討しました。答えは、並列実行コードを別のドライバーシステムに抽象化することでした。

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

このインターフェイスの実装で、Ampと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 Processオブジェクトはマルチプロセスの実行をexecに頼っているため、根本的には別のものです。個人的に慣れているクロージャー機能を実装しましたが、非同期処理のコードの実装に必須ではなく、好みによるものです。

SuperClosureライブラリーはクロージャーとその束縛変数をシリアライズします。コードの大部分はワーカースクリプト内部のコードと同じです。実際、ReactPHPの子プロセスライブラリーを使用する唯一の方法は、クロージャーのシリアライズを除けばタスクをワーカースクリプトに送ることです。

$this->parallelとAmp固有のコードを用いてドライバーを読み込む代わりに、ランナー(runner)の実装を渡します。以下ような非同期処理のコードになります。

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のようなコルーチンの基礎を実装しません。代わりに、ほとんどの場面でコールバックを使います。このコードも、PDFの変換を並列に実行し、その結果PDFデータを返します。

ランナーを抽象化することで、任意の非同期処理フレームワークを使用し、ドライバーがフレームワークの抽象化処理を返せるようになりました。

このライブラリーを利用できるか?

実験としてスタートしましたが、最終的にマルチドライバー・マルチランナーのHTML→PDF変換ライブラリー、Paperができあがりました。FlysystemのHTML→PDF変換と同じような機能ですが、非同期処理ライブラリーの書き方としても良い例です。

非同期処理のPHPアプリケーションを作成しようとすると、ライブラリーのエコシステムに欠陥があるのを知りますが、がっかりしないでください! 代わりに、独自に非同期処理ライブラリーを作る方法を考える機会と捉えて、ReactPHPやAmpが提供する抽象化処理を利用してください。

※本記事はThomas Puntが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。

(原文:Writing Async Libraries – Let’s Convert HTML to PDF

[翻訳:薮田佳佑/編集:Livit

Web Professionalトップへ

WebProfessional 新着記事