[2019/05/05 18:00 更新]
 最近、分散システム上のノード間通信では、RESTよりgRPCが流行っているようなので試してみた。なお、gRPCはjavaではライブラリが提供されており、且つ、SpringでもLogNetというところからboot用のstarterが提供されている様なのでSpring Boot上でそれを使用する。また、私は今後実用するとしたら分散システム上で使うことを想定しているので、Spring Cloudプロジェクトに組み込んで動かすことにした。
意外とただ動かすまでにもハマりポイントがいくつかあったので、今回はそのポイントを共有しつつ、プロジェクトが正常に動作するところまで確認した内容を記載する。
環境
- OS: MacOS Mojava 10.14.3
- Java: java 1.8.0_192
- Spring Boot: 2.1.1.RELEASE
- Spring Cloud: Greenwich.RC2
プロジェクト構成
Spring Cloudの中で何を実装しているかについては別でまた記載するかもしれないが、EurekaとRibbonによってロードバランシングの目論む以下のプロジェクトにてgRPC通信を試してみた。
ユーザが上記のeureka-lb-demoにHTTPリクエストを送ると、eureka-lb-demo→eureka-client-demoにgRPC通信でAPIを呼びにいくといった形。Eurekaの構成に準じてプロジェクト名を付けているので逆になってしまうが、gRPCとしては
- Server側: eureka-client-demo
- Client側: eureka-lb-demo
の関係である。
eureka-demo, config-server-demoは今回のテーマとは関係ない。テーマに関係ある部分は白抜きの部分だけである。
サービスの内容
今回の本質ではないのだが、コードスニペットを見たときに何やっているかわからなくならない様に簡単に説明しておく。
今回は学校名をパラーメータとして渡すと備品情報を取得できるというサービスを実装した。リクエストはeureka-lb-demoが受け取ると、getEquipmentInfoというeureka-client-demoが持つAPIに内部的に問い合わせている。なので、今回のインターフェース定義は、EquipmentServiceという名称にしている。
前提
実装に移る前にgRPCの前提を軽く説明する。
gRPCは.protoというファイルを定義して、protocというコンパイラを使ってコンパイルするとAPI部分のソースは自動生成してくれるという仕組みである。SOAPと同じ感じ。このファイルを作成したら、src/main/protoというクラスパスを作成し、ビルドをする。
なお、mavenではpluginを定義しておけば、通常通りにビルドするだけで勝手にビルドパス上に自動生成ソースを追加してくれる様になっている。
なぜprotoという名前かというと、Protocol Buffersという技術を使うからなのだが、その説明は今回の趣旨と反するため割愛する。
実装
Server, Client共通
- 
.protoファイル
protoファイルはこんな感じで作成した。
やりたいことはパラメータである学校名がDBに存在している場合はオブジェクトをリスト型で返す。存在しなければメッセージのみを返すという仕様にした。
そのため、EquipmentResponseではメッセージが第一フィールド、javaのオブジェクト相当のリストを第二フィールドとして定義している。
syntax = "proto3";
option java_multiple_files = true;
option java_package = "com.example.ek.eureka_client_demo.equipment";
package equipment_proto;
service EquipmentService {
	rpc getEquipment (EquipmentRequest) returns (EquipmentResponse);
}
message EquipmentRequest{
	string schoolName = 1;
}
message EquipmentResponse{
	string message = 1;
	repeated EquipmentInfo equipmentInfo = 2;
}
message EquipmentInfo{
	string catagory = 1;
	string name = 2;
}
なお、EquipmentService内に定義しているgetEquipmentというのが備品情報取得のAPIになる。
protoの詳しい文法は以下を参照されたい。
Protocol Buffers Language Guide (proto3)
Server側(eureka-client-demo)
- pom.xml
 まず、pomに以下のdependencyを追加する。
<!-- gRPC server -->
<dependency>
  <groupId>io.github.lognet</groupId>
  <artifactId>grpc-spring-boot-starter</artifactId>
