SpringBoot
gRPC

Spring BootでgRPCする

この記事は エムスリー Advent Calendar 2017 の3日目の記事です。

自分の関わっているシステムのリニューアルにあたり、マイクロサービスっぽい構成を目指すことになりまして、現在Spring Boot + gRPC (+ Kotlin)でサーバを書きはじめています。

この記事では、Spring BootでgRPCを扱う場合のHello World的な話と、実際にアプリケーションを作りこんでいくにあたって大体必要になりそうな認証・エラーハンドリングといった話をまとめました。

現在 サーバがKotlin & Spring Boot / クライアントが主にRailsという構成で実装を進めている関係上、 サンプルコードがJavaだったりKotlinだったりRubyだったりしますが、予めご了承ください。

Spring BootでgRPC

Spring BootでgRPCサーバを立ち上げる

実は grpc-java を使っていると、以下のライブラリを使えば特に難しい設定をすることなく gRPCサーバを立てることができます。

LogNet/grpc-spring-boot-starter

    @GRpcService
    public static class GreeterService extends  GreeterGrpc.GreeterImplBase{
        @Override
        public void sayHello(GreeterOuterClass.HelloRequest request, StreamObserver<GreeterOuterClass.HelloReply> responseObserver) {
            final GreeterOuterClass.HelloReply.Builder replyBuilder = GreeterOuterClass.HelloReply.newBuilder().setMessage("Hello " + request.getName());
            responseObserver.onNext(replyBuilder.build());
            responseObserver.onCompleted();
        }
    }

gRPCの実装は @GRpcService というアノテーションをつけるだけでOK。

この状態でSpring Bootを立ち上げると…

$ ./gradlew bootRun
# 中略
2017-12-03 10:59:16.744  INFO 41958 --- [           main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 9090 (http)
2017-12-03 10:59:16.747  INFO 41958 --- [           main] o.l.springboot.grpc.GRpcServerRunner     : Starting gRPC Server ...
2017-12-03 10:59:16.875  INFO 41958 --- [           main] o.l.springboot.grpc.GRpcServerRunner     : 'app.grpc.GreeterService' service has been registered.
2017-12-03 10:59:17.408  INFO 41958 --- [           main] o.l.springboot.grpc.GRpcServerRunner     : gRPC Server started, listening on port 6565.
2017-12-03 10:59:17.413  INFO 41958 --- [           main] app.AppApplicationKt                     : Started AppApplicationKt in 9.786 seconds (JVM running for 10.686)

Spring Bootのサーバと一緒に、gRPCのサーバが異なるポートで立ち上がっているのがわかります。

gRPCに関連するコードを変更しても、Spring Boot側のコードを触っているときと同じく、きちんと再コンパイルしてくれますし、今のところSpring Bootを普通に使っている時と開発体験としては何も変わりません。

Inteceptorを使う

gRPCを使うにあたっては、Interceptorがキモになります。これは名前のとおり、リクエストを Intercept して、何らかの処理をはさみこむものです。

これも LogNet/grpc-spring-boot-starter を使えば簡単に実装できます。

// 特定のgRPCのサービスにInterceptorを指定する
@GRpcService(interceptors = { LogInterceptor.class })
public  class GreeterService extends  GreeterGrpc.GreeterImplBase{
    // ommited
}
// 全てのサービスにこのInterceptorを指定する
@GRpcGlobalInterceptor
public  class MyInterceptor implements ServerInterceptor{
    // ommited
}
// 指定した順番でインターセプターをかませる。
@GRpcGlobalInterceptor
@Order(10)
public  class A implements ServerInterceptor{
    // will be called before B
}

@GRpcGlobalInterceptor
@Order(20)
public  class B implements ServerInterceptor{
    // will be called after A
}

アプリケーション共通で実施したい処理は、 この Interceptor を使うことで実現できます。

実際にアプリケーションを作っていくときのアレコレ

ここまではHello World的な内容でした。ここからは実際にアプリケーションを作っていくときに考えなくてはいけない認証・エラーハンドリングといったところをまとめていきます。

認証

gRPCのドキュメントには Authentication の項がありますが、これは実際のところgRPCを使ってきたクライアントが正しいクライアントか?という用途に使われているような気がします。

リクエストをしてきたのが誰かという認証を行う場合には、 Metadata という仕組みを使ってユーザーのアクセストークンなどをリクエストに付与し、それをサーバ側で検証する形で認証を行う場合が多いようです。

例えばRubyのgRPCクライアントから Metadata を使ってアクセストークンを送る場合はこんな感じになります。

# 現在のプロジェクトではRubyを使っているので、サンプルはRubyで書いていますが、
# 他の言語でも同じような感じでセットできるはず。
stub = Helloworld::Greeter::Stub.new('localhost:50051', :this_channel_is_insecure)
req =  Helloworld::Greeter::HelloRequest.new(name: "suusan2go")
stub.say_hello(req, { metadata: {authorization: "76ea9743-bef9-4b1f-b116-3076ea51a1" } })

これをサーバ側で処理すればいいわけですが、呼び出す側で毎回これを検証するのはDRYではありません。これには Interceptor を使います。
以下は Interceptor でクライアント側でセットした認証情報をサーバ側で取り出す場合のサンプルコードです。

// 現在のプロジェクトではKotlinを使っているので、Kotlinで書いていますが、Javaでもおんなじ感じになるはず。
// エラー処理やなんやらは端折ってます。
class AuthenticationInterceptor: ServerInterceptor {
    override fun <ReqT : Any?, RespT : Any?> interceptCall(call: ServerCall<ReqT, RespT>?, headers: Metadata?,
                                                           next: ServerCallHandler<ReqT, RespT>?): ServerCall.Listener<ReqT> {
          val token: String = headers?.let {
              it.get(Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER))
          } ?: ""
          // 何か認証処理

          return next?.startCall(call, headers)!!
    }
}

