豪鬼メモ

一瞬千撃

gRPCのJava版のアプリケーションを手動またはmakeでビルドする方法

gRPCのJava版のチュートリアルには、Gradleを使ったビルド方法だけが説明されている。Java界隈ではMavenやGradleがビルドツールとしてよく使われているのは知っているが、私はmakeが使いたい。よって、まずはビルドツールを使わずに個々の手順を進める方法を明らかにした上で、それをMakefileにまとめる。


まずは依存パッケージをインストールしておく。gRPCのチュートリアルにあるようにGitHubからリポジトリを撮ってきてcmakeでビルドしてもよいが、使っているOSにバイナリパッケージがあるならば、それを使うのが楽だ。Ubuntu(21.10)の場合、aptで以下のパッケージを入れる。

  • libprotobuf-java
  • libgrpc-java
  • protobuf-compiler-grpc-java-plugin
  • libgoogle-common-protos-java
  • libguava-java
  • libnetty-java
  • libperfmark-java

テスト用に、RPCのインターフェイスをprotoファイルとして定義しておく。任意の文字列を送るとそれをそのままエコーバックするサービスだ。lucky_star.protoとかいう名前で保存しておこう。

syntax = "proto3";

package lucky_star;

message EchoRequest {
  string message = 1;
}

message EchoResponse {
  string echo = 1;
}

service HappyService {
  rpc Echo(EchoRequest) returns (EchoResponse);
}

このprotoファイルから、GRPCのJavaコードを生成する。Protocol Buffersのコンパイラドライバであるprotocコマンドと、そのGRPC-Javaプラグインであるgrpc_java_pluginを用いる。プラグインの方はなぜか絶対パスで指定しなければいけないらしい。

$ protoc --plugin=protoc-gen-grpc_java=/usr/bin/grpc_java_plugin \
  --proto_path=. --java_out=. --grpc_java_out=. lucky_star.proto

パッケージ名と同名のlucky_starというディレクトリが作られ、その中にパッケージ名をキャメルケースにしたLuckyStar.javaファイルと、サービス名にGrpcをつけたHappyServiceGrpc.javaファイルが作られる。それをカレントディレクトリに移動させる。

