休日個人開発で学ぶテストコード! 画像に“集中線”を合成するツールを作ってみよう

プライベートでも何か作りたい! そんなときの「今日からはじめる休日個人開発」シリーズ、第二弾はテストコードを書きながら簡単なMVCモデルの画像加工ツールを作ってみましょう。好きな写真に集中線を合成できます。

休日個人開発で学ぶテストコード! 画像に“集中線”を合成するツールを作ってみよう

皆さん、プライベートで何か開発していますか? 「何か作りたい」という気持ちはあるものの、いまひとつ何から始めたらいいのか分からず、動けないままの人も多いと思います。

そんな皆さんのために、「仕事以外にも休日に個人で気軽に何かを作ってみよう!」という企画の第二弾です。今回は、第一弾で用意した開発環境を使って、画像を加工するツールを実際に作っていきます。

せっかくですので、ただ作るだけではなく、テストコードも一緒に書いてみましょう。最近は、CI(継続的インテグレーション)やCD(継続的デリバリー)も一般的になり、テストコードを書く機会が増えています。それを踏まえて、今回はテストコードを書く意義や、実際の書き方に焦点を当てていきます。

なぜテストコードを書くとよいのか

テストコードを駆使した開発手法に、TDDテスト駆動開発, test-driven development)があります。まず、TDDとテストコードの概要に触れておきます。

TDD(テスト駆動開発)とテストコード

TDDとは、簡単に言えばテストコードを中心とした開発手法のことです。TDDではプロダクトに新しい機能を追加する際に、先行してテストコードを書いたあと、それに対応するロジックを書いていきます。

TDDのサイクル

  1. 【Red】テストコードを書く
  2. 【Green】テストコードが動く最低レベルのソースコードを書く
  3. 【Refactor】テストコードが動く状態でそのソースコードをブラッシュアップさせる

最初はロジック自体が存在しないのでテストはもちろん失敗します(【Red】)。後から動作するロジックを書いていき(【Green】)、そのテストを動く状態に保ちながらリファクタリングしていく(【Refactor】)やり方です。

しかし、いきなりTDDを完璧にやろうとしても、失敗する可能性が高いです。始めのうちは雑でもいいので、テストコードを書くこと自体への抵抗をなくし、習慣化することが大切です。まずは、TDDまでやらなくとも、テストコードを書いてみましょう。

テストコードを書く意味

そもそも何のためにテストコードを書くのでしょうか。

開発しているシステムの規模が徐々に大きくなったり、他の開発者から引き継いだりしたときに、「ソースコードのこの部分をいじると副作用で何が起こるか分からないから、怖いので触れたくない」と感じることは意外と多いでしょう。私自身もこういった経験があります。精神衛生上もよろしくなく、「過剰に気を付ける」ことで開発効率も落ちてしまいます。

しかし、きちんと保守されているテストコードがあれば、そのような困った事態になる可能性を下げられます。ソースコードを修正したときにテストを実行すれば、意図しない影響を与えていないのかを確認することができます。テストを動かすだけで「(本来はこうあるべき出力が)こんな値になっているよ」と教えてくれるのです。

また、テストコードは、ドキュメントが整備されなくなった場合に、プログラムの仕様を担保する最後の砦になってくれます。

テストコードを書くときの注意点

テストコードを書く際には、どんなことに気を付けたらいいのでしょうか?

テストコードを書くためには、ソースコード自体も「テストコードを動かす」ことを意識した書き方をすると、テストコードが書きやすくなります。関数を機能ごとに分割しないとテストが書きにくくなるので、多くの処理を一つの関数に詰め込まないなどの意識は必要です。

また、テストコードを書いていくためには、一人だけで頑張るのではなく、一緒に開発する人全員の同意・協力・理解が必要になるでしょう。エンジニアには、ソースコードに手を加える際に一緒にテストコードを直してもらい、エンジニア以外の職種の人にはテストコードを書く意義を理解してもらう必要があるでしょう。

テストコードを書きはじめたばかりのころは、書き方や文化に慣れるまで、それまでより開発速度が一時的に落ちるでしょう。しかし、一時的にコストがかかっても長期的にはメリットが大きいことを理解してもらわないと、職種間で温度差が生じ、トラブルにもなりかねません。

まずは、少しずつでもテストコードを書いてみて、抵抗感をなくしていくことが大切です。いきなり「完璧なテストコードを書こう」と無理すると、長続きせずに挫折してしまう可能性が高いです。

実務の中でも、テストコードを書くことが難しい処理に出くわす場合も少なからずあります。そのようなときでも「必ずテストコードを書かないといけない」と強迫観念にとらわれる必要はありません。手動で結合テストを実施すれば済むものもありますし、処理によってはわざわざテストコードを書く必要がないものもあります。

