spring
spring-boot
GoogleCloudVisionAPI
google-cloud-vision
More than 1 year has passed since last update.


:cloud: 使ったAPI


サンプルを動かす


:key: 認証情報の設定

ライブラリを使ったサンプルコードで、1行目から引っかかった。

ImageAnnotatorClient vision = ImageAnnotatorClient.create();

認証情報を内部で持っているのだが、認証に関しての手順はすっ飛ばしてた。

Cloud API サービスに対する認証に書いてある通りに手順を実行してサービスアカウントのJSONファイルをダウンロードした。

どうやってその内容をライブラリに渡すか。。

ネットで調べると大体専用の環境変数に突っ込んでパスを参照するように書かれていた。

恐らくサンプルコードはそれで動くのだろう。

でも環境変数からパス取得って。。

ちゃんとアプリケーションプロパティから取りたい。

ImageAnnotatorClientのJavadocを読んでみると次のような記載が。

This class can be customized by passing in a custom instance of ImageAnnotatorSettings to create(). For example:

InstantiatingChannelProvider channelProvider =
ImageAnnotatorSettings.defaultChannelProviderBuilder()
.setCredentialsProvider(FixedCredentialsProvider.create(myCredentials))
.build();
ImageAnnotatorSettings imageAnnotatorSettings =
ImageAnnotatorSettings.defaultBuilder().setChannelProvider(channelProvider).build();
ImageAnnotatorClient imageAnnotatorClient =
ImageAnnotatorClient.create(imageAnnotatorSettings);

おぉ、myCredentialsが作れればそれを使っていいのね。

FixedCredentialsProviderは渡したCredentialsをそのまま供給してくれるみたいだから、Credentialsが作れれば!

で、このCredentialsのインスタンス、どうやって作ればいいのかね・・?

もう一度、ImageAnnotatorSettingsクラスを眺めてみる。

defaultCredentialsProviderBuilder() なるメソッドを発見。

public static GoogleCredentialsProvider.Builder defaultCredentialsProviderBuilder() {

return GoogleCredentialsProvider.newBuilder().setScopesToApply(DEFAULT_SERVICE_SCOPES);
}

Credentialsのサブクラスを見てGoogleCredentialsProviderの実装を見たらGoogleCredentialsのインスタンスが作れれば何とかいけそう。

GoogleCredentialsクラスを見ていたらそれらしきメソッドを発見。

こんな風に作る

String jsonFilePath = "..."; // JSONキーファイルのパス

FileInputStream jsonFileInputStream = new FileImputStream(jsonFilePath);
GoogleCredentials myCredentials = GoogleCredentials.fromStream(jsonFileInputStream);


:shield: SSL関連のエラーとの戦い

これでサンプルコード動かすと次のエラーが出た。

java.lang.IllegalArgumentException: Jetty ALPN/NPN has not been properly configured.

at io.grpc.netty.GrpcSslContexts.selectApplicationProtocolConfig(GrpcSslContexts.java:174) ~[grpc-netty-1.2.0.jar:1.2.0]
at io.grpc.netty.GrpcSslContexts.configure(GrpcSslContexts.java:151) ~[grpc-netty-1.2.0.jar:1.2.0]
at io.grpc.netty.GrpcSslContexts.configure(GrpcSslContexts.java:139) ~[grpc-netty-1.2.0.jar:1.2.0]
at io.grpc.netty.GrpcSslContexts.forClient(GrpcSslContexts.java:109) ~[grpc-netty-1.2.0.jar:1.2.0]
・・・

エラー発生箇所を見てみるとSSLのプロバイダーはJDKになってて、そのなかでJetty関連のクラスがあるかを判定している。

・・・Tomcatやし!

OpenSSLでも良かった気がするのだが、なんでOpenSSLを使うようにならなかったのか、もう少し追ってみた。

io.grpc.netty.GrpcSslContexts.defaultSslProvider()が途中で呼ばれているのだがここで決定していて、更に追ってみるとOpenSsl.isAvailable()の戻りがfalseになっていたようだ。

何故だ?

OpenSslクラスの初期化で原因がわかりそうなデバッグログが吐かれるようになっていたのでログレベルを下げてみて再度実行。


Failed to initialize netty-tcnative; OpenSslEngine will be unavailable. See http://netty.io/wiki/forked-tomcat-native.html for more information.


と表示されていた。

ん? netty-tcnativeなるライブラリが必要なのか?URLも載っていたのでアクセスしてみる。

書いてある通り netty-tcnative-boringssl-static のライブラリをpom.xmlに追記して再度実行してみた。

これでOpenSSLを使ってくれるはず。

と思ってもう一度実行してら次はこんな感じ。

java.lang.UnsatisfiedLinkError: no provided in java.library.path

