Spring Bootキャンプ ハンズオン資料

前提条件

  • Java SE 8がインストールされていること。
  • Mavenがインストールされていることかつ基本的なことが分かること。
  • Gitがインストールされていること。
  • curlがインストールされていること。
  • OSがWindowsまたはMacOSであること。
  • DIの基本的な知識を有していること。
  • JavaでWebプログラミングの経験があること。
  • (カメラを使う場合)PCにカメラがついていること。
  • (Dockerを使う場合)boot2dockerがインストールされていること、またはAWSのアカウントを持っていること。

作るシステム

下図のような顔変換サービスを作ります。

_images/system.png

警告

本ハンズオンで作成するアプリはジョークアプリで実用性を考慮していません。実際の開発の参考にする場合は十分気をつけてください。

目次

[事前準備] Spring BootでHello World

Spring BootプロジェクトをMaven Archetypeから作ります。今回は拙作のspring-boot-docker-blankを使用します。 このMaven ArchetypeにはDockerデプロイするための設定が予め行われています。

以下のコマンドでプロジェクトを作成しましょう。ターミナルまたはコマンドプロンプトに貼付けてください。

  • Bashを使っている場合

    $ mvn archetype:generate -B\
     -DarchetypeGroupId=am.ik.archetype\
     -DarchetypeArtifactId=spring-boot-docker-blank-archetype\
     -DarchetypeVersion=1.0.2\
     -DgroupId=kanjava\
     -DartifactId=kusokora\
     -Dversion=1.0.0-SNAPSHOT
    $ cd kusokora
    
  • コマンドプロンプトを使っている場合

    $ mvn archetype:generate -B^
     -DarchetypeGroupId=am.ik.archetype^
     -DarchetypeArtifactId=spring-boot-docker-blank-archetype^
     -DarchetypeVersion=1.0.2^
     -DgroupId=kanjava^
     -DartifactId=kusokora^
     -Dversion=1.0.0-SNAPSHOT
    $ cd kusokora
    

生成されたプロジェクトは以下のような構造になっています。

kusokora/
├── pom.xml
└── src
    ├── main
    │   ├── docker ... Docker用ファイル格納フォルダ。(Dockerデプロイするときのみ使う)
    │   │   ├── Dockerfile.txt
    │   │   └── Dockerrun.aws.json
    │   ├── java
    │   │   └── kanjava
    │   │       └── App.java ... アプリケーションコードを書くJavaファイル。今回のハンズオンでは基本的にこのファイルしか使わない。
    │   └── resources
    │       └── application.yml ... Spring Bootの設定ファイル。無くても良い。
    └── test ... テスト用フォルダ。今回は使わない。
        ├── java
        └── resources

以下のようなpom.xmlになっています。簡単に説明を加えたので、気になる人は見ておいてください。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>kanjava</groupId>
    <artifactId>kusokora</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>Spring Boot Docker Blank Project (from https://github.com/making/spring-boot-docker-blank)</name>

    <!-- 最重要。Spring Bootの諸々設定を引き継ぐための親情報。 -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.2.1.RELEASE</version>
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <start-class>kanjava.App</start-class><!-- mainメソッドのあるクラスを明示的に指定 -->
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <!-- 重要。Webアプリをつくるための設定。必要な依存関係は実はこれだけ。 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- メトリクスや環境変数を返すエンドポイントの設定。ここはおまけ。 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <!-- テストの設定。今回はテストしないので、ここはおまけ。 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <!-- Spring Bootプラグインの設定(必須)。Spring Loadedも設定している。 -->
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <dependencies>
                    <dependency><!-- Java SE 8u40だと動きません。ここの設定を削除してください https://github.com/spring-projects/spring-loaded/issues/108 -->
                        <groupId>org.springframework</groupId>
                        <artifactId>springloaded</artifactId>
                        <version>${spring-loaded.version}</version>
                    </dependency>
                </dependencies>
            </plugin>

            <!-- ここから下はDocker用のちょっとした設定で本質的でない。無視しても良い。 -->
            <!-- Copy Dockerfile -->
            <plugin>
                <artifactId>maven-resources-plugin</artifactId>
                <executions>
                    <execution>
                        <id>copy-resources</id>
                        <phase>validate</phase>
                        <goals>
                            <goal>copy-resources</goal>
                        </goals>
                        <configuration>
                            <outputDirectory>${basedir}/target/</outputDirectory>
                            <resources>
                                <resource>
                                    <directory>src/main/docker</directory>
                                    <filtering>true</filtering>
                                </resource>
                            </resources>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
            <!-- ほんとどうでもいい設定。 -->
            <plugin>
                <groupId>com.coderplus.maven.plugins</groupId>
                <artifactId>copy-rename-maven-plugin</artifactId>
                <version>1.0</version>
                <executions>
                    <execution>
                        <id>rename-file</id>
                        <phase>validate</phase>
                        <goals>
                            <goal>rename</goal>
                        </goals>
                        <configuration>
                            <sourceFile>${basedir}/target/Dockerfile.txt</sourceFile>
                            <destinationFile>${basedir}/target/Dockerfile</destinationFile>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
            <!-- AWS Elastic BeanStalk用のzipを作成。ここも本質的でない。 -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-antrun-plugin</artifactId>
                <version>1.7</version>
                <executions>
                    <execution>
                        <id>zip-files</id>
                        <phase>package</phase>
                        <goals>
                            <goal>run</goal>
                        </goals>
                        <configuration>
                            <target>
                                <zip destfile="${basedir}/target/app.zip" basedir="${basedir}/target" includes="Dockerfile, Dockerrun.aws.json, ${project.artifactId}.jar" />
                            </target>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

src/main/java/kanjava/App.javaを見てください。

package kanjava;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@RestController
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }

    @RequestMapping(value = "/")
    String hello() {
        return "Hello World!";
    }
}

@SpringBootApplicationが魔法のアノテーションです。このアノテーションは以下の3アノテーションを1つにまとめたものです。

アノテーション 説明
@EnableAutoConfiguration
Spring Bootの自動設定群を有効にします。
@ComponentScan
コンポーネントスキャンを行う。このクラスのパッケージ配下で@Component, @Service, @Repository, @Controller, @RestController, @Configuration,@NamedつきのクラスをDIコンテナに登録します。
@Configuration
このクラス自体をBean定義可能にします。@Beanをつけたメソッドをこのクラス内に定義することで、DIコンテナにBeanを登録できます。

@RestControllerをつけることで、このクラス自体がSpring MVCのコントローラーになります。 このアノテーションをつけたクラスのメソッドに@RequestMappingをつけるとリクエストを受けるメソッドになり、そのメソッドの返り値がレスポンスボディに書き込まれます。

この例だと、”/”にアクセスするとhello()メソッドが呼ばれ、”Hello World!”がレスポンスボディに書き込まれます。Content-Typeは”text/plain”になります。

mainメソッドを見てください。SpringApplication.run(App.class, args)がSpring Bootアプリケーションを起動するメソッドです。

このmainメソッドをIDEから実行してみてください。Tomcatが立ち上がり、8080番ポートがlistenされます。すでに8080番ポートが使用されている場合は、起動に失敗するので使用しているプロセスを終了させてください。

http://localhost:8080にアクセスしてください。「Hello World!」が表示されましたか?

次にMavenプラグインから実行してみましょう。

$ mvn spring-boot:run

同様に起動しますね。

注釈

この雛形プロジェクトには”Spring Boot Actuator”が設定されており、環境変数やメトリクス、ヘルスチェックなど非機能面のサポートが初めからされています。 次のURLにアクセスして、色々な情報を取得してみてください。

Chromeを利用している場合は、JSONViewをインストールしておくと便利です。

今度は実行可能jarを作ります。

$ mvn clean package

targetの下にkusokora.jarが出来ています。これを実行してください。

$ java -jar target/kusokora.jar

これも同様に起動します。

ちなみに、ポート番号を変えるときは

$ mvn spring-boot:run -Drun.arguments="--server.port=9999"

$ java -jar target/kusokora.jar --server.port=9999

で指定できます。今度はhttp://localhost:9999にアクセスできます。

最後にsrc/main/resources/application.ymlを見てください。以下の設定がされています。 よく使うものが予め設定されていますが、今回は特に必要ではありません。気になるようであれば削除してください。ファイルごと消しても構いません。

# See http://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html
spring:
  thymeleaf.cache: false # Thymeleafを使ったときにテンプレートをキャッシュさせない(開発用)
  main.show-banner: false # 起動時にバナー表示をOFFにする

注釈

Dockerデプロイも試したい場合は、「Dockerを使ってみる」を先にみてください。

本章の内容を修了したらハッシュタグ「#kanjava_sbc #sbc01」をつけてツイートしてください。

[事前準備] JavaでOpenCVを使う

画像処理を行うために、超有名なライブラリであるOpenCVを使用します。JavaからOpenCVを扱うために、今回はJavaCVというライブラリを使います。

JavaCVはJavaCPPというC++のソースから自動生成してできるブリッジのようなもので作られています。MavenやGradleなどの依存性解決の仕組みで簡単に利用できて、手軽にセットアップできるので今回採用しました。

本章では、手元の環境でJavaCVを利用できるかどうかを確認します。(本章の内容はSpring Bootとは一切関係がありません)

まずは先ほどのkusokoraディレクトリの1つ上の階層に移動してください。

$ cd ..

そして、サンプルプロジェクトをcloneします。

$ git clone https://github.com/making/hello-cv.git
$ cd hello-cv

チェックアウトしたプロジェクトのpom.xmlの後半の部分を見てください。

<profiles>
    <profile>
        <id>macosx-x86_64</id>
        <activation>
            <os>
                <family>mac</family>
                <arch>x86_64</arch>
            </os>
        </activation>
        <properties>
            <classifier>macosx-x86_64</classifier>
        </properties>
    </profile>
    <profile>
        <id>linux-x86_64</id>
        <activation>
            <os>
                <family>unix</family>
                <arch>amd64</arch>
            </os>
        </activation>
        <properties>
            <classifier>linux-x86_64</classifier>
        </properties>
    </profile>
    <profile>
        <id>windows-x86_64</id>
        <activation>
            <os>
                <family>windows</family>
                <arch>amd64</arch>
            </os>
        </activation>
        <properties>
            <classifier>windows-x86_64</classifier>
        </properties>
    </profile>
    <profile>
        <id>windows-x86</id>
        <activation>
            <os>
                <family>windows</family>
                <arch>x86</arch>
            </os>
        </activation>
        <properties>
            <classifier>windows-x86</classifier>
        </properties>
    </profile>
</profiles>

実行環境により、どのプロファイル(アーキテクチャ)を使用するかを判断しています。このプロファイルで定義されている<classifier>プロパティが、