コストとメリットを考え、必要なもの、可能なものから書いていけばいいのです。無理のない範囲から、テストコードに慣れていきましょう。

参考資料:「50分でわかるテスト駆動開発」

日本でTDDの第一人者といえば、和田卓人(@t_wada)さんの名前がよく挙がります。TDDやテストコードについてインターネットで調べていると、t_wadaさんがまとめた発表資料やスライドを目にすることも多いはず。

最近では、マイクロソフトのイベント「de:code 2017」の発表資料(2017年5月)が、当日のトークもあわせて公開されています。TDDの学習に役立つので、時間を作って一度見てみましょう。

1 50分でわかるテスト駆動開発 | de:code 2017 | Channel 9 2

PHPUnitのインストールと準備

テストコードを動かす環境を整えていきましょう。今回はPHPでツールを実行するので、PHPUnitを用いてテストを書いていきます。

PHPUnit – The PHP Testing Framework 3

PHPUnitのインストール方法はいくつかあり、公式サイトのマニュアルに記載されています。

なお、この記事では、第一弾で用意したPHP開発環境を前提として説明します。

今日からはじめる休日個人開発 ~ クラウドサービスの選定から、WebサーバでPHPを動かすまで 5

コラム:開発環境を最新の状態にする

環境を構築してから時間が経っている方は、脆弱性対策のため、次の手順で最新の状態にしましょう。

$ sudo yum update

カーネル関連のアップデートが含まれている場合は、OSを再起動して、更新した内容を反映させます。

$ sudo reboot

Composer - PHPのパッケージ管理ツール

今回はPHPUnitを、Composerを使ってインストールします。Composerは、PHPでよく使われるパッケージ管理ツールなので、使い方を一緒に覚えてしまいましょう。

6 Composer 7

Composerでは、使いたいパッケージをJSONで指定することで管理できます。

例えば、GitHubで公開したソースコードを動かすために、他のパッケージがいくつか必要になる場合もあります。そのような場合に、必要なパッケージやそのバージョン情報をJSONファイルに記載して一緒にGitHubへ上げておくことにより、必要なパッケージに依存するパッケージも含め、そのパッケージ構成を管理できます。利用者は動作に必要なパッケージを、Composerを利用して各自の環境へ簡単にインストールできます。

Composerのメインリポジトリが、Packagistです。このサイトに記載されているパッケージは、Composerで利用できます。つまり、ここにパッケージを登録すれば、全世界の人がComposerで利用することができるようになります。

Packagist - The PHP Package Repository 9

Composerをインストール

Composerをインストールしていきましょう。公式サイトにある手順に従ってComposerをインストールしていきます。

なお、Composerはroot権限で使わないことが推奨されているので、特に必要性がなければ、sudoの乱用はやめましょう。

10 How do I install untrusted packages safely? Is it safe to run Composer as superuser or root?

まず、https://getcomposer.org/installercomposer-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);
  }
}

集中線ツール作りを始めよう

この記事の目的は、テストコードを書きながら簡単な画像加工ツールを作ってみることですが、まずはどんなツールを作るのかを決めましょう。

ブログの記事などで、印象を強くするために写真に集中線が合成されていたり、そういう写真がズームするように何枚も連続で使われたりしているのを見たことありませんか。今回は、そんな集中線付きの画像を自動で生成するツールを作ってみます。

11

この記事で説明するツールで加工できる集中線を追加した画像の例

作成する集中線ツールの仕様を簡単にまとめてみます。

  • 加工したい画像をアップロードできる
  • 画像の右下にコピーライトの文字を重ねることができる
  • 画像の中心に向けて集中線を重ねる(合成する)ことができる
  • 画像の中心に向けてズームになるように任意の枚数に分割してトリミングできる

なお、この記事はテストを書きながら開発するスタイルを大まかに説明するものなので、ツールとして完全なものには仕上げていません。実用には、さらに細かいところを詰めていく必要あります。

集中線ツールのファイル構成

この記事で作成する集中線ツールのファイル構成は、次の図のようになります。

12

先ほど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」もインストールしておきます。

13 Convert, Edit, Or Compose Bitmap Images @ ImageMagick 14

実際には、ImageMagickをPHPから利用可能にするネイティブのPHP拡張モジュール「Imagick」を利用します。

15 PHP: ImageMagick - Manual - 関数リファレンス 16

Imagickは、PHPの拡張モジュールを提供しているPECLでインストールできます。

17 PHP: PECLインストール入門 - Manual 18

そこで、まず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] :と表示されれば、そのままReturnEnter)キーを押します。

最後に以下のようなメッセージが出ていれば成功です。

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」という文字列)とフォームが表示されます。

