読者です 読者をやめる 読者になる 読者になる

Issei.M's Techlog

Web/iOS エンジニアの僕が技術関連のメモ等をつらつらと。主に Symfony について書いています。

[Symfony] 最近のSymfony Standard Editionのディレクトリ構成

Symfony Advent Calendar PHP

この記事はSymfony Advent Calendar 2015 15日目の記事です(当日0時過ぎに枠が空いてる事に気づいて急遽書きました。)。前回は@tai2さんのDoctrineのベストプラクティス(Doctrine 2 documentationから抜粋翻訳)でした。

既にお使いの方はご存知かと思いますが、Symfony Standard Editionのディレクトリ構成がSymfony 3.0になって少し変わりました。

変更点をまとめるとこんな感じです。

  • app/cacheapp/logsがそれぞれvar/cachevar/logsに移動
  • app/consolebin/consoleに移動
  • bootstrap.php.cacheSymfonyRequirements.phpがappではなくvarディレクトリに生成されるようになった
  • app/phpunit.xml.distがプロジェクトルートに移動
  • プロジェクトルートにtestsディレクトリができ、Testsを接頭辞とするPSR-4の名前空間ができた
  • src/AppBundle/Teststests/AppBundleに移動
  • その関係でテストのnamespaceがAppBundle\TestsからTests\AppBundleに移動

因みにこのディレクトリ構成、SensioDistributionBundleが3.0以降であれば、Symfony 2系でも実現可能です。composer.jsonのextraに下記を定義しましょう:

