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

新年明けましておめでとうございます、永井です。
本年も「カルテットコミュニケーションズ技術ブログ」共々頑張っていきたいと思いますので、宜しくお願い致します。

はじめに

カルテット社内におけるデプロイツールのデファクトスタンダードは、Capistranoです。
機能的には特に不満はないのですが、たまに手の込んだことをやろうとすると途端に躓いてしまった経験が何度かあり、出来ればアプリケーションで利用しているプログラミング言語で良いツールがないかなと思っていました。
そこで今回はDeployerを試してみました。

今回のゴール

  • シンプルなPHPのプロジェクト(Silexベース)をDeployerでデプロイする
  • CircleCIを利用して、 特定ブランチが更新された時に自動的にデプロイする

Deployerについて

deployer

DeployerはPHP製のとてもシンプルなデプロイツールです。
公式サイトを見る限り、

  • ロールバック対応
  • 並列処理対応
  • 独自の処理を拡張可能

などなど、デプロイツールに求められるような機能は一通り揃っているように思えます。

デプロイする為のSilexプロジェクトを作成

今回はSilexのプロジェクトをデプロイしてみたいと思います。
deployer-sampleというプロジェクトを作成します。

$ composer create-project fabpot/silex-skeleton deployer-sample "~2.0"

ディレクトリ構成はこんな感じです。

$ tree -L 1
.
├── LICENSE
├── README.rst
├── bin
├── composer.json
├── composer.lock
├── config
├── deploy.php
├── phpunit.xml.dist
├── src
├── templates
├── tests
├── var
├── vendor
└── web

インストール

公式サイトにはいくつかインストール方法が紹介されていますが、今回は直接pharファイルをダウンロードする方法でいきます。
プロジェクト内の bin ディレクトリへインストールします。

# path/to/deployer-sample

$ curl -LO https://deployer.org/deployer.phar
$ mv deployer.phar ./bin/dep
$ chmod +x ./bin/dep

設定ファイルの生成

コマンドで自動生成出来ます。
今回はSilexプロジェクトなので、Commonを選択。
コマンド実行したディレクトリに生成されるので、任意の場所に移動しています。
デプロイ用のファイルはプロジェクト内に置くべきかどうかは、ケースバイケースだと思いますが、今回は後々楽をする為にプロジェクト配下の config ディレクトリ以下に配置します。

# path/to/deployer-sample

$ ./bin/dep init

Please select your project type (defaults to common):
  [0] Common
  [1] Laravel
  [2] Symfony
  [3] Yii
  [4] Zend Framework
  [5] CakePHP
  [6] CodeIgniter
  [7] Drupal
 > 0
Successfully created: /path/to/deployer-sample/deploy.php

$ mv deploy.php ./config/

設定ファイルの設定

見たら分かる感じですね。
主な修正点は

  • 各種パス及びホスト名
  • PHP-FPMを使っていないので削除
  • writable_mode デフォルトでは acl というのが指定されており sudo 必須の為、今回はシンプルに chmod を設定

といったところです。

# config/deploy.php

 // Configuration

-set('repository', 'git@domain.com:username/repository.git');
+set('repository', 'git@github.com:example/deployer-sample.git');
 set('shared_files', []);
-set('shared_dirs', []);
-set('writable_dirs', []);
+set('shared_dirs', ['var/logs']);
+set('writable_dirs', ['var/cache']);
+set('writable_mode', 'chmod');

 // Servers

-server('production', 'domain.com')
-    ->user('username')
+server('production', 'example.com')
+    ->user('user')
     ->identityFile()
-    ->set('deploy_path', '/var/www/domain.com');
+    ->set('deploy_path', '/var/www/example.com');


 // Tasks