<dependency>
    <groupId>org.bytedeco.javacpp-presets</groupId>
    <artifactId>opencv</artifactId>
    <version>${opencv.version}</version>
    <classifier>${classifier}</classifier>
</dependency>

に使われ、環境にあったネイティブライブラリをダウンロードします。

では早速サンプルアプリを実行してみましょう。

$ mvn compile exec:java -Dexec.mainClass=com.example.App

次のようなログが、出力され、

path = /Users/maki/tmp/hello-cv/target/classes/lena.png
image = IplImage[width=512,height=512,depth=8,nChannels=3]

下図のように、src/main/resources/lena.pngのサイズが半分になったhalf-lena.png(右側)が出来あがります。

_images/half.png

このプログラムが問題なく実行で切れいれば、OpenCVの動作検証はOKです。以下は読み飛ばしても構いません。

注釈

以下のようなエラーが出ていたら、ネイティブライブラリが正しく設定されていません。

java.lang.reflect.InvocationTargetException
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:483)
    at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:293)
    at java.lang.Thread.run(Thread.java:745)
Caused by: java.lang.UnsatisfiedLinkError: no jniopencv_core in java.library.path
    at java.lang.ClassLoader.loadLibrary(ClassLoader.java:1857)
    at java.lang.Runtime.loadLibrary0(Runtime.java:870)
    at java.lang.System.loadLibrary(System.java:1119)
    at org.bytedeco.javacpp.Loader.loadLibrary(Loader.java:535)
    at org.bytedeco.javacpp.Loader.load(Loader.java:410)
    at org.bytedeco.javacpp.Loader.load(Loader.java:353)
    at org.bytedeco.javacpp.opencv_core.<clinit>(opencv_core.java:10)
    at java.lang.Class.forName0(Native Method)
    at java.lang.Class.forName(Class.java:340)
    at org.bytedeco.javacpp.Loader.load(Loader.java:385)
    at org.bytedeco.javacpp.Loader.load(Loader.java:353)
    at org.bytedeco.javacpp.opencv_highgui.<clinit>(opencv_highgui.java:13)
    at com.example.App.resize(App.java:18)
    at com.example.App.main(App.java:14)
    ... 6 more
Caused by: java.lang.UnsatisfiedLinkError: no opencv_core in java.library.path
    at java.lang.ClassLoader.loadLibrary(ClassLoader.java:1857)
    at java.lang.Runtime.loadLibrary0(Runtime.java:870)
    at java.lang.System.loadLibrary(System.java:1119)
    at org.bytedeco.javacpp.Loader.loadLibrary(Loader.java:535)
    at org.bytedeco.javacpp.Loader.load(Loader.java:401)
    ... 15 more

うまくいかない場合は、自分の環境に合わせて、次のように明示的にプロファイルを指定してみてください。

$ mvn compile exec:java -Dexec.mainClass=com.example.App -P<classifier>

<classifier>には以下のいずれかの値が入ります。

  • windows-x86_64
  • linux-x86_64
  • macosx-x86_64
  • windows-x86
  • linux-x86

少しだけソースコードを確認しましょう。

package com.example;

import java.net.URISyntaxException;
import java.nio.file.Paths;

import static org.bytedeco.javacpp.opencv_core.*;
import static org.bytedeco.javacpp.opencv_highgui.*;
import static org.bytedeco.javacpp.opencv_imgproc.*;

public class App {
    public static void main(String[] args) throws URISyntaxException {
        // 引数で与えられたパスかクラスパス上のlena.pngを使用する。
        String filepath = args.length > 0 ? args[0] : Paths.get(
                App.class.getResource("/lena.png").toURI()).toString();
        resize(filepath);
    }

    public static void resize(String filepath) {
        // 画像を読み込んで、IplImageインスタンスを作成する
        IplImage source = cvLoadImage(filepath, CV_LOAD_IMAGE_ANYDEPTH | CV_LOAD_IMAGE_ANYCOLOR);
        System.out.println("path = " + filepath);
        System.out.println("image = " + source);
        if (source != null) {
            // 変換後の画像を作成する。幅と高さが元画像の半分になるようにする
            IplImage dest = cvCreateImage(cvSize(source.width() / 2, source.height() / 2), source.depth(), source.nChannels());
            // リサイズする
            cvResize(source, dest, CV_INTER_NN);
            // 画像を保存する
            cvSaveImage("half-" + Paths.get(filepath).getFileName().toString(), dest);
            cvReleaseImage(source);
            cvReleaseImage(dest);
        }
    }
}

引数をとって任意の画像をリサイズする場合は、以下のように実行してください。

$ mvn compile exec:java -Dexec.mainClass=com.example.App -Dexec.args=hoge.png

ここでは古いOpenCVのAPIを使用しました。

次に新しいOpenCV 2系のC++ APIに対応したJava APIを使用します。また、OpenCVでおなじみの顔認識を行います。

注釈

Open CV 2系のAPIリファレンスはこのサイトがわかりやすいです。ほとんどのコードがJavaCVでも利用できるので、遊んでみてください。

サンプルコードのブランチをdukerに切り替えます。

$ git checkout duker

再度、サンプルアプリを実行しましょう。

$ mvn compile exec:java -Dexec.mainClass=com.example.App

次のようなログが、出力され、

load /Users/maki/tmp/hello-cv/target/classes/lena.png
1 faces are detected!

下図のように、src/main/resources/lena.pngの顔の部分がDukeのように変換されたduked-faces.png(右側)が出来あがります。

_images/duke.png

プログラムを見てみましょう。

package com.example;

import static org.bytedeco.javacpp.opencv_core.*;
import static org.bytedeco.javacpp.opencv_objdetect.*;

import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Paths;

import javax.imageio.ImageIO;

public class App {
    public static void main(String[] args) throws URISyntaxException, IOException {
        String filepath = args.length > 0 ? args[0] : Paths.get(
                App.class.getResource("/lena.png").toURI()).toString();
        faceDetect(filepath);
    }

    public static void faceDetect(String filepath) throws URISyntaxException, IOException {
        // 分類器の読み込み
        String classifierName = Paths.get(
                App.class.getResource("/haarcascade_frontalface_default.xml")
                        .toURI()).toString();
        CascadeClassifier faceDetector = new CascadeClassifier(classifierName);
        System.out.println("load " + filepath);
        // 新しいAPIでは画像データを格納するデータ構造としてMatクラスを使用する。
        // ここではJavaの世界とやりとりしやすいようにjava.awt.image.BufferedImageを経由する。
        Mat source = Mat.createFrom(ImageIO.read(new File(filepath)));
        // 顔認識結果
        Rect faceDetections = new Rect();
        // 顔認識実行
        faceDetector.detectMultiScale(source, faceDetections);
        // 認識した顔の数
        int numOfFaces = faceDetections.limit();
        System.out.println(numOfFaces + " faces are detected!");
        for (int i = 0; i < numOfFaces; i++) {
            // i番目の認識結果
            Rect r = faceDetections.position(i);
            int x = r.x(), y = r.y(), h = r.height(), w = r.width();
            // Dukeのように描画する
            // 上半分の黒四角
            rectangle(source, new Point(x, y), new Point(x + w, y + h / 2),
                    new Scalar(0, 0, 0, 0), -1, CV_AA, 0);
            // 下半分の白四角
            rectangle(source, new Point(x, y + h / 2), new Point(x + w, y + h),
                    new Scalar(255, 255, 255, 0), -1, CV_AA, 0);
            // 中央の赤丸
            circle(source, new Point(x + h / 2, y + h / 2), (w + h) / 12,
                    new Scalar(0, 0, 255, 0), -1, CV_AA, 0);
        }

        // 描画結果をjava.awt.image.BufferedImageで取得する。
        BufferedImage image = source.getBufferedImage();
        try (OutputStream out = Files.newOutputStream(Paths
                .get("duked-faces.png"))) {
            // 画像を出力する
            ImageIO.write(image, "png", out);
        }
    }
}

引数に好きな画像をとってDuke化できます。

$ mvn compile exec:java -Dexec.mainClass=com.example.App -Dexec.args=hoge.png

また、ループ処理ないの描画部分を書き換えて、遊んでみてください。描画の例は、このリファレンスが役立ちます。

警告

このプログラムは透過pngを入力画像に使うとうまく描画できません。

本章の内容を修了したらハッシュタグ「#kanjava_sbc #sbc02」をつけてツイートしてください。

次章では、このサンプルとSpring Bootを統合して、顔変換Webサービスを作ります。

顔変換サービスの作成

本章では「[事前準備] Spring BootでHello World」と「[事前準備] JavaでOpenCVを使う」で作成した内容を統合して、顔変換Webサービスを作成します。

まずは「[事前準備] Spring BootでHello World」で作成したpom.xmlに、「[事前準備] JavaでOpenCVを使う」の内容を追記します。

