- 投稿日:2019-12-02T23:46:44+09:00
LombokのBuilderパターン解説
はじめに
本記事は Java Advent Calendar 2019 の3日目の記事です。
LombokのBuilderパターンについて解説します。「Builderパターン」とは?
GoFのデザインパターンの一種で、生成処理を積み上げてインスタンスを生成するパターンです。
処理を積み上げて生成していく様子から「Builder(建築者)パターン」と呼ぶようです。環境
- Java:10.0.2
- IDE:IntelliJ IDEA 2019.2.3 (Community Edition)
- Lombok plugin:0.27-2019.02
Builderパターンの適用方法
Builderパターンを適用するクラスを定義します。
Lombokでは、クラスに@Builder
を付けることでBuilderが自動生成されます。外部からの変更を防ぐため、各プロパティに
private
やfinal
を付けています。FooFooFooDto.javaimport lombok.Builder; @Builder final class FooFooFooDto { private final String title; private final int number; private final boolean isFoo; }この実装により、以下のコードが自動生成されます。
Lombokで自動生成されたコード
final class FooFooFooDto { private final String title; private final int number; private final boolean isFoo; FooFooFooDto(String title, int number, boolean isFoo) { this.title = title; this.number = number; this.isFoo = isFoo; } public static FooFooFooDtoBuilder builder() { return new FooFooFooDtoBuilder(); } public static class FooFooFooDtoBuilder { private String title; private int number; private boolean isFoo; FooFooFooDtoBuilder() { } public FooFooFooDto.FooFooFooDtoBuilder title(String title) { this.title = title; return this; } public FooFooFooDto.FooFooFooDtoBuilder number(int number) { this.number = number; return this; } public FooFooFooDto.FooFooFooDtoBuilder isFoo(boolean isFoo) { this.isFoo = isFoo; return this; } public FooFooFooDto build() { return new FooFooFooDto(title, number, isFoo); } public String toString() { return "FooFooFooDto.FooFooFooDtoBuilder(title=" + this.title + ", number=" + this.number + ", isFoo=" + this.isFoo + ")"; } } }自動生成されたコードのクラス図
コードのみを読んでも頭に入りにくいため、クラス図を作成しました。
PlantUMLで表現する方法がわからなかったのですが、FooFooFooDtoBuilderクラスはstaticです。
関連の+--
は入れ子になっているクラスを表しています。今回だとFooFooFooDtoBuilderクラスはFooFooFooDtoクラスの内部で定義されています。追加された全処理を紹介します。
特に見てほしい処理は 太字 で表しています。
- FooFooFooDtoクラス
- 全プロパティに値を代入するコンストラクタ
クラス図では表現していない- Builderを返す
builder()
メソッド- FooFooFooDtoBuilderクラス
- コンストラクタ
クラス図では表現していない- 全プロパティ
- 全プロパティのセッター
メソッド名はプロパティ名と同等
戻り値にthis
を返す- 生成したいインスタンスを生成して返す
build()
メソッドtoString()
メソッド
Builderを用いたインスタンスの生成方法
以下の手順でインスタンスを生成します。
builder()
メソッドでBuilderを生成する- メソッドチェーンで生成処理(=プロパティのセット)を積み上げる
build()
メソッドを実行してインスタンスを生成するBuilderを用いたインスタンスの生成final var fooFooFooDto = FooFooFooDto.builder() // 1. Builderを生成する .title("テスト") // 2. 生成処理を積み上げる .number(1) .isFoo(true) .build(); // 3. ビルドしてインスタンスを生成するプロパティのセッターで
this
(ここではFooFooFooDtoBuilder)を返すのがポイントで、これによりメソッドチェーンでインスタンスを生成できます。
メソッドとプロパティが同名なのもわかりやすいです。メリット
インスタンスの生成処理がスッキリする
メソッドチェーンでプロパティを初期化できるため、変数名を毎回書かずにスッキリします。
Setterでセットすると毎回変数名を書く必要があります。Setterを使用final var fooFooFooDto = new FooFooFooDto(); fooFooFooDto.setTitle("テスト"); fooFooFooDto.setNumber(1); fooFooFooDto.isFoo(true);Builderパターンを適用final var fooFooFooDto = FooFooFooDto.builder() .title("テスト") .number(1) .isFoo(true) .build();プロパティが他で変更されないことを確約できる
Setterでセットするとバラバラに書けてしまいます。
Setterだとプロパティをいつでも変更できるfinal var fooFooFooDto = new FooFooFooDto(); // … // いろいろな処理 // … fooFooFooDto.setTitle("テスト"); // … // いろいろな処理 // … fooFooFooDto.setNumber(1); // … // いろいろな処理 // … fooFooFooDto.isFoo(true);バラバラに書けると、追うのに時間がかかって可読性が下がります。
もしまとまって書かれていても、他で変更されているか確認する手間がかかります。どのプロパティを初期化しているかわかりやすい
お気づきの方もいると思いますが、上記の2つはコンストラクタで解決できます。
コンストラクタを使用final var fooFooFooDto = new FooFooFooDto("テスト", 1, true);しかし、コンストラクタは呼び出し時に引数名を書けないので、どのプロパティを初期化しているかわかりづらいです。
引数を書く順番も決まっていて、好きな順番で初期化できません。コンストラクタでは引数の順番が決まっているfinal var fooFooFooDto = new FooFooFooDto("テスト", 1, true); // `(1, "テスト", true)` とは書けないBuilderパターンを適用すると、どのプロパティを初期化しているかわかりやすく、初期化する順番も自由に決められます。
Builderパターンでは初期化する順番が自由final var fooFooFooDto = FooFooFooDto.builder() .title("テスト") .number(1) .isFoo(true) .build(); final var fooFooFooDto = FooFooFooDto.builder() .number(1) // 初期化する順番を変えられる .title("テスト") .isFoo(true) .build();引数が多いと渡す順番がわからなくなりますが、Builderパターンだとそれがありません。
引数が多くても実装しやすい// コンストラクタを使用 // メソッドの定義を行き来しないと実装しづらい final var manyFooDto = new ManyFooDto("テスト", 1, true, 2, 3, 4, false, true, false); // Builderパターンを適用 final var manyFooDto = ManyFooDto.builder() .title("テスト") .number(1) .isFoo(true) .number2(2) .number3(3) .number4(4) .isBar(false) .isHoge(true) .isFuga(false) .build();IDEによっては引数名が表示され、Builderパターンを適用しなくても実装しやすいことがあります。
それでも、コードレビュー時などIDEを使わないときは可読性が低いままなので、Builderパターンは効果的です。メソッドチェーンがカッコいい
これが一番のメリットです 。(私調べ)
やはりメソッドチェーンで書けるとカッコいいです。
Stream APIと組み合わせるとカッコよさが増します。適当なカッコいい処理final var fooFooFooDtoList = barDtoList .stream() .filter(Objects::nonNull) .map(it -> { return FooFooFooDto.builder() .title(it.title) .number(it.number) .isFoo(true) .build(); }) .collect(Collectors.toList());デメリット
同一パッケージ内だとインスタンスをnewできる
自動生成されるコンストラクタにはアクセス修飾子が付かないため、同一パッケージ内だとコンストラクタ経由でインスタンスを生成できてしまいます。
コンストラクタをprivate
にすれば解決できそうですが、何かそうしない理由があるのかもしれません。同一パッケージだとnewできるfinal var fooFooFooDto = new FooFooFooDto("テスト", 1, true);コメント を頂いて知ったのですが、
@Builder
は内部で@AllArgsConstructor
と同等のコンストラクタを生成しています。
アクセスレベルをprivate
にしたAllArgsConstructor
を生成すれば、コンストラクタ経由でインスタンスが生成されることを防げます。コンストラクタを明示的にprivateにするimport lombok.AccessLevel; // 追加 import lombok.AllArgsConstructor; // 追加 import lombok.Builder; @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) // 追加 public final class FooFooFooDto { private final String title; private final int number; private final boolean isFoo; }
Lombokで自動生成されたコード
FooFooFooDto.javapublic final class FooFooFooDto { private final String title; private final int number; private final boolean isFoo; // コンストラクタが `private` になる private FooFooFooDto(String title, int number, boolean isFoo) { this.title = title; this.number = number; this.isFoo = isFoo; } public static FooFooFooDtoBuilder builder() { return new FooFooFooDtoBuilder(); } public static class FooFooFooDtoBuilder { private String title; private int number; private boolean isFoo; FooFooFooDtoBuilder() { } public FooFooFooDto.FooFooFooDtoBuilder title(String title) { this.title = title; return this; } public FooFooFooDto.FooFooFooDtoBuilder number(int number) { this.number = number; return this; } public FooFooFooDto.FooFooFooDtoBuilder isFoo(boolean isFoo) { this.isFoo = isFoo; return this; } public FooFooFooDto build() { return new FooFooFooDto(title, number, isFoo); } public String toString() { return "FooFooFooDto.FooFooFooDtoBuilder(title=" + this.title + ", number=" + this.number + ", isFoo=" + this.isFoo + ")"; } } }インスタンスの生成方法が複数あると混乱するので、こちらの実装をデフォルトにしてほしいです…。
コメント を頂いて知ったのですが、BuilderのJavadocには「デフォルトが
private
」と書かれていますww
https://github.com/rzwitserloot/lombok/blob/master/src/core/lombok/Builder.java#L36Issue が上がっていますが、全部読んでもデフォルトが
package private
の理由はわかりませんでした。
「他のパッケージで継承してBuilderパターンを適用したいため」とコメントがありましたが、その場合はpublic
にしないといけないので…。プロパティの初期化を強制できない
コンストラクタではプロパティの初期化を強制できますが、Builderパターンでは強制できません。
プロパティの初期化を強制できないfinal var fooFooFooDto = FooFooFooDto.builder() .title("テスト") .number(1) // `isFoo` が初期化されてなくてもビルドできる .build();あとから追加したプロパティの初期化を忘れやすいため、頻繁にプロパティを追加するクラスには適用しない方がよさそうです。
Builderパターンの用途
データを受け渡すためだけに使うDTOに適用すると効果的です。
応用:Getterを追加する
プロパティには
privte
とfinal
を付けているため、このままだとプロパティの値を取得できません。
クラスに@Getter
を付けると全プロパティにGetterが追加されるのでオススメです。FooFooFooDto.javaimport lombok.Builder; import lombok.Getter; @Builder @Getter // 追加 final class FooFooFooDto { private final String title; private final int number; private final boolean isFoo; }もし外部に公開したくないプロパティが存在する場合、プロパティごとにGetterを付ければOKです。
FooFooFooDto.javaimport lombok.Builder; import lombok.Getter; @Builder final class FooFooFooDto { @Getter //追加 private final String title; private final int number; // Getterを付けなければ隠蔽される @Getter // 追加 private final boolean isFoo; }応用:デフォルト値を設定する
プロパティに
@Builder.Default
を付けることで、デフォルト値を設定できます。
final
を付けていても使えるので便利です。FooFooFooDto.javaimport lombok.Builder; import lombok.Getter; @Builder final class FooFooFooDto { private final String title; @Builder.Default // 追加 private final int number = 1; private final boolean isFoo; }なぜ
final
を付けていても使えるのかは、自動生成されるコードを見るとわかります。
Lombokで自動生成されたコード
final class FooFooFooDto { private final String title; private final int number; private final boolean isFoo; FooFooFooDto(String title, int number, boolean isFoo) { this.title = title; this.number = number; this.isFoo = isFoo; } // デフォルト値を返すメソッド private static int $default$number() { return 1; } public static FooFooFooDtoBuilder builder() { return new FooFooFooDtoBuilder(); } public static class FooFooFooDtoBuilder { private String title; private int number; private boolean number$set; // セットしたかどうかをboolean型で持つ private boolean isFoo; FooFooFooDtoBuilder() { } public FooFooFooDto.FooFooFooDtoBuilder title(String title) { this.title = title; return this; } public FooFooFooDto.FooFooFooDtoBuilder number(int number) { this.number = number; this.number$set = true; // セットしたら `true` にする return this; } public FooFooFooDto.FooFooFooDtoBuilder isFoo(boolean isFoo) { this.isFoo = isFoo; return this; } public FooFooFooDto build() { // セットしていない場合にデフォルト値で初期化する return new FooFooFooDto(title, number$set ? number : FooFooFooDto.$default$number(), isFoo); } public String toString() { return "FooFooFooDto.FooFooFooDtoBuilder(title=" + this.title + ", number=" + this.number + ", isFoo=" + this.isFoo + ")"; } } }応用:継承しているクラスにBuilderパターンを適用する
継承しているクラスにBuilderパターンを適用しようとすると、親クラスのプロパティを考慮しないためビルドエラーになります。
実装を工夫する必要があり、以下の記事が参考になります。
https://www.baeldung.com/lombok-builder-inheritanceおわりに
Builderパターンのよさがわかり、実際に取り入れていただけると嬉しいです?
以上、 Java Advent Calendar 2019 の3日目の記事でした。
明日は @n_slender さんの記事です。参考リンク
- 投稿日:2019-12-02T20:17:24+09:00
Konduit-Servingでかんたんモデルデプロイ
こんにちは、 @kmotohas です。SB-AI Advent Calendar 2019 の2日目、よろしくお願いします。
本稿ではJavaベースの機械学習モデルデプロイツールである Konduit-Serving の紹介をします。
が、実は私、ソフトバンク株式会社公認のクラブ活動である SB AI部 の発起人でして、この団体の紹介や設立の背景から始めようと思います。
SB AI部とは
ソフトバンク株式会社には福利厚生の一環としてクラブ活動をサポートする制度があります。
社内には多数の部活がありますが、大きく分けてスポーツ系、文化系のジャンルがあります。
スポーツ系はイメージしやすいかと思いますが、バスケやフットサルなど、いわゆるクラブ活動感があるラインナップとなっています。
文化系にはテクノロジー企業らしく、ロボットやIoTを扱うPepper部やドローンレースに参加する団体などがあったりします。そこに昨年私が新たに設立したのが SB AI部 という団体です。
専用のSlackワークスペースには2019年12月2日現在568名(アクティブメンバーは160名くらい)のソフトバンク関連企業のメンバーが参加しています。
そちらでAI関連の話題の共有、イベント情報、質問、議論、企画、雑談、希望メンバー同士のシャッフルランチなどなどが行われています。SB AI部設立の背景
ソフトバンク株式会社のテクノロジーユニットではこれまた素敵な Technical Meister という社内認定制度があります。
Yahoo Japanの黒帯制度を参考に設立された制度で、社内に散らばっている技術に強い人にスポット当てるものです。
認定されると年間 ???万円(見せられないよ!)の個人予算が割り当てられ、国際会議やイベントへの参加費や渡航費に用いたり、イベント開催の費用に用いたり、ハイスペックなPCを買ったり、何かしらの発注を行ったりできるわけです。認定される「技術」には様々あり、ネットワーク、5G通信、インフラストラクチャ、ディープラーニング、画像処理、自然言語処理、ロボット制御などの分野でそれぞれ認定されている方がいます。
毎年審査があり、今後ももしかしたら量子コンピューターやブロックチェーンなど、先進的な分野においてTechnical Meister認定される方が出てくるかもしれません。認定されたTechnical Meisterの人々にはある程度社内外発信などの貢献が求められます。
そこで僭越ながらディープラーニング分野のTechnical Meisterとして認定された私はSB AI部を立ち上げたのです。設立以前から全社を上げて"AI"を推進していくというスローガンが掲げられていました。
エンジニアのみならず、経理・事務等バックオフィス系の方々や営業の方々もそれぞれ独自のAI施策を企画・実行していて非常に盛り上がっています。それぞれがそれぞれで頑張っていると多様性が出て色々独創的なアイデアが創出されます。
もちろんこれは素晴らしいことなのですが、どんな分野でも初学者は得てして同じようなところでつまずいたり、疑問を持ったりするものですよね。
そこで、部署横断的にAI関連の話題を扱えるコミュニティスペースを作ったわけです。なお現在部長の座は、学生時代全能アーキテクチャー若手の会副理事をやっていた松岡さんに譲っており、彼がまた精力的に部を盛り上げてくれています。
Twitterなどで有名なコミさんも春から社員となり、きっとまた部の熱量が上がっていくことでしょう。機械学習モデルのデプロイ
導入部分を書いていたら熱中してしまいつい長くなりました。本題に入ります。
近年のディープラーニングの発展には目を見張るものがあります。コンピュータービジョンや自然言語処理、レコメンデーション、自動運転、はたまたゲームの世界などの幅広い領域においてディープラーニングが応用され、華々しい研究成果が毎日のようにニュースとして流れてきています。TensorFlow、Keras、PyTorch、Chainerなど、研究開発向けの優れた Python ベースの開発フレームワークの存在もこの成功の一翼を担ってるかと思います。しかし、ディープラーニングのモデルを本番環境へデプロイする際の方法論にはまだ確固たるものはなく、実務者の方々がそれぞれ試行錯誤しているのが現状です。
と、いきなり引用しましたが、こちらはオライリージャパンの「詳説Deep Learning --- 実務者のためのアプローチ」の監訳者前書きの一説です。拙文です。(ステマ)
近年におけるディープラーニングなど機械学習分野の発達はそれはもう本当に凄まじいことになっており、個人で最新論文を追うのはもはや不可能なレベルになっています。
開発フレームワークとしてはGoogleが開発したTensorFlowが長らく圧倒的人気を誇っていたのですが、FaceBookが開発したPyTorchというフレームワークのユーザーが急増してきて、現在TensorFlowとPyTorchが二大ライブラリとなっています。
出典 https://www.oreilly.com/ideas/one-simple-graphic-researchers-love-pytorch-and-tensorflowただ注意したいのは、ディープラーニングとかいわゆるAIの文脈ではしばしば研究分野と産業分野がごっちゃに語られていることです。
PyTorchは学術研究分野やイノベーター・アーリーアダプター達に広く用いられていますが、産業分野ではまだまだTensorFlowに軍配が上がるようです1。The State of Machine Learning Frameworks in 2019 では、研究者は試行錯誤のしやすさを重視し、産業ではパフォーマンスが重視されると述べています。
また、モデルのデプロイにおける要件について以下のように述べています。
- No Python. Some companies will run servers for which the overhead of the Python runtime is too much to take.
- Mobile. You can’t embed a Python interpreter in your mobile binary.
- Serving. A catch-all for features like no-downtime updates of models, switching between models seamlessly, batching at prediction time, and etc.
企業のシステムをPythonで動かしている事例はまだ全体に比べると少なく、iOSやAndroidアプリのバイナリにPythonインタープリターを組み込むことはできないし、ダウンタイムなしにモデルを切り替えたり、バッチ推論したいという事情があります。
こういった要望に対してTensorFlowは静的なグラフ構築やTensorFlow Lite、TensorFlow Serving (TensorFlow Extended)といったサービスで対応しているのですね。
そもそも「デプロイ」とは
関係あるのかないのか微妙なところですが、学生の頃、捻くれていたので「ソリューション」って言葉が苦手でした。
就活時に企業研究としていろんな会社のホームページを見ていると「○○のソリューション」みたいな何だかよくわからない何も言っていない気がする言葉が並んでいて気色悪く感じたものです。今はもう慣れてむしろソリューションとしか表現できないなって気持ちになっているのですが、「デプロイ」も初めに聞いたとき何のこっちゃよくわかりませんでした。
要するに「システムを利用可能な状態にすること」ですね。Javaで開発したWebアプリケーションをパックしたJARファイルをサーバーに配置することをデプロイと呼んだりするって感じです。
こと機械学習モデルにおいては以下の三つのアプローチがよく用いられています。(参考: Continuously Delivery for Machine Learning)
- モデルの組み込み: 一番単純なアプローチ。指定したモデルを読み込んで利用するコードを直接メインのアプリケーションの中に記述する。
- 外部サービスとしてモデルをデプロイ: メインのアプリケーションとは別のサービス(REST APIなど)としてモデルを配布する。モデルの更新を独立して個別にリリース、バージョニングできる。予測ごとにリモートの呼び出しが必要なので、レイテンシーが発生する可能性あり。
- データとしてモデルを配布: 2番目のアプローチと同様にモデルを独立に扱う。ただし、メインのアプリケーションの実行時にデータとしてモデルをsubscribeする。新しいバージョンのモデルがリリースされたらそのモデルをメモリに読み込んで利用する。
メインのシステムがJavaで記述されていて、そこに機械学習モデルをデプロイするケースを考えます。
1のアプローチが右図で、2のアプローチが左図にあたります。
1のアプローチでは例えばDeeplearning4jなどJavaで開発できるフレームワークで最初からモデル構築したりデータフローを作ったりします。
また、機械学習エンジニアの方々がTensorFlowなどPythonベースのフレームワークで作ったモデルを用いたい場合はTensorFlow for JavaやDeeplearning4j suiteのSameDiffを用いたりしてJavaのオブジェクトに変換します。ただし、モデルがやってくれることはテンソルに変換されたデータを受け取り、推論をし、結果のテンソルを出力することです。
その前後に必要な前処理や後処理は再度Javaで記述し直す必要があります。開発の分業のしやすさやモジュール化(マイクロサービス化)による柔軟性の利点から、感覚的には2のアプローチがよく用いられている気がします。
Javaプロセスの役割はREST APIなどを経由して生データを渡し、結果を受け取ることだけで、前処理や後処理は外部サービスに押し込めることができます。このREST API作成のサポートをしてくれる「ソリューション」のひとつがKonduit-Servingです。
Konduit-Servingとは
Konduit-Servingとは、Deeplearning4j 開発者 Adam Gibson を中心に開発されているJavaベースのオープンソースの機械学習モデルデプロイツールのことです。
TensorFlowやKeras、DL4Jといったフレームワークで作成されたモデルや、PMML、ONNX、PFAといった汎用的なフォーマットのモデルをREST APIとしてデプロイすることができます。
また、面白いのはPythonコードそのものをデプロイできる点です。これについては後述します。Konduit-Servingは主に以下の3つのライブラリをベースに開発されています。
- JavaCPP: C++で書かれたコード (CUDA/TensorFlow/NumPy/etc) のJavaラッパーを作成
- Eclipse Deeplearning4j: JVM用ディープラーニングフレームワーク
- Eclipse Vert.x: イベントドリブンアプリ開発フレームワーク
JavaCPPによりJavaでもCUDAによるGPUアクセラレーションの恩恵を受けることができ、TensorFlowやNumpyなどPython界隈で広く用いられているライブラリをJava上で用いることができます2。
また、Deeplearning4jのツール群を用いてND4JによるJava上での行列計算や、Keras model importの機能を利用しています。
そして、Vert.xベースにノンブロッキングで効率的なスケール性のあるAPIを構築できます。Javaの知識がないと利用できないかというとそういうこともなく、Python SDKが用意されているため、機械学習エンジニアにも安心です。
その他流行りのコンテナオーケストレーターKubernetes統合もサポートされており、PrometheusとGrafanaと連携してパフォーマンスや様々なメトリックのモニタリングも簡単に行うことができます。Java上の組み込みPythonインタープリターを利用してPythonコードそのままデプロイ
上記の「Pythonコードをそのままデプロイ」の意味を解説します。
Konduit-ServingにはPythonインタープリターが内包されています。
先ほど「Mobile. You can’t embed a Python interpreter in your mobile binary.」と書いたばかりですが、工夫すれば何とかなるのです。
Pythonコードに含まれているオブジェクトはメモリに展開され、そのポインターに直接アクセスすることでゼロコピーでJavaからPythonコードを扱えるようになります。まあ正直私も詳しいところは理解していないのですが、とにかくJava上でPythonを扱うことができるのです。
利用方法としてはKonduit-ServingでデプロイされたPythonコードに対して、HTTPのクライアントから入力データを送信します。
すると同名の変数のポインタが書き換えられ、組み込みのPythonが実行され、結果を格納した変数の中身をクライアントに返します。これの何が嬉しいかというと、機械学習エンジニアの方が実験に用いたコードをプロダクションに流用できるので、コード書き直しの工数を減らすことができるというのと、任意のPythonライブラリを利用できるメリットがあります。
(勘がいい方はお気づきかもしれませんが、Pythonを用いるとGILに囚われてしまい、せっかくのJavaのマルチスレッド性を失ってしまいます。トレードオフということですね。Konduit-Servingを用いてモデルをそのままデプロイする場合はマルチスレッドで高パフォーマンスを発揮できます。)
サンプル: 顔認識モデルのデプロイ
ここではPyTorchで開発された顔認識モデルを用いた推論コードをKonduit-Servingでデプロイするデモを紹介します。
Konduit-Servingのセットアップ
それではKonduit-Servingのセットアップから初めてみましょう。
なお、私は Ubuntu 18.04 (w/o GPU) で検証しました。git、Java Development Kit 8 (1.8)、Javaのビルド管理ツールのMavenが必要です。
まず、Anacondaなどを用いて環境を切ります。Pythonのバージョンは3.7(以上?)が推奨されています。
conda create -n konduit python=3.7 conda activate konduitGitHubからソースコードをクローンし、また、Python SDKをインストールします。
git clone https://github.com/KonduitAI/konduit-serving.git cd konduit-serving/python # conda install cython 必要かも python setup.py install cd ..プロジェクトをビルドし、uber jarファイルを作成します。
konduit init --os <your-platform> # windows-x86_64, linux-x86_64`, linux-x86_64-gpu, # macosx-x86_64, linux-armhf and windows-x86_64-gpu # e.g.) konduit init --os linux-x86_64現在のバージョンでは
~/.konduit/konduit-serving/konduit-serving-uberjar/target/konduit-serving-uberjar-1.2.1-bin.jar
に生成されます。
1.2.1
の部分はバージョンによります。
ドキュメントには~/.konduit/konduit-serving/konduit.jar
にコピーが生成されると書いてありますが、私の環境ではできませんでした。
(すぐ修正されるでしょう。)
以下ではドキュメントに沿った解説をします。今のところはcp ~/.konduit/konduit-serving/konduit-serving-uberjar/target/konduit-serving-uberjar-1.2.1-bin.jar ~/.konduit/konduit-serving/konduit.jar
などしてあげてください。次に、uber jarのパスを環境変数
KONDUIT_JAR_PATH
に設定します。echo export KONDUIT_JAR_PATH="~/.konduit/konduit-serving/konduit.jar" >> ~/.bashrc source ~/.bashrc # conda activate konduit し直す必要あるかも # conda activateとすると環境変数がリセットされることがあるので # export JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 などするこれで準備完了です。
顔認識プロジェクトの準備とREST API化
サンプルが置いてあるディレクトリに移動します。
# konduit-servingのディレクトリにいると仮定 cd python/examples/face_detection_pytorchここで用いるモデルはPytorchで開発された非常に軽量な顔認識モデルです。
コードをクローンし、依存ライブラリをインストールします。git clone https://github.com/Linzaer/Ultra-Light-Fast-Generic-Face-Detector-1MB pip install -r ./Ultra-Light-Fast-Generic-Face-Detector-1MB/requirements.txtこちらのリポジトリで公開されている推論コードはこちらです。
https://github.com/Linzaer/Ultra-Light-Fast-Generic-Face-Detector-1MB/blob/master/detect_imgs.py
このコードではディレクトリを指定するとそこに含まれる画像をモデルに入力し、検出された顔のbounding boxを描画した画像を出力します。今回のサンプルではもう少し単純なものになっています。
インプットはBase64エンコードされた画像データ、アウトプットは画像から検出された顔の数とします。
上記の推論コードをサンプル用に修正し、簡略化したものがこちらです。
https://github.com/KonduitAI/konduit-serving/blob/master/python/examples/face_detection_pytorch/detect_image.pydetect_image.pyimport os import sys work_dir = os.path.abspath('./Ultra-Light-Fast-Generic-Face-Detector-1MB') sys.path.append(work_dir) from vision.ssd.config.fd_config import define_img_size from vision.ssd.mb_tiny_RFB_fd import create_Mb_Tiny_RFB_fd, create_Mb_Tiny_RFB_fd_predictor from utils import base64_to_ndarray threshold = 0.7 candidate_size = 1500 define_img_size(640) test_device = 'cpu' label_path = os.path.join(work_dir, "models/voc-model-labels.txt") test_device = test_device class_names = [name.strip() for name in open(label_path).readlines()] model_path = os.path.join(work_dir, "models/pretrained/version-RFB-320.pth") net = create_Mb_Tiny_RFB_fd(len(class_names), is_test=True, device=test_device) predictor = create_Mb_Tiny_RFB_fd_predictor(net, candidate_size=candidate_size, device=test_device) net.load(model_path) # The variable "image" is sent from the konduit client in "konduit_server.py", i.e. in our example "encoded_image" image = base64_to_ndarray(image) boxes, _, _ = predictor.predict(image, candidate_size / 2, threshold) # "num_boxes" is then picked up again from here and returned to the client num_boxes = str(len(boxes))大事なのは下記の部分です。
detect_image.py# The variable "image" is sent from the konduit client in "konduit_server.py", i.e. in our example "encoded_image" image = base64_to_ndarray(image) boxes, _, _ = predictor.predict(image, candidate_size / 2, threshold) # "num_boxes" is then picked up again from here and returned to the client num_boxes = str(len(boxes))
image
がクライアントから送信される入力で、num_boxes
が出力です。
こちらをKonduit-Servingでデプロイし、クライアントからリクエストを送ってみます。python run_server.pyうまく動作すれば、長々とログやデバッグメッセージが表示されたあと以下のように顔の数(この場合51)が返されます。
... 02:37:13.585 [vert.x-worker-thread-6] INFO a.k.s.executioner.PythonExecutioner - Exec done 02:37:13.586 [vert.x-worker-thread-6] INFO a.k.s.executioner.PythonExecutioner - CPython: PyEval_SaveThread() 02:37:13.586 [vert.x-worker-thread-6] INFO a.k.s.executioner.PythonExecutioner - CPython: PyEval_RestoreThread() 02:37:13.586 [vert.x-worker-thread-6] INFO a.k.s.executioner.PythonExecutioner - temp_27_main.json 02:37:13.586 [vert.x-worker-thread-6] INFO a.k.s.executioner.PythonExecutioner - Executioner output: 02:37:13.586 [vert.x-worker-thread-6] INFO a.k.s.executioner.PythonExecutioner - {"num_boxes": "51"} 02:37:13.586 [vert.x-worker-thread-6] INFO a.k.s.executioner.PythonExecutioner - CPython: PyEval_SaveThread() 02:37:13.586 [vert.x-worker-thread-6] INFO a.k.s.executioner.PythonExecutioner - CPython: PyEval_RestoreThread() 51
run_konduit.py
の中身は以下の通りです。run_konduit.pyfrom konduit import * from konduit.server import Server from konduit.client import Client from konduit.utils import default_python_path import os from utils import to_base_64 # Set the working directory to this folder and register the "detect_image.py" script as code to be executed by konduit. work_dir = os.path.abspath('.') python_config = PythonConfig( python_path=default_python_path(work_dir), python_code_path=os.path.join(work_dir, 'detect_image.py'), python_inputs={'image': 'STR'}, python_outputs={'num_boxes': 'STR'}, ) # Configure a Python pipeline step for your Python code. Internally, konduit will take Strings as input and output # for this example. python_pipeline_step = PythonStep().step(python_config) serving_config = ServingConfig(http_port=1337, input_data_format='JSON', output_data_format='JSON') # Start a konduit server and wait for it to start server = Server(serving_config=serving_config, steps=[python_pipeline_step]) server.start() # Initialize a konduit client that takes in and outputs JSON client = Client(input_data_format='JSON', output_data_format='RAW', return_output_data_format='JSON', host='http://localhost', port=1337) # encode the image from a file to base64 and get back a prediction from the konduit server encoded_image = to_base_64(os.path.abspath('./Ultra-Light-Fast-Generic-Face-Detector-1MB/imgs/1.jpg')) predicted = client.predict({'image': encoded_image}) # the actual output can be found under "num_boxes" print(predicted['num_boxes']) server.stop()今回はBase64エンコードされた画像(
image
)をJSONで送って顔の数(num_boxes
)をJSONに入れて返すというデモでした。そのため
PythonConfig
で入出力の型を'STR'
と指定しています。
ServingConfig
ではフォーマットを'JSON'
にしています。参考:
+ PythonConfigで指定できるデータ型のリスト
+ ServingConfigで指定できるデータ型のリスト
server.start()
でサーバーを走らせ、REST APIを準備します。
そして、predicted = client.predict({'image': encoded_image})
の行でエンコードした画像を'image'
という文字列をセットにしてサーバーにPOSTしています。
返ってきた結果のJsonがpredicted
変数に格納されます。
print(predicted['num_boxes'])
の出力が先ほどの51
だったのです。おわりに
いかがでしたでしょうか。
Konduit-Servingはまだ公開されて間もないライブラリですので、変更が多かったりややドキュメントに不整合があったりしますが、デプロイ支援ツールとして可能性があると思っています。
今後また解説記事など公開していくつもりなので乞うご期待。SB-AI Advent Calendar 2019、次回は営業の期待のホープ、那俄牲さんです :)
https://adventar.org/calendars/4324
https://thegradient.pub/state-of-ml-frameworks-2019-pytorch-dominates-research-tensorflow-dominates-industry/ ↩
余談ですが、OpenCVのJavaラッパーであるJavaCVはJavaCPPを用いて移植されています。JavaCPPを用いて移植されたパッケージのプリセットたちはここで公開されています。 ↩
- 投稿日:2019-12-02T19:53:33+09:00
ひとり開発でポートフォリオをストーリーで語るサービスを作った話
この記事は、ひとり開発 Advent Calendar 2019の19日目の記事になります。(現時点ではフライング気味)
はじめに
こんにちは、ひで (@blue_islands)です。
ひとり開発をしていて、自分の作品とかを何かにまとめたりしていますか?
よく見かけるポートフォリオは、ただ作品の完成物を載せているものが多く、実際の苦労話とか、ドヤリングしたい場所など、事細かく乗せることができないものがほとんどです。
そこで、自分が開発をしているCHROCO(クロコ)というサービスを作ったのですが、その時の開発の苦労話が少しでもほかの開発者の参考になればと思っています。この記事は、自分の作品たちをまとめるサービスを作った時の戦いの綴りです。
CHROCOとは
最初に、今回作ったWebサービスであるCHROCOについて先に紹介しておきます。
CHROCOでは、「ふつうのポートフォリオでは語れないストーリーを見せる」を実現しています。
- 制作の過程をタイムラインで見せる
- 想い入れの強いストーリーは、掘り下げて深みを見せる
1. 制作の過程をタイムラインで見せる
アイデアを実現した方法、勉強会や発表で使ったスライド、ボツになった企画書、小さな成果や工夫、そういったものをストーリーとして並べることで、制作時の想いや記録をタイムラインに残すことができます。
2. 想い入れの強いストーリーは、掘り下げて深みを見せる
1つの作品のストーリーの中にさらにストーリーを作ることができ、ストーリーの中のもう一つのストーリーといったことが実現できます。
noteに作った時の経緯とか、コンセプトをまとめてあります。
note「ストーリーで語るポートフォリオのWebサービス「CHROCO」を作った話」アーキテクチャ
サービスのアーキテクチャについては、こんなものを利用しています。
いろいろと初めて利用したものも多くいろいろと苦労しています。ま、その話は後の部分で。1. フロントエンド
- Bulma
- Bulma-Extensions
- jQuery
2. バックエンド
- Java
- Spring boot
- thymeleaf
- Neo4j
3. インフラ
- GMO VPS
- Amazon S3
4. その他
- Auth0
今回こそ、モダンなフロントエンドにしようと思っていたのですが、thymeleafを使ったあたりから、javascritptはjQueryに走ってしまい、ちょっとクラシックな構成になってしまいました。
あまり、利用例が少ない点では、Neo4j、Auth0とかなのですかね。CSSはBulmaフレームワークが
最近のお気に入りです。苦労した点
やはり、苦労した点で行くと、今までつかったことのない、「Neo4j」「Auth0」の扱いでかなり苦労しました。
1. Neo4jで苦労した点
Neo4jとは
Neo4jはJavaで実装された、オープンソースの最も人気のあるグラフデータベースです。
苦労した点
- Neo4jとSpring bootの連携
Spring Data Neo4jというのを使ったのだが、Qiitaとか、日本での記事がなく、英語ドキュメントで苦労した。- Neo4jのユニークキーの扱い
Neo4jのIDは連番であるが、データ削除時の空き番は再利用されるため、ユニークキーとして利用してはいけない。これは、作っている最中にわかったので、データ構造を再設計する羽目になった。2. Auth0で苦労した点
Auth0とは
Auth0 は認証基盤サービス (IdMaaS) です。モバイルアプリや Web アプリに認証基盤を提供し、専用の SDK を利用して新規登録やログインなどといった API を呼び出すことが出来ます。
苦労した点
Auth0とSpring bootの連携
Auth0とSpring bootの連携のサンプルは下の画像のようにたくさんそろっているのだが、thymeleafのサンプルではなくJSPのサンプルのため、置き換え作業にちょっと悩まされてしまった。
Spring Securityが初めて
こちらは、Auth0のSpring Securityのサンプルを利用したため、そもそも触ったことがなかったので、仕組みを理解するのに手間がかかってしまった。おわりに
ここまで、はじめて使ったものが多くだいぶ苦労してしまったので、きっちりと備忘録として、Qiitaに記事を残しておかないと忘れてしまいそうですね。
自分はサービスを作るときは1つ2つは、使ったことのない技術を利用するようにしているのですが、やはり、初めてで実装レベルのものを作るときは参考になるものが少なく、はまりポイントになるので、そこら辺は本当にまとめておきたいと思いました。
最後になりますが、よかったらCHROCOを使って、自分のストーリーをまとめてポートフォリオを作成してみてください!
CHROCO(クロコ)
https://chroco.ooo/
- 投稿日:2019-12-02T19:35:18+09:00
VagrantにSpring Bootの開発環境作成
概要
通常Spring BootにはTomcatが付いており、Java自体がJVMで動くのでローカルで作業出来るのですが、Javaから別のツールの操作を行うためVagrant上に環境を作成しました。
環境
OpenJDK 11
Maven 3.6.3
CentOS 7JDK
yum update -y nss curl libcurl // 古いのでアップデート touch /etc/yum.repos.d/adoptopenjdk.repo echo [AdoptOpenJDK] >> /etc/yum.repos.d/adoptopenjdk.repo echo name=AdoptOpenJDK >> /etc/yum.repos.d/adoptopenjdk.repo echo baseurl=https://adoptopenjdk.jfrog.io/adoptopenjdk/rpm/centos/7/x86_64 >> /etc/yum.repos.d/adoptopenjdk.repo echo enabled=1 >> /etc/yum.repos.d/adoptopenjdk.repo echo gpgcheck=1 >> /etc/yum.repos.d/adoptopenjdk.repo echo gpgkey=https://adoptopenjdk.jfrog.io/adoptopenjdk/api/gpg/key/public >> /etc/yum.repos.d/adoptopenjdk.repo yum -y install adoptopenjdk-11-openj9.x86_64 java -versionMaven
wget http://ftp.yz.yamagata-u.ac.jp/pub/network/apache/maven/maven-3/3.6.3/binaries/apache-maven-3.6.3-bin.tar.gz tar xf apache-maven-3.6.3-bin.tar.gz -C /opt ln -s /opt/apache-maven-3.6.3/ /opt/maven touch /etc/profile.d/maven.sh echo "export JAVA_HOME=/usr/lib/jvm/adoptopenjdk-11-openj9" >> /etc/profile.d/maven.sh echo "export M2_HOME=/opt/maven" >> /etc/profile.d/maven.sh echo "export MAVEN_HOME=/opt/maven" >> /etc/profile.d/maven.sh echo 'export PATH=${M2_HOME}/bin:${PATH}' >> /etc/profile.d/maven.sh rm apache-maven-3.6.3-bin.tar.gz起動
$ mvn spring-boot:run感想
VagrantにrsyncでソースをマウントしてSpring Boot Devtoolsを入れていれば、ホットリロードが出来るので、快適に動かせました。
参考
https://knasmueller.net/spring-boot-development-environment-virtualbox
- 投稿日:2019-12-02T17:09:23+09:00
CentOSでJDK、Tomcatのバージョンを変更する
はじめに
この間やったので、やり方メモ。参考サイト様とほぼ同じ内容。
使用ツール
- Linux環境(今回はCentOS6)
- teraterm
- WinSCP
手順
以下の手順でJDK、Tomcatのバージョンを変更します。
1. yumでインストールしたTomcatを削除
2. yumでインストールしたJDKを削除
3. 新しいバージョンのJDKをインストール
4. 新しいバージョンのTomcatをインストール
5. 確認1. yumでインストールしたTomcatを削除
Tomcatを停止する
sudo service tomcat6 stop sudo /etc/rc.d/init.d/tomcat6 stopTomcatを停止する
sudo chkconfig tomcat6 offインストールしたTomcatを確認
yum list installed | grep tomcatTomcatをアンインストール
sudo yum remove -y tomcat6 tomcat6-webapps tomcat6-admin-webapps apache-tomcat-apis.noarch tomcat6-el-2.1-api.noarch tomcat6-jsp-2.1-api.noarch tomcat6-lib.noarch tomcat6-servlet-2.5-api.noarch2. yumでインストールしたJDKを削除
インストールしたJDKを確認
yum list installed | grep jdkJDKをアンインストール
sudo yum remove -y java-1.7.0-openjdk.i686 java-1.7.0-openjdk-devel.i6863. 新しいバージョンのJDKをインストール
JDKをダウンロードする
以下のアドレスからJDKのtarファイルをダウンロードして転送
http://www.oracle.com/technetwork/java/javase/downloads/index.html
転送したtarファイルを展開
tar xvf jdk-8u5-linux-i586.gzディレクトリを移動
sudo mv jdk1.8.0_05 /usr/local/java/jdk1.8.0_05移動したディレクトリにcd
cd /usr/local/java/バージョン名を削除したシンボリックリンクを作成
sudo ln -s jdk1.8.0_05 jdkJAVA_HOMEの環境変数を設定
export JAVA_HOME="/usr/local/java/jdk"プリインストールされているjavaとjavacのシンボリックリンクを削除
cd /usr/bin sudo rm java sudo rm javacシンボリックリンクをダウンロードして設定した場所に指定して作成
sudo ln -s $JAVA_HOME/bin/java java sudo ln -s $JAVA_HOME/bin/javac javacjavaのバージョンを確認
インストールしたバージョンになっていたらOKです。
java -version javac -version4. 新しいバージョンのTomcatをインストール
インストールしたいバージョンのTomcatをダウンロードする
ここではTomcat8をダウンロード。
tomcat8のダウンロードページ
http://tomcat.apache.org/download-80.cgiバイナリディストリビューションのtarファイルをダウンロード
ダウンロードしたバイナリディストリビューションを展開
tar -xvzf apache-tomcat-8.0.9.tar.gz解凍してできたディレクトリを移動
sudo mv apache-tomcat-8.0.9 /usr/local/tomcat8tomcatを起動
sudo /usr/local/tomcat8/bin/startup.sh5. 確認
ブラウザでアクセスしてみよう
参考
- 投稿日:2019-12-02T10:23:29+09:00
再帰検索なるものをやってみた
つくってみた
Search.javapackage search; import java.io.File; import java.io.IOException; import java.util.Arrays; import java.util.LinkedList; import java.util.List; import java.util.Objects; import java.util.function.Predicate; public class Search { public static void main(String[] args) { String path = "C:\\Users\\"; String word = "\\pool\\plugins\\"; if (validate(path, word)) { // 入力値OKの場合に再帰検索 search(path, word); } System.out.println("おわり"); } /** * ディレクトリ内を文字列で検索する. * <p> * 検索文字列を含む名前のファイル及びディレクトリの絶対パスを出力しつつ * 再帰的に検索していく. * * @param dir 検索対象のディレクトリ * @param word 検索する文字列 * @throws IOException なんかひらけなかったとき */ private static void search(String dir, String word) { // ディレクトリ内の一覧を取得 File[] directory = new File(dir).listFiles(); // オブジェクトが存在しない場合, 処理を終了 if (directory == null) { return; } // 検索ワードを含んでいるものは絶対パスを出力 Arrays.stream(directory).map(File::getName).filter(file -> isMatch(file, word)) .forEach(file -> System.out.println(dir + "\\" + file)); // ディレクトリを再帰検索 Arrays.stream(directory).filter(File::isDirectory).map(File::getPath) .forEach(nextPath -> search(nextPath, word)); } /** * 単語検索. * <p> * 検索する文字を空白で区切り, OR検索する. * <p> * ファイル名が単語のいずれかを含めば{@code ture} * * @param file ファイル名 * @param word 検索する単語 * @return 含めば{@code true} */ private static boolean isMatch(String file, String word) { List<String> stock = new LinkedList<>(); // 空白文字で区切る for (String e : word.split(" ")) { // \\で区切る for (String e2 : e.split("\\\\")) { stock.add(e2); } } // 文字列を含む場合はtrue return stock.stream().map(String::trim).anyMatch(spell -> file.contains(spell)); } /** * nullブランクのチェック. * <ul> * チェック内容 * <li>引数が{@code null}の配列の場合{@code false} * <li>引数の要素すべてに対して以下を満たしたら{@code true} * <ul> * <li>{@code null}でない * <li>{@code String}変換したとき空文字でない * <li>{@code String}変換したとき空白文字でない * </ul> * 一つでも引っかかったら{@code false} * </ul> * * @param obj {@code null}を許容するオブジェクト配列 * @return チェック可否 */ private static boolean validate(Object...obj) { return obj == null ? false : Arrays.stream(obj) .filter(Objects::nonNull) .map(Object::toString) .filter(nonEmpty) .filter(nonBlank) // すべてOKならtrue .count() == obj.length ? true : false; } /** 空文字でなければ真 */ private static final Predicate<String> nonEmpty = str -> !str.equals(""); /** 空白のみでなければ真 */ private static final Predicate<String> nonBlank = v -> v != null && !v.equals("") && Arrays.stream(v.split("")).anyMatch(x -> !x.equals(" ") && !x.equals(" ")); }出力がコンソールなのが安っぽいですが
空白区切りで複数単語のOR検索
まぁ検索処理もちょっと適当なのでisMatch
メソッドのとこはあんま突っ込まないで下さい。。
単にString.contains
でもよかったんですがなんかwebシステムとかならいろいろ工夫が必要かなと
validate
の部分も今回の処理に合わせたので汎用性があるとは言えないかもですということで本題
search.java/** * ディレクトリ内を文字列で検索する. * <p> * 検索文字列を含む名前のファイル及びディレクトリの絶対パスを出力しつつ * 再帰的に検索していく. * * @param dir 検索対象のディレクトリ * @param word 検索する文字列 */ private static void search(String dir, String word) { // ディレクトリ内の一覧を取得 File[] directory = new File(dir).listFiles(); // オブジェクトが存在しない場合, 処理を終了 if (directory == null) { return; } // 検索ワードを含んでいるものは絶対パスを出力 Arrays.stream(directory).map(File::getName).filter(file -> isMatch(file, word)) .forEach(file -> System.out.println(dir + "\\" + file)); // ディレクトリを再帰検索 Arrays.stream(directory).filter(File::isDirectory).map(File::getPath) .forEach(nextPath -> search(nextPath, word)); }再帰検索のところおもしろいですねこれ
一覧からディレクトリのものを探し
パスを取得し
それを引数としてまた同じことする途中でreturn文がないと無限ループになるわけですが
whileを使わないwhile文とでもいうのでしょうか自分で自分を呼ぶって、なにこれめっちゃ面白い!
- 投稿日:2019-12-02T06:37:11+09:00
Spring Bootでログイン機能のテストコードを書いてみる
概要
いつぞやに書いた記事の続きです。
ログイン関連の処理のテストコードを書く際にも色々とハマったので、備忘録として残しておきます。
また、ログイン処理自体についての解説はこちらに記載しているので、よろしければ。間違い等ございましたら、やさしく教えて頂けるとうれしいです(╹◡╹)
ゴール
この記事を読むことで、(きっと)以下のことが分かるようになります。
- ログイン処理のテストコードの書き方
- ログインした状態のつくり方
- ログインが必要な画面のテストコードの書き方
- ユーザ登録処理のテストコードの書き方
環境・コード
環境は以下となります。依存ライブラリ等は前回の記事と同一です。
また、ソースコードはGitHubに置いてあります。
全体像
ログインアプリは、以下の機能を持つシンプルなものです。
- ログイン処理
- 権限ごとのトップ画面(管理者・ユーザ)
- ユーザ登録処理
これをざっくりと図で表すと図1のようになります。
図1. 処理フロー
パッケージ構造
機能自体はシンプルなものですが、あれやこれやを考慮していると、パッケージが増えていき、全体像が掴みづらくなってしまうので、役割単位でふんわりと分類したものを図2に示します。
図2. パッケージ全体像
テストしたいこと
コード化するにしろ、しないにしろ、テストを行う上で最も意識しておく必要があるのは、「何を検証したいか」を明確にすることです。
今回は、SpringSecurityへ定義した機能が想定した通りに動作しているか
ということになります。
これでは堅苦しくて少し雰囲気が掴みづらいので、もう少しとっつきやすい形にしてしまいましょう。図3のように、
SpringSecurityさんが頼んだ通りに頑張ってくれているか
とすると、イメージがしやすくなるかと思います。図3. SpringSecurityの検証対象
字が汚いので重要なことなので、上図をリストでも表しておきましょう。
- 知らない人は通さないか(認証)
- 知ってる人は通してくれるか(認証)
- ユーザはユーザページへ案内してくれるか(認可)
- 管理者は管理者ページへ案内してくれるか(認可)
以下では、これらが正しく動作しているかを検証するためのテストコードの書き方について見ていきます。
Part1. ログイン処理
まずはシンプルにログイン画面から始めていきます。
画面のイメージは、図4のようになります。図4. ログイン画面
ログイン処理が正しく動作できているかを検証するためには、以下をテストする必要があります。
- DBに存在するユーザであれば、ログインできる
- DBに存在するユーザだが、パスワードが間違っている場合、ログインできない
- そもそもユーザがDBに存在しない場合、ログインできない
これは、言い換えると、
SpringSecurityが想定通りの人だけを通してくれるのか
と表すことができます。それでは、実際にSpringSecurityさんが仕事をしてくれるか、検証していきます。
知ってる人は通してくれる
まずは、単純なケースとして、DB上に存在するユーザでログイン処理を行うと、ログインができるかを検証します。
SpringSecurityは、ログイン処理として、以下を実行します。
- ログインリクエストが送信されると、ユーザ名をキーにDBのユーザテーブルを検索
- 結果が空でない場合、DBから取得したユーザに紐づくパスワードを復号化したものが入力値と一致するか検査
- 一致した場合、SuccessHandlerを呼び出す
処理自体のコードの解説は別記事で行なっているので、ここでは割愛し、早速テストコードを見ていきます。
テストコード
LoginControllerTest.java@DbUnitConfiguration(dataSetLoader = CsvDataSetLoader.class) @TestExecutionListeners({ DependencyInjectionTestExecutionListener.class, TransactionalTestExecutionListener.class, DbUnitTestExecutionListener.class, }) @AutoConfigureMockMvc @SpringBootTest(classes = {LoginUtApplication.class}) @Transactional public class LoginControllerTest { @Autowired private MockMvc mockMvc; @Test @DatabaseSetup(value = "/controller/top/setUp/") void DB上に存在する利用者ユーザでログインできる() throws Exception { this.mockMvc.perform(formLogin("/sign_in") .user("top_user") .password("password")) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("/top_user/init")); } }解説
テストコードの書き方自体は前回の記事でざっくりと書いてきたので、ここでは、新しく登場した要素について触れていきます。
formLogin
mockMvcのperfromメソッド
へ渡す引数として、formLogin
なるものが指定されています。
ログイン処理はお馴染みのものなので、雰囲気で動作はイメージできるかと思いますが、ここで大事なのは、以下のことです。
- 引数にはSpringSecurityのConfigクラスのloginProcessingUrlを指定
- CSRFトークンはformLoginメソッドによって自動的に指定される
ログイン処理がある程度理解できていれば、特につまずくことはないかと思います。
リダイレクト処理
公式さんに書かれている通り、SpringSecurityは、ログインの成功/失敗時に指定したURLへリダイレクトさせるよう動作します。
よって、ここでは、リダイレクト先のURLが想定通りかを検証します。
後ほど触れていきますが、ここでは、ログインが上手くいったらユーザのトップ画面っぽいURLへ飛ばされるんだなー、ぐらいを理解して頂けたら十分です。知らない人は通さない
上記のテストコードで、何やら、SpringSecurityはユーザを通してくれることが分かりました。
しかし、これだけでは、もしかしたら誰でもウェルカムながばがばセキュリティである可能性が残ってしまっています。
ここでは、セキュリティが担保できている、すなわち、通して欲しくない人を通さないか
を検証します。以下では、対象のテストコードを抜粋して記載します。
テストコード
LoginControllerTest(抜粋)@Test @DatabaseSetup(value = "/controller/top/setUp/") void DB上に存在するユーザでパスワードを間違えると失敗画面へリダイレクトされる() throws Exception { this.mockMvc.perform(formLogin("/sign_in") .user("top_user") .password("wrongpassword")) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("/?error")); } @Test @DatabaseSetup(value = "/controller/top/setUp/") void DB上に存在しないユーザでログインするとエラーURLへリダイレクトされる() throws Exception { this.mockMvc.perform(formLogin("/sign_in") .user("nobody") .password("password")) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("/?error")); }解説
正常系と大きな違いはないのですが、リダイレクト先が
/?error
となっていることがポイントです。これが正しく動作していれば、ログイン可能なユーザがログインできることだけでなく、ログインできないはずのユーザがログインに失敗することが検証できます。
これで最低限のレベルではありますが、ログイン処理についてテストコードを書くことができました。DaoやService層については、前回の記事と重なるものなので、割愛させて頂いています。興味がございましたら、ソースコードをご参照頂ければと思います。
さて、Webアプリケーションはログインして終わり...ではなく、ログインした状態で様々な画面を利用していきます。しかし、ログインが必要な画面のテストコードに毎回ログインして、それから画面のテストコードを書いて...としてしまうと、非常に面倒です。
これを解決するため、
ログイン済みの状態
を手動で作ることができれば、とても便利です。
それでは、ログイン後の画面のテストコードを見ていく前に、ログイン済みの状態のつくり方に触れていきます。Part1.5 ログインした状態のつくり方
さて、
ログインした状態
をつくれば良い、と書きましたが、具体的には何をすればよいのでしょうか。
前処理として、セッションIDを発行して、紐づくユーザオブジェクトを作成しておき、SpringSecurityが扱える形にあれこれして...とSpringSecurityが行なっている処理を一つ一つ再現しても作ることはできますが、ちょっとしんどそうです。実際には、公式で紹介されている通り、アノテーションで処理をちょろっと書くだけでログイン済みの状態を擬似的に作成することができます。
ここで、ログインした状態
は、正確には、SecurityContext
と呼ばれています。コンテキストについての説明は、ここが分かりやすいかと思います。急に話が抽象的になってしまいました。
もう少しイメージしやすくするため、コンテキストを簡単に図で表したものを図5に示します。図5. セキュリティコンテキスト
コンテキストがあることによって、画面でかちかちログインしたときと同じ状態を
コードによってつくり出す
ことができます。便利ですね。実際にコンテキストをつくる為のコードを見ていきます。
ログイン済みユーザ(仮)
まずは、中身に入る前に、テストコードの中でどのようにログイン済みの状態が記述されているか、見てみましょう。
これから触れていくコードを書いておくことによって、どのようにテストコードが書きやすくなるか、メリットを先に知っておくと、理解もしやすくなるかと思います。ログイン済みの状態を利用したテストコードの例@Test @DatabaseSetup(value = "/controller/top/setUp/") @WithMockCustomUser(username="top_user", password="password") void init処理でviewとしてユーザトップが渡される() throws Exception { this.mockMvc.perform(get("/top_user/init")) .andExpect(view().name("top_user")); }重要なのは、
WithMockCustomUser
アノテーションです。パラメータとして、ユーザ名・パスワードを渡す。たったこれだけでログイン済みユーザとして、ログインが必要な画面のテストコードを実行することができます。ありがてえ。さてさて、このアノテーションはカスタムアノテーションで、作るのに少し頑張らないといけないものですが、一度つくってしまえば、ログインが必要なアプリケーションで使い回すことができます。楽をするために頑張って見てみましょう。
アノテーションのコード
まずは先ほど書かれていた、
WithMockCustomUser
アノテーションのコードです。
カスタムアノテーションについては、こちらをば。WithMockCustomUser.java@Retention(RetentionPolicy.RUNTIME) @WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class) public @interface WithMockCustomUser { String username(); String password(); }解説
アノテーション自体は、2つのフィールドを持つシンプルなものなのですが、
WithSecurityContext
なるすごく長いパラメータを持つアノテーションが難しそうな雰囲気を漂わせています。これは、
SpringSecurityContext
を定義するためのアノテーションです。いまいちピンとこないので、言い方を変えると、SpringSecurityさんに、あらかじめ「この人は覚えておいてね!!」という情報を覚えてもらうためのメモのようなものです。WithMockCustomUserでは、名前の通り、ユーザ情報しか定義していないので、続いて、コンテキストを作成している処理を見てみます。
コンテキストの工場
コンテキストの工場と言われてもなんのことやらさっぱりなので、実際のコードを見てみることにしましょう。
WithMockCustomUserSecurityContextFactory.javapublic class WithMockCustomUserSecurityContextFactory implements WithSecurityContextFactory<WithMockCustomUser>{ @Autowired private AuthenticationManager authenticationManager; @Override public SecurityContext createSecurityContext(WithMockCustomUser customUser) { SecurityContext context = SecurityContextHolder.createEmptyContext(); // ユーザ名・パスワードで認証するためのトークンを発行 UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(customUser.username(), customUser.password()); // ログイン処理 Authentication auth = authenticationManager.authenticate(authToken); context.setAuthentication(auth); return context; } }解説
色々とコードは書いてありますが、やっていることはごくシンプルで、
ログイン済みの状態
すなわちSecurityContextを作っています。個々の処理としては大まかに以下のことをおこなっています。
- SecurityContextHolderをもとに空のSecurityContextを生成
- ユーザ名・パスワードによる認証トークンを生成
- トークンをもとに認証処理を行い、認証情報を格納したオブジェクトをコンテキストに設定
SecurityContextHolderは、コンテキストを管理するためのオブジェクトで、スレッド周りのあれこれを管理していたりするのですが、本筋から外れてしまうので、今回は割愛致します。
コンテキストを作る大元なんだなーぐらいに思って頂ければ問題ないかと思います。そして、大事なこととして、ここでの
認証トークン
は、クッキーとよく対比されたり、APIトークンなどと呼ばれるものとは全くの別物だということです。
これは、ユーザ名・パスワードが認証のための鍵となる、という意味から、「Hard Token」の方が意味的に近いかと思われます。
少し話がそれてしまいましたが、カスタムアノテーションを定義することにより、テストコード上では、ユーザ名・パスワードを指定するだけで簡単にログイン済みの状態、すなわち、
SecurityContext
を作れました。やったね。Part2. 利用画面
少し大変でしたが、ログイン済みの状態が作れるようになったので、ログインが必要な画面のテストコードはぐっと楽に書けるようになります。
さて、ログインが必要な画面の例として、ユーザ用トップ画面を見てみます。
画面イメージを図6に示します。図6. ユーザ用トップ画面
ユーザに挨拶しているだけの単純な画面です。
サンプル用の画面なので、デザインは...気にしないでください。さて、今回はログイン関連の処理を検証することが目的なので、この画面については、以下のことが想定した通りに動作しているかを検証していきます。
- ログイン済みユーザの場合、画面遷移が可能か
- 未ログインユーザの場合、画面遷移ができないか
- 管理者権限でログインした場合、ユーザ用画面へ遷移できないか
- ログアウト処理が有効か
おおむねログイン画面と同様、
悪いことはできないか
を軸に検証すれば良さそうですね。
では、実際のテストコードを見ていきます。知ってる人を案内してくれるか
ログイン処理と同様、最初は正規ルートでの動作を見ていきます。
先ほど紹介したアノテーションが増えたぐらいなので、すっと理解できるかと思います。テストコード
TopUserControllerTest.java@DbUnitConfiguration(dataSetLoader = CsvDataSetLoader.class) @TestExecutionListeners({ DependencyInjectionTestExecutionListener.class, TransactionalTestExecutionListener.class, DbUnitTestExecutionListener.class, WithSecurityContextTestExecutionListener.class }) @AutoConfigureMockMvc @SpringBootTest(classes = {LoginUtApplication.class}) @Transactional public class TopUserControllerTest { @Autowired private MockMvc mockMvc; @Test @DatabaseSetup(value = "/controller/top/setUp/") @WithMockCustomUser(username="top_user", password="password") void init処理でviewとしてユーザトップが渡される() throws Exception { this.mockMvc.perform(get("/top_user/init")) .andExpect(view().name("top_user")); } @Test @DatabaseSetup(value = "/controller/top/setUp/") @WithMockCustomUser(username="top_user", password="password") void init処理でログインユーザ名がモデルへ渡される() throws Exception { this.mockMvc.perform(get("/top_user/init")) .andExpect(model().attribute("loginUsername", "top_user")); } }解説
WithMockCustomUser
については、これまで触れてきた通りで、ユーザ名・パスワードを指定することで、ログイン済みの状態を作り出しています。
ここで、WithSecurityContextTestExecutionListener
という何やら関連がありそうなクラスがTestExecutionListeners
に指定されていることが分かります。これは、
TestContextManager
がSecurityContext
を扱えるようにするための設定です。
コンテキストのためのコンテキストと書くと少しややこしくなってしまうので、ここでも図で整理してみましょう。
TestContextManagerを図でざっくりと表したものを図7に示します。図7. TestContextManager
コンテキスト以外にも前処理・後処理等も担っているのですが、ここで重要なのは、
テストの実行に必要なコンテキストを管理している
点です。
ぱっとイメージは掴みづらいかとは思いますが、テストを実行する前に事前にログインした状態をつくっておくためには、必要な処理となるので、意識しておくとよいかと思います。悪だくみを防げるか
続いて、
悪いことをたくらんでいる人
を防げるかを見ていきます。
ここで、整理するため、悪いこと
が何を表しているか、もう一度見てみます。
- 未ログインユーザがユーザトップ画面へURLを直接入力して覗く
- 権限を持たないユーザがユーザトップ画面へURLを直接入力して覗く
上記を防ぐための実装自体はSpringSecurityのConfigクラスで定義しているので、ここでは、本当に防げるかどうかを
テスト的なリクエストを送信することで
確かめてみます。テストコード
TopUserControllerTest.java(抜粋)@Test @DatabaseSetup(value = "/controller/top/setUp/") void 未ログインユーザはユーザトップ画面へURL直打ちで遷移できない() throws Exception { this.mockMvc.perform(get("/top_user/init")) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("http://localhost/")); } @Test @DatabaseSetup(value = "/controller/top/setUp/") @WithMockCustomUser(username="admin_user", password="password") void 管理者権限のユーザはユーザトップ画面へURL直打ちで遷移できない() throws Exception { this.mockMvc.perform(get("/top_user/init")) .andExpect(status().isForbidden()); }解説
ポイントとなるのは、悪事を防いだらどうするのか、です。
ログインしていないユーザの場合、
ログイン画面へリダイレクト
して欲しいので、ステータスコードがリダイレクト(3始まり)であること、遷移先がログイン画面であることを記述しています。そして、権限を持たないユーザの場合(管理者は見ることができた方が自然っぽいですが...)、ステータスコードとして403Forbiddenが返ってくることを記述しています。
これらのテストコードにより、SpringSecurityさんは想定していた仕事は問題なくこなしてくれていることが分かりました。
最後に後処理として、ログアウトについても、見ておきたいと思います。ログアウトで後始末ができるか
テストコード
ログアウト処理@Test @DatabaseSetup(value = "/controller/top/setUp/") @WithMockCustomUser(username="top_user", password="password") void ログアウト処理でログイン画面へ遷移する() throws Exception { this.mockMvc.perform(post("/logout") .with(SecurityMockMvcRequestPostProcessors.csrf())) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("/?logout")); }解説
ログアウト処理も、SpringSecurityで設定したログアウト用のURLへPOSTリクエストを送信しているだけなので、雰囲気で理解できるかと思います。
ただ、formLoginメソッド
と異なり、通常のPOSTリクエストでは、CSRFトークンは付与してくれません。よって、wirh(SecurityMockMvcRequestPostProcessors.csrf)
メソッドでCSRFトークンをリクエストに追加する必要があります。これだけでも、ログアウト処理が動いていることの検証には問題なさそうですが、もう一歩踏み込んでみましょう。
ログアウトしたということは、ログインしていない状態
へと変化したとも言えます。よって、ログアウト後には、ログインが必要な画面へは、遷移できないことが正しい動作です。これについても、念のため見ておきたいと思います。
といっても、難しいことはしておらず、単にログアウト後に画面遷移処理を追加してみただけです。
以下の図8, 図9を見て頂ければログアウト処理によってログインしていない状態が作り出されたことが分かります。図8. ログアウト後はログアウト前でできていたことで失敗する
図9. ログアウト後は再度ログインを求められる
少し長くなってしまいましたが、これでログイン処理についてもテストコードで検証することができました。やったぜ。
まとめに入る前に、ログイン処理とは少しずれてしまいますが、それなりにはまったユーザ登録処理
について、補足として簡単に触れておきたいと思います。Extra ユーザ登録処理
さてさて、ユーザ登録処理についてですが、処理自体は、
SecurityContext
を作ることと、ほぼ同じです。
実装についてはソースコードをば。ここで取り上げたいのは、バリデーション処理です。
動きのイメージとしては、図10のようなものとなります。図10. ユーザ登録
バリデーション処理のテストコードの書き方について、色々とはまってしまったので、備忘録としてポイントを書いていきます。
バリデーション
テストコード
バリデーション処理のテストコード@Test void 半角英数ハイフンアンダースコア以外に属する記号群をPOSTするとAuthInpuTypeエラーが発生() throws Exception { this.mockMvc.perform(post("/signup/register") .contentType(MediaType.APPLICATION_FORM_URLENCODED) .param("username", "<script>alert(1)</script>") .param("rawPassword", ";delete from ut_user") .with(SecurityMockMvcRequestPostProcessors.csrf())) .andExpect(model().hasErrors()) .andExpect(model().attributeHasFieldErrorCode("userForm", "username", "AuthInputType")) .andExpect(model().attributeHasFieldErrorCode("userForm", "rawPassword", "AuthInputType")); } @DatabaseSetup(value = "/controller/signup/setUp/") @Test void DBに既に存在するユーザ名でPOSTするとUniqueUsernameエラーが発生() throws Exception { this.mockMvc.perform(post("/signup/register") .contentType(MediaType.APPLICATION_FORM_URLENCODED) .param("username", "test_user") .param("rawPassword", "password") .with(SecurityMockMvcRequestPostProcessors.csrf())) .andExpect(model().hasErrors()) .andExpect(model().attributeHasFieldErrorCode("userForm", "username", "UniqueUsername")); }解説
色々と書いてありますが、やっていることは値を設定してPOSTリクエストを手動で送信しているだけです。ここで、重要なのは、
attributeHasFieldErrorCodeメソッド
です。
引数として、name, fieldName, error
を渡しています。
これらは、それぞれ、以下の対応関係を持っています。
- name...フィールドを格納するオブジェクト Formオブジェクトに相当
- fieldName...エラーが存在するFormオブジェクト内のフィールド ユーザ名・パスワードのいずれかが該当
- error...エラーの内容 後述
name, fieldNameについては、MVCアプリを触ったことがあれば、イメージはわくかと思います。errorについては、フィールドを実際に見て頂くとわかるかと思います。
UserForm.java@Data public class UserForm { @AuthInputType @UniqueUsername private String username; @AuthInputType private String rawPassword; }ここで、errorとして引数に渡されたものが、アノテーションの名前と対応しています。
指定されているアノテーションは、カスタムアノテーションで、Constraint
アノテーションが付与されているので、制約アノテーション
として振る舞います。
制約違反が存在すると、BindingResult
がエラーとして検知してくれるので、バリデーション処理で扱えるようになります。
詳細については、公式を見て頂くのがよいかと思います。相関チェックについては理解しきれておらず、パスワード合致のテストコードとかで心が折れてしまったので、お詳しい方がいらっしゃったら、情報を頂けるとうれしいです。(:
まとめ
ログイン処理についても、ざっくりとではありますが、テストコードを記述し、動作が検証できるようになりました。
前回の記事で簡単なCRUD、今回の記事でWebアプリで重要なログイン処理を見ていくことで、簡単なアプリケーションであれば、しっかりテストコードを書きながら開発ができるのではないかと思います。コードを書いていると、超いい感じのリファクタリングを突然思いついて、コードをあれこれ変えたくなりますが、こうやってテストコードを書いていれば、
検証した振る舞いが崩れていないか
はボタン一発で検証できるようになります。他にも、毎回ログインして、画面をかちかちしてエラー画面を出して...としなくても、効率よく、かつ再現性を担保してテストが実行できるようになります。
テストコードの書き方を習得するにはコストがそれなりにかかってしまいますが、楽しいコードを書く時間が更に増えてくれるので、楽しみながら少しずつ取り入れていくのがよいかなと。エラそうに言っていますが、私もテストコードに関してはまだまだ勉強中の身なので、頑張ってもっと色々勉強したいです。
- 投稿日:2019-12-02T02:00:43+09:00
OkHttpを使った場合にWebViewのJavascriptInterfaceはどう呼ばれるか軽く調べてみた
AndroidのWebViewでは、addJavascriptInterfaceという、JavaオブジェクトをWebViewに埋め込む機能があります。これにより、WebView内で表示しているWebページに埋め込まれたJavascriptから、当該Javaオブジェクトで定義されたメソッドを呼び出せるようになります。
今回は、WebViewでのHTTP通信にOkHttpを利用した時、WebViewのコールバックメソッドはどう呼ばれるか軽く調べてみました。
読むのがめんどくさい方は最後の「まとめ」だけを読んでいただいてもよいです。WebViewを使用するActivityの実装
WebViewを使用するActivityのソースコードはこんな感じです。
サンプルコードがお粗末なのはご愛嬌です。MainActivity.ktclass MainActivity : AppCompatActivity() { companion object { const val TAG = "WebViewTest_" const val TAG_OKHTTP = "OkHttp_WebViewTest" const val TAG_TID = "Tid_webView" const val JS_INTERFACE_NAME = "Android" const val INITIAL_ENDPOINT = "http://${sever_ipAddress}:10000/" } private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = DataBindingUtil.setContentView(this, R.layout.activity_main) initWebView() } @SuppressLint("JavascriptInterface", "SetJavaScriptEnabled") fun initWebView() { Log.d(TAG_TID, "MainActivity#initWebView called. tid = " + Thread.currentThread().id) binding.webView.apply { settings.javaScriptEnabled = true addJavascriptInterface(this@MainActivity, JS_INTERFACE_NAME) webViewClient = object: WebViewClient() { override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { Log.d(TAG, "shouldOverrideUrlLoading called. url = " + request?.url.toString()) return super.shouldOverrideUrlLoading(view, request) } override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { Log.d(TAG, "onPageStarted called. url = " + url!!) super.onPageStarted(view, url, favicon) } override fun onPageFinished(view: WebView?, url: String?) { Log.d(TAG, "onPageFinished called. url = " + url!!) super.onPageFinished(view, url) } override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { Log.d(TAG, "onReceivedError error = " + error!!) super.onReceivedError(view, request, error) } override fun onLoadResource(view: WebView?, url: String?) { Log.d(TAG, "onLoadResource called. url = " + url!!) super.onLoadResource(view, url) } override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? { Log.d(TAG_TID, "shouldInterceptRequest called. tid = " + Thread.currentThread().id) if (!request?.url.toString().endsWith("/favicon.ico")) { Log.d(TAG, "shouldInterceptRequest. url = " + request?.url.toString()) } val latch = CountDownLatch(1) var res: InputStream? = null val call = if (request!!.url.path!!.endsWith("/getJsBySrc") or request.url.path!!.endsWith("/doHttpReqFromJsCode.js") ) { createOkHttpClient().newCall(Request.Builder().url(request.url.toString()).method("POST", RequestBody.create(null, "hoge")).build()) } else { createOkHttpClient().newCall(Request.Builder().url(request.url.toString()).build()) } call.enqueue(object: Callback { override fun onFailure(call: Call, e: IOException) { //Log.d(TAG_OKHTTP, "okhttp Callback#onFailure called. callUrl = " + call.request().url()) Log.d(TAG_OKHTTP, "okhttp Callback#onFailure called. error = " + e.message.toString()) latch.countDown() } override fun onResponse(call: Call, response: Response) { //Log.d(TAG_OKHTTP, "okhttp Callback#onResponse called. callUrl = " + call.request().url()) Log.d(TAG_OKHTTP, "okhttp Callback#onResponse called. resUrl = " + response.request().url()) res = response.body()?.byteStream() latch.countDown() } }) latch.await() return WebResourceResponse( "text/html", "UTF-8", res ) } } loadUrl(INITIAL_ENDPOINT) } } private val cookieStore = HashMap<String, MutableList<Cookie>>() fun createOkHttpClient(): OkHttpClient { return OkHttpClient.Builder() .addNetworkInterceptor { chain -> Log.d(TAG_TID, "okhttp intercepted: tid = " + Thread.currentThread().id) Log.d(TAG_OKHTTP, "okhttp intercepted: " + chain.request().url().toString()) chain.proceed(chain.request()) } //.connectTimeout(1, TimeUnit.MILLISECONDS) .cookieJar(object: CookieJar { override fun saveFromResponse(url: HttpUrl, cookies: MutableList<Cookie>) { cookieStore[url.host()] = cookies } override fun loadForRequest(url: HttpUrl): MutableList<Cookie> { val cookies = cookieStore[url.host()] return cookies ?: ArrayList() } }) .build() } @JavascriptInterface fun showToast(str: String) { Toast.makeText(this, str, Toast.LENGTH_LONG).show() } }ポイントは以下。
- MainActivityをJavascriptInterfaceとして「Android」という名前で追加
- showToastというメソッドをWebView内からJavascriptにて実行できるよう定義
- 各コールバックが呼ばれるタイミングでLogを出力し、どのようにHTTP通信が制御されているのか確認
サーバ側の実装
サーバ側の実装は以下です。今回はクライアントからrootのエンドポイントにアクセスされることを前提としています。
app/Main.hs1 {-# LANGUAGE OverloadedStrings #-} 2 module Main where 3 4 import Network.Wai.Middleware.Static 5 import Network.HTTP.Types.Status 6 import Web.Spock 7 import Web.Spock.Config 8 9 import Control.Monad.Trans 10 import qualified Data.Text as T 11 12 data MySession = MySession { msUserId :: Maybe String } 13 14 main :: IO () 15 main = do 16 spockCfg <- defaultSpockCfg (MySession Nothing) PCNoDatabase () 17 runSpock 10000 (spock spockCfg app) 18 19 app :: SpockM () MySession () () 20 app = do 21 middleware $ staticPolicy (addBase "static") 22 get root $ 23 (modifySession $ \sess -> sess { msUserId = Just "dummy" }) >> redirect "http://${server_ipAddress}:10000/submitFormByJs" 24 get ("submitFormByJs") $ do 25 sess <- readSession 26 case msUserId sess of 27 Nothing -> setStatus status401 >> text "No session" 28 Just _ -> (liftIO . readFile $ "static/submitFormByJs.html") >>= html . T.pack 29 post ("getJsBySrc") $ 30 (liftIO . readFile $ "static/getJsBySrc.html") >>= html . T.pack 31 post ("doHttpReqFromJsCode.js") $ 32 file "application/javascript" "static/doHttpReqFromJsCode.js" 33 get ("fromXMLHttpReq") $ 34 (liftIO . readFile $ "static/fromXMLHttpReq.html") >>= html . T.pack 35 get ("onReceivedXHRResAndSetLocationHref") $ 36 (liftIO . readFile $ "static/onReceivedXHRResAndSetLocationHref.html") >>= html . T.pack 37 get ("favicon.ico") $ 38 file "image/png" "favicon.ico" ~通信フローまとめ
上記のWebサーバのrootエンドポイントアクセスした際の通信フローをまとめると、以下のような感じになります。
- root
- "submitFormByJs"
- "getJsBySrc"
- "doHttpReqFromJsCode.js"
- "fromXMLHttpReq"
- "onReceivedXHRResAndSetLocationHref"
1. root
HTTPステータス302で"submitFormByJs"へリダイレクト
2. "submitFormByJs"
<form>タグにより、"getJsBySrc"エンドポイントへのPOSTリクエストを送出するhtmlを応答。
WebViewがこのHTMLを解釈するときに、上記POSTリクエストが実行される。submitFormByJs.html1 <html> 2 <body> 3 <script type="text/javascript"> 4 const ua = window.navigator.userAgent 5 if (ua.includes("Android")) { 6 Android.showToast("A post request is going to be sent from a <form> tag."); 7 } 8 </script> 9 <script type="text/javascript"> 10 function doPost() { 11 document.form1.method = "post"; 12 document.form1.submit(); 13 } 14 </script> 15 <form name="form1" action="http://${server_ip}:10000/getJsBySrc" method="post"> 16 </form> 17 <h1>submitFormByJs</h1> 18 <script type="text/javascript"> 19 doPost(); 20 </script> 21 22 </body> 23 </html> ~3. "getJsBySrc"
<script>タグの実行により、エンドポイント"doHttpReqFromJsCode"へのGETリクエストを行うhtmlを応答。
getJsBySrc.html1 <html> 2 <body> 3 <h1>GetJSBySrc</h1> 4 <!-- <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script> --> 5 <script type="text/javascript"> 6 const ua = window.navigator.userAgent 7 if (ua.includes("Android")) { 8 Android.showToast("A javascript file is going to be loaded by <script ... src=...>"); 9 } 10 </script> 11 <script type="text/javascript" src="http://${server_ip}:10000/doHttpReqFromJsCode.js"></script> 12 </body> 13 </html>4. "doHttpReqFromJsCode.js"
XMLHttpReqeustにより、エンドポイント"fromXMLHttpReq"へHTTP通信を行うJavascriptを応答。
上記通信が成功したときに、location.hrefによりエンドポイント"onReceivedXHRResAndSetLocationHref"へGET通信によるリダイレクトをさせる。doHttpReqFromJsCode.js1 const xhr = new XMLHttpRequest(); 2 3 xhr.open('POST', 'http://${server_ip}:10000/fromXMLHttpReq.html'); 4 xhr.send(); 5 6 xhr.onreadystatechange = function() { 7 if(xhr.readyState === 4 && xhr.status === 200) { 8 const ua = window.navigator.userAgent 9 if (ua.includes("Android")) { 10 Android.showToast("doHttpReqFromJsCode.js"); 11 } 12 location.href="http://${server_ip}:10000/onReceivedXHRResAndSetLocationHref" 13 } 14 }5. "fromXMLHttpReq"
doHttpReqFromJsCode.js内の、XMLHttpRequest実行によりアクセスされる。
fromXMLHttpReq.html1 <html> 2 <body> 3 <h1>XMLHttpRequest</h1> 4 <script type="text/javascript"> 5 const ua = window.navigator.userAgent 6 if (ua.includes("Android")) { 7 Android.showToast("XMLHttpRequest succeeded. location.href is going to be modified."); 8 } 9 </script> 10 </body> 11 </html>6. ""onReceivedXHRResAndSetLocationHref"
doHttpReqFromJsCode.jsによるXMLHttpRequest通信が成功したときアクセスされる。
onReceivedXHRResAndSetLocationHref.html1 <html> 2 <body> 3 <script type="text/javascript"> 4 const ua = window.navigator.userAgent 5 if (ua.includes("Android")) { 6 Android.showToast("location.href has been modified."); 7 } 8 </script> 9 <h1>Received XMLHttpRequest's response</h1> 10 </body> 11 </html>ログ出力結果
WebViewでloadUrl("http://${server_ip}:10000/")した際のログ出力は以下です。
※192.168.100.151は筆者のローカルマシンのIPアドレスです。タグ「WebViewTest_」でフィルター1 D/WebViewTest_: shouldInterceptRequest. url = http://192.168.100.151:10000/ 2 D/WebViewTest_: onPageStarted called. url = http://192.168.100.151:10000/ 3 D/WebViewTest_: JavascriptInterface method called. str = A post request is going to be sent from a <form> tag. 4 D/WebViewTest_: shouldInterceptRequest. url = http://192.168.100.151:10000/getJsBySrc 5 D/WebViewTest_: onPageFinished called. url = http://192.168.100.151:10000/ 6 D/WebViewTest_: onPageStarted called. url = http://192.168.100.151:10000/getJsBySrc 7 D/WebViewTest_: shouldInterceptRequest. url = http://192.168.100.151:10000/doHttpReqFromJsCode.js 8 D/WebViewTest_: JavascriptInterface method called. str = A javascript file is going to be loaded by <script ... src=...> 9 D/WebViewTest_: shouldInterceptRequest. url = http://192.168.100.151:10000/fromXMLHttpReq.html 10 D/WebViewTest_: onPageFinished called. url = http://192.168.100.151:10000/getJsBySrc 11 D/WebViewTest_: JavascriptInterface method called. str = doHttpReqFromJsCode.js 12 D/WebViewTest_: shouldOverrideUrlLoading called. url = http://192.168.100.151:10000/onReceivedXHRResAndSetLocationHref 13 D/WebViewTest_: onPageStarted called. url = http://192.168.100.151:10000/onReceivedXHRResAndSetLocationHref 14 D/WebViewTest_: shouldInterceptRequest. url = http://192.168.100.151:10000/onReceivedXHRResAndSetLocationHref 15 D/WebViewTest_: JavascriptInterface method called. str = location.href has been modified. 16 D/WebViewTest_: onPageFinished called. url = http://192.168.100.151:10000/onReceivedXHRResAndSetLocationHrefOkHttp通信の箇所のLog出力結果は以下。
タグ「OkHttp_WebViewTest」でフィルター18 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/ 19 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/submitFormByJs 20 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/submitFormByJs 21 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/getJsBySrc 22 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/favicon.ico 23 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/getJsBySrc 24 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/favicon.ico 25 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/doHttpReqFromJsCode.js 26 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/doHttpReqFromJsCode.js 27 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/fromXMLHttpReq.html 28 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/fromXMLHttpReq.html 29 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/favicon.ico 30 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/favicon.ico 31 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/onReceivedXHRResAndSetLocationHref 32 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/onReceivedXHRResAndSetLocationHref 33 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/favicon.ico 34 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/favicon.icoまとめ
上記結果をまとめてみます。(あくまで筆者のローカル環境で検証した結果のため、挙動に差異が出る可能性あり。)
WebViewのshouldInterceptRequestが呼ばれるパターン
- HTML内の<form>タグで発生するHTTP通信
- HTML内の<script type="text/javascript" src="...">で発生するHTTP通信
- HTTP通信によるJavascript取得(GETリクエストで"xxx.js"にアクセス)
- JavascriptでのXMLHttpRequestでのHTTP通信
- HTML内でのlocation.href変更によるリダイレクト
HTTPステータス302によるリダイレクトではWebViewのshouldInterceptRequestは呼ばれなかった。
OkHttpは(デフォルトでは)HTTPステータス302リダイレクトをよしなにハンドリングして、レスポンスを取得するため、rootエンドポイントからのリダイレクト先"submitFormByJs"に対して、WebViewのshouldInterceptRequestは上記の実装では呼ばれない。JavascriptInterfaceメソッドが呼ばれるパターン
- HTML内の<form>タグで発生するHTTP通信
- HTML内の<script type="text/javascript" src="...">で発生するHTTP通信
- HTTP通信によるJavascript取得(GETリクエストで"xxx.js"にアクセス)
- JavascriptでのXMLHttpRequestでのHTTP通信
- HTML内でのlocation.href変更によるリダイレクト
上記の実装では、XMLHttpRequest通信でアクセスする"fromXMLHttpReq"でのJavascriptInterfaceメソッド呼び出しは実行されなかった。レスポンスをWebViewで表示しようとすれば呼ばれるかもしれませんが、時間なかったので調べてません。
その他JavascriptInterface呼び出しについて思うこと
・Android Developers公式サイトにも記載の通り、Javascriptを利用したWebView <-> Native間のデータ連携はセキュリティリスクが伴うので、なるべくやりたくない。
・上記の通り、リダイレクトが絡むJavascriptInterfaceの実行制御はトリッキーな実装になりがちなので、既存のWebサービスがあるからといって、WebAPIの作成を怠って、安易にJavascriptInterfaceを採用するべきではないでしょう。WebViewの主目的はWebコンテンツを表示することのはずです。WebAPI代わりに使ったら、無駄なHTML評価や画像取得とか色々無駄な処理が走っちゃいますね。無駄な処理を省くには結局、スクラッチレベルでHTMLを解析し、必要な処理だけ通信を行う処理を書く必要がありそうです(極力、スマホアプリで使用する専用のWebAPIを作りましょう)。
・WebViewは文字通りViewの一種なので、単純なWebAPIのように、任意のタイミングでHTTP通信実行とか、複数画面にまたがるデータ保持とか難しい。AndroidシステムのWindowにアタッチした状態でないと、WebViewによるHTMLの評価は走らないのではないでしょうか(よく知りませんが)。AndroidコンポーネントであるServiceクラスにWebViewを定義してHTMLの評価ができるみたいな情報もありますが、できたとしてもバグが出そうですね。商用のアプリでは採用すべきではないでしょう。(スマホアプリで使用する専用のWebAPIを作りましょう)
・JavascriptInterfaceの使用は、基本、既存のシステム設計上止むを得ない場合のみに、セキュリティを十分に考慮した上で使用するに留めた方が良い(スマホアプリで使用する専用のWebAPIを作りましょう)
- 投稿日:2019-12-02T00:54:36+09:00
謎回路が挑むJava 2日目
謎回路とはわたしのことです。
本を読み返していると、finalどころじゃなく、忘れてることが多い。
ひとまず、JVM君の絵を真似することで、心を落ち着ける。本物のかわいらしさが出ない・・・。
ちなみに双子のようなやつに、コンパイラ君もいる。かわいい。ソースコード → コンパイラ君 → Javaバーチャルマシン君 → 実行結果
バイトコードにするのがコンパイラ君なので、機能としてはコンパイラ君のほうが好きだ。
あとは練習問題をやった。
明日はeclipseをダウンロードしよう。
ここは「〇〇をした」の1行でもいいから、続けることが大切。今日はここまで。
- 投稿日:2019-12-02T00:54:36+09:00
謎回路のJava 2日目
謎回路とはわたしのことです。
本を読み返していると、finalどころじゃなく、忘れてることが多い。
ひとまず、JVM君の絵を真似することで、心を落ち着ける。本物のかわいらしさが出ない・・・。
ちなみに双子のようなやつに、コンパイラ君もいる。かわいい。ソースコード → コンパイラ君 → Javaバーチャルマシン君 → 実行結果
バイトコードにするのがコンパイラ君なので、機能としてはコンパイラ君のほうが好きだ。
あとは練習問題をやった。
明日はeclipseをダウンロードしよう。
ここは「〇〇をした」の1行でもいいから、続けることが大切。今日はここまで。
- 投稿日:2019-12-02T00:25:11+09:00
【Gang of Four】デザインパターン学習 - Visitor
Visitor - 訪問者
目次
訪問者に全ての要素に対するインターフェースを用意しておき、その要素に関する処理を訪問者にやってもらうというパターンだと理解しています。目的
あるオブジェクト構造上の要素で実行されるオペレーションを表現する。Visitor パターンにより、オペレーションを加えるオブジェクトのクラスに変更を加えずに、新しいオペレーションを定義することができるようになる。
構成要素
・Visitor 訪問者抽象クラス
・ConcreteVisitor 訪問者具象クラス
・Element 訪問者に処理を移譲する要素の抽象クラス
・ConcreteElement 訪問者に処理を移譲する要素の具象クラス
・ObjectStructure 要素を列挙することができるクラス(らしいんですが必要性を理解できていません)実装
用意された材料を使って料理を作るプログラムを実装します。
Visitor 訪問者抽象クラス
Visitor.ktpackage visitor interface Visitor { fun visit(e: PotatoElement) fun visit(e: CarrotElement) fun visit(e: PorkElement) fun visit(e: OtherElement) fun serve() }ConcreteVisitor 訪問者具象クラス
カレーを作る訪問者
CurryVisitor.ktpackage visitor class CurryVisitor: Visitor { private var cookingPotato = false private var cookingCarrot = false private var cookingPork = false private var cookingOther = false override fun visit(e: PotatoElement) { cookingPotato = true println("${e.name()}の皮を剥いて切って鍋で煮込みます。") } override fun visit(e: CarrotElement) { cookingCarrot = true println("${e.name()}の芽をとって皮を剥いて切って鍋で煮込みます。") } override fun visit(e: PorkElement) { cookingPork = true println("${e.name()}を切って鍋で煮込みます。") } override fun visit(e: OtherElement) { cookingOther = true println("カレーのルーと${e.name()}を鍋に入れて煮込みます。") } override fun serve() { if (cookingPotato && cookingCarrot && cookingOther && cookingPork) { println("カレーの完成!") } else { println("まだ料理中です。") } } }Element 訪問者に処理を移譲する要素の抽象クラス
材料抽象クラス
Element.ktpackage visitor interface Element { fun name(): String fun cooking(v: Visitor) }ConcreteElement 訪問者に処理を移譲する要素の具象クラス
材料具象クラスたち
じゃがいもPotatoElement.ktpackage visitor class PotatoElement: Element { override fun name(): String { return "じゃがいも" } override fun cooking(v: Visitor) { v.visit(this) } }にんじん
CarrotElement.ktpackage visitor class CarrotElement: Element { override fun name(): String { return "にんじん" } override fun cooking(v: Visitor) { v.visit(this) } }豚肉
PorkElement.ktpackage visitor class PorkElement: Element { override fun name(): String { return "豚肉" } override fun cooking(v: Visitor) { v.visit(this) } }その他
OtherElement.ktpackage visitor class OtherElement: Element { override fun name(): String { return "塩と胡椒" } override fun cooking(v: Visitor) { v.visit(this) } }使う人
Client.ktpackage visitor class Client { init { serveCurry() } private fun serveCurry() { val pot = PotatoElement() val c = CarrotElement() val por = PorkElement() val o = OtherElement() val v = CurryVisitor() pot.cooking(v) c.cooking(v) por.cooking(v) v.serve() o.cooking(v) v.serve() } }[out-put] じゃがいもの皮を剥いて切って鍋で煮込みます。 にんじんの芽をとって皮を剥いて切って鍋で煮込みます。 豚肉を切って鍋で煮込みます。 まだ料理中です。 カレーのルーと塩と胡椒を鍋に入れて煮込みます。 カレーの完成!野菜炒めを作りたくなったのでFriedVegetablesVisitorを実装します。
FriedVegetablesVisitor.ktpackage visitor class FriedVegetablesVisitor: Visitor { private var cookingPotato = false private var cookingCarrot = false private var cookingPork = false private var cookingOther = false override fun visit(e: PotatoElement) { cookingPotato = true println("${e.name()}の皮を剥いて切って炒めます。") } override fun visit(e: CarrotElement) { cookingCarrot = true println("${e.name()}の芽をとって皮を剥いて切って炒めます。") } override fun visit(e: PorkElement) { cookingPork = true println("${e.name()}を切って炒めます。") } override fun visit(e: OtherElement) { cookingOther = true println("${e.name()}を鍋に入れて炒めます。") } override fun serve() { if (cookingPotato && cookingCarrot && cookingOther && cookingPork) { println("野菜炒めの完成!") } else { println("まだ料理中です。") } } }Client.ktpackage visitor class Client { init { serveCurry() println("--------------------------------") serveFriedVegetables() } private fun serveFriedVegetables() { val pot = PotatoElement() val c = CarrotElement() val por = PorkElement() val o = OtherElement() val v = FriedVegetablesVisitor() pot.cooking(v) c.cooking(v) por.cooking(v) v.serve() o.cooking(v) v.serve() } private fun serveCurry() { val pot = PotatoElement() val c = CarrotElement() val por = PorkElement() val o = OtherElement() val v = CurryVisitor() pot.cooking(v) c.cooking(v) por.cooking(v) v.serve() o.cooking(v) v.serve() } }出力結果
[out-put] じゃがいもの皮を剥いて切って鍋で煮込みます。 にんじんの芽をとって皮を剥いて切って鍋で煮込みます。 豚肉を切って鍋で煮込みます。 まだ料理中です。 カレーのルーと塩と胡椒を鍋に入れて煮込みます。 カレーの完成! -------------------------------- じゃがいもの皮を剥いて切って炒めます。 にんじんの芽をとって皮を剥いて切って炒めます。 豚肉を切って炒めます。 まだ料理中です。 塩と胡椒を鍋に入れて炒めます。 野菜炒めの完成!