-desc('Restart PHP-FPM service');
-task('php-fpm:restart', function () {
-    // The user must have rights for restart service
-    // /etc/sudoers: username ALL=NOPASSWD:/bin/systemctl restart php-fpm.service
-    run('sudo systemctl restart php-fpm.service');
-});
-after('deploy:symlink', 'php-fpm:restart');
-
 desc('Deploy your project');
 task('deploy', [
     'deploy:prepare',
...

ローカルからの実行

ローカルから実行してみます。

前提条件

  • 公開鍵方式でデプロイ先サーバへSSH接続が出来る
  • 秘密鍵はデフォルトの~/.ssh/id_rsaを利用する
# path/to/deployer-sample

$ ./bin/dep -f=./config/deploy.php deploy production

デプロイ先はこんな感じになりました。

$ tree -a -L 2
.
├── .dep
│   └── releases
├── current -> /path/to/deployer-sample/releases/1
├── releases
│   └── 1
└── shared
│   └── var

少し変わっているのは、.dep/releases というファイルでしょうか。
中身は以下のように、リリースバージョン番号とタイムスタンプの一覧が保存されています。

20161206130959,1
20161206135544,2

CircleCIから自動的にデプロイ出来るようにする

SSHの秘密鍵を設定

Project Setting > SSH Permissions にてSSHの秘密鍵をセットします。
今回はホスト名をセットせずに登録しました。

circleci

デプロイ先のサーバにも公開鍵をセットします。

circle.ymlを用意

master ブランチが更新されたらデプロイをするように設定しました。

# path/to/deployer-sample/circle.yml

machine:
  timezone: Asia/Tokyo
  php:
    version: 5.6.22

dependencies:
  pre:
    - echo 'date.timezone = "Asia/Tokyo"' > /opt/circleci/php/$(phpenv global)/etc/conf.d/date_timezone.ini

deployment:
  production:
    branch: master
    commands:
      - |
        curl -LO https://deployer.org/deployer.phar
        mv deployer.phar /home/ubuntu/bin/dep
        chmod +x /home/ubuntu/bin/dep
      - dep -f=./config/deploy.php deploy production

deploy.phpの修正

このままだと、デプロイ時にデプロイ先サーバとの接続に失敗してしまいます。
これは、CircleCIのSSH Permissionsでセットした秘密鍵が~/.ssh/id_[HASH]のようなファイルで保存され~/.ssh/configにて以下のように解決していることが原因です。

Host !github.com *
IdentitiesOnly no
IdentityFile /home/ubuntu/.ssh/id_[HASH]

Deployerのデフォルトで利用されているSSHクライアントは、秘密鍵のパスがデフォルトで ~/.ssh/id_rsa にセットされるようになっているため、うまく認証ができません。
いくつか回避方法はありますが、今回はPHPのSSHクライアントではなく、サーバにインストールされているSSHクライアントを利用するようにDeployerの設定を変更して対応しました。

# path/to/deployer-sample

 set('writable_dirs', ['var/cache']);
 set('writable_mode', 'chmod');
+set('ssh_type', 'native');

これで、自動でデプロイされるようになります!
(実はここが一番ハマりました)

おわりに

PHP製アプリケーションにPHP製デプロイツールを利用することで、学習コストや環境構築コストなど低く抑えることが出来るというのは、ひとつのメリットだと思います。
Capistranoと比較して飛び抜けて優れている点があるかといわれると特にないかもしれませんが、シンプルで拡張しやすく作られており、個人的にはとても好印象でした。


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

Symfony Advent Calendar 2016 22日目の記事です。

はじめに

Doctrineは多機能で私も開発においてはたいへん便利に使っています。反面、多機能がゆえにどの機能が何を提供してくれるのか把握できていないために苦労することもしばしばです。

ここでは私がいつもどれがなにを示すのかわからなくて毎回ドキュメントを見ながら使っていたcascade={"remove"}onDelete="CASCADE"orphanRemoval=trueの記述がもたらす各振舞についてこの場を借りて整理してみたいと思います。

前提

  • エンティティとしてUserとProfileがあり、UserがProfileを1つ所有しているとする
  • 以下のapp.phpテストスクリプト(注目箇所のみ抜粋)にて、振舞を検証する
<?php
// app.php

//...

// データ登録
$user = new User();
$profile = new Profile();
$user->setProfile($profile);
$profile->setUser($user);

$em->persist($profile);
$em->persist($user);
$em->flush();

// データ削除
$em->remove($user);
$em->flush();

これ以降、エンティティの定義方法を変更することでテストスクリプトの振舞にどう影響するのかを確認してみます。

1. プレーンな状態

まずは考えうるもっともプレーンな状態でエンティティを定義してみます。

<?php

class User
{
    //...
    
    /**
     * @OneToOne(targetEntity="Profile", mappedBy="user")
     */
    private $profile;

    //...
}

class Profile
{
    //...

    /**
     * @OneToOne(targetEntity="User", inversedBy="profile")
     * @JoinColumn(name="user_id", referencedColumnName="id")
     */
    private $user;
    
    //...
}

エンティティ定義をもとにスキーマを作成後、テストスクリプトを実行するとエラーが送出され、完遂しません。

# スキーマを作成
$ ./vendor/bin/doctrine orm:schema-tool:create

# テストスクリプトを実行
$ php app.php
 :
PHP Fatal error:  Uncaught PDOException: SQLSTATE[23000]: Integrity constraint violation: 1451 Cannot delete or update a parent row: a foreign key constraint fails (`2016_12_doctrine_association_remove_test`.`profile`, CONSTRAINT `FK_4EEA9393A76ED395` FOREIGN KEY (`user_id`) REFERENCES `User` (`id`)) in /path/to/2016-12-doctrine/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/PDOStatement.php:91

実行時に走ったクエリは以下です。

INSERT INTO User (id) VALUES (null);
INSERT INTO Profile (user_id) VALUES (1);
DELETE FROM User WHERE id = 1;

テストスクリプトのコードではUserエンティティだけを削除しようとしています。しかし、DBにおいてはProfile.user_idからUser.idに外部キーが貼られているため、紐付いた親にあたるUserエンティティが存在しなくなる矛盾からエラーになっていますね。

2. cascade={“remove”}

そこでcascade={"remove"}を追記してみます。

<?php

class User
{
    //...
    
    /**
-    * @OneToOne(targetEntity="Profile", mappedBy="user")
+    * @OneToOne(targetEntity="Profile", mappedBy="user", cascade={"remove"})
     */
    private $profile;

    //...
}

class Profile
{
    //...

    /**
     * @OneToOne(targetEntity="User", inversedBy="profile")
     * @JoinColumn(name="user_id", referencedColumnName="id")
     */
    private $user;
}

スキーマを更新後テストスクリプトを実行してみます。エラーになることなく完遂しました。 ちなみに走ったクエリは以下です。DELETE FROM Profile WHERE id = 1;はさきほどは見られなかったクエリですね。

INSERT INTO User (id) VALUES (null);
INSERT INTO Profile (user_id) VALUES (1);
DELETE FROM Profile WHERE id = 1;
DELETE FROM User WHERE id = 1;

これはテストスクリプトが以下のように書き換えられたのと同様の動きといえます。

// app.php

//..

// データ削除
+ $em->remove($profile);
$em->remove($user);
$em->flush();

つまりcascade={"remove"}におけるremoveEntityManagerに生えているremoveメソッドを指す識別子だったことがわかります。remove以外にもpersistmergeなどが定義できますし複数の定義も可能です。ちなみに各々のメソッドがどのような振舞をするかについては 7. Working with Objects / Doctrine 2 ORM 2 documentation に詳しいです。

3. onDelete=”CASCADE”

removedeleteは同義ですしcascadeという文言もありますし、cascade={"remove"}とやりたいことは似ているように感じます。 実際に動きを見てみましょう。

<?php

class User
{
    //...
    
    /**
-    * @OneToOne(targetEntity="Profile", mappedBy="user", cascade={"remove"})
+    * @OneToOne(targetEntity="Profile", mappedBy="user")
     */
    private $profile;
    //...
}

class Profile
{
    //...

    /**
     * @OneToOne(targetEntity="User", inversedBy="profile")
-    * @JoinColumn(name="user_id", referencedColumnName="id")
+    * @JoinColumn(name="user_id", referencedColumnName="id", onDelete="CASCADE"))
     */
    private $user;
}

上記のように書き換えた後、ここではひとまずスキーマの更新を行わずテストスクリプトを実行してみます。さきほどと同じように外部キーエラーが送出され完遂しません。

# テストスクリプトを実行
$ php app.php
 :
PHP Fatal error:  Uncaught PDOException: SQLSTATE[23000]: Integrity constraint violation: 1451 Cannot delete or update a parent row: a foreign key constraint fails (`2016_12_doctrine_association_remove_test`.`profile`, CONSTRAINT `FK_4EEA9393A76ED395` FOREIGN KEY (`user_id`) REFERENCES `User` (`id`)) in /path/to/2016-12-doctrine/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/PDOStatement.php:91

ちなみに走ったクエリは以下です。

INSERT INTO User (id) VALUES (null);
INSERT INTO Profile (user_id) VALUES (1);
DELETE FROM User WHERE id = 1;

1. プレーンな状態のときと同様ですね。onDelete="CASCADE"は何もしてくれないのでしょうか?そんなはずはありません。以下のようにスキーマを更新してみます。

$ ./vendor/bin/doctrine orm:schema-tool:update --force
 :
ALTER TABLE Profile DROP FOREIGN KEY FK_4EEA9393A76ED395
ALTER TABLE Profile ADD CONSTRAINT FK_4EEA9393A76ED395 FOREIGN KEY (user_id) REFERENCES User (id) ON DELETE CASCADE

再度テストスクリプトを実行すると無事に完遂しました。ちなみに走ったクエリは以下です。エラーになったさきほどと変わりありません。

INSERT INTO User (id) VALUES (null);
INSERT INTO Profile (user_id) VALUES (1);
DELETE FROM User WHERE id = 1;

つまりonDelete="CASCADE"はDoctrineがORMとしてよしなに何かをしてくれるためではなく、DBのレイヤで対応するようにDDLを生成するための記述でしかないということですね。

4. orphanRemoval=true

次はOrphan Removalです。とりあえず追記して使ってみます。

<?php

class User
{
    //...
    
    /**
-    * @OneToOne(targetEntity="Profile", mappedBy="user")
+    * @OneToOne(targetEntity="Profile", mappedBy="user", orphanRemoval=true)
     */
    private $profile;

    //...
}

class Profile
{
    //...

    /**
     * @OneToOne(targetEntity="User", inversedBy="profile")
-    * @JoinColumn(name="user_id", referencedColumnName="id", onDelete="CASCADE"))
+    * @JoinColumn(name="user_id", referencedColumnName="id")
     */
    private $user;
}

スキーマを更新してからテストスクリプトを実行してみると以下のクエリが走り、所望の振舞となりました。

INSERT INTO User (id) VALUES (null);
INSERT INTO Profile (user_id) VALUES (1);
DELETE FROM Profile WHERE id = 1;
DELETE FROM User WHERE id = 1;

cascade={"remove"}と同様の動きのように見えますね。ではどちらのように書いても同じなのでしょうか?

cascade={“remove”}とorphanRemoval=trueの違い

テストスクリプトの仕様を以下のコードのように「ユーザに新たなプロフィールをセットする」というように変更してみます。

<?php
// app.php

//...

- // データ削除
- $em->remove($user);
+ // 新たなプロフィールをセット
+ $profile2 = new Profile();
+ $user->setProfile($profile2);
+ $em->persist($profile2);
$em->flush();

まずは以下のようにcascade={"remove"}を追記してみます。

<?php

class User
{
    //...
    
    /**
-    * @OneToOne(targetEntity="Profile", mappedBy="user", orphanRemoval=true)
+    * @OneToOne(targetEntity="Profile", mappedBy="user", cascade={"remove"})
     */
    private $profile;

    //...
}

class Profile
{
    //...

    /**
     * @OneToOne(targetEntity="User", inversedBy="profile")
     * @JoinColumn(name="user_id", referencedColumnName="id")
     */
    private $user;
}

さっそくテストスクリプトを実行してみます。走ったクエリは以下です。DELETE文は発行されていませんのでこれは所望の振舞ではありません。

INSERT INTO User (id) VALUES (null)
INSERT INTO Profile (user_id) VALUES (1)
INSERT INTO Profile (user_id) VALUES (NULL)

そこでorphanRemoval=trueに戻してみます。

<?php

class User
{
    //...
    
    /**
-    * @OneToOne(targetEntity="Profile", mappedBy="user", cascade={"remove"})
+    * @OneToOne(targetEntity="Profile", mappedBy="user", orphanRemoval=true)
     */
    private $profile;

    //...
}

class Profile
{
    //...

    /**
     * @OneToOne(targetEntity="User", inversedBy="profile")
     * @JoinColumn(name="user_id", referencedColumnName="id")
     */
    private $user;
}

テストスクリプトを実行してみると以下のクエリが走ります。これで所望の振舞となりました。

INSERT INTO User (id) VALUES (null);
INSERT INTO Profile (user_id) VALUES (1);
INSERT INTO Profile (user_id) VALUES (NULL)
DELETE FROM Profile WHERE id = 1;

以上から、cascade={"remove"}が親が削除されることでそれに伴い子も削除されるのに対して、orphanRemoval=trueは親そのものが存在しても子との関係が断たれた状態になった時点で子が削除されるようですね。

まとめ

cascade={"remove"}onDelete="CASCADE"orphanRemoval=true それぞれの記述のもたらす振舞をそれぞれ確認してみました。目的が同様に見えても状態によっては別の振舞を行うことが明確になりました。 DBに近い振舞はパフォーマンスへの影響も小さくないこともあり手法の選定は慎重になりたいですね。

参考


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

Symfony Advent Calendar 2016 19日目の記事です。

はじめに

Symfony2でアプリケーションを開発している人たちの多くは、アプリケーションのファクショナルテストを書いているかと思いますが、 今回はアプリケーションのファンクショナルテストではなくて、バンドルのファンクショナルテストについて書きたいと思います。

バンドル単体のファンクショナルテストがない場合の問題

バンドルはKernelにインストールされて使用されます。
バンドルをファンクショナルテストしないままリリースしてしまうと、アプリケーションのKernelにインストールされてから問題に直面する可能性が上がってしまいます。

  • DIコンテナのコンパイルに失敗する
  • Deprecation Warningが出てしまう
  • 期待した通りに動かない
  • などなど・・・

問題は修正してしまえば良いのですが、Symfonyの文化圏内だとそう簡単には後方互換を捨てられません。
後方互換を維持したまま修正できれば何の問題もないです。もしそうでない場合はメジャーバージョンを上げるべきでしょうが、そうホイホイ上げたくありませんよね。

問題1: DIコンテナのコンパイルに失敗する

OSSで提供されているバンドルの多くは、Symfonyの複数のバージョンをサポートしています。
Symfonyはv2.3から後方互換性を維持してくれている ので、そこまで神経質にならなくても大丈夫ですが、メジャーバージョンを跨いで複数のSymfonyをサポートしていると、バンドルをアプリケーションのKernelにインストールした結果、DIコンテナのコンパイルに失敗するようになってしまう可能性は低くありません。

メジャーバージョンが変更される際に、削除が予定されているサービスは削除されるので、もし参照が残っていたりするとこれに該当します。

問題2: Deprecation Warningが出てしまう

Symfonyのアップグレードをスムーズに行わせる仕組みの1つにDeprecation Warningを出力する仕組みがあります。さらに、テスト結果からDeprecation Warningを確認できる仕組みも提供されています。

なのでSymfonyユーザーはDeprecation Warningに敏感です。
あるバージョンのSymfonyでバンドルを利用しているユーザーは大丈夫だけれども、より新しいバージョンのSymfonyを利用しているユーザーだとそうではない場合があります。

あるバージョンで非推奨となったAPIや、サービスを実行したり参照したりするとこれに該当します。

問題3: 期待した通りに動かない

Symfonyはプラガブルにフレームワークの振る舞いを変更する事ができ、もちろんアプリケーションだけでなくバンドルからも変更する事が可能ですが、それらはバンドル自身の機能ではありません。
例えばEventDispatcherはSymfonyのコアに組み込まれていますが、サービスとしてはFrameworkBundleが提供していますし、KernelのイベントもHttpコンポーネントが発行しています。

つまり、フレームワークの振る舞いを変更する機能がバンドル内にある場合、ユニットテストだけでは想定通りのタイミングで動作するのか、想定通りな値を受け取れるのか、想定通りに振る舞いを変更できているのか、そもそも動作するのか、何も保証できないという事です。アプリケーションの場合と同じですね。

どうやってファンクショナルテストするか

手順は基本的にアプリケーションの時と同じです。

  • テスト用のKernelを作る
  • テスト用のKernelにバンドルをインストールする
  • テスト用の設定をテスト用のKernelにロードさせる
  • テスト用のKernelでファンクショナルテストする

テスト用のKernelを作る

普段のアプリケーションと同じように、Symfony\Component\HttpKernel\Kernelを継承したKernelを作ります。

<?php

use Symfony\Component\HttpKernel\Kernel;

class TestKernel extends Kernel
{
}

テスト用のKernelにバンドルをインストールする

テストしたいバンドルと、普段のアプリケーションと同じようにFrameworkBundleもインストールします。

<?php

use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\HttpKernel\Kernel;

class TestKernel extends Kernel
{
+    public function registerBundles()
+    {
+        return [
+            new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
+            new Your\Bundle\YourSpecialBundle(),
+        ];
+    }
}

テスト用の設定をテスト用のKernelにロードさせる

どの設定ファイルをロードするか指定します。(もちろんテストしたい設定も追加してください)

<?php

+use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\HttpKernel\Kernel;

class TestKernel extends Kernel
{
    public function registerBundles()
    {
        return [
            new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
            new Your\Bundle\YourSpecialBundle(),
        ];
    }

+    public function registerContainerConfiguration(LoaderInterface $loader)
+    {
+        $loader->load(__DIR__.'/config.yml');
+    }
}

テスト用のKernelでファンクショナルテストする

Symfonyが提供しているファンクショナルテスト用のクラスSymfony\Bundle\FrameworkBundle\Test\WebTestCaseがありますが、そのクラスにはテストに使うKernelを指定する事ができます。
毎回Kernelを指定しても問題ないですが手間なのでファンクショナルテスト用のサブクラスを作っておくと便利です。

<?php

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase as BaseWebTestCase;

class WebTestCase extends BaseWebTestCase
{
    protected static function getKernelClass()
    {
        return TestKernel::class;
    }
}

あとはいつも通りの手順でバンドルのファンクショナルテストができます。

<?php

class YourFunctionalTest extends WebTestCase
{
    public function testFeature()
    {
        $client = self::createClient();
        $client->request('GET', '/path/to/feature');
    }
}

複数バージョンのSymfonyでファンクショナルテストする

特定のSymfonyだけでなく、サポートする全てのバージョンのSymfonyでテストできればより良いですよね。 そんな時に Travis CI を使えば、複数の環境を1度にテストできて大変捗るのでオススメです。

Travis CI には Build Matrix 機能があり、下図のように全てのバージョンのSymfonyにインストールされた状態を、全てのPHPバージョンでテストする事ができ、殆ど完璧な状態を保つ事が簡単にできます。

  PHP 5.5 PHP 7.0
Symfony v2.8.x Symfony v2.8.x - PHP 5.5 Symfony v2.8.x - PHP 7.0
Symfony v3.1.x Symfony v3.1.x - PHP 5.5 Symfony v3.1.x - PHP 7.0
Symfony v3.2.x Symfony v3.2.x - PHP 5.5 Symfony v3.2.x - PHP 7.0

どのように設定するかはSymfony本家FriendsOfSymfonyのリポジトリが大変参考になります。

まとめ

基本的にはバンドルもアプリケーションと同じ方法でファンクショナルテストができますが、Symfony Standard Edition と違い、初めからファイルが準備されていないので少しだけ準備が必要だというだけでした。本当によくできていて感心します。