以下のハイライトされた文を追加してください。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>kanjava</groupId>
    <artifactId>kusokora</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>Spring Boot Docker Blank Project (from https://github.com/making/spring-boot-docker-blank)</name>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.2.1.RELEASE</version>
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <start-class>kanjava.App</start-class>
        <java.version>1.8</java.version>
        <javacv.version>0.10</javacv.version>
        <opencv.version>2.4.10-${javacv.version}</opencv.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.bytedeco</groupId>
            <artifactId>javacv</artifactId>
            <version>${javacv.version}</version>
        </dependency>
        <dependency>
            <groupId>org.bytedeco.javacpp-presets</groupId>
            <artifactId>opencv</artifactId>
            <version>${opencv.version}</version>
        </dependency>
        <dependency>
            <groupId>org.bytedeco.javacpp-presets</groupId>
            <artifactId>opencv</artifactId>
            <version>${opencv.version}</version>
            <classifier>${classifier}</classifier>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <dependencies>
                    <dependency>
                        <groupId>org.springframework</groupId>
                        <artifactId>springloaded</artifactId>
                        <version>${spring-loaded.version}</version>
                    </dependency>
                </dependencies>
            </plugin>

            <!-- Copy Dockerfile -->
            <plugin>
                <artifactId>maven-resources-plugin</artifactId>
                <executions>
                    <execution>
                        <id>copy-resources</id>
                        <phase>validate</phase>
                        <goals>
                            <goal>copy-resources</goal>
                        </goals>
                        <configuration>
                            <outputDirectory>${basedir}/target/</outputDirectory>
                            <resources>
                                <resource>
                                    <directory>src/main/docker</directory>
                                    <filtering>true</filtering>
                                </resource>
                            </resources>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>com.coderplus.maven.plugins</groupId>
                <artifactId>copy-rename-maven-plugin</artifactId>
                <version>1.0</version>
                <executions>
                    <execution>
                        <id>rename-file</id>
                        <phase>validate</phase>
                        <goals>
                            <goal>rename</goal>
                        </goals>
                        <configuration>
                            <sourceFile>${basedir}/target/Dockerfile.txt</sourceFile>
                            <destinationFile>${basedir}/target/Dockerfile</destinationFile>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-antrun-plugin</artifactId>
                <version>1.7</version>
                <executions>
                    <execution>
                        <id>zip-files</id>
                        <phase>package</phase>
                        <goals>
                            <goal>run</goal>
                        </goals>
                        <configuration>
                            <target>
                                <zip destfile="${basedir}/target/app.zip" basedir="${basedir}/target"
                                     includes="Dockerfile, Dockerrun.aws.json, ${project.artifactId}.jar"/>
                            </target>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

    <profiles>
        <profile>
            <id>macosx-x86_64</id>
            <activation>
                <os>
                    <family>mac</family>
                    <arch>x86_64</arch>
                </os>
            </activation>
            <properties>
                <classifier>macosx-x86_64</classifier>
            </properties>
        </profile>
        <profile>
            <id>linux-x86_64</id>
            <activation>
                <os>
                    <family>unix</family>
                    <arch>amd64</arch>
                </os>
            </activation>
            <properties>
                <classifier>linux-x86_64</classifier>
            </properties>
        </profile>
        <profile>
            <id>windows-x86_64</id>
            <activation>
                <os>
                    <family>windows</family>
                    <arch>amd64</arch>
                </os>
            </activation>
            <properties>
                <classifier>windows-x86_64</classifier>
            </properties>
        </profile>
        <profile>
            <id>windows-x86</id>
            <activation>
                <os>
                    <family>windows</family>
                    <arch>x86</arch>
                </os>
            </activation>
            <properties>
                <classifier>windows-x86</classifier>
            </properties>
        </profile>
    </profiles>
</project>

次に「[事前準備] Spring BootでHello World」で作成したAppクラスに、「[事前準備] JavaでOpenCVを使う」で作成した顔変換処理を移植します。

[事前準備] JavaでOpenCVを使う」では1メソッドにベタ書きしたので、今回は以下のように顔検出処理と顔変換処理を分けて、それぞれ別クラスに定義します。

package kanjava;

import static org.bytedeco.javacpp.opencv_core.*;
import static org.bytedeco.javacpp.opencv_objdetect.*;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.function.BiConsumer;

@SpringBootApplication
@RestController
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }

    @RequestMapping(value = "/")
    String hello() {
        return "Hello World!";
    }
}

@Component // コンポーネントスキャン対象にする。@Serviceでも@NamedでもOK
class FaceDetector {
    public void detectFaces(Mat source /* 入力画像 */, BiConsumer<Mat, Rect> detectAction /* 顔領域に対応する処理 */) {
        // ここに顔検出処理を実装する
    }
}

class FaceTranslator {
    public static void duker(Mat source, Rect r) { // Duke化するメソッド
        // ここに顔変換処理を実装する
    }
}

実際の処理を埋めましょう。

package kanjava;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.PostConstruct;
import java.io.File;
import java.io.IOException;
import java.util.function.BiConsumer;

import static org.bytedeco.javacpp.opencv_core.*;
import static org.bytedeco.javacpp.opencv_objdetect.*;

@SpringBootApplication
@RestController
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }

    @RequestMapping(value = "/")
    String hello() {
        return "Hello World!";
    }
}

@Component
class FaceDetector {
    // 分類器のパスをプロパティから取得できるようにする
    @Value("${classifierFile:classpath:/haarcascade_frontalface_default.xml}")
    File classifierFile;

    CascadeClassifier classifier;

    static final Logger log = LoggerFactory.getLogger(FaceDetector.class);

    public void detectFaces(Mat source, BiConsumer<Mat, Rect> detectAction) {
        // 顔認識結果
        Rect faceDetections = new Rect();
        // 顔認識実行
        classifier.detectMultiScale(source, faceDetections);
        // 認識した顔の数
        int numOfFaces = faceDetections.limit();
        log.info("{} faces are detected!", numOfFaces);
        for (int i = 0; i < numOfFaces; i++) {
            // i番目の認識結果
            Rect r = faceDetections.position(i);
            // 1件ごとの認識結果を変換処理(関数)にかける
            detectAction.accept(source, r);
        }
    }

    @PostConstruct // 初期化処理。DIでプロパティがセットされたあとにclassifierインスタンスを生成したいのでここで書く。
    void init() throws IOException {
        if (log.isInfoEnabled()) {
            log.info("load {}", classifierFile.toPath());
        }
        // 分類器の読み込み
        this.classifier = new CascadeClassifier(classifierFile.toPath()
                .toString());
    }
}

class FaceTranslator {
    public static void duker(Mat source, Rect r) { // BiConsumer<Mat, Rect>で渡せるようにする
        int x = r.x(), y = r.y(), h = r.height(), w = r.width();
        // Dukeのように描画する
        // 上半分の黒四角
        rectangle(source, new Point(x, y), new Point(x + w, y + h / 2),
                new Scalar(0, 0, 0, 0), -1, CV_AA, 0);
        // 下半分の白四角
        rectangle(source, new Point(x, y + h / 2), new Point(x + w, y + h),
                new Scalar(255, 255, 255, 0), -1, CV_AA, 0);
        // 中央の赤丸
        circle(source, new Point(x + h / 2, y + h / 2), (w + h) / 12,
                new Scalar(0, 0, 255, 0), -1, CV_AA, 0);
    }
}

次に、この画像処理ロジックをControllerから叩きます。処理結果の画像をレスポンスとして返すのにJavaCVから扱いやすいBufferedImageをそのままシリアライズさせましょう。 BufferedImageのシリアライズはSpring Bootのデフォルトでは対応していないのですが、特定の型に対するリクエスト・レスポンスを扱うためのHttpMessageConverterBufferedImageは用意されています。org.springframework.http.converter.BufferedImageHttpMessageConverterです。

Spring Bootで新しいHttpMessageConverterを追加したい場合、対象のHttpMessageConverterをBean定義するだけで良いです。

Spring BootでBean定義する場合は通常、@Beanを使ってJavaで定義します。@Configuration (またはそれを内包する@SpringBootApplication) がついたクラスの中で、 インスタンスを生成するメソッドを書き、そのメソッドに@Beanをつければ良いです。

今回の場合、以下のようになります。

@Bean
BufferedImageHttpMessageConverter bufferedImageHttpMessageConverter() {
    return new BufferedImageHttpMessageConverter();
}

それでは画像変換を行うControllerの処理を追加しましょう。

package kanjava;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.http.converter.BufferedImageHttpMessageConverter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.PostConstruct;
import javax.imageio.ImageIO;
import javax.servlet.http.Part;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.function.BiConsumer;

import static org.bytedeco.javacpp.opencv_core.*;
import static org.bytedeco.javacpp.opencv_objdetect.*;

@SpringBootApplication
@RestController
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }

    @Autowired // FaceDetectorをインジェクション
    FaceDetector faceDetector;

    @Bean // HTTPのリクエスト・レスポンスボディにBufferedImageを使えるようにする
    BufferedImageHttpMessageConverter bufferedImageHttpMessageConverter() {
        return new BufferedImageHttpMessageConverter();
    }

    @RequestMapping(value = "/")
    String hello() {
        return "Hello World!";
    }

    // curl -v -F 'file=@hoge.jpg' http://localhost:8080/duker > after.jpg という風に使えるようにする
    @RequestMapping(value = "/duker", method = RequestMethod.POST) // POSTで/dukerへのリクエストに対する処理
    BufferedImage duker(@RequestParam Part file /* パラメータ名fileのマルチパートリクエストのパラメータを取得 */) throws IOException {
        Mat source = Mat.createFrom(ImageIO.read(file.getInputStream())); // Part -> BufferedImage -> Matと変換
        faceDetector.detectFaces(source, FaceTranslator::duker); // 対象のMatに対して顔認識。認識結果に対してduker関数を適用する。
        BufferedImage image = source.getBufferedImage(); // Mat -> BufferedImage
        return image;
    }
}

@Component
class FaceDetector {
    @Value("${classifierFile:classpath:/haarcascade_frontalface_default.xml}")
    File classifierFile;

    CascadeClassifier classifier;

    static final Logger log = LoggerFactory.getLogger(FaceDetector.class);

    public void detectFaces(Mat source, BiConsumer<Mat, Rect> detectAction) {
        // 顔認識結果
        Rect faceDetections = new Rect();
        // 顔認識実行
        classifier.detectMultiScale(source, faceDetections);
        // 認識した顔の数
        int numOfFaces = faceDetections.limit();
        log.info("{} faces are detected!", numOfFaces);
        for (int i = 0; i < numOfFaces; i++) {
            // i番目の認識結果
            Rect r = faceDetections.position(i);
            // 認識結果を変換処理にかける
            detectAction.accept(source, r);
        }
    }

    @PostConstruct
    void init() throws IOException {
        if (log.isInfoEnabled()) {
            log.info("load {}", classifierFile.toPath());
        }
        // 分類器の読み込み
        this.classifier = new CascadeClassifier(classifierFile.toPath()
                .toString());
    }
}

class FaceTranslator {
    public static void duker(Mat source, Rect r) {
        int x = r.x(), y = r.y(), h = r.height(), w = r.width();
        // Dukeのように描画する
        // 上半分の黒四角
        rectangle(source, new Point(x, y), new Point(x + w, y + h / 2),
                new Scalar(0, 0, 0, 0), -1, CV_AA, 0);
        // 下半分の白四角
        rectangle(source, new Point(x, y + h / 2), new Point(x + w, y + h),
                new Scalar(255, 255, 255, 0), -1, CV_AA, 0);
        // 中央の赤丸
        circle(source, new Point(x + h / 2, y + h / 2), (w + h) / 12,
                new Scalar(0, 0, 255, 0), -1, CV_AA, 0);
    }
}

実行する前に、「[事前準備] JavaでOpenCVを使う」で使用したhaarcascade_frontalface_default.xmlsrc/main/resourcesにコピーしましょう。以下のようにwgetしても構いません。

$ wget https://github.com/making/hello-cv/raw/duker/src/main/resources/haarcascade_frontalface_default.xml

ファイルをコピーしたら、早速起動しましょう。

$ mvn spring-boot:run

mainメソッド実行でも構いません。

顔画像を以下のように送ってください。

$ curl -v -F 'file=@hoge.jpg' http://localhost:8080/duker > after.jpg

画像のフォーマットが認識されない場合は、リクエストパスに拡張子をつけてメディアタイプを明示してください。

$ curl -v -F 'file=@hoge.jpg' http://localhost:8080/duker.jpg > after.jpg

変換後のafter.jpgを開いてください。顔がduke化されていますか?

余裕があれば、FaceTranslatorに独自の顔変換ロジックを書いてみましょう。

class FaceTranslator {
    // ...