</dependency>
次に、既述のprotocを使用する必要があるため以下を<build>タグ下に追加する。ここら辺は参考記事1も合わせて参照されたい。
<extensions>
  <extension>
    <groupId>kr.motd.maven</groupId>
    <artifactId>os-maven-plugin</artifactId>
    <version>1.5.0.Final</version>
  </extension>
</extensions>
// 略
<plugin>
  <groupId>org.xolstice.maven.plugins</groupId>
  <artifactId>protobuf-maven-plugin</artifactId>
  <version>0.5.1</version>
  <configuration>
    <protocArtifact>com.google.protobuf:protoc:3.5.1-1:exe:${os.detected.classifier}</protocArtifact>
    <pluginId>grpc-java</pluginId>
    <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.18.0:exe:${os.detected.classifier}</pluginArtifact>
  </configuration>
  <executions>
    <execution>
      <goals>
        <goal>compile</goal>
        <goal>compile-custom</goal>
      </goals>
    </execution>
  </executions>
</plugin>
まず、ここでエラー発生。私のMacOS上では${os.detected.classifier}が特定できないというエラーが出たため、プロパティを指定して凌いだ。settings.xmlでprofile設定でも良い様だが、私は同じpomファイル上でプロパティを指定。windowsでは変数の状態で行けたためOSに依って行けたり行けなかったりがある模様なのでご注意を。
<properties>
  <java.version>1.8</java.version>
  <spring-cloud.version>Greenwich.RC2</spring-cloud.version>
  <os.detected.classifier>osx-x86_64</os.detected.classifier>