"extra": {
    "symfony-bin-dir": "bin",
    "symfony-var-dir": "var",
    ...

定義したらcomposerのScriptを起動します:

$ composer run-script post-install-cmd

実際にGitHubに公開してみたので気になる方は要チェック!(2系と3系の差分はこちら

appディレクトリがすっきりするので、個人的にvarディレクトリのアイディアが気に入ってます。

おわり

明日は@teematsuさんのSymfony 2.8のAuto WiringとPHP-DIです。乞うご期待!

Plug 'n Play with Puli

Symfony AdventCalendar Puli PHP

この記事はSymfony Advent Calendar 2015 13日目の記事です。前回は@77webさんのSymfonyのFormをテストするとき、どんなテストクラスを書くべきか?でした。

今日はSymfonyのコアメンバーであり、Form, Validator, OptionsResolverなどの主要コンポーネントのメンテナであるBarnhard Schussek (a.k.a @webmozart)さんが手掛ける、次世代のユニバーサルパッケージシステム "Puli" のお話とSymfonyでの導入例を紹介したいと思います。

Plug 'n Play

Composerの登場により、昨今のPHPの開発現場でサードパーティ製のパッケージを使う事は当たり前となりました。PSRの発足とComposerのオートローダーの機構により、利用者は労する事なくパッケージクラスの利用に集中できるようになったのです。 しかし、PHPクラスの他に、Twigテンプレートや言語カタログ、WebアセットなどのリソースはComposerの恩恵を受ける事ができず、これらを含むパッケージを利用する場合は手動でプロジェクトへの統合(通常、ロケータへパスを通す作業)が必要です。また非常に大きなパッケージではクラスが抽象化されており、実際に利用する前に多数の設定を書く必要があります。

この問題は通常、大手のフレームワークに大抵備わっているモジュールシステム(Symfonyで言うとBundle)の上で配布する事で解決します。現にTwigやDoctrineと言った主要なパッケージは各フレームワーク用に統合するためのモジュールが作られています:

f:id:issei_m:20151213004547p:plain

しかし、当然ながらこれらのモジュールはそのフレームワーク用に特化しており、それぞれ一定の規約がある為、別のフレームワークを使用したプロジェクトに導入するのは困難です。

これを解決する為に考えられたのがPuliです。Puliは、あらゆるPHPプロジェクト上でのPlug 'n Play、すなわち、導入してすぐ利用可能なパッケージの構築を可能にする事を目標としています:

公式ドキュメントによると、読みは "poo-lee" だそうです。カタカナにするとプーリーでしょうか。また、語源は犬のPuli)から来ているらしいです。

f:id:issei_m:20151213004510p:plain

次章ではPuliがどのようにPlug 'n Playを実現しようとしているか、またプロジェクトでどのように活用していくかをSymfonyを使ってその一部分をご紹介します。

Let's get started

Installation

※Puliは現在BETAバージョンとして公開されています。実際の製品で使う場合は注意して下さい。また今回使用したPuliのバージョンは1.0.0-beta9でした。

今回はSymfony 2.8を使います。本当は3でやりたかったのですが、Puliの対応が不十分な為やむなく2系を使う事にしました。

$ symfony new puli-symfony 2.8

幸いPuliはSymfonyのBundleを提供している為、導入は簡単です。 プロジェクトのcomposer.jsonminimum-stability と以下の3つパッケージを定義し、インストールします。

...
"require": {
   ...
   "puli/symfony-bundle": "~1.0",
   "puli/twig-extension": "~1.0",
   "puli/cli": "~1.0"
},
"minimum-stability": "beta"
...
$ composer update puli/symfony-bundle puli/twig-extension puli/cli

オートローダーの生成後、なにやら見慣れない文字列が表示されますが、これはPuliのComposer Pluginによる物で、後述するパスマッピングのログです。

Writing lock file
Generating autoload files
Generating PULI_FACTORY_CLASS constant
Registering Puli\GeneratedPuliFactory with the class-map autoloader
Setting "bootstrap-file" to "vendor/autoload.php"
Looking for updated Puli packages
Installing paragonie/random_compat (vendor/paragonie/random_compat) in prod
Installing symfony/polyfill-php70 (vendor/symfony/polyfill-php70) in prod
Installing symfony/polyfill-util (vendor/symfony/polyfill-util) in prod
...
Running "puli build"

また、プロジェクトルートには puli.json が生成されます。これがパッケージ設定ファイルとなる訳です。インストールしたパッケージの全ての設定と共にマージされ、ビルドされます。ビルド結果は .puli ディレクトリ内に生成されます。この為、.puliディレクトリは .gitignore に追加した方が良いでしょう。

最後にKernelにBundleを登録してインストール完了です:

<?php

public function registerBundles()
{
    $bundles = array(
        // ...
        new Puli\SymfonyBundle\PuliBundle(),
        // ...
    );
    
    // ...
}

Using Resources

ビルドされたPuliパッケージのリソースは全てResourceRepositoryに集約されます。 SymfonyBundleでは、RepositoryをSymfonyとTwigのロケーターに統合しており、 Bundleのpuli.jsonにはいくつかのSymfony Bundleのリソースへのマッピングが定義されている為、すぐに試す事ができます。試しに app/config/routing_dev.yml のWebProfilerの定義を以下の様に修正してみます:

_wdt:
    resource: "/symfony/web-profiler-bundle/config/routing/wdt.xml"
    prefix:   /_wdt

画面をリロードしても問題なくプロファイラーが表示されると思います。次に、プロジェクトのリソースへのマッピングを定義してみましょう。puli.json に次の行を加えます:

"path-mappings": {
    "/app": "app/Resources",
    "/config": "app/config"
}

※キー名はパッケージ毎にユニークになるような名前を付ける必要があります。基本的にはPSR−0の名前空間に準拠した命名規則に従えば問題無さそうです。

Puliをリビルドします:

$ bin/puli build

音沙汰、無し!wですがきちんとビルドされてます。試しにDefaultControllerのindexActionを次のように書き換えます:

<?php

public function indexAction(Request $request)
{
    // replace this example code with whatever you need
    return $this->render('/app/views/default/index.html.twig', array(
        'base_dir' => realpath($this->container->getParameter('kernel.root_dir').'/..'),
    ));
}

同様に config.yml のrouting部分を次のように置き換えます。

framework:
    # ...
    router:
        resource: "/config/routing.yml"
    # ...

画面をリロードしてもエラーなく画面が表示されるはずです。このように、本来Symfonyしか解釈できない @WebProfilerBundle/~~default/index.html.twig%kernel.root%/~~ のような定義をPuliを使う事で正規化する事が可能となります。

Providing Package

最後にパッケージの配布方法を紹介します。 ドキュメントによれば、以下の様なディレクトリ構成がベストプラクティス(強制ではない)のようです:

my-package/
    src/
        ... PHP files ...
    res/
        ... non-PHP files ...
    puli.json

この構成に則ってテストパッケージを作成しました。 内容は見てもらうとわかりますが、ただHello World!と表示するTwigを1つ作成し、リソースのマッピングを定義しているだけとなります。

このパッケージを試しにインストールしてみます。composer.jsonに次の行を加えてupdateして下さい:

"require": {
    ...
    "issei-m/puli-test": "dev-master#9d46daa9bc84fac0515052d906542edcbb5c0092"
},
"repositories": [
    {
        "type": "vcs",
        "url": "https://github.com/issei-m/puli-test"
    }
]

※Packagistに登録していないのでリポジトリを直接指定しています。

インストール後、デフォルトのテンプレートのbodyブロックの先頭に次の行を追加します:

{# app/Resources/views/default/index.html.twig #}

{% block body %}
    {% include '/issei-m/puli-test/views/print_hello_world.twig' %}

    {# ... #}

画面にHello World!と表示されれば成功です。

f:id:issei_m:20151213004618p:plain

Resource Discovery

Puliには、Discoveryと言う概念が存在します。PuliパッケージのリソースがRepositoryに集約されると言う話は前章でしました。 Discoveryは、集約したリソースの集合をグルーピングします。これにより、そのリソースが特定のライブラリの為に存在すると言う意味付けをする事ができます。最終的に一つのグループがデファクトスタンダード化すれば、各パッケージはそのグループに所属するようリソースを定義づける事で、いかなるフレームワーク上でも特定のライブラリのリソースを最小限のコード量で集約する事が可能となります。

実装例については、Symfonyの話から大きく逸脱してしまう為、詳しくはドキュメントをご覧ください。

最後に

今回のPuliのまとめ:

  • PuliはPHPパッケージの、フレームワーク間のインテグレーションコードを最小限に留める事を目的としたユニバーサルパッケージシステム
  • 各PuliパッケージのリソースファイルはResourceRepositoryに集約され、アクセス手段の正規化を図る
  • リソースはグルーピングする事ができ、Discoveryによってグループ別に取り出す事ができる

本当は他にもPlug 'n Playを実現する機能が多数あるのですが、今回はSymfonyのAdvent Calendarなので多くは語りません。興味のある方は是非ご自身で体験してみて下さい! また、Puliについては僕自身も最近注目した物で、解釈を誤っている可能性が大いにあります。もし誤りがあればご指摘頂けると嬉しいです! Puli自身、開発途上なので皆さんどんどんお試し頂いて、その発展に貢献しましょう!パッケージマネージャは広く普及してその真価を発揮すると思いますので。今ならPRを送ればすぐマージされるかもしれませんよ(笑)

なお、今回作業したプロジェクトはGitHub公開してあります。

明日は@tai2さんのDoctorineのBest Practices翻訳です!乞うご期待!

[Symfony] LiipFunctionalTestBundleを使ってファンクショナルテストで楽をする

Symfony テスト LiipFunctionalTestBundle AdventCalendar

この記事は Symfony Advent Calendar 2014 の21日目の記事です。

いよいよAdvent Calendarも最終週、残す所あと5日となりましたね。

LiipFunctionalTestBundleは、以前日本Symfonyユーザ会でも紹介されていたSymfonyアプリケーションのファンクショナルテストの作成を補助するバンドルで、これを使うとファンクショナルテストを少し楽に書く事ができます。

今回はこのバンドルを使ったファンクショナルテストの書き方の例を紹介します。

インストール

Composerでインストールします。テスト用のライブラリなので、require-dev に追記しましょう:

"require-dev": {
    "liip/functional-test-bundle": "dev-master"
}
php ./composer.phar update liip/functional-test-bundle

Kernelにバンドルを追加します。通常は、test 環境のみでOKだと思います:

<?php # app/AppKernel.php

// ...

class AppKernel extends Kernel
{
    public function registerBundles()
    {
        // ...
        
        if ('test' === $this->getEnvironment()) {
            $bundles[] = new Liip\FunctionalTestBundle\LiipFunctionalTestBundle();
        }
    }
    
    // ...
}

最後に test 環境の設定を一部変更します:

# application/config/config_test.yml

framework:
    test: ~
    session:
        storage_id: session.storage.filesystem

必要な設定はこれだけですが、テスト用のDBエンジンにSQLiteを使いたい場合は、以下の行を追加します:

doctrine:
    dbal:
        default_connection: default
        connections:
            default:
                driver: pdo_sqlite
                path:   %kernel.cache_dir%/test.db
                
liip_functional_test:
    cache_sqlite_db: true

こうする事でテスト用のDBエンジンにSQLiteが使用され、テストで使用するフィクスチャを初期化するのが非常に楽になります。実際に使用するDBエンジンと互換性があれば、この設定は非常に便利です。

これで準備はOKです。

テストクラスの作成

通常、Symfonyのファンクショナルテストを書く時はFrameworkBundleのWebTestCaseを継承しますが、今回はLiipFunctionalTestBundleのWebTestCaseを継承します。

<?php // src/AppBundle/Tests/Controller/DefaultControllerTest.php

namespace AppBundleests\Controller;

use Liip\FunctionalTestBundleest\WebTestCase;

class DefaultControllerTest extends WebTestCase
{
}

実際はこのWebTestCaseは、FrameworkBundleのWebTestCaseを継承しているので使い勝手はさほど変わりません。 代わりにいくつかのショートカット機能を備えています。

フィクスチャの読み込み

多くのファンクショナルテストがテスト用のフィクスチャを読み込む必要がありますが、LiipFuncionalTestBundleではそれが非常に簡単に行えます。::loadFixtures() を実行するだけです。

※予めプロジェクトにDoctrineFixturesBundleをインストールしておく必要があります。

<?php // src/AppBundle/Tests/Controller/DefaultControllerTest.php

// ...

class DefaultControllerTest extends WebTestCase
{
    $this->loadFixtures([
        'AppBundle\DataFixtures\ORM\LoadUserData',
    ]);
}

引数には、配列で読み込みたいフィクスチャのクラスをFQCNで定義します。この時、先ほど設定でDBエンジンをSQLiteにしていれば、自動的にスキーマを初期化した上で読み込んでくれます。
また、勿論フィクスチャは複数読み込むことができ、空の配列を指定した場合はスキーマの初期化だけが行われます(=空のテーブルだけが作成されます)

もしSQLiteを使っていなければ、フィクスチャを読み込む前に以下を実行する必要があります:

<?php

$em = $this->getContainer()->get('doctrine')->getManager();
if (!isset($metadatas)) {
    $metadatas = $em->getMetadataFactory()->getAllMetadata();
}
$schemaTool = new SchemaTool($em);
$schemaTool->dropDatabase();
if (!empty($metadatas)) {
    $schemaTool->createSchema($metadatas);
}
$this->postFixtureSetup();

// ↑フィクスチャ読み込みの前に手動でスキーマを初期化

$this->loadFixtures([
    'AppBundle\DataFixtures\ORM\LoadUserData',
]);

何気に面倒ですが、スーパークラスやトレイトに定義しておけば問題ないと思います。

認証済みのクライアント作成

ファンクショナルテストで各種ページのテストを行う時、::createClient() で仮想クライアントを作成してリクエストを送信しますが、ページがユーザの認証を必要としている場合、少々面倒ですよね。

この為、LiipFunctionalTestBundleでは予めユーザを認証させた状態のクライアントを作成することができます。 ::loginAs() に認証させたいユーザオブジェクトと、ファイヤウォール名を指定するだけです:

<?php

$user = $this->getContainer()->get('doctrine')->getRepository('AppBundle:User')->find(1);
$this->loginAs($user, 'customer_area');

$client = $this->makeClient();

この時、クライアントの作成には ::makeClient() を使用する必要がある点だけ注意して下さい。
また、ファイヤウォール名とは、security.ymlfirewalls ディレクティブに指定している各ファイヤウォールの名称の事です:

# app/config/security.yml

security:
    # ...
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
               
        customer_area: # ←これの事
            pattern: # ...
            # ...

勿論、一度に複数のファイヤウォールの認証をパスする事もできます:

<?php

$user = $this->getContainer()->get('doctrine')->getRepository('AppBundle:User')->find(1);
$adminUser = $this->getContainer()->get('doctrine')->getRepository('AppBundle:User')->find(2);

$this->loginAs($user, 'customer_area');
$this->loginAs($adminUser, 'administration_area');

$client = $this->makeClient();

コンソールコマンドの実行

コンソールコマンドの実行も非常に簡単です。::runCommand() を実行するだけでOK:

<?php // src/AppBundle/Tests/Command/SayHelloCommandTest.php

// ...

class SayHelloCommandTest extends WebTestCase
{
    $output = $this->runCommand('app:say:hello', [ '--dry-run' => false, ]);
}

第1引数にコマンド名、第2引数にパラメータを指定します。また、返り値には標準出力に表示される結果がそのまま文字列で返されます。

その他に

HTML5のバリデーションや、DIコンテナのサービスIDから該当クラスのモックを作ったり、色々便利な機能が満載です。 これから、Symfonyでファンクショナルテストを作る方は是非試してみて下さい。

[Symfony] [Routing] ルートの host はなるべく設定した方がいいよと言うお話

Symfony Routing AdventCalendar

この記事は Symfony Advent Calendar 2014 の12日目の記事です。

当初はFormについて何か書く予定でしたが、どうしても長くなってしまうのでRoutingについて書こうと思います。

SymfonyのRoutingでは、YAML, XML, Annotationなど様々な形式でルートを設定する事ができますが、皆さんは何で設定していますか?僕はYAMLとAnnotationの両方をよく使います。

Webアプリケーションでは、エンドユーザ向けやシステム管理者向けと言った、複数のサイト構成を取る事が多いと思います。その場合、サイト毎の共通設定をYAML、ページ毎の設定をAnnotationで設定します。

# app/config/routing.yml

# エンドユーザ向け
frontend:
  host:     app.com
  prefix:   /
  resource: @AppBundle/Controller/Frontend/

# システム管理者向け
backend:
  host:     app.com
  prefix:   /administration/
  resource: @AppBundle/Controller/Backend/
<?php // src/AppBundle/Controller/Frontend/DefaultController.php

// ...

/**
 * @Route("/")
 */
class DefaultContrller extends Controller
{
    /**
     * http://app.com/ のコントローラ
     *
     * @Route("/", name="app_frontend_default_top")
     */
    public function topAction(Request $request)
    {
        // ...
    }
}

// src/AppBundle/Controller/Backend/DefaultController.php

// ...

/**
 * @Route("/")
 */
class DefaultContrller extends Controller
{
    /**
     * http://app.com/management/dashboard のコントローラ
     *
     * @Route("/dashboard", name="app_backend_default_dashboard")
     */
    public function dashboardAction(Request $request)
    {
        // ...
    }
}

本題に入りますが、皆さんは普段ルートの host ディレクティブを設定していますか?勿論、サイト毎に異なるドメインを使う場合はしていると思いますが、上記のように同じドメインを使う場合はしていない方もいると思います。

そもそもホストルーティングはHTTPサーバがやってくれるので、URLマッチングでは必要ありません。ですが、これらの設定はURL生成でも使われる為、きちんと設定していないと問題が起きる事があります。

例えば、RouterItnerface::generate() の第3引数を true にすると絶対URLを生成する事ができますが、試しに先ほどの hostコメントアウトして試してみましょう。

コントローラ:

<?php

echo $this->get('router')->generate('app_backend_default_dashboard', [], true);

// FrameworkBundleのControllerを継承していればショートカットが使える
echo $this->generateUrl('app_backend_default_dashboard', [], true);

Twigテンプレート:

{{ url('app_frontend_default_top') }}

上記3パターン、いずれも結果はこうなります:

http://app.com/administration/dashboard

URLは正しく生成されました。一見問題が無いように見えますが、コンソールアプリケーションではどうでしょうか?

<?php // src/AppBundle/Command/EchoAbsoluteUrlCommand.php

// ...
class EchoAbsoluteUrlCommand extends ContainerAwareCommand
{
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $router = $this->getContainer()->get('router');
        $output->writeln($router->generate('app_backend_default_dashboard', [], true));
    }
}

結果は次のとおりです:

http://localhost/administration/dashboard

ホストが localhost になってます。エンドユーザ向けに配信されるメールに使われるURLがこうなってしまったら大変ですね。

ではなぜコンソールだと正しいURLが作られないのでしょうか?答えはドキュメントに書いてあります。

絶対 URL のホスト部分には、現在の Request オブジェクトのホストが使用されます。ホスト情報は PHP のサーバー情報から自動的に検出されるため、コマンドラインから実行するスクリプトの場合は、RequestContext オブジェクトで明示的にホストを指定してください。
ルーティング | Symfony2日本語ドキュメント

具体的には、HttpKernelのonKernelRequestイベントでRequestContextにサーバ情報が渡されます。そして、このイベントはコンソールでは実行されない為、ホストが localhost となってしまった訳です。

ドキュメントに沿って、URL生成前にRequestContextにホストを指定してやれば正しく動作するのですが、実はRouterは対象のルートにホストが設定されている場合はそちらの値を使用します。

なので、最初から host ディレクティブの設定をした方が簡単かつ安全です。そしてこの値には、DIコンテナのパラメータが使えるので、環境毎に異なる設定をする事も可能となっています:

# app/config/parameters.yml
parameters:
  # ...
  base_domain: app.com

# app/config/routing.yml

# エンドユーザ向け
frontend:
  host:     %base_domain%
  prefix:   /
  resource: @AppBundle/Controller/Frontend/

# システム管理者向け
backend:
  host:     %base_domain%
  prefix:   /administration/
  resource: @AppBundle/Controller/Backend/

 

まとめ

  • ルートのhostディレクティブはURLマッチングだけでなく、絶対URLの生成の時にも使われる
  • 設定されてなければRequestContextの値を使う
  • コンソールではRequestContextが正しく初期化されない
  • なのでURLマッチングでは不要でもhostは設定しておいた方がいい

[Doctrine] SQLiteでQuery::iterate()を使うとDBがロックする件

PHP Doctrine Symfony SQLite PDO LiipFunctionalTestBundle

僕はSymfony+Doctrineプロジェクトで機能テストを作る際、 LiipFunctionalTestBundle を使ってテスト環境だけSQLiteで動作するようにしているのですが、ある時から途中でDBがロックしてしまい、テストスイートがパスしなくなってしまいました。

SQLSTATE[HY000]: General error: 5 database table is locked

原因を探っていくうちに、以下のテストケースに問題がある事が分かりました。

<?php

$query = $entityRepository->createHogeQuery(); // Doctrine\ORM\Query

$iterator = $query->iterate();
$iterator->rewind();

$this->assertSame($expectedEntity, $iterator->current()[0]);

実際にアプリケーションでも大量の結果セットを返す可能性のある::createHogeQuery()iterate()して使っていたのでテストも同様にしたのですが、横着したのがいけなかったようです。

どう言う事かと言うと、iterate()を使う場合は最後の行までフェッチしないと、内部のPDOStatementcloseCursor()されないようで、その結果次回以降のテストに影響が出ていたと言うわけです。

テストに以下を追加する事でとりあえず解決しました。

<?php

while ($iterator->next()); // cleanup

あるいは、テストデータの量はたかがしてれているのでそもそもテストではiterate()を使わなければ良いのかもしれません。
ところでこれ、普通にforeachしたとしてもbreakされたら同様の事が起きそうですよね。誰か試してみて下さい。