    public static void kusokora(Mat source, Rect r) {
        // 変換処理
    }
}

Controllerにも以下のメソッドを追加しましょう。

@RequestMapping(value = "/kusokora", method = RequestMethod.POST)
BufferedImage kusokora(@RequestParam Part file) throws IOException {
    Mat source = Mat.createFrom(ImageIO.read(file.getInputStream()));
    faceDetector.detectFaces(source, FaceTranslator::kusokora);
    BufferedImage image = source.getBufferedImage();
    return image;
}

以上で本章は終了です。

本章の内容を修了したらハッシュタグ「#kanjava_sbc #sbc03」をつけてツイートしてください。 是非変換後の画像もつけてツイートしてください。

警告

実はこのクラス(Controller)にはバグがあります。「JMSで画像変換を非同期処理」で修正しますが、問題に気づきましたか?

次はこの顔変換処理を非同期で行うようにします。次章ではその前段として、Spring BootでJMSを使う方法を学びます。

JMSを使ってみる

前章では画像処理サーバーをSpring MVCで作成しました。一般的に、画像処理のような重い処理を同期実行していくと、 同時リクエストによってリクエスト処理スレッドが枯渇しやすくなってしまいます。

そこで、画像処理リクエストを受けてもすぐに処理は行わずレスポンスだけ返し、実際の処理は非同期で行うことを考えましょう。

本章ではJMS(Java Message Service)を使用して、非同期プログラミングを試します。次章で画像処理サーバーのJMS対応を行います。

HTTPリクエストを受けたControllerは、すぐに本処理を行うのではなく、本処理に必要なデータを詰めたメッセージをJMSに対応したメッセージキュー製品に送信します。 メッセージ送信が完了すればHTTPレスポンスを返却します。

_images/jms-send.png

送信されたメッセージは受信側によって取り出され、本処理がおこなれます。JMSによるメッセージ受信方法は色々あるのですが、今回はMessage Listenerを使用します。

_images/jms-receive.png

本ハンズオンでは、メッセージキューとしてHornetQを使います。通常メッセージキューは別プロセスとして起動させますが、今回はセットアップの手間を省くため、 組み込みインメモリHornetQを使い、アプリと同じプロセス内で起動させます。

また、JMS APIを直接使わず、SpringのJMSサポート機能を使用します。

まずは、これまで作ったプロジェクトのpom.xmlに以下の依存関係を追加してください。

<!-- SpringのJMSサポートとHornetQのクライアントライブラリを追加 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-hornetq</artifactId>
</dependency>
<!-- 組み込みインメモリHornetQ -->
<dependency>
    <groupId>org.hornetq</groupId>
    <artifactId>hornetq-jms-server</artifactId>
</dependency>

次にapplication.ymlに組み込みHornetQの設定を行います。

spring:
  thymeleaf.cache: false
  main.show-banner: false
  hornetq:
    mode: embedded
    embedded:
      enabled: true
      queues: hello # 宛先名

この段階でAppクラスを実行してみてください。以下のようなログが出て、組み込みHornetQが起動しているのがわかります。

2015-02-28 20:33:14.431  INFO 13107 --- [           main] org.hornetq.core.server                  : HQ221000: live server is starting with configuration HornetQ Configuration (clustered=false,backup=false,sharedStore=true,journalDirectory=/var/folders/9p/hr0h11p124l0z7d3sqpvf5lw0000gn/T/hornetq-data/journal,bindingsDirectory=data/bindings,largeMessagesDirectory=data/largemessages,pagingDirectory=data/paging)
2015-02-28 20:33:14.443  INFO 13107 --- [           main] org.hornetq.core.server                  : HQ221045: libaio is not available, switching the configuration into NIO
2015-02-28 20:33:14.488  INFO 13107 --- [           main] org.hornetq.core.server                  : HQ221043: Adding protocol support CORE
2015-02-28 20:33:14.558  INFO 13107 --- [           main] org.hornetq.core.server                  : HQ221003: trying to deploy queue jms.queue.hello
2015-02-28 20:33:14.642  INFO 13107 --- [           main] org.hornetq.core.server                  : HQ221007: Server is now live
2015-02-28 20:33:14.642  INFO 13107 --- [           main] org.hornetq.core.server                  : HQ221001: HornetQ Server version 2.4.5.FINAL (Wild Hornet, 124) [95281656-bf3d-11e4-aec8-496255b6cee1]

簡単なJMSプログラミングを行いましょう。まずは送信部分を作ります。

package kanjava;

// ...
import org.springframework.jms.core.JmsMessagingTemplate;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;

// ...

@SpringBootApplication
@RestController
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }

    private static final Logger log = LoggerFactory.getLogger(App.class); // 後で使う

    @Autowired
    FaceDetector faceDetector;
    @Autowired
    JmsMessagingTemplate jmsMessagingTemplate; // メッセージ操作用APIのJMSラッパー

    // ...

    @RequestMapping(value = "/send")
    String send(@RequestParam String msg /* リクエストパラメータmsgでメッセージ本文を受け取る */) {
        Message<String> message = MessageBuilder
                .withPayload(msg)
                .build(); // メッセージを作成
        jmsMessagingTemplate.send("hello", message); // 宛先helloにメッセージを送信
        return "OK"; // とりあえずOKと即時応答しておく
    }
}

これだけだと送りっぱなしで、メッセージを受け取り側がいません。次に受信部分(MessageListener)を書きましょう。

Spring 4.1からJMSのMessageListenerはとても書きやすくなり、Listenerとなるメソッドに@JmsListenerをつけるだけでよくなりました。 専用のクラスを作っても良いですし、Controllerの中に書いても有効です。

今回はシンプルにするため、Appクラス内にListenerメソッドを作成します。

package kanjava;

// ...
import org.springframework.jms.annotation.JmsListener;
// ...

@SpringBootApplication
@RestController
public class App {
    // ...

    @RequestMapping(value = "/send")
    String send(@RequestParam String msg) {
        Message<String> message = MessageBuilder
                .withPayload(msg)
                .build();
        jmsMessagingTemplate.send("hello", message);
        return "OK";
    }

    @JmsListener(destination = "hello" /* 処理するメッセージの宛先を指定 */)
    void handleHelloMessage(Message<String> message /* 送信されたメッセージを受け取る */) {
        log.info("received! {}", message);
        log.info("msg={}", message.getPayload());
    }
}

Appクラスを実行して、次のリクエストを送りましょう。

$ curl localhost:8080/send?msg=test
OK

レスポンスが即返ってきました。サーバーログを確認しましょう。

2015-02-28 21:09:12.616  INFO 13367 --- [enerContainer-1] kanjava.App                              : received! GenericMessage [payload=test, headers={jms_redelivered=false, jms_deliveryMode=2, JMSXDeliveryCount=1, jms_destination=HornetQQueue[hello], jms_priority=4, id=2eb99131-6f39-f6ca-9214-30221896c891, jms_timestamp=1425125352607, jms_expiration=0, jms_messageId=ID:9b87c38b-bf42-11e4-add4-7d91fe97ae4c, timestamp=1425125352615}]
2015-02-28 21:09:12.616  INFO 13367 --- [enerContainer-1] kanjava.App                              : msg=test

メッセージキュー側のスレッド(スレッド名: DefaultMessageListenerContainer-スレッド数)で処理されているのがわかります。

デフォルトでは処理スレッド数は1です。スレッド数を変更する場合は@JmsListenerconcurrency属性を設定します。

@JmsListener(destination = "hello", concurrency = "1-5" /* 最小1スレッド、最大5スレッドに設定 */)

10リクエスト送ってみましょう。

$ for i in `seq 1 10`;do curl localhost:8080/send?msg=test;done
OKOKOKOKOKOKOKOKOKOK

サーバーログは以下のようになります。