</properties>
次に、この状態でビルドを行うと、gRPC用のソースコードを自動生成される。
その自動生成コードを用いてeureka-lb-demo(クライアント側)からのリクエストを受け付けるControllerを実装する。
作成したContorollerはは以下の通りである。
- Contoller
package com.example.ek.eureka_client_demo.equipment;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import org.apache.commons.collections.CollectionUtils;
import org.lognet.springboot.grpc.GRpcService;
import org.springframework.stereotype.Controller;
import com.example.ek.eureka_client_demo.equipment.EquipmentServiceGrpc.EquipmentServiceImplBase;
import io.grpc.stub.StreamObserver;
@GRpcService // (1)
@Controller
public class EquipmentController extends EquipmentServiceImplBase { // (2)
	private static Map<String, List<Equipment>> schoolEquipmentDB; // (3)
	@Override
	public void getEquipment(EquipmentRequest req, StreamObserver<EquipmentResponse> resObserber) { // (4)
		String schoolNm = req.getSchoolName(); // (5)
		List<Equipment> list = schoolEquipmentDB.get(schoolNm);
		EquipmentResponse equipmentResponsse = null;
		if (CollectionUtils.isEmpty(list)) {
			equipmentResponsse = EquipmentResponse.newBuilder()
					.setMessage(
							"There is no equipment in this scool " + schoolNm + System.getProperty("line.separator"))
					.build(); // (6)
			resObserber.onNext(equipmentResponsse); // (8)
		}
		// 備品情報が取得できた場合
		else {
			EquipmentResponse.Builder responseBuilder = EquipmentResponse.newBuilder();
			for (Equipment eq : list) {
				EquipmentInfo eqInfo = EquipmentInfo.newBuilder()
						.setCatagory(eq.getCategory())
						.setName(eq.getName())
						.build(); // (7)
				responseBuilder.addEquipmentInfo(eqInfo);
			}
			EquipmentResponse res = responseBuilder.setMessage("").build();
			resObserber.onNext(res); //(8)
		}
		resObserber.onCompleted(); // (9)
	}
	// (3)
	static {
		schoolEquipmentDB = new HashMap<String, List<Equipment>>();
		List<Equipment> lst = new ArrayList<Equipment>();
		Equipment eqp = new Equipment("chair", "High Grade Chair I");
		lst.add(eqp);
		eqp = new Equipment("desk", "High Grade Desk I");
		lst.add(eqp);
		schoolEquipmentDB.put("abcschool", lst);
		lst = new ArrayList<Equipment>();
		eqp = new Equipment("chair", "Low Grade Chair I");
		lst.add(eqp);
				eqp = new Equipment("desk", "Low Grade Desk I");
				lst.add(eqp);
		schoolEquipmentDB.put("xyzschool", lst);
		// DBの中を確認
		System.out.println("School Equipment information are as below.");
		for (Entry<String, List<Equipment>> entry : schoolEquipmentDB.entrySet()) {
			System.out.println("School: " + entry.getKey());
			for (Equipment e : entry.getValue()) {
				System.out.println("    Equipment:" + e.getCategory() + "," + e.getName());
			}
		}
	}
| No. | 説明 | 
|---|---|
| (1) | gRPCを使用する場合にはこのアノテーションを付与する。 | 
| (2) | 自動生成された EquipmentServiceImplBaseを継承する。自動生成されるファイルはいくつかあるが、.ptoroで指定したサービス名 + ImplBaseである。 | 
| (3) | DBを構築するのが面倒だったのでstaticで作った変数に値を入れただけのDBモック。 | 
| (4) | こちらも .protoで指定したメソッド名をオーバーライドする。 | 
| (5) | .protoで定義した通り、リクエストにはschoolNameというパラメータを定義したのでリクエストオブジェクトからgetしてDBモックから値を取得する。これより下はレスポンスを返すためのソースである。 | 
| (6) | レスポンスはこれまた自動生成されたBeanをbuilderパターンで値をセットしていく。ここでは備品情報が取得できなかった場合に、メッセージだけセットしている箇所である。 | 
| (7) | こちらは値が取れた場合の分岐で、メッセージではなく、リスト要素のオブジェクトである EquipmentInfo(これも自動生成ソース)に値をセットする。こちらもレスポンスオブジェクト同様、builderパターンにてオブジェクトを生成する。 | 
| (8) | 当該メソッドの引数である StreamObserverのonNextメソッド引数にレスポンスオブジェクトをセットすることによりクライアント側に値を送る。 | 
| (9) | 最後は通信を終了するために onCompleteメソッドを呼び出す。 | 
あと、gRPC用のプロパティ設定
## grpc settings
grpc:
 port: 6565
で、$ mvn clean install spring-boot:runでビルドおよび起動までを試みる。と以下のエラーがでた。
***************************
APPLICATION FAILED TO START
***************************
Description:
An attempt was made to call the method com.google.common.base.Preconditions.checkArgument(ZLjava/lang/String;CLjava/lang/Object;)
V but it does not exist. Its class, com.google.common.base.Preconditions, is available from the following locations:
    jar:file:/Users/***/.m2/repository/com/google/guava/guava/16.0/guava-16.0.jar!/com/google/common/base/Preconditions.class
調べるとguavaのライブラリが古くてアップデートしろというGit issueがあったので$ mvn dependency:treeでguavaの使用元を調べると
[INFO] |  +- com.netflix.eureka:eureka-client:jar:1.9.8:compile
[INFO] |  |  +- org.codehaus.jettison:jettison:jar:1.3.7:runtime
[INFO] |  |  |  \- stax:stax-api:jar:1.0.1:runtime
[INFO] |  |  +- com.netflix.netflix-commons:netflix-eventbus:jar:0.3.0:runtime
[INFO] |  |  |  +- com.netflix.netflix-commons:netflix-infix:jar:0.3.0:runtime
[INFO] |  |  |  |  +- commons-jxpath:commons-jxpath:jar:1.3:runtime
[INFO] |  |  |  |  +- joda-time:joda-time:jar:2.10.1:runtime
[INFO] |  |  |  |  \- org.antlr:antlr-runtime:jar:3.4:runtime
[INFO] |  |  |  |     +- org.antlr:stringtemplate:jar:3.2.1:runtime
[INFO] |  |  |  |     \- antlr:antlr:jar:2.7.7:runtime
[INFO] |  |  |  \- org.apache.commons:commons-math:jar:2.2:runtime
[INFO] |  |  +- com.netflix.archaius:archaius-core:jar:0.7.6:compile
[INFO] |  |  |  \- com.google.guava:guava:jar:16.0:compile
とのことでeureka-clientのjarとgrpcのboot-starterが合わない模様。eureka-clientの方のjarは以下の様にexludeして再ビルド&実行。
<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
  <exclusions>
          <exclusion>
              <groupId>com.google.guava</groupId>
              <artifactId>guava</artifactId>
          </exclusion>
    </exclusions>
</dependency>
なお、Spring Cloud系のプロジェクトは基本的にguavaのjarを内包しているのでgRPCを利用する場合にはきちんとバージョンを合わせる必要がある。私はconfig server, hystrixもdependencyに追加していてそれぞれguavaの別バージョンが入っていたのでそちらも同様にexclude設定を入れた。
ビルド結果は無事成功。
Started EurekaClientDemoApplication in 15.476 seconds (JVM running for 56.095)
o.l.springboot.grpc.GRpcServerRunner     : gRPC Server started, listening on port 6565.
Client側(eureka-lb-demo)
pomはServer側と同じくguavaをexcludeし、gRPCのboot-starterを追加するだけなので割愛する。
また、.protoファイルもServer側と全く同じものをコピペして、ビルド→ソースコードを自動生成するという形になり、特段Client側特有のことはない。Client側固有の内容は呼び出し方(javaコード)だけになるのでContollerについてのみ記載する。
- Contorller
@RestController
public class SchoolController {
	@Autowired
	EurekaClient client; // (1)
  // 省略
	@GetMapping(value = "/getEquipmentInfo/{schoolname}")
	public String getEquipments(@PathVariable String schoolname) {
		System.out.println("Getting School details for " + schoolname);
		InstanceInfo instance = client.getNextServerFromEureka("studentService", false);
		ManagedChannel channel = ManagedChannelBuilder.forAddress(instance.getIPAddr(), 6565)
				.usePlaintext().build(); // (2)
		EquipmentServiceBlockingStub stb = EquipmentServiceGrpc.newBlockingStub(channel);
		EquipmentRequest req = EquipmentRequest.newBuilder().setSchoolName(schoolname).build();
		EquipmentResponse res = stb.getEquipment(req); // (3)
		if (StringUtils.hasLength(res.getMessage())) {
			return res.getMessage();
		}
		StringBuilder sb = new StringBuilder();
		for (EquipmentInfo eq : res.getEquipmentInfoList()) {
			sb.append(editResponseForEquipment(eq));
			sb.append(System.getProperty("line.separator"));
		}
		return sb.toString();
	}
	private String editResponseForEquipment(EquipmentInfo eq) {
		return "Category: " + eq.getCatagory() + ", " + "EquipmentName: " + eq.getName() + ";";
	}
| No. | 説明 | 
|---|---|
| (1) | eureka-client-demo側の情報を取得するためにEurekaClientをDIしておく。 | 
| (2) | gRPCの使用には MannagedChannelを使用する。channel生成のためにManagedChannelBuilder.forAddressにeureka-client-demoのIPとportを渡す。私はeureka-client-demoとの通信でREST用の口も用意している都合で、ポートをハードコードしているが、gRPCしか使用しないのであればinstance.getPortで大丈夫である. | 
| (3) | これはこのライブラリ規定の書き方の様で自動生成されたスタブクラス EquipmentServiceBlockingStubを用いて通信を行う。リクエストにパラメータをセットしたら、あとはstb.getEquipmentメソッドの引数にリクエストをセットして、レスポンスを受け取る形となる。 | 
実行
これに対してHTTPリクエストを投げると以下の様に無事結果を取得することができる。
$ curl http://localhost:8086/getEquipmentInfo/abcschool
Category: chair, EquipmentName: High Grade Chair I;
Category: desk, EquipmentName: High Grade Desk I;
DBに存在しない学校名を指定した場合
$ curl http://localhost:8086/getEquipmentInfo/defschool
There is no equipment in this school defschool
ということでオブジェクトのリスト型も、Stringのメッセージも狙い通りに通信できていることがわかる。
ソースコード
色々他の内容も混ざってはいるが、ソースは以下。
ソースコード
なお、gRPCに関係しないプロパティ設定値は全てconfig-server-demoに集約し、各プロジェクトはsrc/main/resource/bootstrap.ymlがconfig-serverの各プロジェクト用設定値(config)を見にいく様になっているため閲覧する際にはお気をつけください。