19

この画面から画像をアップロードし、集中線を合成して、指定の分割数で返すツールを作っていきます。

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」の表示が消えます。

20

ここで[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」を指定します。

21

[Submit]ボタンを押すとModelが呼び出され、アップロードされた画像が表示されます。

22

無事にファイルがアップロードできました。ディレクトリ内にもファイルが存在しています。

$ 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、分割数が31枚目の場合、大きさが90()
  {
    $expected = 90;
    $this->assertEquals($expected, Model::getDisplaySize(180, 3, 0));
  }

  /**
   * @test
   */
  public function getDisplaySize_元の大きさが180、分割数が32枚目の場合、大きさが135()
  {
    $expected = 135;
    $this->assertEquals($expected, Model::getDisplaySize(180, 3, 1));
  }

  /**
   * @test
   */
  public function getDisplaySize_元の大きさが180、分割数が33枚目の場合、大きさが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'];

    }

ここで集中線ツールの画面を確認してみましょう。

23

指定分割数の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内で$imagesDataprint_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.

画像が異なれば、ファイルサイズやシグネチャの値が異なるので検知できます。

画像に集中線を合成

最後に、集中線を重ねてみます。集中線は、ニコニ・コモンズの素材ライブラリーにある画像を利用させてもらいました。

24 集中線 透過・合成用 1000px*1000px - ニコニ・コモンズ

インターネット上の素材を利用する際には利用条件を必ず確認しましょう2。ニコニ・コモンズでは、営利目的利用と利用許可範囲の組み合わせで利用条件が選択されます。この素材は、本稿の公開時点で「利用許可範囲:インターネット全般」「営利目的:利用可能」となっていました。

ダウンロードした素材を、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);

実行してみましょう。

26
集中線のテストコードの作成

集中線の合成処理も、コピーライトと同様にテストを書いておきましょう。

  /**
   * @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を修正するときには安心してソースをいじることができます。

動作確認してみよう

最後に、実際に画像を使って動作確認をしてみましょう。ネコの画像を用意して、ツールにアップロードしてみました。

27

迫力ありますね。

みなさんも、自分が作成したツールに手近な人物や動物の写真をアップロードしてみてください。

おわりに

テストコードを書きながら、簡単な画像加工ツールを作ってみました。この記事で作成したソースコード等の全体は下記のURLから入手できます。

28 github-sample/tool

この集中線ツールをサービスとして提供するには、いろいろと気を付けないといけないことがあります。例えば uploadディレクトリにアップロードされた画像を削除する処理がありません。このままリリースしたら、いつかはディスク容量を食い潰してしまうでしょう。

脆弱性にも注意

脆弱性を含まないサービスにするために気を配る必要もあります。不特定多数の利用者が任意のファイルをアップロードできるということは、脆弱性を生み出す可能性も持ち合わせることになります。アップロードできる拡張子を制限したり、ファイルサイズの制限を定めたり、攻撃をされないようにいろいろ気を付けましょう。

ImageMagick自体の脆弱性が見つかることもあるので、その際にはImageMagickのアップデートを早急に行う必要があります。サービスを提供する場合には、このようなことを意識する必要もあります。

自動テストとデプロイ

今回はテストコードを書いて手動で実行していましたが、Jenkinsなどで自動的にテストを実行する環境も作っていきましょう。

また、デプロイに関する内容を紹介していないので、シンボリックリンクでひとまず動かしました。サービスとして提供する場合はこのやり方ではなく、きちんとしたデプロイの手段を用いる必要があります。デプロイに関しては機会があれば続編を書きたいと思います。

執筆者

池田健人(いけだ・けんと) @ikenyal

{$image_29}
サーバサイドエンジニア。学生時代に研究室や学部の各種サーバ・ネットワーク構築などを経験し、プログラミング以外の技術も学ぶ。2011年に某Web系IT企業に入社。エンジニア職でありつつも、校正スキルには自信あり。現在はリーダーとしてマネジメントスキルを習得中。

編集:薄井千春(ZINE)

*1:

*2:エンジニアと著作権など法律との関係については、エンジニアHubに掲載した「あなたのコード、違法かも? エンジニアも知りたい、弁護士が教える著作権と開発契約の法知識」(https://eh-career.com/engineerhub/entry/2017/07/27/110000)の記事などを参照してください。


  1. デプロイの方法や自動化に関しては今回の記事では割愛しますが、機会があれば次回以降に紹介したいと思います。

  2. エンジニアと著作権など法律との関係については、エンジニアHubに掲載した「あなたのコード、違法かも? エンジニアも知りたい、弁護士が教える著作権と開発契約の法知識」の記事などを参照してください。

若手ハイキャリアのスカウト転職