2015-02-28 21:19:47.655  INFO 13487 --- [enerContainer-1] kanjava.App                              : received! GenericMessage [payload=test, headers={jms_redelivered=false, jms_deliveryMode=2, JMSXDeliveryCount=1, jms_destination=HornetQQueue[hello], jms_priority=4, id=76de8a0c-fd68-b059-8c91-5165e9845668, jms_timestamp=1425125987654, jms_expiration=0, jms_messageId=ID:160c3ea7-bf44-11e4-a10f-3f034703c26f, timestamp=1425125987655}]
2015-02-28 21:19:47.655  INFO 13487 --- [enerContainer-1] kanjava.App                              : msg=test
2015-02-28 21:19:47.681  INFO 13487 --- [enerContainer-2] kanjava.App                              : received! GenericMessage [payload=test, headers={jms_redelivered=false, jms_deliveryMode=2, JMSXDeliveryCount=1, jms_destination=HornetQQueue[hello], jms_priority=4, id=39870da1-e482-d98d-dba0-24702fa9cac9, jms_timestamp=1425125987680, jms_expiration=0, jms_messageId=ID:16105d5d-bf44-11e4-a10f-3f034703c26f, timestamp=1425125987681}]
2015-02-28 21:19:47.682  INFO 13487 --- [enerContainer-2] kanjava.App                              : msg=test
2015-02-28 21:19:47.705  INFO 13487 --- [enerContainer-3] kanjava.App                              : received! GenericMessage [payload=test, headers={jms_redelivered=false, jms_deliveryMode=2, JMSXDeliveryCount=1, jms_destination=HornetQQueue[hello], jms_priority=4, id=1f30f1b8-9c52-936a-5e8f-935f4f9db38b, jms_timestamp=1425125987703, jms_expiration=0, jms_messageId=ID:1613b8c3-bf44-11e4-a10f-3f034703c26f, timestamp=1425125987705}]
2015-02-28 21:19:47.705  INFO 13487 --- [enerContainer-3] kanjava.App                              : msg=test
2015-02-28 21:19:47.729  INFO 13487 --- [enerContainer-4] kanjava.App                              : received! GenericMessage [payload=test, headers={jms_redelivered=false, jms_deliveryMode=2, JMSXDeliveryCount=1, jms_destination=HornetQQueue[hello], jms_priority=4, id=4694e874-883b-8cb6-0dcf-09cf848bf9f1, jms_timestamp=1425125987727, jms_expiration=0, jms_messageId=ID:16176249-bf44-11e4-a10f-3f034703c26f, timestamp=1425125987729}]
2015-02-28 21:19:47.729  INFO 13487 --- [enerContainer-4] kanjava.App                              : msg=test
2015-02-28 21:19:47.751  INFO 13487 --- [enerContainer-5] kanjava.App                              : received! GenericMessage [payload=test, headers={jms_redelivered=false, jms_deliveryMode=2, JMSXDeliveryCount=1, jms_destination=HornetQQueue[hello], jms_priority=4, id=93c2b05a-6350-4cca-1bf7-2cb6e8e6fef8, jms_timestamp=1425125987749, jms_expiration=0, jms_messageId=ID:161abdaf-bf44-11e4-a10f-3f034703c26f, timestamp=1425125987751}]
2015-02-28 21:19:47.751  INFO 13487 --- [enerContainer-5] kanjava.App                              : msg=test
2015-02-28 21:19:47.775  INFO 13487 --- [enerContainer-1] kanjava.App                              : received! GenericMessage [payload=test, headers={jms_redelivered=false, jms_deliveryMode=2, JMSXDeliveryCount=1, jms_destination=HornetQQueue[hello], jms_priority=4, id=398007e6-70dd-4fef-1954-443fc82269c4, jms_timestamp=1425125987773, jms_expiration=0, jms_messageId=ID:161e6735-bf44-11e4-a10f-3f034703c26f, timestamp=1425125987774}]
2015-02-28 21:19:47.775  INFO 13487 --- [enerContainer-1] kanjava.App                              : msg=test
2015-02-28 21:19:47.803  INFO 13487 --- [enerContainer-2] kanjava.App                              : received! GenericMessage [payload=test, headers={jms_redelivered=false, jms_deliveryMode=2, JMSXDeliveryCount=1, jms_destination=HornetQQueue[hello], jms_priority=4, id=78080611-f326-a5b7-5737-6ff93c58c4b3, jms_timestamp=1425125987800, jms_expiration=0, jms_messageId=ID:162285eb-bf44-11e4-a10f-3f034703c26f, timestamp=1425125987803}]
2015-02-28 21:19:47.803  INFO 13487 --- [enerContainer-2] kanjava.App                              : msg=test
2015-02-28 21:19:47.835  INFO 13487 --- [enerContainer-3] kanjava.App                              : received! GenericMessage [payload=test, headers={jms_redelivered=false, jms_deliveryMode=2, JMSXDeliveryCount=1, jms_destination=HornetQQueue[hello], jms_priority=4, id=a28ddffb-e0ef-a2f9-8b09-5248f78d23d5, jms_timestamp=1425125987834, jms_expiration=0, jms_messageId=ID:1627b611-bf44-11e4-a10f-3f034703c26f, timestamp=1425125987835}]
2015-02-28 21:19:47.836  INFO 13487 --- [enerContainer-3] kanjava.App                              : msg=test
2015-02-28 21:19:47.858  INFO 13487 --- [enerContainer-4] kanjava.App                              : received! GenericMessage [payload=test, headers={jms_redelivered=false, jms_deliveryMode=2, JMSXDeliveryCount=1, jms_destination=HornetQQueue[hello], jms_priority=4, id=641fc649-d29c-ef42-b564-77985b96eb05, jms_timestamp=1425125987856, jms_expiration=0, jms_messageId=ID:162b1177-bf44-11e4-a10f-3f034703c26f, timestamp=1425125987858}]
2015-02-28 21:19:47.858  INFO 13487 --- [enerContainer-4] kanjava.App                              : msg=test
2015-02-28 21:19:47.886  INFO 13487 --- [enerContainer-5] kanjava.App                              : received! GenericMessage [payload=test, headers={jms_redelivered=false, jms_deliveryMode=2, JMSXDeliveryCount=1, jms_destination=HornetQQueue[hello], jms_priority=4, id=43dabfa3-3f07-a75b-41b9-1bfed1b716fb, jms_timestamp=1425125987884, jms_expiration=0, jms_messageId=ID:162f573d-bf44-11e4-a10f-3f034703c26f, timestamp=1425125987886}]
2015-02-28 21:19:47.887  INFO 13487 --- [enerContainer-5] kanjava.App                              : msg=test

5スレッドで処理されていることがわかります。

注釈

本章で使用したJmsMessagingTemplateは、昔からあるSpringのJMS APIラッパーであるJmsTemplateをメッセージング抽象化機構でさらにラップしたものです。Spring 4.1から追加されました。 メッセージ操作の用のシグニチャ(MessageSendingOperationsなど)や送信するMessageクラスはJMSに限らず、次に説明するSTOMPなどSpringのメッセージング関連のプログラミングで使用できます。 このメッセージング抽象化プロジェクトはspring-messagingと名付けられ、Spring 4.0から入りました。

Spring 4.1で追加されたJMS連携機能やspring-messagingについてはこちらの資料を参照してください。

JMSの簡単な使い方を学びました。以上で本章は終了です。

本章の内容を修了したらハッシュタグ「#kanjava_sbc #sbc04」をつけてツイートしてください。

次は顔変換処理をMessageListenerで行うようにします。

なお、本章が終わったら handleHelloMessageを削除(またはコメントアウト)しておいてください。

JMSで画像変換を非同期処理

前章で学んだJMSを使って、画像データをメッセージに詰めて送信し、画像変換処理をMessageListenerで非同期処理するようにしましょう。

まずは画像処理用のキューを追加するためにapplication.ymlを以下のように修正します。

spring:
  thymeleaf.cache: false
  main.show-banner: false
  hornetq:
    mode: embedded
    embedded:
      enabled: true
      queues: hello,faceConverter # 宛先名

次に送信処理を作成しましょう。

JmsMessagingTemplateはデフォルトでは

  • String
  • byte[]
  • Map
  • Serializable

の変換に対応しています。今回は非効率的ですが、送られたjavax.servlet.http.Partから取得した画像データをbyte配列に変換し、 MessageListener側でbyte配列からBufferedImageに変換し、OpenCVに渡します。

まずはControllerにリクエスト受付処理を追加しましょう。

package kanjava;

// ...
import org.springframework.util.StreamUtils;
// ...

@SpringBootApplication
@RestController
public class App {
    // ...

    @RequestMapping(value = "/queue", method = RequestMethod.POST)
    String queue(@RequestParam Part file) throws IOException {
        byte[] src = StreamUtils.copyToByteArray(file.getInputStream()); // InputStream -> byte[]
        Message<byte[]> message = MessageBuilder.withPayload(src).build(); // byte[]を持つMessageを作成
        jmsMessagingTemplate.send("faceConverter", message); // convertAndSend("faceConverter", src)でも可
        return "OK";
    }

    // ...
}

次に、宛先faceConverterに対するMessageListenerを追加しましょう。

@SpringBootApplication
@RestController
public class App {
    // ...

    @RequestMapping(value = "/queue", method = RequestMethod.POST)
    String queue(@RequestParam Part file) throws IOException {
        byte[] src = StreamUtils.copyToByteArray(file.getInputStream());
        Message<byte[]> message = MessageBuilder.withPayload(src).build();
        jmsMessagingTemplate.send("faceConverter", message);
        return "OK";
    }

    // ...

    @JmsListener(destination = "faceConverter", concurrency = "1-5")
    void convertFace(Message<byte[]> message) throws IOException {
        log.info("received! {}", message);
        try (InputStream stream = new ByteArrayInputStream(message.getPayload())) { // byte[] -> InputStream
            Mat source = Mat.createFrom(ImageIO.read(stream)); // InputStream -> BufferedImage -> Mat
            faceDetector.detectFaces(source, FaceTranslator::duker);
            BufferedImage image = source.getBufferedImage();
            // do nothing...
        }
    }
}

ここまでの内容を組み合わせれば、内容を理解できると思います。

$ curl -F 'file=@hoge.jpg' localhost:8080/queue
OK

サーバーログは以下のようになります。

2015-03-01 00:19:22.366  INFO 14014 --- [enerContainer-1] kanjava.App                              : received! GenericMessage [payload=byte[52075], headers={jms_redelivered=false, jms_deliveryMode=2, JMSXDeliveryCount=1, jms_destination=HornetQQueue[faceConverter], jms_priority=4, id=ba27919f-8758-58fc-9976-99262605295c, jms_timestamp=1425136762365, jms_expiration=0, jms_messageId=ID:2c46ab2c-bf5d-11e4-850e-eff6d41dec3e, timestamp=1425136762366}]
2015-03-01 00:19:22.512  INFO 14014 --- [enerContainer-1] kanjava.FaceDetector                     : 1 faces are detected!

この処理では結果がわかりませんね。

次に50リクエストを同時に送ってみましょう。

$ for i in `seq 1 50`;do curl -F 'file=@hoge.jpg' localhost:8080/queue; done
OKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOKOK

全てレスポンスは返ってきています。サーバーログはどうでしょうか。

#
# A fatal error has been detected by the Java Runtime Environment:
#
#  [thread 23815 also had an error]
#
# JRE version: Java(TM) SE Runtime Environment (8.0_20-b26) (build 1.8.0_20-b26)
# Java VM: Java HotSpot(TM) 64-Bit Server VM (25.20-b23 mixed mode bsd-amd64 compressed oops)
# Problematic frame:
# C  [libopencv_objdetect.2.4.dylib+0xe307]  cv::HaarEvaluator::operator()(int) const+0x23
#
# Failed to write core dump. Core dumps have been disabled. To enable core dumping, try "ulimit -c unlimited" before starting Java again
#
# An error report file with more information is saved as:
# /xxxx/hs_err_pid14014.log
#
# If you would like to submit a bug report, please visit:
#   http://bugreport.sun.com/bugreport/crash.jsp
# The crash happened outside the Java Virtual Machine in native code.
# See problematic frame for where to report the bug.
#

JVMがハングしています・・・。

実は、顔変換サービスの作成の段階でバグがありました。複数リクエストを同時に捌く際に起きているバグなので、 スレッドアンセーフによるバグですね。どこでしょうか。

JVMが落ちていることと、cv::HaarEvaluator::operator()(int)がヒントです。OpenCVの顔検出部分が怪しいです。

以下のハイライト部分がスレッドアンセーフです。

@JmsListener(destination = "faceConverter", concurrency = "1-5")
void convertFace(Message<byte[]> message) throws IOException {
    log.info("received! {}", message);
    try (InputStream stream = new ByteArrayInputStream(message.getPayload())) {
        Mat source = Mat.createFrom(ImageIO.read(stream));
        faceDetector.detectFaces(source, FaceTranslator::duker); // この中の処理がスレッドアンセーフ!
        BufferedImage image = source.getBufferedImage();
        // do nothing...
    }
}

正確にはclassifier.detectMultiScale(source, faceDetections);の部分です。

classifierがステートフルなため、FaceDetectorをデフォルトのsingletonスコープで登録しているのが問題なようです。

都度インスタンスを作り直す、prototypeスコープに変更しましょう。

以下のように、コンポーネントスキャン対象のクラスに@Scopeアノテーションをつけてスコープを明示します。

@Component
@Scope(value = "prototype")
class FaceDetector {
    // ...
}

実はこれだけでは、期待通りには動きません。Springではインスタンスのライフサイクルは寿命の長い方に合わせられます。