Spring Securityを使いたい

Spring で認証といえば Spring Security です。gRPCは使っている場合、当然ではありますが Spring Security の機能をそのまま使うことはできません。

Spring SecuritygRPC を統合している良い例として以下を見つけました。
https://github.com/revinate/grpc-spring-security-demo

以下はコードの抜粋です。
gRPCの各メソッドに対して、 通常 controller につけるような @PreAuthorize("hasRole('USER')") というアノテーションを付けても、動作するようになっています。

    @Override
    @PreAuthorize("hasRole('USER')")
    public void fibonacci(FibonacciRequest request, StreamObserver<FibonacciResponse> responseObserver) {
        if (request.getValue() < 0) {
            responseObserver.onError(Status.INVALID_ARGUMENT.withDescription("Number cannot be negative").asRuntimeException());
            return;
        }

        FibonacciResponse response = FibonacciResponse.newBuilder()
                .setValue(numberService.fibonacci(request.getValue()))
                .build();

        responseObserver.onNext(response);
        responseObserver.onCompleted();
    }

これらは通常Spring Securityが自動でやってくれていることを Interceptor を使って自前で実装することで実現されています。作者(会社?)のブログに詳しい解説があるのでそちらを見てみるとよいでしょう。

https://eng.revinate.com/2017/11/07/grpc-spring-security.html

エラーハンドリング

gRPCを使う場合には、メソッドが実行されるのは実際にはリモートのサーバになります。
ですのでサーバ側で何か例外が起きた場合には、それがどのような例外なのかきちんとクライアントに伝えてあげる必要があります。

例えばRubyクライアントからgRPCを使ってリモートのサーバでなにか例外が起きた場合、何もケアしていないとこんな感じになります。

# 現在のプロジェクトではRubyを使っているので、サンプルはRubyで書いていますが、
# 他の言語でも同じような感じになるはず
> stub.say_hello(req)
GRPC::Unknown: 2:

これでは中々厳しいですね。 grpc-java では適切なステータスコードを定義して、 call.close を呼んであげることで、クライアントに適切な例外、及びメッセージを返せるようになります。
上述した認証の例に、例外処理を足してみると以下のような感じになります。

class AuthenticationInterceptor: ServerInterceptor {
    override fun <ReqT : Any?, RespT : Any?> interceptCall(call: ServerCall<ReqT, RespT>?, headers: Metadata?,
                                                           next: ServerCallHandler<ReqT, RespT>?): ServerCall.Listener<ReqT> {
        try {
            val token: String = headers?.let {
                it.get(Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER))
            } ?: ""
            // 何か認証処理
            return next?.startCall(call, headers)!!
        } catch(e: InvalidTokenException) {
            call?.close(Status.fromCode(Status.UNAUTHENTICATED.code).withDescription(e.message), Metadata())
            throw e
        }
    }
}

こうするとクライアント側では、適切な例外クラスで適切なエラーメッセージを受け取れるようになります。

> stub.say_hello(req, { metadata: {authorization: "76ea9743-bef9-4b1f-b116-3076ea51a1" } })
GRPC::Unauthenticated: 16:Invalid access token: 76ea9743-bef9-4b1f-b116-3076ea51a1

当然毎回これをやり続けるのは冗長です。gRPCサーバで横断的にキャッチしたい例外がある場合には、 Interceptor を使って実装するとよいでしょう。

Interceptor でのエラーハンドリングについては、 nsoushi さんのブログがとても参考になりました。 grpc-java のリポジトリにも参考になりそうな実装があるので見てみると良さそうです。

まとめ

gRPCをSpring Bootから扱う方法についてまとめました。最初はなかなか取っつきにくい部分もあるgRPCですが、 InterceptorMetadata といったところを理解できるとアプリケーション的にやりたいこと(今回紹介した以外にもロギングなど)は大体実現できる感じがしました。

gRPCの動くサンプル(本当に簡単なやつですが)は以下にまとまっているので、参考になれば幸いです。
https://github.com/suusan2go/nuxtjs-auth-with-spring/tree/master/spring-backend

参考資料