休日個人開発で学ぶテストコード! 画像に“集中線”を合成するツールを作ってみよう
プライベートでも何か作りたい! そんなときの「今日からはじめる休日個人開発」シリーズ、第二弾はテストコードを書きながら簡単なMVCモデルの画像加工ツールを作ってみましょう。好きな写真に集中線を合成できます。
皆さん、プライベートで何か開発していますか? 「何か作りたい」という気持ちはあるものの、いまひとつ何から始めたらいいのか分からず、動けないままの人も多いと思います。
そんな皆さんのために、「仕事以外にも休日に個人で気軽に何かを作ってみよう!」という企画の第二弾です。今回は、第一弾で用意した開発環境を使って、画像を加工するツールを実際に作っていきます。
せっかくですので、ただ作るだけではなく、テストコードも一緒に書いてみましょう。最近は、CI(継続的インテグレーション)やCD(継続的デリバリー)も一般的になり、テストコードを書く機会が増えています。それを踏まえて、今回はテストコードを書く意義や、実際の書き方に焦点を当てていきます。
- なぜテストコードを書くとよいのか
- PHPUnitのインストールと準備
- PHPUnitでテストコードを動かしてみよう
- 集中線ツール作りを始めよう
- 集中線ツールの開発1 - テンプレートの表示を調整
- 集中線ツールの開発2 - 画像を加工するロジックを追加
- おわりに
- 執筆者
なぜテストコードを書くとよいのか
テストコードを駆使した開発手法に、TDD(テスト駆動開発, test-driven development)があります。まず、TDDとテストコードの概要に触れておきます。
TDD(テスト駆動開発)とテストコード
TDDとは、簡単に言えばテストコードを中心とした開発手法のことです。TDDではプロダクトに新しい機能を追加する際に、先行してテストコードを書いたあと、それに対応するロジックを書いていきます。
TDDのサイクル
- 【Red】テストコードを書く
- 【Green】テストコードが動く最低レベルのソースコードを書く
- 【Refactor】テストコードが動く状態でそのソースコードをブラッシュアップさせる
最初はロジック自体が存在しないのでテストはもちろん失敗します(【Red】)。後から動作するロジックを書いていき(【Green】)、そのテストを動く状態に保ちながらリファクタリングしていく(【Refactor】)やり方です。
しかし、いきなりTDDを完璧にやろうとしても、失敗する可能性が高いです。始めのうちは雑でもいいので、テストコードを書くこと自体への抵抗をなくし、習慣化することが大切です。まずは、TDDまでやらなくとも、テストコードを書いてみましょう。
テストコードを書く意味
そもそも何のためにテストコードを書くのでしょうか。
開発しているシステムの規模が徐々に大きくなったり、他の開発者から引き継いだりしたときに、「ソースコードのこの部分をいじると副作用で何が起こるか分からないから、怖いので触れたくない」と感じることは意外と多いでしょう。私自身もこういった経験があります。精神衛生上もよろしくなく、「過剰に気を付ける」ことで開発効率も落ちてしまいます。
しかし、きちんと保守されているテストコードがあれば、そのような困った事態になる可能性を下げられます。ソースコードを修正したときにテストを実行すれば、意図しない影響を与えていないのかを確認することができます。テストを動かすだけで「(本来はこうあるべき出力が)こんな値になっているよ」と教えてくれるのです。
また、テストコードは、ドキュメントが整備されなくなった場合に、プログラムの仕様を担保する最後の砦になってくれます。
テストコードを書くときの注意点
テストコードを書く際には、どんなことに気を付けたらいいのでしょうか?
テストコードを書くためには、ソースコード自体も「テストコードを動かす」ことを意識した書き方をすると、テストコードが書きやすくなります。関数を機能ごとに分割しないとテストが書きにくくなるので、多くの処理を一つの関数に詰め込まないなどの意識は必要です。
また、テストコードを書いていくためには、一人だけで頑張るのではなく、一緒に開発する人全員の同意・協力・理解が必要になるでしょう。エンジニアには、ソースコードに手を加える際に一緒にテストコードを直してもらい、エンジニア以外の職種の人にはテストコードを書く意義を理解してもらう必要があるでしょう。
テストコードを書きはじめたばかりのころは、書き方や文化に慣れるまで、それまでより開発速度が一時的に落ちるでしょう。しかし、一時的にコストがかかっても長期的にはメリットが大きいことを理解してもらわないと、職種間で温度差が生じ、トラブルにもなりかねません。
まずは、少しずつでもテストコードを書いてみて、抵抗感をなくしていくことが大切です。いきなり「完璧なテストコードを書こう」と無理すると、長続きせずに挫折してしまう可能性が高いです。
実務の中でも、テストコードを書くことが難しい処理に出くわす場合も少なからずあります。そのようなときでも「必ずテストコードを書かないといけない」と強迫観念にとらわれる必要はありません。手動で結合テストを実施すれば済むものもありますし、処理によってはわざわざテストコードを書く必要がないものもあります。
コストとメリットを考え、必要なもの、可能なものから書いていけばいいのです。無理のない範囲から、テストコードに慣れていきましょう。
参考資料:「50分でわかるテスト駆動開発」
日本でTDDの第一人者といえば、和田卓人(@t_wada)さんの名前がよく挙がります。TDDやテストコードについてインターネットで調べていると、t_wadaさんがまとめた発表資料やスライドを目にすることも多いはず。
最近では、マイクロソフトのイベント「de:code 2017」の発表資料(2017年5月)が、当日のトークもあわせて公開されています。TDDの学習に役立つので、時間を作って一度見てみましょう。
PHPUnitのインストールと準備
テストコードを動かす環境を整えていきましょう。今回はPHPでツールを実行するので、PHPUnitを用いてテストを書いていきます。
▽ PHPUnit – The PHP Testing Framework
PHPUnitのインストール方法はいくつかあり、公式サイトのマニュアルに記載されています。
なお、この記事では、第一弾で用意したPHP開発環境を前提として説明します。
今日からはじめる休日個人開発 ~ クラウドサービスの選定から、WebサーバでPHPを動かすまで
コラム:開発環境を最新の状態にする
環境を構築してから時間が経っている方は、脆弱性対策のため、次の手順で最新の状態にしましょう。
$ sudo yum update
カーネル関連のアップデートが含まれている場合は、OSを再起動して、更新した内容を反映させます。
$ sudo reboot
Composer - PHPのパッケージ管理ツール
今回はPHPUnitを、Composerを使ってインストールします。Composerは、PHPでよく使われるパッケージ管理ツールなので、使い方を一緒に覚えてしまいましょう。
Composerでは、使いたいパッケージをJSONで指定することで管理できます。
例えば、GitHubで公開したソースコードを動かすために、他のパッケージがいくつか必要になる場合もあります。そのような場合に、必要なパッケージやそのバージョン情報をJSONファイルに記載して一緒にGitHubへ上げておくことにより、必要なパッケージに依存するパッケージも含め、そのパッケージ構成を管理できます。利用者は動作に必要なパッケージを、Composerを利用して各自の環境へ簡単にインストールできます。
Composerのメインリポジトリが、Packagistです。このサイトに記載されているパッケージは、Composerで利用できます。つまり、ここにパッケージを登録すれば、全世界の人がComposerで利用することができるようになります。
Packagist - The PHP Package Repository
Composerをインストール
Composerをインストールしていきましょう。公式サイトにある手順に従ってComposerをインストールしていきます。
なお、Composerはroot権限で使わないことが推奨されているので、特に必要性がなければ、sudoの乱用はやめましょう。
How do I install untrusted packages safely? Is it safe to run Composer as superuser or root?
まず、https://getcomposer.org/installer をcomposer-setup.php
というファイル名で保存します。
$ php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" $ ls composer-setup.php
次に、ダウンロードしたファイルが意図しているものと同じなのか、ハッシュ値から確認します。
$ php -r "if (hash_file('SHA384', 'composer-setup.php') === '669656bab3166a7aff8a7506b8cb2d1c292f042046c5a994c43155c0be6190fa0355160742ab2e1c88d40d5be660b410') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" Installer verified
ダウンロードしたファイルを実行します。ここでは、filename
オプションを付けて、インストールされる実行ファイルのファイル名をcomposer
と指定しています。
$ php composer-setup.php --filename=composer All settings correct for using Composer Downloading... Composer (version 1.4.2) successfully installed to: /home/user1/composer Use it: php composer
composer
が生成されています。
$ ls
composer composer-setup.php
インストールに使ったファイルを削除します。
$ php -r "unlink('composer-setup.php');" $ ls composer
最後に、Composerをどのディレクトリからでも使えるよう、移動させます。
$ sudo mv composer /usr/local/bin/
以上で、Composerが利用できるようになりました。
$ composer --version Composer version 1.4.2 2017-05-17 08:17:52
PHPUnitをComposerでインストール
Composerが用意できたので、PHPUnitをインストールしていきます。
この記事で前提としている、第一弾で構築したパッケージ構成の環境では、PHPUnitに必要なOSのパッケージを事前にインストールしておく必要があります。
$ sudo yum -y install php-xml
その前に、これから作る画像加工ツールを、PHPUnitも含めてまとめて配置する適当なディレクトリを、各自のホームディレクトリに作成しておきましょう。
$ mkdir tool $ cd tool/
ここではtool
というディレクトリ名を作成しました。以下の作業はここで行っていきます。
それではPHPUnitをインストールしていきましょう。まず、composer.json
を作成します。このJSONファイルに、Composerで管理・インストールしたいパッケージを記載します。今回はPHPUnitを記載します。
$ vi composer.json { "require-dev": { "phpunit/phpunit": "3.7.*" } }
これだけで準備完了です。Composerを使ってインストールしてみましょう。
$ composer install Loading composer repositories with package information Updating dependencies (including require-dev) Package operations: 8 installs, 0 updates, 0 removals - Installing symfony/yaml (v2.8.24): Downloading (100%) - Installing phpunit/php-text-template (1.2.1): Downloading (100%) - Installing phpunit/phpunit-mock-objects (1.2.3): Downloading (100%) - Installing phpunit/php-timer (1.0.9): Downloading (100%) - Installing phpunit/php-file-iterator (1.4.2): Downloading (100%) - Installing phpunit/php-token-stream (1.2.2): Downloading (100%) - Installing phpunit/php-code-coverage (1.2.18): Downloading (100%) - Installing phpunit/phpunit (3.7.38): Downloading (100%) phpunit/phpunit-mock-objects suggests installing ext-soap (*) phpunit/php-code-coverage suggests installing ext-xdebug (>=2.0.5) phpunit/phpunit suggests installing phpunit/php-invoker (~1.1) Writing lock file Generating autoload files
これで、./vendor/bin/
にPHPUnitがインストールされました。確認してみましょう。
$ ./vendor/bin/phpunit --version
PHPUnit 3.7.38 by Sebastian Bergmann.
PHPUnitでテストコードを動かしてみよう
インストールしたPHPUnitを試しに動かしてみましょう。サンプルのテストコードを記述したファイルを作成します。
テストコードでは、「正解とする値」と、「実際のソースにあるロジックのアウトプットとして出てきた値」が等しいかどうかを比較します。
テストが成功する場合
次のようなSampleTest.php
ファイルを作成します。
<?php class SampleTest extends PHPUnit_Framework_TestCase { public function testEqual() { $expected = 5; // 期待する正解の値 $actual = 2 + 3; // 実際に得られる値 $this->assertEquals($expected, $actual); } }
このサンプルでは、「2 + 3」の結果が「5」、ということをテストしています。加算している部分は、本来であればプロダクト内にある関数を呼び出すコードに相当します。
これをPHPUnitで動かしてみます。
$ ./vendor/bin/phpunit SampleTest.php PHPUnit 3.7.38 by Sebastian Bergmann. . Time: 19 ms, Memory: 2.25MB OK (1 test, 1 assertion)
OKとなり、テストが無事に通りました。
テストが失敗する場合
次に、SampleTest.php
を失敗するように書き換え、挙動を確認してみます。
<?php class SampleTest extends PHPUnit_Framework_TestCase { public function testEqual() { $expected = 5; $actual = 2 + 4; // 期待される$expectedの値「5」と異なる $this->assertEquals($expected, $actual); } }
期待される値は「5」ですが、「2 + 4」の結果は「6」なので、期待値とは異なります。
$ ./vendor/bin/phpunit SampleTest.php PHPUnit 3.7.38 by Sebastian Bergmann. F Time: 18 ms, Memory: 2.50MB There was 1 failure: 1) SampleTest::testEqual Failed asserting that 6 matches expected 5. /home/user1/phpunit/SampleTest.php:8 FAILURES! Tests: 1, Assertions: 1, Failures: 1.
先ほどと異なり、FAILURESでテストが失敗しました。「8行目で5が期待されているのに実際には6になっている」、と教えてくれています。
このようなテストコードを、ロジックのソースコードを書く際にセットで用意しておくと、リファクタリングや機能追加によって意図しない影響を与えた場合でも、テストを実行すれば不具合を検知することができます。
テストコードの関数名
SampleTest.php
では、テストコードの関数名をtestEqual()
としていました。このようにtest
で始まる関数は、PHPUnitが自動的にテストだと判断します。
また、@test
アノテーションを使うことにより、test
で始まらない関数でもテストとして認識させることができます。これにより、テストの関数名を日本語で付けることもできます。
<?php class SampleTest extends PHPUnit_Framework_TestCase { /** * @test */ public function 値が等しいかどうか() { $expected = 5; $actual = 2 + 4; $this->assertEquals($expected, $actual); } }
集中線ツール作りを始めよう
この記事の目的は、テストコードを書きながら簡単な画像加工ツールを作ってみることですが、まずはどんなツールを作るのかを決めましょう。
ブログの記事などで、印象を強くするために写真に集中線が合成されていたり、そういう写真がズームするように何枚も連続で使われたりしているのを見たことありませんか。今回は、そんな集中線付きの画像を自動で生成するツールを作ってみます。
作成する集中線ツールの仕様を簡単にまとめてみます。
- 加工したい画像をアップロードできる
- 画像の右下にコピーライトの文字を重ねることができる
- 画像の中心に向けて集中線を重ねる(合成する)ことができる
- 画像の中心に向けてズームになるように任意の枚数に分割してトリミングできる
なお、この記事はテストを書きながら開発するスタイルを大まかに説明するものなので、ツールとして完全なものには仕上げていません。実用には、さらに細かいところを詰めていく必要あります。
集中線ツールのファイル構成
この記事で作成する集中線ツールのファイル構成は、次の図のようになります。
先ほどPHPUnitをインストールしたtool
配下に、ファイルを配置していきます。
デフォルト設定で、Apache上で動作させたいPHPファイルは、/var/www/html/
に配置しなければなりません。開発中のホームディレクトリにあるファイルを、修正するたびにすべてコピーするのは手間がかかります。そこで、今回はシンボリックリンクを作成してしまいましょう1。
$ sudo ln -s /home/user1/tool/ /var/www/html/ $ chmod 755 /home/user1/ $ ls -l /var/www/html/ 合計 0 lrwxrwxrwx 1 root root 17 X月 XX XX:XX tool -> /home/user1/tool/
これにより、ホームディレクトリにあるtool/
の中身を修正すれば、/var/www/html/tool/
にもその内容が反映され、ブラウザからアクセスしたときにもその内容が反映された状態になります。
集中線ツールに必要なファイルの準備
今回は既存のフレームワークは使わず、簡単なMVCモデルを自分で用意してみます。ここで準備するのは、以下のファイルです。
index.php
-
lib/
…… MVCを構成するファイル群 -
lib/controller.php
…… コントローラ -
lib/model.php
…… モデル -
lib/view.php
…… ビュー -
template/index.tpl
…… テンプレート
- MVCモデル
- UIを持つアプリケーション開発で使われる基本的なモデル。プログラムを、モデル(Model)、ビュー(View)、コントローラ(Controller)の3つの要素に分割し、それぞれビジネスロジック、出力、入力を担当させる。
index.php
集中線ツールにアクセスがあったときに、まず最初に呼び出されるPHPファイルです。この中でControllerを呼び出し、execute
関数を実行させます。
<?php require_once('lib/controller.php'); $controller = new Controller(); $controller->execute();
lib/controller.php
Controllerです。ModelとViewを順に呼び出します。
<?php class Controller { public function __construct() { } /** * index.phpから実行される関数 */ public function execute() { require_once('model.php'); require_once('view.php'); $modelInstance = new Model(); $viewInstance = new View(); $data = $modelInstance->dispatch(); $viewInstance->display($data); } }
lib/model.php
Modelです。この記事では、実際の画像処理ロジックをここに書いていきます。
<?php class Model { public function __construct() { } public function dispatch() { // データを処理し、$dataに格納 $data['msg'] = 'tmp'; return $data; } // 動作確認用 public function test() { return 'test'; } }
lib/view.php
Viewです。Modelで処理された値を用いてテンプレートを呼び出します。
<?php class View { public function __construct() { } /** * 画面を表示する */ public function display($data) { include('template/index.tpl'); } }
template/index.tpl
表示に利用されるテンプレートです。
<html> <body> <?php echo $data['msg']; ?> </body> </html>
以上のファイルを配置すると「tmp」と表示されるページが作成されます。次のURLにアクセスしてみましょう。
http://サーバのIPアドレスまたはドメイン/tool/
Model内で仮に与えているtmp
の文字が表示されていることが確認できます。
テストに必要なファイルを準備
集中線ツールに必要なファイルの次には、テストに必要なファイルを追加します。
-
phpunit.xml
…… 設定ファイル -
tests/bootstrap.php
…… 最初に実行されるbootstrapファイル -
tests/ModelTest.php
…… 実際のテストコードを記述するファイル
phpunit.xml
PHPUnitが実行されるときの設定をXMLで記載します。
テストコードが配置されているディレクトリや、呼び出すbootstrapファイルを指定しています。
<?xml version="1.0" encoding="UTF-8"?> <phpunit backupGlobals="false" backupStaticAttributes="false" bootstrap="./tests/bootstrap.php" colors="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" syntaxCheck="false" > <testsuites> <testsuite name="Application Test Suite"> <directory>./tests/</directory> </testsuite> </testsuites> </phpunit>
tests/bootstrap.php
それぞれのテストコードでrequire
を都度記載しなくても済むように、bootstrapファイル内でrequire
しています。この記述により、テストコード内からModelにある関数が呼び出せるようになります。
<?php require_once(__DIR__ . '/../lib/model.php');
tests/ModelTest.php
実際のテストコードを書いていくファイルです。
<?php class ModelTest extends PHPUnit_Framework_TestCase { public function testEqual() { $expected = 'test'; $this->assertEquals($expected, Model::test()); } }
テストの動作確認
tool
ディレクトリでPHPUnitを実行してみましょう。
テストを実行する際のカレントディレクトリにあるphpunit.xml
が読まれるため、テストの実行時にtests/bootstrap.php
が呼び出され、テスト対象のlib/model.php
が自動的にrequire_once
される仕組みです。
$ ./vendor/bin/phpunit PHPUnit 3.7.38 by Sebastian Bergmann. Configuration read from /home/user1/tool/phpunit.xml . Time: 21 ms, Memory: 2.50MB OK (1 test, 1 assertion)
これで開発に必要な土台は完成です。
ImageMagickを準備
ここで、画像の作成や加工に必要なソフトウェア「ImageMagick」もインストールしておきます。
Convert, Edit, Or Compose Bitmap Images @ ImageMagick
実際には、ImageMagickをPHPから利用可能にするネイティブのPHP拡張モジュール「Imagick」を利用します。
PHP: ImageMagick - Manual - 関数リファレンス
Imagickは、PHPの拡張モジュールを提供しているPECLでインストールできます。
そこで、まずPECLを使えるようにします。あわせて、Imagickに必要なImageMagick本体や、その動作に必要なパッケージをインストールします。
$ sudo yum -y install php-pear php-devel gcc ImageMagick ImageMagick-devel ImageMagick-perl
続いて、PECLでImagickモジュールをインストールします。
$ sudo pecl install imagick
Please provide the prefix of Imagemagick installation [autodetect] :
と表示されれば、そのままReturn
(Enter
)キーを押します。
最後に以下のようなメッセージが出ていれば成功です。
Build process completed successfully Installing '/usr/lib64/php/modules/imagick.so' Installing '/usr/include/php/ext/imagick/php_imagick_shared.h' install ok: channel://pecl.php.net/imagick-3.4.3 configuration option "php_ini" is not set to php.ini location You should add "extension=imagick.so" to php.ini
メッセージにある通り、/etc/php.ini
ファイルに次の行を追加しましょう。
extension=imagick.so
php.ini
ファイルの修正を反映させます。
$ systemctl restart httpd.service
以上で、PHPからImageMagickを利用できるようになりました。
集中線ツールの開発1 - テンプレートの表示を調整
Modelにロジックを追加していく前に、いま「tmp」とだけ表示されている画面を、実際にファイルがアップロードできるフォーム画面にしてみましょう。
テンプレートを修正してフォームを用意
template/index.tpl
を修正して画面を作りましょう。フォームを用意するだけの簡単なHTMLです。
<html> <body> <?php echo $data['msg']; ?> <form method="post" enctype="multipart/form-data"> 画像ファイル <input type="file" name="upload"> <br> 分割数:<input type="number" name="divide" value="4"><br> <button type="submit" name="submit" value="submit">Submit</button> </form> </body> </html>
この状態で先ほどのURLにアクセスすると、次のようにModelから渡されるデータ(「tmp」という文字列)とフォームが表示されます。
この画面から画像をアップロードし、集中線を合成して、指定の分割数で返すツールを作っていきます。
Controllerの修正 - POSTのみでModelを呼び出す
集中線ツールにアクセスしたときに、必ずModelから渡されるデータ(現在は「tmp」という文字列)が表示されています。しかし、今回のツールの仕様から、フォームから画像データがアップロードされた場合にのみModelが実行され、データを渡すようにします。
そこで、Controllerに少し手を加えます。先ほど作成したフォームでは、POSTで値を投げます。つまり、ツールへの最初のアクセスはGET、画像を指定して[Submit]ボタンが押されたときはPOSTと、メソッドによって処理を切り分けることができます。
これを用いて、POSTのアクセスのみModelを呼び出すように、Controllerを修正します。
<?php class Controller { public function __construct() { } /** * index.phpから実行される関数 */ public function execute() { require_once('model.php'); require_once('view.php'); $modelInstance = new Model(); $viewInstance = new View(); if (!empty($_POST)) { $data = $modelInstance->dispatch(); } $viewInstance->display($data); } }
この状態で先ほどのURLを開くと、GETのアクセスなのでModelは経由されず、「tmp」の表示が消えます。
ここで[Submit]ボタンを押すと、POSTになるのでModelを経由し、先ほどと同様に「tmp」が表示されます。
集中線ツールの開発2 - 画像を加工するロジックを追加
いよいよ本題となるModelを開発し、画像の処理を行っていきます。あわせて、書けるところについてテストコードを書いていきましょう。
ところで、画像が正しく加工できたのかどうかは、どうやってテストすればいいのでしょうか? 普段は画像のテストを書く機会がないので調べてみましたが、なかなかこれというベストな方法が見つかりません。
真面目に画像を比較するのにはコストがかかりそうなので、何か楽な手段を探してみましょう。せっかくImagickを使うのだから、その中に何か使えそうなものはないかと探してみたところ……。
ありました!
identifyImage()
という関数(ヘルプ参照)で、画像の大きさやファイルサイズ、シグネチャまで含めた配列を取得できるではありませんか。これを使えば単純な配列の比較で、あらかじめ用意した正解データの画像と、実際にModelで加工された画像の比較ができそうです。
画像の配置先を用意
画像をアップロードできるようにしていきます。画像のアップロード先のディレクトリと、加工した画像を出力するディレクトリを用意しましょう。
まずはアップロード先のディレクトリです。フォームから投げられた画像をまずはこのディレクトリに配置します。
$ mkdir upload $ chmod 777 upload/
同様に、加工した画像を配置するディレクトリも作成します。
$ mkdir output $ chmod 777 output/
画像をアップロードしてテンプレートに表示
フォームで指定した画像をアップロードし、その画像を表示させてみます。
Modelを修正しましょう。フォームで送られたファイルの情報は、$_FILES
から取得します。
<?php class Model { public function __construct() { } public function dispatch() { // アップロード先 $uploadPath = './upload/'; // ファイル名 $filename = $_FILES['upload']['name']; // tmp名 $tmpname = $_FILES['upload']['tmp_name']; // エラー $error = $_FILES['upload']['error']; // ファイルサイズ $size = $_FILES['upload']['size']; // エラーが無く、ファイルサイズが0ではない場合アップロード if ($error === 0 && $size > 0) { move_uploaded_file($tmpname, $uploadPath.$filename); } else { return; } // アップロードした画像を試しに表示 $data['msg'] = '<img src="'.$uploadPath.$filename.'" width="200">'; return $data; } }
実際に動作させてみましょう。画像ファイルとして「テスト素材.jpeg」を指定します。
[Submit]ボタンを押すとModelが呼び出され、アップロードされた画像が表示されます。
無事にファイルがアップロードできました。ディレクトリ内にもファイルが存在しています。
$ ls -la upload/ 合計 324 drwxrwxrwx 2 user1 user1 33 X月 XX XX:XX . drwxrwxr-x 8 user1 user1 4096 X月 XX XX:XX .. -rw-r--r-- 1 apache apache 326299 X月 XX XX:XX テスト素材.jpeg
画像のサイズ・座標を計算
集中線ツールの機能を1つずつ作っていきます。
まずは、画像を扱う際には欠かせない、各種サイズ・座標を計算する関数を用意します。元の画像の縦横のサイズを元に、指定した数で分割した画像の縦横サイズと、元画像から切り取る基準となる座標を計算して返します。
作る関数は生成する画像の大きさを計算する関数、生成する画像の切り取り開始位置を計算する関数に加えこの2つを利用してツールで必要なサイズ・座標の計算をする関数の計3つです。
ロジックを書き出す前に、テストを作ってみましょう。実際に期待する値は紙で計算して算出されたものを用いてテストを書いてみます。
まず、一つ目の生成する画像の大きさを計算する関数です。以下のような関数を作ろうと思います。
/** * 生成する画像の大きさ * @param int $size 元の大きさ * @param int $divide 分割数 * @param int $num 分割の何枚目なのかのインデックス番号 * @return int or double $dispSize 生成する画像の大きさ */
画像サイズなどのテストコードの作成
この仕様に基づき、期待する値を埋めたテストを作ります。
<?php class ModelTest extends PHPUnit_Framework_TestCase { /** * @test */ public function getDisplaySize_元の大きさが180、分割数が3、1枚目の場合、大きさが90() { $expected = 90; $this->assertEquals($expected, Model::getDisplaySize(180, 3, 0)); } /** * @test */ public function getDisplaySize_元の大きさが180、分割数が3、2枚目の場合、大きさが135() { $expected = 135; $this->assertEquals($expected, Model::getDisplaySize(180, 3, 1)); } /** * @test */ public function getDisplaySize_元の大きさが180、分割数が3、3枚目の場合、大きさが180() { $expected = 180; $this->assertEquals($expected, Model::getDisplaySize(180, 3, 2)); } }
ここまでの状態では、まだModelに関数が存在しないので、テストを実行するとこのように失敗します。
$ ./vendor/bin/phpunit PHPUnit 3.7.38 by Sebastian Bergmann. Configuration read from /home/user1/tool/phpunit.xml PHP Fatal error: Call to undefined method Model::getDisplaySize() in /home/user1/tool/tests/ModelTest.php on line 10
Modelにロジックを追加
この関数をModelに追加してみます。
/** * 生成する画像の大きさ * @param int $size 元の大きさ * @param int $divide 分割数 * @param int $num 分割の何枚目なのかのインデックス番号 * @return int or double $dispSize 生成する画像の大きさ */ public function getDisplaySize($size, $divide, $num) { $dispSize = $size * 1 / 2 + $size * 1 / 2 * 1 / ($divide - 1) * $num; return $dispSize; }
再度テストを実行してみます。
$ ./vendor/bin/phpunit PHPUnit 3.7.38 by Sebastian Bergmann. Configuration read from /home/user1/tool/phpunit.xml ... Time: 22 ms, Memory: 2.50MB OK (3 tests, 3 assertions)
無事にテストが通過しました。
他の2つの処理のテストとロジックを追加
この要領で、他の2つの関数のテストとロジックを書いていきます。
テストコードはこのように書きました。
/** * @test */ public function getDisplayPosition_元の大きさが180、生成する画像の大きさが90の場合、開始位置が45() { $expected = 45; $this->assertEquals($expected, Model::getDisplayPosition(180, 90)); } /** * @test */ public function getDisplayPosition_元の大きさが180、生成する画像の大きさが135の場合、開始位置が22.5() { $expected = 22.5; $this->assertEquals($expected, Model::getDisplayPosition(180, 135)); } /** * @test */ public function getDisplayPosition_元の大きさが180、生成する画像の大きさが180の場合、開始位置が0() { $expected = 0; $this->assertEquals($expected, Model::getDisplayPosition(180, 180)); } /** * @test */ public function calcImage_元の画像の幅が250、元の画像の高さが150、分割数が3、1枚目の場合のデータ() { $expected = array('width' => 250, 'height' => 150, 'dispWidth' => 125.0, 'dispHeight' => 75.0, 'dispX' => 62.5, 'dispY' => 37.5); $this->assertEquals($expected, Model::calcImage(250, 150, 3, 0)); } /** * @test */ public function calcImage_元の画像の幅が250、元の画像の高さが150、分割数が3、2枚目の場合のデータ() { $expected = array('width' => 250, 'height' => 150, 'dispWidth' => 187.5, 'dispHeight' => 112.5, 'dispX' => 31.25, 'dispY' => 18.75); $this->assertEquals($expected, Model::calcImage(250, 150, 3, 1)); } /** * @test */ public function calcImage_元の画像の幅が250、元の画像の高さが150、分割数が3、3枚目の場合のデータ() { $expected = array('width' => 250, 'height' => 150, 'dispWidth' => 250.0, 'dispHeight' => 150.0, 'dispX' => 0.0, 'dispY' => 0.0); $this->assertEquals($expected, Model::calcImage(250, 150, 3, 2)); }
Modelのロジックは以下の通りです。
/** * 生成する画像の切り取り開始位置 * @param int $size 元の大きさ * @param int $dispSize 生成する画像の大きさ * @return int or double $dispPosition 生成する画像の切り取り開始位置 */ public function getDisplayPosition($size, $dispSize) { $dispPosition = ($size - $dispSize) * 1 / 2; return $dispPosition; } /** * サイズ・座標の計算 * @param int $width 元画像の幅 * @param int $height 元画像の高さ * @param int $divide 分割数 * @param int $num 分割の何枚目なのかのインデックス番号 * @return array $imageData */ public function calcImage($width, $height, $divide, $num) { $imageData = array(); // 元画像の幅 $imageData['width'] = $width; // 元画像の高さ $imageData['height'] = $height; // 表示する画像の幅 $imageData['dispWidth'] = Model::getDisplaySize($width, $divide, $num); // 表示する画像の高さ $imageData['dispHeight'] = Model::getDisplaySize($height, $divide, $num); // 表示する画像を切り取るX座標 $imageData['dispX'] = Model::getDisplayPosition($width, $imageData['dispWidth']); // 表示する画像を切り取るY座標 $imageData['dispY'] = Model::getDisplayPosition($height, $imageData['dispHeight']); return $imageData; }
分割した画像を生成
ここまでの関数を使い、画像を任意の分割数に応じて画像を生成してみましょう。Imagickを使い、縦横の大きさを取得したり、画像をクロッピングして書き出しを行っています。
$image = new Imagick($uploadPath.$filename); // 元の画像サイズをImagickの関数で取得 $width = $image->getImageWidth(); $height = $image->getImageHeight(); $image->clear(); for ($i = 0; $i < $divide; $i++) { $imageData = $this->calcImage($width, $height, $divide, $i); $tmpImage = new Imagick($uploadPath.$filename); $tmpImage->cropImage($imageData['dispWidth'], $imageData['dispHeight'], $imageData['dispX'], $imageData['dispY']); $tmpImage->writeImage(__DIR__ . '/../output/'.preg_replace('/(.+)(\.[^.]+$)/', '$1', $filename).'_'.$i.'.jpg'); $tmpImage->clear(); $data['msg'] = '<img src="./output/'.preg_replace('/(.+)(\.[^.]+$)/', '$1', $filename).'_'.$i.'.jpg" width="300"><br>' . $data['msg']; }
ここで集中線ツールの画面を確認してみましょう。
指定分割数の4枚に分けて、画像の中心に向かってズームしていく連続画像ができました。
なお、この処理で呼び出している関数のテストは既に書いているので、テストは省略します。
画像にコピーライトを追加
次は画像の右下にコピーライトを追加してみましょう。
文字はImagickDrawで追加することができます。半角文字であればデフォルトのままで問題ありませんが、全角文字を扱いたい場合は全角文字に対応したフォントファイルを用意しましょう。
埋め込む文字列、フォントサイズや色を指定し、queryFontMetrics()
にて取得可能な埋め込む文字のサイズを元に、文字を配置する開始座標を指定して描画します。
生成された画像は、今回はimgタグのwidthによって表示する画像の大きさが統一されています。そのため、フォントサイズもその縮尺に合わせることで、画像によって文字の大きさにばらつきが出ないようにしています。$x
や$y
を求めている際の余白調整で、実数値ではなくフォントサイズを元に値を出しているのも、その縮尺に揃えるためです。
/** * コピーライトを追加 * @param imagick $image * @param array $imageData calcImage()で計算した結果 * @return imagick */ public function addCopyright($image, $imageData) { $text = '(C)ikenyal'; $draw = new ImagickDraw(); $fontSize = (int)(32 * $imageData['dispWidth'] / $imageData['width']); $draw->setFontSize($fontSize); $draw->setFillColor('#ff0000'); $metrics = $image->queryFontMetrics($draw, $text); $x = $imageData['dispWidth'] - $metrics['textWidth'] + $imageData['dispX'] - $fontSize * 0.5; $y = $imageData['dispHeight'] - $metrics['textHeight'] + $imageData['dispY'] + $fontSize * 0.5; $draw->annotation($x, $y, $text); $image->drawImage($draw); return $image; }
cropImage()
とwriteImage()
の間でこの関数を呼び出しましょう。
$tmpImage = $this->addCopyright($tmpImage, $imageData);
コピーライトのテストコードの作成
コピーライトを追加する関数のテストを書いてみましょう。
この関数のテストは楽して作ってしまおうと思います。コピーライト入りの画像は、ロジックを先に書いて実際にそれを実行して生成されたものを正解データとします。初回にきちんと目視確認したデータを正解データとして保存しておき、今後何かの改修時に異なるアウトプットになっていないかをテストで確認するようにします。
tests
ディレクトリの下にテスト用の画像を配置するimages
ディレクトリを作成します。
$ mkdir tests/images/
Model内で$imagesData
をprint_r()
で表示してその値を控え、それに対応するアウトプットされた画像をtests/images/
にコピーしておきます。
identifyImage()
で画像データが解釈された状態で配列として返されるので、その比較を行うだけで画像が同じものなのか判断できるようにしています。なお、ファイル名はもちろん異なるので、identifyImage()
で取得されるimageName
は意図的に空にしています。
/** * @test */ public function addCopyright_コピーライトが正しく追加されているか() { // 正解データ $expectedImage = new Imagick(__DIR__.'/images/withCopyright.jpg'); $expectedIdent = $expectedImage->identifyImage(); $expectedImage->clear(); // addCopyright()を実行してその結果を一時保存 $image = new Imagick(__DIR__.'/images/test.jpg'); $imageData = array('width' => 1024, 'height' => 768, 'dispWidth' => 1024, 'dispHeight' => 768, 'dispX' => 0, 'dispY' => 0); $image = Model::addCopyright($image, $imageData); $image->writeImage(__DIR__.'/images/test_after.jpg'); $image->clear(); // 一時保存された画像データ $image = new Imagick(__DIR__.'/images/test_after.jpg'); $ident = $image->identifyImage(); $image->clear(); unlink(__DIR__.'/images/test_after.jpg'); // ファイル名は異なるので意図的に空にする $expectedIdent['imageName'] = ''; $ident['imageName'] = ''; $this->assertEquals($expectedIdent, $ident); }
これでテストで画像の比較をするようにできました。
$ ./vendor/bin/phpunit PHPUnit 3.7.38 by Sebastian Bergmann. Configuration read from /home/user1/tool/phpunit.xml .......... Time: 542 ms, Memory: 2.50MB OK (10 tests, 10 assertions)
試しに、コピーライトの文言を一文字消してテストしてみるとこのように検知できます。
$ ./vendor/bin/phpunit PHPUnit 3.7.38 by Sebastian Bergmann. Configuration read from /home/user1/tool/phpunit.xml .........F Time: 556 ms, Memory: 2.75MB There was 1 failure: 1) ModelTest::addCopyright_コピーライトが正しく追加されているか Failed asserting that two arrays are equal. --- Expected +++ Actual @@ @@ 'compression' => 'JPEG' - 'fileSize' => '299KB' + 'fileSize' => '300KB' 'geometry' => Array (...) 'resolution' => Array (...) - 'signature' => '73a4afa50bf0e0fa6faa6d46c7288d765947c4f70375fd68cfc0a2be69b2c8b1' + 'signature' => 'eb3b380031a846d689602635c2cc88f8e910a72061cd9e81e61c5d7e48d6fcd6' ) /home/user1/tool/tests/ModelTest.php:127 FAILURES! Tests: 10, Assertions: 10, Failures: 1.
画像が異なれば、ファイルサイズやシグネチャの値が異なるので検知できます。
画像に集中線を合成
最後に、集中線を重ねてみます。集中線は、ニコニ・コモンズの素材ライブラリーにある画像を利用させてもらいました。
集中線 透過・合成用 1000px*1000px - ニコニ・コモンズ
ダウンロードした素材を、scp
コマンドなどでサーバにアップロードしておきます。
今回は、tool/linework.png
として配置しました。この画像を合成する関数を追加します。
/** * 集中線を追加 * @param imagick $image * @param array $imageData calcImage()で計算した結果 * @return imagick */ public function addLinework($image, $imageData) { $frameImage = new Imagick(__DIR__ . '/../linework.png'); $frameImage->scaleimage($imageData['dispWidth'], $imageData['dispHeight']); $image->compositeImage($frameImage, imagick::COMPOSITE_DEFAULT, 0, 0); return $image; }
addCopyright()
の前に、このaddLinework()
の処理を実行させます。
$tmpImage = $this->addLinework($tmpImage, $imageData);
実行してみましょう。
集中線のテストコードの作成
集中線の合成処理も、コピーライトと同様にテストを書いておきましょう。
/** * @test */ public function addLinework_集中線が正しく追加されているか() { // 正解データ $expectedImage = new Imagick(__DIR__.'/images/withLinework.jpg'); $expectedIdent = $expectedImage->identifyImage(); $expectedImage->clear(); // addCopyright()を実行してその結果を一時保存 $image = new Imagick(__DIR__.'/images/test.jpg'); $imageData = array('width' => 1024, 'height' => 768, 'dispWidth' => 1024, 'dispHeight' => 768, 'dispX' => 0, 'dispY' => 0); $image = Model::addLinework($image, $imageData); $image->writeImage(__DIR__.'/images/test_linework_after.jpg'); $image->clear(); // 一時保存された画像データ $image = new Imagick(__DIR__.'/images/test_linework_after.jpg'); $ident = $image->identifyImage(); $image->clear(); unlink(__DIR__.'/images/test_linework_after.jpg'); // ファイル名は異なるので意図的に空にする $expectedIdent['imageName'] = ''; $ident['imageName'] = ''; $this->assertEquals($expectedIdent, $ident); }
これで基本的な機能のテストも用意できたので、今後Modelを修正するときには安心してソースをいじることができます。
動作確認してみよう
最後に、実際に画像を使って動作確認をしてみましょう。ネコの画像を用意して、ツールにアップロードしてみました。
迫力ありますね。
みなさんも、自分が作成したツールに手近な人物や動物の写真をアップロードしてみてください。
おわりに
テストコードを書きながら、簡単な画像加工ツールを作ってみました。この記事で作成したソースコード等の全体は下記のURLから入手できます。
この集中線ツールをサービスとして提供するには、いろいろと気を付けないといけないことがあります。例えば upload
ディレクトリにアップロードされた画像を削除する処理がありません。このままリリースしたら、いつかはディスク容量を食い潰してしまうでしょう。
脆弱性にも注意
脆弱性を含まないサービスにするために気を配る必要もあります。不特定多数の利用者が任意のファイルをアップロードできるということは、脆弱性を生み出す可能性も持ち合わせることになります。アップロードできる拡張子を制限したり、ファイルサイズの制限を定めたり、攻撃をされないようにいろいろ気を付けましょう。
ImageMagick自体の脆弱性が見つかることもあるので、その際にはImageMagickのアップデートを早急に行う必要があります。サービスを提供する場合には、このようなことを意識する必要もあります。
自動テストとデプロイ
今回はテストコードを書いて手動で実行していましたが、Jenkinsなどで自動的にテストを実行する環境も作っていきましょう。
また、デプロイに関する内容を紹介していないので、シンボリックリンクでひとまず動かしました。サービスとして提供する場合はこのやり方ではなく、きちんとしたデプロイの手段を用いる必要があります。デプロイに関しては機会があれば続編を書きたいと思います。
執筆者
池田健人(いけだ・けんと) @ikenyal
編集:薄井千春(ZINE)
*1:
*2:エンジニアと著作権など法律との関係については、エンジニアHubに掲載した「あなたのコード、違法かも? エンジニアも知りたい、弁護士が教える著作権と開発契約の法知識」(https://eh-career.com/engineerhub/entry/2017/07/27/110000)の記事などを参照してください。