すなわちsingletonスコープのAppコントローラーに対して、prototypeスコープのFaceDetectorをインジェクションしても、 faceDetectorフィールドは寿命の長いsingletonスコープとして振る舞います。

この関係を変える(faceDetectorフィールドをprototypeスコープとして振る舞わせる)ために、scoped-proxyという仕組みを導入します。

@ScopeproxyMode属性に以下のような設定を行います。

@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
class FaceDetector {
    // ...
}

これでFaceDetectorがProxyでラップされた状態でAppにインジェクションされるため、Appのスコープによらず、 faceDetectorフィールドはprototypeスコープでいられます。

この状態でAppクラスを再起動し、再度50リクエストを送ってみてください。FaceDetectorが毎回初期化され、無事全てのリクエストが捌かれているのがわかると思います。

注釈

FaceDetectorの初期化コストも大きいので、singletonスコープのままsynchronizedによる同期化を行っても良いです。 どちらの性能が良いかは、サーバースペックと同時リクエスト数次第です。

本章では画像処理を非同期に実行しました。またインスタンスのスコープについて学びました。以上で本章は終了です。

本章の内容を修了したらハッシュタグ「#kanjava_sbc #sbc05」をつけてツイートしてください。

次は非同期に実行した処理結果を通知するために、STOMPという別のメッセージングプロトコルを使用します。 次章ではまずはSTOMPをつかってみましょう。

STOMPを使ってみる

前章でMessageListenerで非同期に画像処理を行いましたが、処理結果がクライアントには返ってきていません。 今度は処理結果もメッセージングで送るようにしましょう。ここではメッセージングプロトコルとしてSTOMPを使用します。

STOMPは「Simple (or Streaming) Text Orientated Messaging Protocol」の略で、軽量なメッセージングプロトコルです。 TCPやWebSocket上で利用できます。

本章ではSTOMP over WebSocketの簡単な使い方を学びましょう。

まずはpom.xmlに以下の依存関係を追加してください。

<!-- WebSocketプログラミングに必要な諸々 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

Appクラスに以下を追加します。

@SpringBootApplication
@RestController
public class App {
    // ...

    @Configuration
    @EnableWebSocketMessageBroker // WebSocketに関する設定クラス
    static class StompConfig extends AbstractWebSocketMessageBrokerConfigurer {

        @Override
        public void registerStompEndpoints(StompEndpointRegistry registry) {
            registry.addEndpoint("endpoint"); // WebSocketのエンドポイント
        }

        @Override
        public void configureMessageBroker(MessageBrokerRegistry registry) {
            registry.setApplicationDestinationPrefixes("/app"); // Controllerに処理させる宛先のPrefix
            registry.enableSimpleBroker("/topic"); // queueまたはtopicを有効にする(両方可)。queueは1対1(P2P)、topicは1対多(Pub-Sub)
        }
    }

    // ...

    @MessageMapping(value = "/greet" /* 宛先名 */) // Controller内の@MessageMappingアノテーションをつけたメソッドが、メッセージを受け付ける
    @SendTo(value = "/topic/greetings") // 処理結果の送り先
    String greet(String name) {
        log.info("received {}", name);
        return "Hello " + name;
    }

    // ※ handleHelloMessageは削除(またはコメントアウト)しておいてください。
    // ...
}

ソースだけでは分かりにくいと思いますが、メッセージのフローは下図のようになります。

_images/stomp-message-flow.png

宛先が/topicや/queueで始まるものはメッセージブローカー(仲介役)が直接ハンドリングします。 宛先が/appから始まるものはControllerに渡って処理され、その処理結果がメッセージブローカーに渡ります。

メッセージブローカーによって制御されたメッセージは、その宛先を購読しているクライアントへと送られます。

次にStomp.jsを使ってクライアントを作りましょう。src/main/resources/statichello.htmlを作成してください。

注釈

Spring Bootではsrc/main/resources/static以下が静的リソース置き場になります。このディレクトリにファイルを置くと、コンテキストパスから相対的にアクセスできます。

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Hello STOMP</title>
</head>
<body>
<div>
    <button id="connect">Connect</button>
    <button id="disconnect" disabled="disabled">Disconnect</button>
</div>
<div>
    <input type="text" id="name" placeholder="Your Name">
    <button id="send" disabled="disabled">Send</button>
    <div id="response"></div>
</div>
</body>
<script src="stomp.js"></script>
<script type="text/javascript">
    /**
     * 初期化処理
     */
    var HelloStomp = function () {
        this.connectButton = document.getElementById('connect');
        this.disconnectButton = document.getElementById('disconnect');
        this.sendButton = document.getElementById('send');

        // イベントハンドラの登録
        this.connectButton.addEventListener('click', this.connect.bind(this));
        this.disconnectButton.addEventListener('click', this.disconnect.bind(this));
        this.sendButton.addEventListener('click', this.sendName.bind(this));
    };

    /**
     * エンドポイントへの接続処理
     */
    HelloStomp.prototype.connect = function () {
        var socket = new WebSocket('ws://' + location.host + '/endpoint'); // エンドポイントのURL
        this.stompClient = Stomp.over(socket); // WebSocketを使ったStompクライアントを作成
        this.stompClient.connect({}, this.onConnected.bind(this)); // エンドポイントに接続し、接続した際のコールバックを登録
    };

    /**
     * エンドポイントへ接続したときの処理
     */
    HelloStomp.prototype.onConnected = function (frame) {
        console.log('Connected: ' + frame);
        // 宛先が'/topic/greetings'のメッセージを購読し、コールバック処理を登録
        this.stompClient.subscribe('/topic/greetings', this.onSubscribeGreeting.bind(this));
        this.setConnected(true);
    };

    /**
     * 宛先'/topic/greetings'なメッセージを受信したときの処理
     */
    HelloStomp.prototype.onSubscribeGreeting = function (message) {
        var response = document.getElementById('response');
        var p = document.createElement('p');
        p.appendChild(document.createTextNode(message.body));
        response.insertBefore(p, response.children[0]);
    };

    /**
     * 宛先'/app/greet'へのメッセージ送信処理
     */
    HelloStomp.prototype.sendName = function () {
        var name = document.getElementById('name').value;
        this.stompClient.send('/app/greet', {}, name); // 宛先'/app/greet'へメッセージを送信
    };

    /**
     * 接続切断処理
     */
    HelloStomp.prototype.disconnect = function () {
        if (this.stompClient) {
            this.stompClient.disconnect();
            this.stompClient = null;
        }
        this.setConnected(false);
    };

    /**
     * ボタン表示の切り替え
     */
    HelloStomp.prototype.setConnected = function (connected) {
        this.connectButton.disabled = connected;
        this.disconnectButton.disabled = !connected;
        this.sendButton.disabled = !connected;
    };

    new HelloStomp();
</script>
</html>

Stomp.jsをsrc/main/resources/staticにダウンロードしましょう。

$ cd src/main/resources/static
$ wget https://raw.github.com/jmesnil/stomp-websocket/master/lib/stomp.js

Appクラスを起動し、http://localhost:8080/hello.htmlにアクセスしてください。

_images/hello-html-01.png

「Connect」ボタンを押して、フォームに名前を入力し、「Send」ボタンを押してください。

_images/hello-html-02.png

結果が返ってきました。今回は宛先をTopicにしているため、他のタブで別途Connectすれば全てのタブに結果が表示されます。

注釈

mvn spring-boot:runAppクラスを起動すれば、静的リソースの変更が即反映されるので開発中は便利です。

STOMPの簡単な使い方を学びました。以上で本章は終了です。

本章の内容を修了したらハッシュタグ「#kanjava_sbc #sbc06」をつけてツイートしてください。

次章ではJMSのMessageListenerの処理結果をSTOMPの宛先に送り、クライアントで表示させましょう。

STOMP over WebSocketで非同期処理結果を受信する

本章ではJMSのMessageListenerの画像処理結果をSTOMPの宛先に送り、クライアントで表示させます。

STOMPの結果を送信するためにSimpMessagingTemplateを使用します。これまで使用したJmsMessagingTemplateとほぼ同じインターフェースです。 画像はHTMLで表示しやすいようにbyte[]に変換した後、Base64にエンコードして送信します(これも非効率)。

以下のコードを追加してください。

@SpringBootApplication
@RestController
public class App {
    // ...

    @Autowired
    SimpMessagingTemplate simpMessagingTemplate;

    // ...

    @Configuration
    @EnableWebSocketMessageBroker
    static class StompConfig extends AbstractWebSocketMessageBrokerConfigurer {

        // ...

        @Override
        public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
            registration.setMessageSizeLimit(10 * 1024 * 1024); // メッセージサイズの上限を10MBに上げる(デフォルトは64KB)
        }
    }

    // ...

    @JmsListener(destination = "faceConverter", concurrency = "1-5")
    void convertFace(Message<byte[]> message) throws IOException {
        log.info("received! {}", message);
        try (InputStream stream = new ByteArrayInputStream(message.getPayload())) {
            Mat source = Mat.createFrom(ImageIO.read(stream));
            faceDetector.detectFaces(source, FaceTranslator::duker);
            BufferedImage image = source.getBufferedImage();

            try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { // BufferedImageをbyte[]に変換
                ImageIO.write(image, "png", baos);
                baos.flush();
                // 画像をBase64にエンコードしてメッセージ作成し、宛先'/topic/faces'へメッセージ送信
                simpMessagingTemplate.convertAndSend("/topic/faces",
                        Base64.getEncoder().encodeToString(baos.toByteArray()));
            }
        }
    }
}

HTMLも変更しましょう。宛先/topic/facesへの処理を追加するため、先ほどのhello.htmlをコピーしてface.htmlを作成し、以下の修正を加えてください。

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Hello STOMP</title>
</head>
<body>
<div>
    <button id="connect">Connect</button>
    <button id="disconnect" disabled="disabled">Disconnect</button>
</div>
<div>
    <input type="text" id="name" placeholder="Your Name">
    <button id="send" disabled="disabled">Send</button>
    <div id="response"></div>
