9
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaでgRPCクライアント&サーバーを実装したときにハマったこと

9
Posted at

はじめに

本記事では、JavaでgRPCを使ったクライアント・サーバーアプリケーションを実装した際に遭遇した問題点と、その解決方法についてまとめます。

特にProtobufメッセージの扱いに関するハマりポイントが多かったため、同じ問題に直面した方の参考になれば幸いです。

技術スタック

技術 バージョン
Java 21
Spring Boot 3.5.3
Spring gRPC 0.9.0
gRPC-Java 1.72.0
Protobuf 4.30.2(proto3構文)
MapStruct 1.6.3

ハマったこと

1. Protobufから自動生成したJavaクラスのSetterにnullを渡すとNPEが発生する

問題

MyMessage.Builder builder = MyMessage.newBuilder();
builder.setName(null);  // NullPointerException!

通常のJavaオブジェクトであればSetterにnullを渡しても問題ありませんが、Protobufの自動生成クラスでは例外がスローされます。

原因

Protobufの自動生成コードでは、Builderの各Setterメソッド内部でnullチェックが行われています。内部実装のイメージは以下のとおりです。

// Protobufが自動生成するSetterの内部実装(イメージ)
public Builder setName(String value) {
    if (value == null) {
        throw new NullPointerException();
    }
    name_ = value;
    onChanged();
    return this;
}

これはproto3の設計思想に基づくもので、proto3ではフィールドにnullという概念が存在しません。すべてのフィールドには型ごとのデフォルト値(文字列なら""、数値なら0、boolならfalse)が定義されており、「値が存在しない」状態は「デフォルト値が設定されている」状態と同義として扱われます。

解決策

nullの可能性がある値をSetterに渡す場合は、事前にnullチェックを行うか、clearXxx()で明示的にデフォルト値にリセットします。

// 方法1:nullチェックしてからセットする
if (name != null) {
    builder.setName(name);
}

// 方法2:Optional.ofNullableを活用する
Optional.ofNullable(name).ifPresent(builder::setName);

// 方法3:値をクリアしたい場合はclearXxxを使う
builder.clearName();  // フィールドをデフォルト値にリセット

DBから取得した値やDTO経由で受け取った値をProtobufメッセージに詰め替える処理では、nullが混入しやすいため特に注意が必要です。


2. ProtobufメッセージのGetterは未設定でもnullを返さない

問題

MyMessage message = MyMessage.newBuilder().build();
String name = message.getName();      // nullではなく空文字""が返る
int age = message.getAge();           // nullではなく0が返る
boolean active = message.getActive(); // nullではなくfalseが返る

通常のJavaオブジェクトでは、フィールドを設定していなければGetterはnullを返します。しかしProtobufのGetterはnullを返さず、型ごとのデフォルト値を返します。

デフォルト値の一覧

