このエントリーをはてなブックマークに追加

Angular2のRCリリースが順調に行われている中、今更ながらAngularJS記事です。
TypeScriptを使ったAngularJSアプリケーションをどのようにしてSymfony2フレームワークに組み込むかといった話が主になります。

おまけコンテンツとして、Browserifyがなかった時代に作られたAngularJSをCommonJSでどのようにコードの分離を行えば良いかも紹介します。

はじめに

弊社ではSymfony2のバンドル毎に比較的大きなJSのアプリケーションが存在しており、バンドル間でJSの依存を共有しないようにして独立性を高めに保っています。
こういった背景がありバンドル毎に独立してJSがビルドできる環境、というのが今回の記事内容になりますのでご注意ください。

ゴール

  • バンドル毎にnpmパッケージをインストールする
  • バンドル毎にJSをビルドする
  • ビルドしたJSのURLをSymfony2に出力させる

バンドル毎にnpmパッケージをインストールする

bowerの場合はsp/bower-bundleが機能を提供してくれていましたが、npmの場合はhshn/npm-bundleを使います。
hshn/npm-bundlesp/bower-bundleと比べると全くと言っていい程に機能がありませんが、バンドル毎にnpmコマンドを実行する分には十分です。

このバンドルはinstallrunコマンドをバンドル内で実行するだけの機能しかありません。(※ v0.2.0現在)
単純にやるべき事は下記の3ステップだけです。

  1. バンドル内にpackage.jsonを準備する
  2. 必要なnpmパッケージをインストールする
  3. Symfonyコンソールからhshn:npm:installを実行

詳細な使い方は公式ドキュメントを参照してください。

バンドル毎にJSをビルドする

先ほどhshn/npm-bundleの説明で述べた通り、このバンドルはnpm installnpm runの実行しか機能提供されていません。

このバンドルはinstallrunコマンドをバンドル内で実行するだけの機能しかありません。(※ v0.2.0現在)

なのでビルドシステムに、gulpwebpack, broccoliを採用しようともそれらのコマンドを直接実行できないので、 Symfonyコンソールから実行したい処理はすべてnpmスクリプトとして登録します。

{
  "scripts": {
    "build:gulp": "gulp build",
    "build:webpack": "webpack",
    "build:grunt": "grunt build"
  }
}

お馴染みですね。
あとはSymfonyコンソールからnpmスクリプトを実行すればJSのビルドが行えるといった流れになります。

$ bin/console hshn:npm:run build:webpack

TypeScriptの型ファイルはどうしたら?

型ファイルのインストールコマンドもnpmスクリプトに登録して、Symfonyコンソールから実行すれば良いですが
全てのバンドルがTypeScriptとは限りませんし、何より実行しなけらばならないコマンドが増えて大変です。

なのでnpm install後に型ファイルがインストールされるように、hookスクリプトを定義します。

{
  "scripts": {
    "postinstall": "typings install"
  }
}

これでSymfonyコンソールからnpmパッケージをインストールするだけで、型ファイルのインストールまで完了するようになります。

補足:

tsdは半年程前にdeprecatedとなりtypingsが推奨されるようになりました。

https://github.com/DefinitelyTyped/tsd/issues/269
https://github.com/typings/typings

minifyはどうしたら?

Assetic(kriswallsmith/assetic)であれば、symfony/assetic-bundleがSymfonyのenvironment毎にminifyする/しない、フィルタをかける/かけないを制御してくれますが、今回JSをビルドするnpmの世界にはSymfonyの世界の情報は伝わりません。

開発環境はminifyせずに本番環境だけminifyしたい場合は、開発環境用ビルドと本番環境用ビルドとでnpmスクリプトをそれぞれ定義し、環境毎に実行するスクリプトを変更すれば良いです。

{
  "scripts": {
    "build": "gulp build",
    "build:prod": "gulp build --production",
    "build:test": "gulp build:test"
  }
}

nodeのenvironmentを使うなり、タスクランナーのタスクを切り替えるなりお好みの方法で。

# 開発環境
$ bin/console hshn:npm:run build

# 本番環境
$ bin/console hshn:npm:run build:prod

ビルドしたJSのURLをSymfony2に出力させる

ビルドしたJSファイルをSymfony2が出力するhtmlに、<script>タグのURLとして出力しなければいけません。

ここで問題になるのはブラウザキャッシュなのですが、ファイル名が同じまま内容だけ更新しても
ブラウザが最新のJSファイルをロードしてくれるとは限らないという問題です。

多くのビルドシステムではファイルのハッシュ値などをファイル名の一部として利用できる仕組みがありますが、
Symfony2はphpなので、たとえgulpでハッシュ値を含んだファイル名で生成したとしても「生成されたファイル名」を受け取れず、どのファイルを参照して良いか把握する術がありません。

もちろんディレクトリをスキャンしたり、ダサい方法はあります。

この問題を解決する最も簡単な手段として、Symfony2ユーザーが慣れ親しんだAsseticを使います。
Asseticにはブラウザキャッシュを無効化させるCache Bustingという機能があり、Assetic経由でURLの取得さえすればJSファイルのURLにユニークなキーが含まれるようになりこの問題を解決する事ができます。

If you serve your assets from static files as just described, you can use the CacheBustingWorker to rewrite the target paths for assets. It will insert an identifier before the filename extension that is unique for a particular version of the asset