</div>
</body>
<script src="stomp.js"></script>
<script type="text/javascript">
    /**
     * 初期化処理
     */
    var HelloStomp = function () {
        this.connectButton = document.getElementById('connect');
        this.disconnectButton = document.getElementById('disconnect');
        this.sendButton = document.getElementById('send');

        // イベントハンドラの登録
        this.connectButton.addEventListener('click', this.connect.bind(this));
        this.disconnectButton.addEventListener('click', this.disconnect.bind(this));
        this.sendButton.addEventListener('click', this.sendName.bind(this));
    };

    /**
     * エンドポイントへの接続処理
     */
    HelloStomp.prototype.connect = function () {
        var socket = new WebSocket('ws://' + location.host + '/endpoint'); // エンドポイントのURL
        this.stompClient = Stomp.over(socket); // WebSocketを使ったStompクライアントを作成
        this.stompClient.debug = null; // デバッグログを出さない(Base64の文字列が大きするため)
        this.stompClient.connect({}, this.onConnected.bind(this)); // エンドポイントに接続し、接続した際のコールバックを登録
    };

    /**
     * エンドポイントへ接続したときの処理
     */
    HelloStomp.prototype.onConnected = function (frame) {
        console.log('Connected: ' + frame);
        // 宛先が'/topic/greetings'のメッセージを購読し、コールバック処理を登録
        this.stompClient.subscribe('/topic/greetings', this.onSubscribeGreeting.bind(this));
        // 宛先が'/topic/faces'のメッセージを購読し、コールバック処理を登録
        this.stompClient.subscribe('/topic/faces', this.onSubscribeFace.bind(this));
        this.setConnected(true);
    };

    /**
     * 宛先'/topic/greetings'なメッセージを受信したときの処理
     */
    HelloStomp.prototype.onSubscribeGreeting = function (message) {
        var response = document.getElementById('response');
        var p = document.createElement('p');
        p.appendChild(document.createTextNode(message.body));
        response.insertBefore(p, response.children[0]);
    };

    /**
     * 宛先'/topic/faces'なメッセージを受信したときの処理
     */
    HelloStomp.prototype.onSubscribeFace = function (message) {
        var response = document.getElementById('response');
        var img = document.createElement('img');
        img.setAttribute("src", "data:image/png;base64," + message.body);
        response.insertBefore(img, response.children[0]);
    };

    /**
     * 宛先'/app/greet'へのメッセージ送信処理
     */
    HelloStomp.prototype.sendName = function () {
        var name = document.getElementById('name').value;
        this.stompClient.send('/app/greet', {}, name); // 宛先'/app/greet'へメッセージを送信
    };

    /**
     * 接続切断処理
     */
    HelloStomp.prototype.disconnect = function () {
        if (this.stompClient) {
            this.stompClient.disconnect();
            this.stompClient = null;
        }
        this.setConnected(false);
    };

    /**
     * ボタン表示の切り替え
     */
    HelloStomp.prototype.setConnected = function (connected) {
        this.connectButton.disabled = connected;
        this.disconnectButton.disabled = !connected;
        this.sendButton.disabled = !connected;
    };

    new HelloStomp();
</script>
</html>

Appクラスを再起動し、http://localhost:8080/face.htmlにアクセスし、「Connect」ボタンを押してください。 その後、JMSで画像変換を非同期処理で作成したサービスに変換したい画像を送信します。

$ curl -F 'file=@lena.png' localhost:8080/queue
OK

送信した後、ブラウザを確認すると以下のように変換後の画像が表示されるはずです。

_images/face-html-01.png

画像サイズが少し大きく、転送量が肥大化してしまうため、サーバーサイドでリサイズするようにしましょう。Appクラスを以下のように変更してください。

// ...
import static org.bytedeco.javacpp.opencv_imgproc.*;

@SpringBootApplication
@RestController
public class App {
    // ...

    @Value("${faceduker.width:200}")
    int resizedWidth; // リサイズ後の幅

    // ...

    @JmsListener(destination = "faceConverter", concurrency = "1-5")
    void convertFace(Message<byte[]> message) throws IOException {
        log.info("received! {}", message);
        try (InputStream stream = new ByteArrayInputStream(message.getPayload())) {
            Mat source = Mat.createFrom(ImageIO.read(stream));
            faceDetector.detectFaces(source, FaceTranslator::duker);

            // リサイズ
            double ratio = ((double) resizedWidth) / source.cols();
            int height = (int) (ratio * source.rows());
            Mat out = new Mat(height, resizedWidth, source.type());
            resize(source, out, new Size(), ratio, ratio, INTER_LINEAR);

            BufferedImage image = out.getBufferedImage();

            try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
                ImageIO.write(image, "png", baos);
                baos.flush();
                // 画像をBase64にエンコードしてメッセージ作成し、宛先'/topic/faces'へメッセージ送信
                simpMessagingTemplate.convertAndSend("/topic/faces",
                        Base64.getEncoder().encodeToString(baos.toByteArray()));
            }
        }
    }
}

Appクラスを再起動して、クライアントを再接続してください。そして画像処理のリクエストを送り、ブラウザに以下のように表示されることを確認してください。

_images/face-html-02.png

以上で本章は終了です。

本章の内容を修了したらハッシュタグ「#kanjava_sbc #sbc07」をつけてツイートしてください。

次はカメラをつかって顔画像を撮り、STOMPで撮った画像を送信し、その結果を今回同様に表示しましょう。次章ではまずはWebRTCによるカメラを使ってみましょう。

WebRTCを使ってみる

本章ではまずはWebRTCのgetUserMedia APIカメラにアクセスしてみましょう。

WebRTC(Web Real-Time Communication)ではリアルタイムコミュニケーション用のAPIが用意されていますが、本ハンズオンではgetUserMedia APIのみ使用します。

src/main/resources/static/camera.htmlを作成して、以下の内容を記述してください。

<!doctype html>
<html>
<head>
    <title>Cameraテスト</title>
</head>
<body>
<video autoplay width="400" height="300"></video>
<img src="" width="400" height="300">
<canvas style="display:none;" width="400" height="300"></canvas>

<script type="text/javascript">
    navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || window.navigator.mozGetUserMedia || navigator.msGetUserMedia;
    window.URL = window.URL || window.webkitURL;

    var video = document.querySelector('video');
    var canvas = document.querySelector('canvas');
    var ctx = canvas.getContext('2d');
    var localMediaStream;

    navigator.getUserMedia({video: true, audio: false},
            function (stream) {
                video.src = window.URL.createObjectURL(stream);
                localMediaStream = stream;
            },
            function (error) {
                alert(JSON.stringify(error));
            }
    );

    function takeSnapshot() {
        if (localMediaStream) {
            ctx.drawImage(video, 0, 0, 400, 300);
            document.querySelector('img').src = canvas.toDataURL('image/webp');
        }
    }
    video.addEventListener('click', takeSnapshot, false);
</script>
</body>
</html>

http://localhost:8080/camera.htmlにアクセスしてください。

_images/camera-html-01.png

カメラアクセスへの許可を確認されますので、「許可」をクリックしてください。そうするとカメラの結果が左側に表示されます。

左のカメラ画像をクリックすると、右側にスナップショットして表示されます。

_images/camera-html-02.png

以上で本章は終了です。

本章の内容を修了したらハッシュタグ「#kanjava_sbc #sbc08」をつけてツイートしてください。

次章ではいよいよカメラ画像をサーバーに送信し、撮った画像が変換されて表示するようにします。

WebRTCで撮った写真を顔変換サービスに送信

これまで学んできたことを統合しましょう。

まずはface.htmlcamera.htmlの内容を追加します。カメラのスナップショットを撮ったら、 その画像を宛先app/faceConverterへ送ります(サーバーサイドの処理は後ほど追加します)。

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Hello STOMP</title>
</head>
<body>
<div>
    <button id="connect">Connect</button>
    <button id="disconnect" disabled="disabled">Disconnect</button>
</div>
<div>
    <video autoplay width="400" height="300"></video>
    <img id="snapshot" src="" width="400" height="300">
    <canvas style="display:none;" width="400" height="300"></canvas>
    <br>

    <div id="response"></div>
</div>
</body>
<script src="stomp.js"></script>
<script type="text/javascript">
    // camera
    navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || window.navigator.mozGetUserMedia || navigator.msGetUserMedia;
    window.URL = window.URL || window.webkitURL;

    /**
     * 初期化処理
     */
    var HelloStomp = function () {
        this.connectButton = document.getElementById('connect');
        this.disconnectButton = document.getElementById('disconnect');
        // this.sendButton = document.getElementById('send'); この行は削除
        this.video = document.querySelector('video');
        this.canvas = document.querySelector('canvas');
        this.canvasContext = this.canvas.getContext('2d');
        this.snapshot = document.getElementById('snapshot');
        this.canvasContext.globalAlpha = 1.0;

        // イベントハンドラの登録
        this.connectButton.addEventListener('click', this.connect.bind(this));
        this.disconnectButton.addEventListener('click', this.disconnect.bind(this));
        // this.sendButton.addEventListener('click', this.sendName.bind(this)); この行は削除
        this.video.addEventListener('click', this.takeSnapshot.bind(this));

        // getUserMedia API
        navigator.getUserMedia({video: true, audio: false},
                function (stream) {
                    this.video.src = window.URL.createObjectURL(stream);
                    this.localMediaStream = stream;
                }.bind(this),
                function (error) {
                    alert(JSON.stringify(error));
                }
        );
    };

    /**
     * エンドポイントへの接続処理
     */
    HelloStomp.prototype.connect = function () {
        var socket = new WebSocket('ws://' + location.host + '/endpoint'); // エンドポイントのURL
        this.stompClient = Stomp.over(socket); // WebSocketを使ったStompクライアントを作成
        this.stompClient.debug = null; // デバッグログを出さない
        this.stompClient.connect({}, this.onConnected.bind(this)); // エンドポイントに接続し、接続した際のコールバックを登録
    };

    /**
     * エンドポイントへ接続したときの処理
     */
    HelloStomp.prototype.onConnected = function (frame) {
        console.log('Connected: ' + frame);
        // 宛先が'/topic/greetings'のメッセージを購読し、コールバック処理を登録
        this.stompClient.subscribe('/topic/greetings', this.onSubscribeGreeting.bind(this));
        // 宛先が'/topic/faces'のメッセージを購読し、コールバック処理を登録
        this.stompClient.subscribe('/topic/faces', this.onSubscribeFace.bind(this));
        this.setConnected(true);
    };

    /**
     * 宛先'/topic/greetings'なメッセージを受信したときの処理
     */
    HelloStomp.prototype.onSubscribeGreeting = function (message) {
        var response = document.getElementById('response');
        var p = document.createElement('p');
        p.appendChild(document.createTextNode(message.body));
        response.insertBefore(p, response.children[0]);
    };

    /**
     * 宛先'/topic/faces'なメッセージを受信したときの処理
     */
    HelloStomp.prototype.onSubscribeFace = function (message) {
        var response = document.getElementById('response');
        var img = document.createElement('img');
        img.setAttribute("src", "data:image/png;base64," + message.body); // Base64エンコードされた画像をそのまま表示する
        response.insertBefore(img, response.children[0]);
    };

    /**
     * 宛先'/app/greet'へのメッセージ送信処理
     */
    HelloStomp.prototype.sendName = function () {
        var name = document.getElementById('name').value;
        this.stompClient.send('/app/greet', {}, name); // 宛先'/app/greet'へメッセージを送信
    };

    /**
     * 接続切断処理
     */
    HelloStomp.prototype.disconnect = function () {
        if (this.stompClient) {
            this.stompClient.disconnect();
            this.stompClient = null;
        }
        this.setConnected(false);
    };

    /**
     * ボタン表示の切り替え
     */
    HelloStomp.prototype.setConnected = function (connected) {
        this.connectButton.disabled = connected;
        this.disconnectButton.disabled = !connected;
        // this.sendButton.disabled = !connected; この行は削除
    };

    /**
     * カメラのスナップショットを取得
     */
    HelloStomp.prototype.takeSnapshot = function () {
        this.canvasContext.drawImage(this.video, 0, 0, 400, 300);
        var dataUrl = this.canvas.toDataURL('image/jpeg');
        this.snapshot.src = dataUrl;
        this.sendFace(dataUrl);
    };

    /**
     * 顔画像の送信
     */
    HelloStomp.prototype.sendFace = function (dataUrl) {
        if (this.stompClient) {
            this.stompClient.send("/app/faceConverter", {}, dataUrl.replace(/^.*,/, '')); // 宛先'/app/faceConverter'へメッセージを送信
        } else {
            alert('not connected!');
        }
    };

    new HelloStomp();