proto3の型 Javaの型 デフォルト値
string String "" (空文字)
int32 / sint32 / sfixed32 int 0
int64 / sint64 / sfixed64 long 0L
float float 0.0f
double double 0.0
bool boolean false
bytes ByteString ByteString.EMPTY
enum 生成されたEnum型 定義の先頭の値(番号0
message 生成されたメッセージ型 ※後述

message型のフィールドのみ挙動が異なります。Getterはnullではなくデフォルトインスタンスを返しますが、hasXxx()メソッドで未設定かどうかを判定できます(optionalキーワードの付与は不要)。

注意点

この仕様により、「値が意図的に設定されたのか、それとも未設定なのか」をGetterの戻り値だけでは判別できません。例えば以下のようなケースで問題が発生しやすくなります。

  • age0のとき、実際に0が設定されたのか未設定なのか区別できない
  • name""のとき、空文字が意図的に設定されたのか未設定なのか区別できない
  • Protobufメッセージ → DTO変換時に、未設定フィールドをnullとして扱いたいのにデフォルト値が入ってしまう

解決策:hasXxx()で値の有無を判定する

proto3では、スカラー型フィールドにoptionalキーワードを付与するとhasXxx()メソッドが生成されます。

// .protoファイルでoptionalを指定する
message MyMessage {
    optional string name = 1;
    optional int32 age = 2;
}
if (message.hasName()) {
    String name = message.getName();
    // nameが明示的に設定されている場合の処理
} else {
    // nameが未設定の場合の処理(nullとして扱うなど)
}

hasXxx()の生成条件まとめ

フィールドの種類 hasXxx()の生成 備考
optional付きスカラー型 生成される proto3ではoptionalの明示的な付与が必要
message 生成される optionalの有無にかかわらず常に生成
repeated / map 生成されない getXxxCount() == 0getXxxList().isEmpty()で代替
optionalなしスカラー型 生成されない デフォルト値との区別が不可能

プロジェクトの.protoファイルを設計する段階で、「未設定かどうかを判定する必要があるフィールドにはoptionalを付ける」という方針を決めておくのがおすすめです。


3. repeatedやmapフィールドの自動生成Getter名にListやMapが付与される

問題

message MyMessage {
    repeated string item = 1;
    map<string, string> attribute = 2;
}
// .protoのフィールド名とJavaのGetter名が一致しない
message.getItemList();       // "item" → "getItemList()"
message.getAttributeMap();   // "attribute" → "getAttributeMap()"

.protoファイルではitemと定義しているのに、Java側のGetterはgetItemList()になります。通常のJavaBeansの規約(getItem())と異なるため、Beanマッピングライブラリやリフレクションベースのツールを使う際に問題が発生します。

MapStructを使う場合の注意点

MapStructでProtobufメッセージとDTOをマッピングする場合、プロパティ名が一致しないため自動マッピングが効きません。@Mappingで明示的にプロパティ名を指定する必要があります。

public class MyDto {
    private List<String> item;
    private Map<String, String> attribute;
    // getter / setter
}
@Mapper
public interface MyMapper {

    // Protobuf → DTO
    @Mapping(source = "itemList", target = "item")
    @Mapping(source = "attributeMap", target = "attribute")
    MyDto toDto(MyMessage message);

    // DTO → Protobuf Builder
    @Mapping(source = "item", target = "itemList")
    @Mapping(source = "attribute", target = "attributeMap")
    MyMessage.Builder toProtoBuilder(MyDto dto);
}

Protobufメッセージのフィールド数が多い場合、@Mappingの記述が煩雑になるため、マッピング対象のフィールドが漏れていないか注意が必要です。


4. repeatedやmapフィールドのGetterはイミュータブルなコレクションを返す

問題

List<String> items = message.getItemList();
items.add("newItem");  // UnsupportedOperationException!

Map<String, String> attrs = message.getAttributeMap();
attrs.put("key", "value");  // UnsupportedOperationException!

原因

Protobufでは、build()で生成されたメッセージオブジェクトはイミュータブル(不変)として設計されています。getItemList()getAttributeMap()が返すコレクションは変更不可能なコレクションであり、要素の追加・削除・変更を行うとUnsupportedOperationExceptionがスローされます。

解決策

// 方法1:新しいMutableなコレクションにコピーしてから操作する
List<String> items = new ArrayList<>(message.getItemList());
items.add("newItem");

// 方法2:toBuilder()で既存メッセージをベースに再構築する
MyMessage newMessage = message.toBuilder()
    .addItem("newItem")              // repeatedフィールドへの追加
    .putAttribute("key", "value")    // mapフィールドへの追加
    .build();

// 方法3:newBuilder()でaddAll/putAllを使って一括設定する
MyMessage newMessage = MyMessage.newBuilder()
    .addAllItem(List.of("item1", "item2", "item3"))
    .putAllAttribute(Map.of("key1", "val1", "key2", "val2"))
    .build();

Protobufメッセージの内容を変更したい場合は、方法2・3のようにBuilderを経由するのが設計思想に沿った方法です。取得したコレクションを別の処理に渡して加工する場合は、方法1のようにコピーしてから渡すようにしましょう。

まとめ

JavaでgRPC(Protobuf)を扱う際にハマりやすいポイントを4つ紹介しました。

# ハマりポイント 対処法
1 Setterにnullを渡すとNPE nullチェックしてからセットする、またはclearXxx()を使う
2 Getterは未設定でもデフォルト値を返す .protooptionalを付与しhasXxx()で判定する
3 repeated/mapのGetter名にList/Mapが付く MapStructでは@Mappingで明示的にプロパティ名を指定する
4 Getterが返すコレクションはイミュータブル Builderを経由して操作するか、Mutableなコレクションにコピーする

いずれもProtobufの設計思想(nullの不在、デフォルト値の存在、イミュータビリティ)を理解していれば納得できるものですが、通常のJavaオブジェクト(POJO)の感覚で扱うと思わぬところで躓きます。

プロジェクトの早い段階で以下を整備しておくことをおすすめします。

  • .proto設計方針: 未設定判定が必要なフィールドにはoptionalを付けるルールを決める
  • DTO変換のユーティリティ: ヘルパーメソッドを整備し、null/デフォルト値の変換ルールを統一する

参考資料

9
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?