{# twigテンプレート #}
{% javascripts '/path/to/bundle.js' %}
<script src="{{ asset_url }}"></script>
{% endjavascripts %}
<!-- 出力されたhtml -->
<script src="/js/f9550d1.js"></script>

おまけコンテンツ

これでめでたくお好みのAltJSをnpmエコシステムを利用してJSにビルドできる様になりました。
おまけコンテンツとして、AngularJSアプリケーションをCommonJSの世界でどのようにしたら上手にファイルを管理できるのか紹介したいと思います。

AngularJSアプリケーションのファイルをいい感じに分離する

AngularJSはモジュール単位でのファイル分割は考慮されていますが、それ以外はあまりうまくいきません。

ディレクティブやサービスを作ったとしてもモジュールに登録しなければならず、多くは登録時に名前を必要とします。
ものによってはモジュールへの登録順序も振る舞いに影響を及ぼすものもあるため、せっかくクラス毎にファイルを分けたとしてもモジュールファイルが大きくなりがちになります。

import * as angular from 'angular';

import directiveFooFactory from './directive/foo';
import directiveBarFactory from './directive/bar';
import FooService from './service/foo';
import barFactory from './factory/bar';

// 登録するものが多ければもっと増える
export angular.module('myApp', [])
  .directive('foo', directiveFooFactory)
  .directive('bar', directiveBarFactory)
  .service('foo', FooService)
  .factory('bar', barFactory);

Providerを使う

RC1がリリースされてそこそこ日数も経つのでAngular2に触れてみたことのある方も少なくないかと思います。

Angular2でもDIコンテナはグローバルではなくツリーになりましたが健在です。
また、サービスその他はProviderによってDIコンテナへ登録する仕組みになりました

解釈によりますが厳密には異っていて「Providerクラス」は非推奨になりました。Provider自体はまだ存在します。

要はサービスその他を直接コンテナに登録するのではなく、「コンテナへの登録」を抽象化するレイヤーが挟まった感じなのだと思います。

// Angular2 Provider
import { bootstrap }      from '@angular/platform-browser-dynamic';
import { HTTP_PROVIDERS } from '@angular/http';
import { AppComponent }   from './app';

bootstrap(AppComponent, [
  HTTP_PROVIDERS,
]);

今回はこのProviderをAngularJSに持ち込んだangular-provideを使いファイルを分離する方法を紹介します。
このProviderを導入する事によって「ディレクティブやサービスの定義」と「モジュールへの登録」を分離(完全ではない)する事ができます。

AngularJSのディレクティブやサービスの解決方法が名前ベースである事や、DIコンテナがグローバルである性質上
他のディレクティブやサービスに依存している「ディレクティブやサービスの定義」はそれらの「モジュールへの登録(名前)」へ依存するため、完全には分離できない。

angular-provideを導入するとどう変わるか

ディレクティブ以外も網羅的にProviderを生成する機能が提供されていますが、ボリュームの関係上ディレクティブのみの紹介です。

ディレクティブの定義

いつも通りのディレクティブの定義です。
CommonJSになってファイルを分けようとしたら自然とこうなるので特別な事はないですね。

// ./directives/foo.ts
export function fooDirectiveFactory(): angular.IDirective {
  return {
    restrict: 'E',
    scope: {},
    template: `
      <h1>foo directive</h1>
    `
  };
}

ディレクティブのProviderを定義

先ほど作ったディレクティブの定義をfooディレクティブとして登録するProviderを作り、
それをProviderのコレクションとしてexportします。(Angular2 styleですね)

これまではモジュールに登録するまで名前を決定できませんでしたが、Provider生成時に名前を決定できるようになりました。
これによってモジュール単位で集約する以外にも、好きな単位でProviderを生成し集約する事ができるようになります。

// ./directivs/index.ts
import provide from 'angular-provide';
import { fooDirectiveFactory } from './foo';

export const DIRECTIVE_PROVIDERS = [
  provide.directive('foo', fooDirectiveFactory)
];

モジュールの生成

provide関数を利用して生成したモジュールにProviderを適用します。
Providerを定義する事によって名前が事前に決定されているため、モジュールのコードがかなりシンプルになりました。

// ./app.ts
import * as angular from 'angular';
import provide from 'angular-provide';

import { DIRECTIVE_PROVIDERS } from './directives';

provide(angular.module('myApp', []),
  ...DIRECTIVE_PROVIDERS
);

今回はDIRECTIVE_PROVIDERSとコンポーネントの種類でひとまとめにしましたが、Angular2のHTTP_PROVIDERSのように機能毎にまとめた方が取り外しもしやすく良いのではないかと思います。


このエントリーをはてなブックマークに追加

はじめに

Dockerは何となく触ってみてはいたものの、Docker上で本格的にWebアプリケーションを構築したことがなかったので、練習も兼ねて趣味プロダクトを以下の構成で作ってみました。

恥ずかしながらDocker ComposeもNginxも今回初めて触ったので、いろいろ間違った使い方をしているところもあるかもしれませんが、お気付きの点があれば @ttskch まで一言いただければ幸いです。

ちなみに今回作ったのは audio2video というサービスで、音声ファイルを動画ファイルに変換するだけのものです。サービスの大まかな紹介については Qiitaに記事を書きました ので、よろしければこちらもご参照ください。

なお、今回作ったものはデータの永続化を必要としないアプリだったので、データボリュームの話とかは出てきません。

対象読者

今回はDockerの初歩的な解説等は割愛させていただきますので、最低限Dockerの概念を理解されている方に向けた内容となります。

実際に手を動かしながら読み進める場合は、事前にDockerエンジンおよびDocker Composeが動作する環境をお手元にご用意ください。先日パブリックベータになった Docker for Mac/Windowsをインストールするのが手っ取り早いと思います。

GitHubリポジトリ

本エントリーで作成したサンプルは こちらのGitHubリポジトリ で公開していますので、参考にしてみてください。(解説の手順ごとにコミットも分けてあります)

目次

Step1. 普通にローカルでSilexアプリを動かす

まずは silexphp/Silex-Skeleton でアプリケーションを作成して動かしてみましょう。

$ composer create-project fabpot/silex-skeleton docker-compose-nginx-silex-sample ~2.0@dev
$ cd docker-compose-nginx-silex-sample
$ composer run
> echo 'Started web server on http://localhost:8888'
Started web server on http://localhost:8888
> php -S localhost:8888 -t web web/index_dev.php

image

ここまでは特に問題ないですね。

Step2. Docker上でSilexアプリを動かす

ひとまずPHP環境のDockerコンテナを作りましょう。

./Dockerfile

FROM php:7.0

# install composer
RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
RUN php -r "if (hash_file('SHA384', 'composer-setup.php') === 'e115a8dc7871f15d853148a7fbac7da27d6c0030b848d9b3dc09e2a0388afed865e6a3d6b3c0fad45c48e2b5fc1196ae') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
RUN php composer-setup.php
RUN php -r "unlink('composer-setup.php');"
RUN mv composer.phar /usr/local/bin/composer

オフィシャルのPHPイメージ を継承して Composeをインストール しただけのDockerfileです。これをビルドします。

$ docker build . -t silex-sample
$ docker images
REPOSITORY          TAG                  IMAGE ID            CREATED              SIZE
silex-sample        latest               c15ad5cc878b        About a minute ago   493.9 MB
php                 7.0                  2e2f36b7013d        6 days ago           490.3 MB

では、このコンテナでSilexアプリを動かしてみましょう。

$ docker run -it -v $(pwd):/opt/work -w /opt/work -p 8888:8888 silex-sample composer run
Running composer as root/super user is highly discouraged as packages, plugins and scripts cannot always be trusted
> echo 'Started web server on http://localhost:8888'
Started web server on http://localhost:8888
> php -S localhost:8888 -t web web/index_dev.php

image

はい、動きませんね。ここで一旦ハマりました。

$ docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS                    NAMES
f366e437df88        silex-sample        "composer run"      2 seconds ago       Up 2 seconds        0.0.0.0:8888->8888/tcp   boring_brown

上記のとおり、docker ps を見るとちゃんとホストの8888番ポートがコンテナの 0.0.0.0:8888 に転送されています。なのになぜかWebサーバーに接続できていません。

コンテナに入って原因を調べてみる

$ docker run -it -v $(pwd):/opt/work -w /opt/work -p 8888:8888 silex-sample bash
%
% apt-get update && apt-get install -y net-tools # 調査のためにnetstatが使いたいのでインストール
%
% composer run & # バックグラウンドでWebサーバーを起動
%
% netstat -naop --tcp
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name Timer
tcp6       0      0 ::1:8888                :::*                    LISTEN      13/php           off (0.00/0/0)

どうやら8888番ポートはIPv6でLISTENされています。ホストとコンテナの間のポート転送はIPv4で行われているので、これが原因と思われます。

コンテナ内の /etc/hosts を編集して localhost のアドレスを 0.0.0.0 に固定してしまうという方法でも解決できそうですが、残念ながらコンテナ内の /etc/hosts はビルド時にはRead OnlyでDockerfileから編集することができないので、Silexアプリの composer.json の方を修正します。

"scripts": {
    "run": [
        "echo 'Started web server on http://localhost:8888'",
-       "php -S localhost:8888 -t web web/index_dev.php"
+       "php -S 0.0.0.0:8888 -t web web/index_dev.php"
    ]
}

ソースを修正したら、一旦コンテナ内のプロセスを終了させて、起動しなおしましょう。

% jobs -l
[1]+    33 Running                 composer run &
% kill -9 33
% jobs
[1]+  Killed                  composer run
% jobs
% # 何も出なくなればプロセスが終了できている
% composer run &
%
% netstat -naop --tcp
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name Timer
tcp        0      0 0.0.0.0:8888            0.0.0.0:*               LISTEN      39/php           off (0.00/0/0)

今度はIPv4の 0.0.0.0 でLISTENされていますね。この状態でブラウザで動作確認してみましょう。

image

無事に動きました!

ホストのブラウザから index_dev.php に接続できるようにする

動くには動きましたが、画面をよく見ると、Silex-Skeletonの web/index_dev.php の処理によって外部サーバーからの接続が拒否されている旨が表示されています。

このままだとアプリの開発に支障があるので、接続を拒否する条件を変更して対処しておきましょう。

web/index_dev.php

+ /*
  if (isset($_SERVER['HTTP_CLIENT_IP'])
      || isset($_SERVER['HTTP_X_FORWARDED_FOR'])
      || !in_array(@$_SERVER['REMOTE_ADDR'], array('127.0.0.1', 'fe80::1', '::1'))
  ) {
+ */
+ if (!preg_match('/:8888$/', $_SERVER['HTTP_HOST'])) {
      header('HTTP/1.0 403 Forbidden');
      exit('You are not allowed to access this file. Check '.basename(__FILE__).' for more information.');
  }

いろいろ考えてみましたが、要は本番運用時に index_dev.php へのアクセスを弾きたいだけなので、「8888番ポートでアクセスしているかどうか」で判定することにしました。

再度ブラウザで確認してみましょう。

image

問題ないですね!

Step3. 同じコンテナ内にNginxを立てる

次は、今作ったDockerイメージにNginxサーバーを追加して、 Nginx + PHP-FPM の構成でSilexアプリが動作するようにしてみましょう。

Nginx + PHP-FPMの構成については、以下の記事が大変参考になります。
nginx と PHP-FPM の仕組みをちゃんと理解しながら PHP の実行環境を構築する

とりあえずDockerfileを修正してみます。

  • 継承元のイメージを php:fpm に変更
  • apt-get でNginxをインストール
  • バーチャルホストの設定ファイルを設置(default.conf
  • エントリーポイントでNginxとPHP-FPMの両方を起動
- FROM php:7.0
+ FROM php:fpm

〜略〜

+ # install nginx
+ RUN apt-get update
+ RUN apt-get install -y nginx
+
+ # config nginx
+ COPY ./default.conf /etc/nginx/conf.d/
+
+ CMD nginx && php-fpm

バーチャルホストの設定ファイルの元ネタとなるファイルもコードベースに追加します。

./default.conf

+ server {
+     listen 80;
+     server_name localhost;
+
+     location / {
+         root /usr/share/nginx/html;
+     }
+
+     location ~ \.php$ {
+         fastcgi_pass localhost:9000;
+         fastcgi_index index.php;
+         include fastcgi_params;
+         fastcgi_param SCRIPT_FILENAME /opt/work/web/$fastcgi_script_name;
+     }
+ }

上記の default.conf の内容は、

  • Nginxが80番ポートをLISTENする
  • / 配下へのアクセスは /usr/share/nginx/html 配下にルーティングされる(Nginxのデフォルトのドキュメントルート)
  • .php で終わるURIに対しては、localhostの9000番ポートで待っているPHP-FPMに処理が渡される
    • その際、ドキュメントルートは /opt/work/web となる

というような設定になっています。

一旦これで動作を確認してみましょう。

$ docker build . -t silex-sample
$ docker run -it -v $(pwd):/opt/work -w /opt/work -p 8888:80 silex-sample

ポート転送が 8888:80 に変わっていることに注意(コンテナ内は8888番ではなく80番をLISTENしている)

image

image

なんとなく狙いどおりに動いているっぽいですね。/ へのアクセスでは /usr/share/nginx/html/index.html が表示され、index.php へのアクセスでは /opt/work/web/index.php が表示されています。

では、index_dev.php にもアクセスしてみましょう。

image

おや?デバッグツールバーが404で表示できないというエラーが出ましたね。

image

コンソールを見てみると /css/main.css にもアクセスできていないし、/index_dev.php/_profiler/ 配下のURIにもアクセスできていません。

先ほどの default.conf の内容を思い出してみましょう。

server {
    listen 80;
    server_name localhost;

    location / {
        root /usr/share/nginx/html;
    }

    location ~ \.php$ {
        fastcgi_pass localhost:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME /opt/work/web/$fastcgi_script_name;
    }
}

この設定に従うと、/css/main.css/index_dev.php/_profiler/*.php で終わるURIではないので、/ 側にルーティングされますね。しかし /usr/share/nginx/html 配下には該当するリソースがないので、当然のごとく404になるわけです。

default.conf のルーティングの設定をもう少しちゃんと考える必要がありそうです。

結論としては以下のような感じになります。

server {
    listen 80;
    server_name localhost;

    location / {
        if ($request_uri ~ 'index_dev\.php') {   # ... (1)
            rewrite .* index_dev.php last;
        }
        if (!-f $request_filename) {   # ... (2)
            rewrite .* index.php last;
        }
        root /opt/work/web;   # ... (3)
    }

    location ~ \.php$ {
        fastcgi_pass localhost:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME /opt/work/web/$fastcgi_script_name;
    }
}
  • (1) URIに index_dev.php を含む場合はすべて index_dev.php にルーティング

  • (2) (1) に該当せず、かつリクエストされたURIにファイルが実在しない場合はすべて index.php にルーティング

  • (3) (1) にも (2) にも該当しない場合(=アセットへのリクエスト)は、/opt/work/web 配下にルーティング

image

image

この設定で、完全に期待どおりに動作するようになりました。

(最初は例として /root をNginxデフォルトの /usr/share/nginx/html にしていましたが、実際にはこのディレクトリを見せる意味はないですね)

Step4. NginxとSilexを別コンテナにしてDocker Composeで連携する

最後に、Docker Composeを使ってNginxサーバーとPHP-FPMサーバーを別々のコンテナに分けましょう。(Dockerの運用原則は「1コンテナ1プロセス」です)

まずはDocker Composeの設定ファイルである docker-compose.yml を作成しましょう。

version: '2'
services:
  nginx:
    build: ./docker/nginx   # ... (1)
    ports:
      - '8888:80'   # ... (2)
    depends_on:
      - php   # ... (3)
    volumes:
      - .:/opt/work   # ... (4)
  php:
    build: ./docker/php   # ... (5)
    volumes:
      - .:/opt/work   # ... (6)

ごくごくシンプルですね。

  • nginxphp という2つのサービスを定義
  • (1) nginx サービスは ./docker/nginx/Dockerfile をビルドしてコンテナを作成
  • (2) nginx サービスは 8888:80 でポートを転送
  • (3) nginx サービスは php サービスが起動したあとで起動される
  • (4) nginx サービスは . をコンテナの /opt/work にマウント
  • (5) php サービスは ./docker/php/Dockerfile をビルドしてコンテナを作成
  • (6) php サービスは . をコンテナの /opt/work にマウント

docker-compose.yml で使える各種ディレクティブについては以下の日本語ドキュメントが詳しいです。 http://docs.docker.jp/compose/compose-file.html

(1)(5) でDockerfileの場所を指定したので、これに従ってファイルを配置し直しましょう。

$ tree docker
docker
├── nginx
│   ├── Dockerfile
│   └── default.conf
└── php
    └── Dockerfile

docker/nginx/Dockerfile

FROM nginx

COPY ./default.conf /etc/nginx/conf.d/

せっかくDockerfileが独立したので、先ほどまでのように apt-get で手動インストールせずに オフィシャルのnginxイメージ を活用します。

docker/nginx/default.conf

server {
    listen 80;
    server_name localhost;

    location / {
        if ($request_uri ~ 'index_dev\.php') {
            rewrite .* index_dev.php last;
        }
        if (!-f $request_filename) {
            rewrite .* index.php last;
        }
        root /opt/work/web/;
    }

    location ~ \.php$ {
-       fastcgi_pass localhost:9000;
+       fastcgi_pass php:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME /opt/work/web/$fastcgi_script_name;
    }
}

default.conf の内容は先ほどまでとほぼ同じですが、一箇所だけ変更が必要です。

先ほどまではNginxとPHP-FPMは同じサーバー内で起動していましたが、今はコンテナを分けたことによってNginxサーバーから見たPHP-FPMサーバーのアドレスはlocalhostではなくなっています。

Docker Composeを使ってコンテナを起動すると、各コンテナの /etc/hosts に自動的に 他のコンテナのIPアドレスがサービス名で登録される ため、上記のようにあまり深く考えずにサービス名をホスト名として使用することができます。便利ですね。

docker/php/Dockerfile

FROM php:fpm

# install composer
RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
RUN php -r "if (hash_file('SHA384', 'composer-setup.php') === 'e115a8dc7871f15d853148a7fbac7da27d6c0030b848d9b3dc09e2a0388afed865e6a3d6b3c0fad45c48e2b5fc1196ae') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
RUN php composer-setup.php
RUN php -r "unlink('composer-setup.php');"
RUN mv composer.phar /usr/local/bin/composer

こちらは先ほどまでのDockerfileからNginxに関する定義を削除しただけの内容です。

なお、Nginx・PHP-FPMとも、それぞれのコンテナのポートの解放やエントリーポイントの指定などは継承元のイメージに定義されているので今回の例では自前で追記する必要はありません。

エントリーポイントが正常に定義されていない(何かしらのプロセスがフォアグラウンドで起動しない)コンテナがあると期待どおりに動作しませんので、Dockerイメージを自作する際にはご注意ください。

起動してみる

docker-compose.yml が置いてあるディレクトリで以下のコマンドを叩けば起動できます。

$ docker-compose up

この状態でブラウザで表示してみて、先ほどまでと同様に正常に動作することを確認してください。

Step5. 本番環境として適用する

本番環境として使う場合は、ホストの8888番ポートではなく80番ポートを転送するように docker-compose.yml を修正しましょう。

version: '2'
services:
  nginx:
    build: ./docker/nginx
    ports:
-     - '8888:80'
+     - '80:80'
    depends_on:
      - php
    volumes:
      - .:/opt/work
  php:
    build: ./docker/php
    volumes:
      - .:/opt/work

image

image

ちゃんと index_dev.php へのアクセスも拒否できていますね。

本番サーバー上で docker-compose up する場合は、コンテナをバックグラウンドで起動したいので、以下のように -d オプションを使用します。

$ docker-compose up -d

バックグラウンドで起動している各コンテナをまとめて終了させるには docker-compose.yml が置いてあるディレクトリで

$ docker-compose down

とすればOKです。

おわりに

というわけで、Docker Compose + Nginx + Silex でとりあえずアプリケーションを動かすことができましたね。

ちなみに、Step5で「本番環境」と表現しましたが、実際に業務システムを本番運用するならもっと色々考えることが出てくると思います。今のままだとサーバーを再起動した時にコンテナが自動起動すらしませんし。本エントリーの内容はあくまで入門レベルと考えてください 😅

繰り返しになりますが、内容の間違いや「本番で使うならこういう点に気をつけた方がいいよ」等、ご意見がありましたらぜひ @ttskch までご連絡ください。

また、本エントリーで作成したサンプルは こちらのGitHubリポジトリ で公開していますので、よろしければご参照ください。


このエントリーをはてなブックマークに追加

はじめに

PHPアプリケーションからWebアプリケーションに対してアクセスすることは多々あります。もっともよくあるケースはREST APIなどのテキストのみを返すWebアプリケーションへのアクセスかと思います。しかしまれにGoogle ChromeやFirefoxを操作したときのようにJavaScriptの動作を伴ったアクセスを行いたいときもありますよね。

PHPにはV8jsという拡張モジュールもあるようですが、今回は PHP PhantomJS を利用してその要件を実現することにします。

PHP PhantomJSとは

PHP PhantomJSは別途インストールしたPhantomJSをPHPから使うためのインターフェイスを提供してくれるライブラリです。PhantomJSはいわゆる「ヘッドレスブラウザ」というものでGUIを持たないブラウザです。

当エントリでは基礎知識としてPhantomJSを簡単に触ったあとにPHP PhantomJSの使用方法を提示していきます。

インストール

composer.jsonを作成し以下のように記述します。

// composer.json
{
    "config": {
        "bin-dir": "bin"
    },
    "scripts": {
        "post-install-cmd": [
            "PhantomInstaller\\Installer::installPhantomJS"
        ],
        "post-update-cmd": [
            "PhantomInstaller\\Installer::installPhantomJS"
        ]
    }
}

その後composerコマンドを実行することでPHP PhantomJSがインストールされ、指定したbin-dirディレクトリ配下に最新のphantomjsもインストールされます。

$ composer require "jonnyw/php-phantomjs:4.*"
$ tree bin
bin
└── phantomjs

PhantomJSを使う

まずはPhantomJSの挙動を確認してみます。

Hello World

phantomjsの実行するJSスクリプトファイルhello.jsを作成します。

// hello.js
console.log('hello phantomjs');
phantom.exit();

そして以下のように実行するとhello phantomjsと表示されます。簡単ですね。

$ $APP/bin/phantomjs hello.js
hello phantomjs

JavaSriptが実行されるWebページの操作

次は以下のHTMLを取得してその内容を出力したいと思います。
ブラウザにおいてHTMLがDOMとしてロード完了したら#contentの内容としてhello phantomjsという文字列を注入するようなJavaScriptのコードが書かれています。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
    <div id="content"></div>
</body>
<script type="text/javascript">
  (function () {
    document.addEventListener("DOMContentLoaded", function() {
      document.querySelector("#content").innerHTML = 'hello phantomjs';
    });
  })();
</script>
</html>

PhantomJSで取得する前にPHPで取得してみます。PHPスクリプトは以下のようになるでしょうか。

<?php
// get_hello_phantomjs.php
$url = 'file:///path/to/hello_phantomjs.html';
echo file_get_contents($url);

実行することでHTMLが取得できますね。

$ php get_hello_phantomjs.php
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
    <div id="content"></div>
</body>
<script type="text/javascript">
  (function () {
    document.addEventListener("DOMContentLoaded", function() {
      document.querySelector("#content").innerHTML = 'hello phantomjs';
    });
  })();
</script>
</html>

では、PhantomJSで実行するJSスクリプトget_hello_phantomjs.jsを書いてみましょう。
初見の記述もあるかと思いますがやっていることはなんとなくわかると思います。

// get_hello_phantomjs.js

var page = require('webpage').create();
var url = 'file:///path/to/hello_phantomjs.html';

page.open(url, function(status) {
    var html = page.evaluate(function () {
        return document.documentElement.outerHTML;
    });

    console.log(html);

    phantom.exit();
});

実行結果は以下です。#contenthello phantomjsという文字列が挿入されている形で出力されました。JavaScriptが実行されているのがわかりますね。WebKitのアルゴリズムでレンダリングされているためHTML構造も元のファイルとは若干異なっています。

$ $APP/bin/phantomjs get_hello_phantomjs.js
<html><head>
<meta charset="utf-8">
</head>
<body>
    <div id="content">hello phantomjs</div>

<script type="text/javascript">
  (function () {
    document.addEventListener("DOMContentLoaded", function() {
      document.querySelector("#content").innerHTML = 'hello phantomjs';
    });
  })();
</script>

</body></html>

get_hello_phantomjs.jsにはpageオブジェクトやphantomオブジェクトなど見慣れないものが出てきました。これらPhantomJSの提供するAPIについてはAPI : PhantomJSに詳しく記述してあります。(詳しくないものもいくつかありますが :ghost:

PHP PhantomJSを使う

PhantomJSの挙動がなんとなくわかったところでPHP PhantomJSの使用に移りましょう。 PHP PhantomJSはデフォルトでWebページをPDFとして出力したりスクリーンキャプチャを取得したりなどいろいろな機能を持っています。
しかし、当エントリではとりあえずは先ほどの例と同じようにhello_phantomjs.htmlのコンテントを表示してみることにします。

<?php
// get_hello_phantomjs-with-php_phantomjs.php

use JonnyW\PhantomJs\Client;

$client = Client::getInstance();

$request  = $client->getMessageFactory()->createRequest();
$response = $client->getMessageFactory()->createResponse();

$url = 'file:///path/to/hello_phantomjs.html';
$request->setUrl($url);

$client->send($request, $response);
echo $response->getContent();

上記のように記述することで先ほど$APP/bin/phantomjs get_hello_phantomjs.jsとしたときと同様の結果を得られます。 #contenthello phantomjsという文字列が挿入されている形で出力されていますね。

$ php get_hello_phantomjs-with-php_phantomjs.php
<head>
<meta charset="utf-8">
</head>
<body>
    <div id="content">hello phantomjs</div>

<script type="text/javascript">
  (function () {
    document.addEventListener("DOMContentLoaded", function() {
      document.querySelector("#content").innerHTML = 'hello phantomjs';
    });
  })();
</script>

PHP PhantomJSの処理

PHP PhantomJSの主な処理は以下の3つになります。

  1. PhantomJSが実行するJSスクリプトファイルを作成
  2. 1.で作成したファイルをPhantomJSで実行
  3. 2.の実行結果を取得

よってphp get_hello_phantomjs-with-php_phantomjs.phpの際もJSスクリプトファイルは作成されています。
PhpStormなどのステップ実行が可能なIDEを使用している方は \JonnyW\PhantomJs\Procedure\Procedure::run にブレークポイントを設定してデバッグ実行してみてください。

procedure_php_-_lisket-serpscraper_-____projects_lisket_libs_lisket-serpscraper_

$executableという変数に、あるファイルへのパスが設定されていることが確認できます。
これをテキストエディタで開いてみます。ちょっと長いですが引用します。

/**
 * Set up page and script parameters
 */
var page       = require('webpage').create(),
    system     = require('system'),
    response   = {},
    debug      = [],
    logs       = [],
    procedure  = {};

/**
 * Global variables
 */


/**
 * Define width & height of capture
 */


/**
 * Define paper size.
 */


/**
 * Define viewport size.
 */

var viewportWidth  = 0,
    viewportHeight = 0;

if(viewportWidth && viewportHeight) {
    
    debug.push(new Date().toISOString().slice(0, -5) + ' [INFO] PhantomJS - Set viewport size ~ width: ' + viewportWidth + ' height: ' + viewportHeight);
    
    page.viewportSize = {
        width: viewportWidth,
        height: viewportHeight
    };
}




/**
 * Define custom headers.
 */

var headers = [];

page.customHeaders = headers ? headers : {};



/**
 * Page settings
 */

page.settings.resourceTimeout = 5000;



/**
 * On resource timeout
 */
page.onResourceTimeout = function (error) {
    
response        = error;
response.status = error.errorCode;


};

/**
 * On resource received
 */
page.onResourceReceived = function (resource) {
    
if(!response.status) {
    response = resource;
}


};

/**
 * Handle page errors
 */
page.onError = function (msg, trace) {
    
var error = {
    message: msg,
    trace: []
};

trace.forEach(function(t) {
    error.trace.push(' -> ' + (t.file || t.sourceURL) + ': ' + t.line + (t.function ? ' (in function ' + t.function + ')' : ''));
});

logs.push(error);


};

/**
 * Handle global errors
 */
phantom.onError = function(msg, trace) {
        
var stack = [];

trace.forEach(function(t) {
    stack.push(' -> ' + (t.file || t.sourceURL) + ': ' + t.line + (t.function ? ' (in function ' + t.function + ')' : ''));
});

response.status  = 500;
response.content = msg;
response.console = stack;

system.stdout.write(JSON.stringify(response, undefined, 4));
phantom.exit(1);


};

/**
 * Open page
 */
page.open ('file:///path/to/hello_phantomjs.html', 'GET', '', function (status) {
    
var delay = 0;

if(!delay) {
    return procedure.execute(status);
}

debug.push(new Date().toISOString().slice(0, -5) + ' [INFO] PhantomJS - Delaying page render for ' + delay + ' second(s)');

window.setTimeout(function () { 

    debug.push(new Date().toISOString().slice(0, -5) + ' [INFO] PhantomJS - Rendering page after delaying for ' + delay + ' second(s)');
    procedure.execute(status); 

}, (delay * 1000));


});

/**
 * Execute procedure
 */
procedure.execute = function (status) {
    if (status === 'success') {

    try {

        response.content = page.evaluate(function () {
            return document.getElementsByTagName('html')[0].innerHTML
        });

    } catch(e) {

        response.status  = 500;
        response.content = e.message;
    }
}

response.console = logs;

system.stderr.write(debug.join('\\n') + '\\n');
system.stdout.write(JSON.stringify(response, undefined, 4));

phantom.exit();

};

page.open ('file:///path/to/hello_phantomjs.html', 'GET', '', function (status) {とあるようにPHPで指定したURLがJSスクリプトファイルに埋め込まれているのがわかりますね。
またHandle page errorsExecute procedureといったコメントがあると思います。これはのちほどまた出てきますので覚えておいてください。
このようにPHP PhantomJSは内部的にJSファイルを作成して実行しています。

この例ではレンダリングされたWebページの内容すべてを取得するというだけの簡単な要件でしたが、もう少し複雑なことをしたいときはどうすればよいでしょうか?
PHP PhantomJSではカスタムJSスクリプトファイルを作成して対応することができます。

次項以降ではfile:///path/to/hello_phantomjs.htmlにおける#contentの内容のみを取得するという要件を、カスタムJSスクリプトファイルを作成することで実現したいと思います。

カスタムJSスクリプトファイルの作成

PHP PhantomJSはカスタムJSスクリプトファイルの作成方法を2つ提供しています。「部分的なスクリプトの埋め込み」と「カスタムテンプレートの作成」です。

部分的なスクリプトの埋め込み

実装は簡単ですがその分できることに制約があります。

実行されるJSスクリプトファイルはデフォルトテンプレートをもとに作成されます。
デフォルトテンプレートには[% autoescape false %]といったTwigの宣言が確認できますね。PHP PhantomJSはTwigを利用してJSスクリプトファイルを作成しているのです。

Handle page errorsExecute procedureといったコメントがここでも確認できますね。そして[[ engine.load('page_on_error') ]][[ engine.load( 'procedure_' ~ procedure_type ) ]]といった記述もあります。これらがプリセットされたブロックです。 このブロックに所望の処理を埋め込むことがでPHP PhantomJSの提供するデフォルトの挙動をカスタマイズすることができるのです。

たとえばpage_open.partialというブロックはページが開いたときに実行される処理、page_on_error.partialはページがエラーになったときに実行される処理、などです。こちらにすべてのブロックの説明が載っています。
今回はprocedure_default.partialブロックに処理を埋め込んでみます。

1. 埋め込み処理ファイルの設置

埋め込み処理ファイル設置用のディレクトリを作成してprocedure_default.partialという名前でファイルを生成して実行権限を付与します。この例のようにファイル名はブロック名と同じにする必要があります。

$ mkdir procedures; cd procedures
$ touch procedure_default.partial
$ chmod +x procedure_default.partial

2. 埋め込み処理ファイルの記述

responsedebugなどはデフォルトテンプレートの冒頭で作成されているオブジェクトです。標準出力に出力されたJSON文字列の内容が、のちに示すPHPのスクリプト内の$responseオブジェクトとして変換されます。

/** procedure_default.partial */

response.content = page.evaluate(function () {
    return document.querySelector("#content").innerHTML;
});

response.console = logs;

system.stderr.write(debug.join('\\n') + '\\n');
system.stdout.write(JSON.stringify(response, undefined, 4));

phantom.exit();

3. PHPから使う

PHPスクリプトget_hello_phantomjs-with-php_phantomjs_partial_script.phpを以下のように作成します。

(a)において、埋め込み処理ファイルをロードするディレクトリのパスを指定してプロシージャローダと呼ばれるものを作成します。
(b)において、(a)で作成したプロシージャローダをHTTPクライアントに追加します。

<?php
// get_hello_phantomjs-with-php_phantomjs_partial_script.php

use JonnyW\PhantomJs\Client;
use JonnyW\PhantomJs\DependencyInjection\ServiceContainer;


$location = __DIR__.'/procedures';

$serviceContainer = ServiceContainer::getInstance();
$procedureLoader =
    $serviceContainer->get('procedure_loader_factory')->createProcedureLoader($location); // (a)


$client = Client::getInstance();
$client->getProcedureLoader()->addLoader($procedureLoader); // (b)

$request  = $client->getMessageFactory()->createRequest();
$response = $client->getMessageFactory()->createResponse();

$url = 'file:///path/to/hello_phantomjs.html';
$request->setUrl($url);

$client->send($request, $response);

echo $response->getContent();

4. 実行

うまくいきました。

$ php get_hello_phantomjs-with-php_phantomjs_partial_script.php
hello phantomjs

IDEでステップ実行して、生成されるスクリプトファイルを確認してみます。

 :

/**
 * Open page
 */
page.open ('file:///path/to/hello_phantomjs.html', 'GET', '', function (status) {
    
var delay = 0;

if(!delay) {
    return procedure.execute(status);
}

 :

/**
 * Execute procedure
 */
procedure.execute = function (status) {
    response.content = page.evaluate(function () {
    return document.querySelector("#content").innerHTML;
});

response.console = logs;

system.stderr.write(debug.join('\\n') + '\\n');
system.stdout.write(JSON.stringify(response, undefined, 4));

phantom.exit();

さきほどの「デフォルト」のスクリプトファイルとほとんど違いはありません。URLも埋め込まれていますね。 異なるのはExecute procedureとコメントされているブロックにprocedure_default.partialファイルで定義した処理がそのまま埋め込まれているところです。
このように基本的にデフォルトのスクリプトを使いつつ、固有の処理を行いたい箇所に独自処理を埋め込めるのが「部分的なスクリプトの埋め込み」です。

カスタムテンプレートの作成

ここまでご覧になった方ならこちらの理解も難しくないと思います。
「部分的なスクリプトの埋め込み」で使用したデフォルトテンプレートにあたるスクリプトを自分で書いてしまおうというものです。これを以降はカスタムテンプレートと呼ぶことにします。

1. カスタムテンプレートの設置

今回は埋め込み処理ファイルと同じ場所に設置しました。ファイル名は任意で構いませんが必ず.procという拡張子にして実行権限を付与してください。

$ cd procedures
$ touch get_hello_phantomjs.proc
$ chmod +x get_hello_phantomjs.proc

2. カスタムテンプレートの記述

PhantomJSを単体で使ったときのスクリプトファイルget_hello_phantomjs.jsにいくらか手を加えたものをカスタムテンプレートget_hello_phantomjs.procとして記述します。

特筆すべきは'{{ input.getUrl() }}'です。PHPで指定したURLがここに埋め込まれることになります。
デフォルトで埋め込めるパラメータはこちらに記載してあります。また、今回は取り上げませんが独自のパラメータを渡すこともできます。詳しくはドキュメントをご覧ください。

/** get_hello_phantomjs.proc */

var page = require('webpage').create();
+ var system = require('system');
+ var response = {};
- var url = 'file:///path/to/hello_phantomjs.html';

- page.open(url, function(status) {
+ page.open('{{ input.getUrl() }}', function(status) {
    var html = page.evaluate(function () {
        return document.documentElement.outerHTML;
    });

-    console.log(html);
+    system.stdout.write(JSON.stringify(response, undefined, 4));

    phantom.exit();
});

3. PHPから使う

get_hello_phantomjs-with-php_phantomjs_partial_script.phpに手を加えたものをget_hello_phantomjs-with-php_phantomjs_custom_template.phpとして作成します。
以下のように先ほど作成したカスタムテンプレート名から拡張子を除いた文字列を記述します。

// get_hello_phantomjs-with-php_phantomjs_custom_template.php

$client = Client::getInstance();
+ $client->setProcedure('get_hello_phantomjs');
$client->getProcedureLoader()->addLoader($procedureLoader);

4. 実行

うまくいきました。

$ php get_hello_phantomjs-with-php_phantomjs_custom_template.php
hello phantomjs

おわりに

PHP PhantomJSおよびその前提となるPhantomJSの使用方法の概要を記しました。

実際の開発では、phantomjsコマンドを駆使して実行可能なJSスクリプトファイルをまず完成させたのち、それをPHP PhantomJSで使用可能なスクリプトファイルに加工するというのが現実的な手法だと思います。
またPHP PhantomJSで作成する埋め込み処理ファイルやスクリプトファイルにおいては//を使用したコメントアウトはsyntaxエラーとなりましたので注意が必要です。
このあたりを気をつけていただければとりあえずは使用に耐えうるものだと思います。参考にしていただければ幸いです。