</script>
</html>

サーバーサイドに、宛先app/faceConverterに対する処理を追加しましょう。

Base64でエンコードされた画像をbyte[]にデコードして、JMSのMessageListenerへ送信するだけです。

@SpringBootApplication
@RestController
public class App {
    // ...

    @MessageMapping(value = "/faceConverter")
    void faceConverter(String base64Image) {
        Message<byte[]> message = MessageBuilder.withPayload(Base64.getDecoder().decode(base64Image)).build();
        jmsMessagingTemplate.send("faceConverter", message);
    }

    // ...
}

Appクラスを再起動し、http://localhost:8080/face.htmlにアクセスしてください。

「Connect」ボタンを押して接続したら、カメラの画像をクリックしてください。スナップショットが保存され、サーバーへ送信されます。 しばらくすると処理結果を受信し、表示します。連続してクリックしてもスムーズに処理されることを確認してください。

最後に、カメラ画像だけでなく、ローカルファイルも送信できるように機能追加しましょう。face.htmlを以下のように修正してください。

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Hello STOMP</title>
</head>
<body>
<div>
    <button id="connect">Connect</button>
    <button id="disconnect" disabled="disabled">Disconnect</button>
</div>
<div>
    <video autoplay width="400" height="300"></video>
    <img id="snapshot" src="" width="400" height="300">
    <canvas style="display:none;" width="400" height="300"></canvas>
    <br>
    <input id="files" type="file" disabled="disabled" multiple>

    <div id="response"></div>
</div>
</body>
<script src="stomp.js"></script>
<script type="text/javascript">
    // camera
    navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || window.navigator.mozGetUserMedia || navigator.msGetUserMedia;
    window.URL = window.URL || window.webkitURL;

    /**
     * 初期化処理
     */
    var HelloStomp = function () {
        this.connectButton = document.getElementById('connect');
        this.disconnectButton = document.getElementById('disconnect');
        this.files = document.getElementById('files');
        this.video = document.querySelector('video');
        this.canvas = document.querySelector('canvas');
        this.canvasContext = this.canvas.getContext('2d');
        this.snapshot = document.getElementById('snapshot');
        this.canvasContext.globalAlpha = 1.0;

        // イベントハンドラの登録
        this.connectButton.addEventListener('click', this.connect.bind(this));
        this.disconnectButton.addEventListener('click', this.disconnect.bind(this));
        this.video.addEventListener('click', this.takeSnapshot.bind(this));
        this.files.addEventListener('change', this.sendFiles.bind(this));

        // getUserMedia API
        navigator.getUserMedia({video: true, audio: false},
                function (stream) {
                    this.video.src = window.URL.createObjectURL(stream);
                    this.localMediaStream = stream;
                }.bind(this),
                function (error) {
                    alert(JSON.stringify(error));
                }
        );
    };

    /**
     * エンドポイントへの接続処理
     */
    HelloStomp.prototype.connect = function () {
        var socket = new WebSocket('ws://' + location.host + '/endpoint'); // エンドポイントのURL
        this.stompClient = Stomp.over(socket); // WebSocketを使ったStompクライアントを作成
        this.stompClient.debug = null; // デバッグログを出さない
        this.stompClient.connect({}, this.onConnected.bind(this)); // エンドポイントに接続し、接続した際のコールバックを登録
    };

    /**
     * エンドポイントへ接続したときの処理
     */
    HelloStomp.prototype.onConnected = function (frame) {
        console.log('Connected: ' + frame);
        // 宛先が'/topic/greetings'のメッセージを購読し、コールバック処理を登録
        this.stompClient.subscribe('/topic/greetings', this.onSubscribeGreeting.bind(this));
        // 宛先が'/topic/faces'のメッセージを購読し、コールバック処理を登録
        this.stompClient.subscribe('/topic/faces', this.onSubscribeFace.bind(this));
        this.setConnected(true);
    };

    /**
     * 宛先'/topic/greetings'なメッセージを受信したときの処理
     */
    HelloStomp.prototype.onSubscribeGreeting = function (message) {
        var response = document.getElementById('response');
        var p = document.createElement('p');
        p.appendChild(document.createTextNode(message.body));
        response.insertBefore(p, response.children[0]);
    };

    /**
     * 宛先'/topic/faces'なメッセージを受信したときの処理
     */
    HelloStomp.prototype.onSubscribeFace = function (message) {
        var response = document.getElementById('response');
        var img = document.createElement('img');
        img.setAttribute("src", "data:image/png;base64," + message.body); // Base64エンコードされた画像をそのまま表示する
        response.insertBefore(img, response.children[0]);
    };

    /**
     * 宛先'/app/greet'へのメッセージ送信処理
     */
    HelloStomp.prototype.sendName = function () {
        var name = document.getElementById('name').value;
        this.stompClient.send('/app/greet', {}, name); // 宛先'/app/greet'へメッセージを送信
    };

    /**
     * 接続切断処理
     */
    HelloStomp.prototype.disconnect = function () {
        if (this.stompClient) {
            this.stompClient.disconnect();
            this.stompClient = null;
        }
        this.setConnected(false);
    };

    /**
     * ボタン表示の切り替え
     */
    HelloStomp.prototype.setConnected = function (connected) {
        this.connectButton.disabled = connected;
        this.disconnectButton.disabled = !connected;
        this.files.disabled = !connected;
    };

    /**
     * カメラのスナップショットを取得
     */
    HelloStomp.prototype.takeSnapshot = function () {
        this.canvasContext.drawImage(this.video, 0, 0, 400, 300);
        var dataUrl = this.canvas.toDataURL('image/jpeg');
        this.snapshot.src = dataUrl;
        this.sendFace(dataUrl);
    };

    /**
     * 顔画像の送信
     */
    HelloStomp.prototype.sendFace = function (dataUrl) {
        if (this.stompClient) {
            this.stompClient.send("/app/faceConverter", {}, dataUrl.replace(/^.*,/, ''));
        } else {
            alert('not connected!');
        }
    };

    /**
     * 選択した画像ファイルを送信
     */
    HelloStomp.prototype.sendFiles = function (event) {
        var input = event.target;
        for (var i = 0; i < input.files.length; i++) {
            var file = input.files[i];
            var reader = new FileReader();
            reader.onload = function (event) {
                var dataUrl = event.target.result;
                this.sendFace(dataUrl);
            }.bind(this);
            reader.readAsDataURL(file);
        }
    };

    new HelloStomp();
</script>
</html>

再度、http://localhost:8080/face.htmlにアクセスしてファイルアップロードしてみてください。複数ファイルを一度に送信できます。

以上で本章は終了です。

本章の内容を修了したらハッシュタグ「#kanjava_sbc #sbc09」をつけてツイートしてください。

Dockerを使ってみる

いつか書く。基本的にはこちらの内容。

[事前準備] Spring BootでHello World」の内容に戻って、AWS Elastic Beanstalkへデプロイしてみてください。

本章の内容を修了したらハッシュタグ「#kanjava_sbc #sbc10」をつけてツイートしてください。

顔変換サービスをDocker化

src/main/docker/Dockerfile.txtをみてください。

FROM dockerfile/java:oracle-java8

ADD kusokora.jar /opt/kusokora/
EXPOSE 8080
WORKDIR /opt/kusokora/
CMD ["java", "-Xms512m", "-Xmx1g", "-jar", "kusokora.jar"]

実行可能jarをコピーして実行しているだけです。通常はこれでOKなのですが、今回はOpenCVを使うため、OpenCV用の準備が必要です。

src/main/docker/Dockerfile.txt以下のように書き換えてください。

FROM centos:centos7
RUN yum -y update && yum clean all
RUN yum -y install wget glibc gtk2 gstreamer gstreamer-plugins-base libv4l
RUN wget -c -O /tmp/jdk-8u31-linux-x64.rpm --no-check-certificate --no-cookies --header "Cookie: oraclelicense=accept-securebackup-cookie" http://download.oracle.com/otn-pub/java/jdk/8u31-b13/jdk-8u31-linux-x64.rpm
RUN yum -y localinstall /tmp/jdk-8u31-linux-x64.rpm
RUN rm -f /tmp/jdk-8u31-linux-x64.rpm

ADD kusokora.jar /opt/kusokora/
ADD classes/haarcascade_frontalface_default.xml /opt/kusokora/
EXPOSE 8080
WORKDIR /opt/kusokora/
CMD ["java", "-Xms512m", "-Xmx1g", "-jar", "kusokora.jar", "--classifierFile=haarcascade_frontalface_default.xml"]

また、JavaCVの制限でhaarcascade_frontalface_default.xmlをjarの中から読み込めないため、外出しして渡す必要があります。 haarcascade_frontalface_default.xmlをAWSデプロイ用のzipに含めるためにpom.xmlの以下の部分を修正します。

<execution>
    <id>zip-files</id>
    <phase>package</phase>
    <goals>
        <goal>run</goal>
    </goals>
    <configuration>
        <target>
            <!-- classes/haarcascade_frontalface_default.xmlを追加 -->
            <zip destfile="${basedir}/target/app.zip" basedir="${basedir}/target" includes="Dockerfile, Dockerrun.aws.json, ${project.artifactId}.jar, classes/haarcascade_frontalface_default.xml" />
        </target>
    </configuration>
</execution>

Docker上はLinux(CentOS 7)を使用するので、Linux用にアプリケーションをビルドします。

$ mvn clean package -Plinux-x86_64

target以下にapp.zipができるので、これを「Dockerを使ってみる」のように、AWSにデプロイしたり、 ローカルのboot2dockerで試すなりしてみてください。

本章の内容を修了したらハッシュタグ「#kanjava_sbc #sbc11」をつけてツイートしてください。