$ mv lucky_star/*.java .

私がバイナリパッケージとして入れたバージョンのgrpc_java_pluginは、Java8以前のパッケージを想定したコードを生成するが、最新のJDKを使っていると互換性の問題がある。そのままビルドしようとすると、以下のエラーが出てしまうのだ。

HappyServiceGrpc.java:23: error: cannot find symbol
@javax.annotation.Generated(
                 ^
  symbol:   class Generated
  location: package javax.annotation

この報告にあるように、Java8まではjavax.annotation.GeneratedであったクラスがJava9からはjavax.annotation.processing.Generatedに変わっているのが原因だ。いずれgrpc_java_plugin側で新しい構造をデフォルトにしてくれると期待しつつ、今は生成されたコードを書き換えるという回避策を取る。

$ sed -e 's/javax\.annotation\.Generated/javax.annotation.processing.Generated/g' \
  HappyServiceGrpc.java > ~HappyServiceGrpc.java
$ mv -f ~HappyServiceGrpc.java HappyServiceGrpc.java

実際のアプリケーションのコードを書く準備が整ったので、サーバから実装していこう。以下のようなコードになる。SampleServer.javaなどとして保存されたい。

package lucky_star;

import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.stub.StreamObserver;

public class SampleServer {
  public static void main(String[] args) throws Exception {
    // サーバを作って、サービスを登録する
    Server server = ServerBuilder.forPort(1978)
        .addService(new HappyServiceImpl()).build();
    // サーバを開始する
    server.start();
    System.out.println("The server is running.");
    System.out.flush();
    // サーバが終了するまで待つ
    server.awaitTermination();
  }

  // サービスの実装クラス
  static class HappyServiceImpl extends HappyServiceGrpc.HappyServiceImplBase {
    // Echoの実装
    @Override
    public void echo(LuckyStar.EchoRequest request,
                     StreamObserver<LuckyStar.EchoResponse> observer) {
      System.out.println("Server Echo: " + request.getMessage());
      System.out.flush();
      // レスポンスを生成する
      LuckyStar.EchoResponse.Builder response = LuckyStar.EchoResponse.newBuilder();
      response.setEcho(request.getMessage());
      // レスポンスを出力する
      observer.onNext(response.build());
      // 出力処理が完了するまで待つ
      observer.onCompleted();
    }
  }
}

クライアントは以下のようなコードになる。SampleClient.javaなどとして保存されたい。

package lucky_star;

import io.grpc.Channel;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.StatusRuntimeException;
import java.util.concurrent.TimeUnit;

public class SampleClient {
  public static void main(String[] args) throws Exception {
    ManagedChannel channel = null;
    try {
      // gRPCのチャンネルを作る
      channel = ManagedChannelBuilder.forTarget("localhost:1978")
          .usePlaintext().build();
      // サービスのスタブを作る
      HappyServiceGrpc.HappyServiceBlockingStub stub =
          HappyServiceGrpc.newBlockingStub(channel);
      // Echoメソッドのリクエストを作る
      LuckyStar.EchoRequest.Builder request = LuckyStar.EchoRequest.newBuilder();
      request.setMessage("Hello World");
      // Echoメソッドを実行して結果を表示する
      LuckyStar.EchoResponse response;
      try {
        response = stub.echo(request.build());
      } catch (StatusRuntimeException e) {
        System.err.println("RPC failed: " + e.getStatus());
        return;
      }
      System.out.println("Client: Echo: " + response.getEcho());
    } finally {
      if (channel != null) {
        // チャンネルの接続を切る
        channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
      }
    }
  }
}

アプリケーションをビルドしたり実行したりする際には、gRPCのライブラリをクラスパスに含める必要がある。gRPC-Javaをバイナリパッケージで入れた場合、/usr/share/javaの下に多数のjarファイルがあるはずだ。それをワイルドカードで全部指定する。

$ javac -d . -cp .:/usr/share/java/* HappyServiceGrpc.java LuckyStar.java SampleServer.java SampleClient.java

オプションで "-d ." を指定したので、パッケージと同じ名前のディレクトリlucky_starが作られて、その中に大量のクラスファイルが作られているはずだ。あとは普通に、javaコマンドでサーバやクライアントのmainのあるクラスを指定すれば実行できる。サーバ側は以下のようにする。

$ java -cp .:/usr/share/java/* lucky_star.SampleServer
The server is running.

別の端末でクライアントを起動すると、無事にエコーバックの出力が得られるはずだ。

$ java -cp .:/usr/share/java/* lucky_star.SampleClient
Client: Echo: Hello World

JARファイルにまとめて扱いやすくする。また、JARファイルだけで動くか確認もする

$ jar cvf lucky_star/*.class
...
$ rm -rf lucky_star
$ java -cp lucky_star.jar:/usr/share/java/* lucky_star.SampleServer
The server is running.
$ java -cp lucky_star.jar:/usr/share/java/* lucky_star.SampleClient
Client: Echo: Hello World

以上で、一通りのビルド手順が網羅できている。MavenやGradleがなくても生きていける(あった方が便利なのは言うまでもないが)。さて、私は古き良きmake派なので、Makefileを書こう。本当はconfigureから書くのだが、説明のためにMakefileをいきなり書く。コピペするときは先頭のインデントはタブに置換する必要がある。

SHELL = /bin/bash
JARFILES = lucky_star.jar
JAVAFILES = HappyServiceGrpc.java LuckyStar.java SampleServer.java SampleClient.java
JAVAC = /usr/java/bin/javac
GRPCJARDIR = /usr/share/java
JAVACFLAGS = -d . -cp .:$(GRPCJARDIR)/*
JAR = /usr/java/bin/jar
JAVARUN = /usr/java/bin/java
JAVARUNFLAGS = -cp lucky_star.jar:$(GRPCJARDIR)/*
PROTOC = /usr/bin/protoc
GRPCJAVAPLUGIN = /usr/bin/grpc_java_plugin

all : $(JARFILES)

clean :
  rm -rf $(JARFILES) lucky_star *~

check :
  $(JAVARUN) $(JAVARUNFLAGS) lucky_star.SampleServer & SERVER_PID="$$!" ; sleep 2 ;\
  $(JAVARUN) $(JAVARUNFLAGS) lucky_star.SampleClient ;\
  kill $$SERVER_PID

proto2java : lucky_star.proto
  rm -rf lucky_star
  $(PROTOC) --plugin=protoc-gen-grpc_java=$(GRPCJAVAPLUGIN) \
    --proto_path=. --java_out=. --grpc_java_out=. lucky_star.proto
  mv lucky_star/*.java .
  rm -rf lucky_star
  sed -e 's/javax\.annotation\.Generated/javax.annotation.processing.Generated/g' \
    HappyServiceGrpc.java > ~HappyServiceGrpc.java
  mv -f ~HappyServiceGrpc.java HappyServiceGrpc.java  

lucky_star.jar : $(JAVAFILES)
  $(JAVAC) $(JAVACFLAGS) $(JAVAFILES)
  $(JAR) cvf $@ lucky_star/*.class
  rm -rf lucky_star

protoファイルから生成したjavaファイルは、予めパッケージやリポジトリに入れておく方針にした。そうすれば、利用者はprotocやprotoc-gen-grpc_javaをインストールする必要がなくなるからだ。なので、開発時にprotoファイルを更新した場合には、明示的に make prot2java を実行する。利用者側では、make && make check を実行するだけでビルドと動作確認ができる。make checkの結果はこんな感じになる。

$ make check
/usr/java/bin/java -cp lucky_star.jar:/usr/share/java/* lucky_star.SampleServer & SERVER_PID="$!" ; sleep 2 ;\
/usr/java/bin/java -cp lucky_star.jar:/usr/share/java/* lucky_star.SampleClient ;\
kill $SERVER_PID
The server is running.
Server Echo: Hello World
Client: Echo: Hello World


まとめ。gRPCのJava版のアプリケーションを手動でビルドする手順を紹介した。また、それをMakefileにまとめた。ビルド手順に躓いてgRPCを回避していた人も少なからずいると思うので、この記事を見て戻ってきてくれる人がいたなら嬉しい。

Tkrzw-RPCのJava版クライアントの開発が後回しになっていたのは、この手順を調べるのがやたら面倒だったからだ。というか、Gradleをビルドするためにprotobufのバージョンを上げなきゃならなかったのだが、そうすると現状のシステムと非互換が発生するので作業できなかったのだ。一昨日、システムをUbuntu 21.10にアップグレードしたので、いろいろ捗るようになった。gRPC周りは依存コンポーネントのバージョンの違いに敏感なので、バイナリパッケージで入れないときつい。Gradle使えよと言われそうだが、C++PythonRubyとGoもメンテせにゃならんもんで。