at java.lang.ClassLoader.loadLibrary(ClassLoader.java:1864) ~[na:1.8.0_65]
at java.lang.Runtime.loadLibrary0(Runtime.java:870) ~[na:1.8.0_65]
at java.lang.System.loadLibrary(System.java:1122) ~[na:1.8.0_65]
at org.apache.tomcat.jni.Library.<init>(Library.java:80) ~[tomcat-embed-core-8.5.14.jar:8.5.14]
at org.apache.tomcat.jni.Library.initialize(Library.java:180) ~[tomcat-embed-core-8.5.14.jar:8.5.14]
at io.netty.handler.ssl.OpenSsl.initializeTcNative(OpenSsl.java:417) ~[netty-handler-4.1.8.Final.jar:4.1.8.Final]

Issueでも上がってた。

netty/netty-tcnative#151 java.lang.UnsatisfiedLinkError: no provided in java.library.path using spring boot/tomcat embedded

Tomcat使うなとか、諦めてJetty使うように変更したPRとか見てしまったので、もうおとなしくJettyにしました。

結果的にTomcatに戻したのですが、その方法は後述します。

まずはspring-bootの設定からtomcatを取り除き、jettyに変更する作業。


pom.xml

    <dependency>

<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
+ <exclusions>
+ <exclusion>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-tomcat</artifactId>
+ </exclusion>
+ </exclusions>
</dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-jetty</artifactId>
+ </dependency>

これでやっと正常に動きました。

こんな時間かかるならRestTemplateからAPI叩いた方が早かったのでは?

いや、APIはシンプルだが何だかんだDTOの準備に手間取る。。

じゃあ、DTOはライブラリのものを使って、あとはJSONにうまく変換できれば!


:cherry_blossom: Spring実装


JSON変換モジュールの捜索

DTOを見る限りJacksonのObjectMapperでは色々設定しないとうまくシリアライズできそうになかったので、ライブラリ側でJSONに変換できるモジュールが無いか探してみた。

Vision APIのライブラリで使用しているリクエストやレスポンス周りのクラスを改めて見てみると全てGeneratedMessageV3やらMessageを継承している。

ここからはかなり時間がかかって、どう辿ったかは記憶が曖昧。。

何とかJsonFormatなるクラスを見つけて内部クラスのJsonPrinterMessage.Builderを引数に取るそれらしきメソッドを見つけた。

これで何とかHttpMessageConverterが実装できる。


Jacksonを使わないJSON用のHttpMessageConverterを実装

もう単純に何も迷わずシンプルに実装してみる。

まずはPrinterParserのインスタンス生成。

private Printer printer = JsonFormat.printer().omittingInsignificantWhitespace();

private Parser parser = JsonFormat.parser().ignoringUnknownFields();

次にAbstractHttpMessageConverterを継承して実装する。

メインどころだけ記載します。まずはJSONからMessageクラスに変換するロジック

@Override

protected Message readInternal(Class<? extends Message> clazz, HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException {
// Content-Typeとエンコードを特定
MediaType contentType = inputMessage.getHeaders().getContentType();
Charset charset = (contentType.getCharset() != null ? contentType.getCharset() : this.getDefaultCharset());
// 指定クラスのBuilderクラスを生成
Message.Builder builder = getMessageBuilder(clazz);

// BuilderインスタンスにJSONをマージ
this.parser.merge(new InputStreamReader(inputMessage.getBody(), charset), builder);

// ビルド!
return builder.build();
}

とそんな難しいことしてないです。

ただ、Message.Builderインスタンスの生成だけはReflection経由で生成しています。

これはProtobufHttpMessageConverterを参考にしました。

(...ってかこのクラスで良かったんじゃないか。。?)

次に書き出しの方です。

@Override

protected void writeInternal(Message message, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
// Content-Typeとエンコードを特定
MediaType contentType = outputMessage.getHeaders().getContentType();
if (contentType == null) {
contentType = this.getDefaultContentType(message);
}
Charset charset = contentType.getCharset();
if (charset == null) {
charset = this.getDefaultCharset();
}

OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputMessage.getBody(), charset);
// JSONの書き出し
this.printer.appendTo(message, outputStreamWriter);
outputStreamWriter.flush();
}

ここまでできれば後は簡単です。

RestTemplateに突っ込んでMessageを継承しているクラスなら何も気にせずにJSONの変換が可能です。

こんな風に。

BatchAnnotateImagesRequest batchRequest = BatchAnnotateImagesRequest.newBuilder()

.addRequests(request)
.buidl();
RestTemplate restTemplate = new RestTemplate(Collections.singletonList(new GoogleMessageHttpMessageConverter()));

BatchAnnotateImagesResponse response = restTemplate.postForObject(VISION_API_URL, batchRequest,
BatchAnnotateImagesResponse.class);


まとめ


  • 無理にSpring使わなくてもJetty環境ならそのままLibrary使えば良いと思う

  • Tomcat環境でもそこまでたくさん実装しなくてもLibraryの恩恵は受けられる

  • Spring実装の場合はgRPCではなくなる

  • Libraryの実装ではリトライ設定もよしなに設定されているのでSpringの場合は別途設定する必要がある(逆に言えばリトライ設定が見える化できるからSpringの方が良いかもしれない)

今回のコードもGitHubに上げてます。

結果テキストの英語を日本語に変換するためにTranslate APIも使ってます。

また、サービスアカウント用のJsonKeyファイルではなく、API-Keyにも対応しています。

良ければ覗いてみてください。