Spring Bootキャンプ ハンズオン資料¶
前提条件¶
- Java SE 8がインストールされていること。
- Mavenがインストールされていることかつ基本的なことが分かること。
- Gitがインストールされていること。
- curlがインストールされていること。
- OSがWindowsまたはMacOSであること。
- DIの基本的な知識を有していること。
- JavaでWebプログラミングの経験があること。
- (カメラを使う場合)PCにカメラがついていること。
- (Dockerを使う場合)boot2dockerがインストールされていること、またはAWSのアカウントを持っていること。
目次¶
[事前準備] 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にアクセスして、色々な情報を取得してみてください。
- http://localhost:8080/env
- http://localhost:8080/health
- http://localhost:8080/configprops
- http://localhost:8080/mappings
- http://localhost:8080/metrics
- http://localhost:8080/beans
- http://localhost:8080/trace
- http://localhost:8080/dump
- http://localhost:8080/info
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(右側)が出来あがります。
このプログラムが問題なく実行で切れいれば、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(右側)が出来あがります。
プログラムを見てみましょう。
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のデフォルトでは対応していないのですが、特定の型に対するリクエスト・レスポンスを扱うためのHttpMessageConverter
のBufferedImage
は用意されています。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.xml
をsrc/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レスポンスを返却します。
送信されたメッセージは受信側によって取り出され、本処理がおこなれます。JMSによるメッセージ受信方法は色々あるのですが、今回はMessage Listenerを使用します。
本ハンズオンでは、メッセージキューとして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です。スレッド数を変更する場合は@JmsListener
のconcurrency
属性を設定します。
@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という仕組みを導入します。
@Scope
のproxyMode
属性に以下のような設定を行います。
@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は削除(またはコメントアウト)しておいてください。
// ...
}
ソースだけでは分かりにくいと思いますが、メッセージのフローは下図のようになります。
宛先が/topicや/queueで始まるものはメッセージブローカー(仲介役)が直接ハンドリングします。 宛先が/appから始まるものはControllerに渡って処理され、その処理結果がメッセージブローカーに渡ります。
メッセージブローカーによって制御されたメッセージは、その宛先を購読しているクライアントへと送られます。
次にStomp.jsを使ってクライアントを作りましょう。src/main/resources/static
にhello.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にアクセスしてください。
「Connect」ボタンを押して、フォームに名前を入力し、「Send」ボタンを押してください。
結果が返ってきました。今回は宛先をTopicにしているため、他のタブで別途Connectすれば全てのタブに結果が表示されます。
注釈
mvn spring-boot:run
でApp
クラスを起動すれば、静的リソースの変更が即反映されるので開発中は便利です。
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
送信した後、ブラウザを確認すると以下のように変換後の画像が表示されるはずです。
画像サイズが少し大きく、転送量が肥大化してしまうため、サーバーサイドでリサイズするようにしましょう。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
クラスを再起動して、クライアントを再接続してください。そして画像処理のリクエストを送り、ブラウザに以下のように表示されることを確認してください。
以上で本章は終了です。
本章の内容を修了したらハッシュタグ「#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にアクセスしてください。
カメラアクセスへの許可を確認されますので、「許可」をクリックしてください。そうするとカメラの結果が左側に表示されます。
左のカメラ画像をクリックすると、右側にスナップショットして表示されます。
以上で本章は終了です。
本章の内容を修了したらハッシュタグ「#kanjava_sbc #sbc08」をつけてツイートしてください。
次章ではいよいよカメラ画像をサーバーに送信し、撮った画像が変換されて表示するようにします。
WebRTCで撮った写真を顔変換サービスに送信¶
これまで学んできたことを統合しましょう。
まずはface.html
にcamera.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」をつけてツイートしてください。