はじめに
本記事では、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の戻り値だけでは判別できません。例えば以下のようなケースで問題が発生しやすくなります。
-
ageが0のとき、実際に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() == 0やgetXxxList().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は未設定でもデフォルト値を返す |
.protoでoptionalを付与しhasXxx()で判定する |
| 3 | repeated/mapのGetter名にList/Mapが付く |
MapStructでは@Mappingで明示的にプロパティ名を指定する |
| 4 | Getterが返すコレクションはイミュータブル | Builderを経由して操作するか、Mutableなコレクションにコピーする |
いずれもProtobufの設計思想(nullの不在、デフォルト値の存在、イミュータビリティ)を理解していれば納得できるものですが、通常のJavaオブジェクト(POJO)の感覚で扱うと思わぬところで躓きます。
プロジェクトの早い段階で以下を整備しておくことをおすすめします。
-
.proto設計方針: 未設定判定が必要なフィールドにはoptionalを付けるルールを決める -
DTO変換のユーティリティ: ヘルパーメソッドを整備し、
null/デフォルト値の変換ルールを統一する