Protocol Buffersはfield名ではなく番号でdataを運ぶ
Protocol Buffers(Protobuf)は、.protoでmessageのfield、type、番号を定義し、compilerが各言語向けのclassを生成するschema付きserialization形式です。JSONのようにfield名を毎回送るのではなく、binary wire formatではfield番号とwire typeをkeyとして値をencodeします。
messageを小さく交換できる一方、schema変更ではfield番号がdataの意味を支えます。公開後に番号を付け替える、削除した番号を別fieldへ再利用する、scalar fieldの未指定とdefault値を混同すると、byte列を正常にparseできても別の意味として処理される可能性があります。
field番号を永続IDとして扱い、削除時はreservedへ移し、新旧schemaを混在させたtestを続けることが重要です。
.protoがinterface契約になる
部品recordを例に、最小のschemaを定義します。質量は浮動小数点数一つにせず、coefficient、exponent、unitへ分けます。releasedにはoptionalを付け、未指定と明示的なfalseを区別できるようにします。
syntax = "proto3";
package ekplabz.parts.v1;
import "google/protobuf/timestamp.proto";
message DecimalValue {
sint64 coefficient = 1;
sint32 exponent = 2;
string unit = 3;
}
message Part {
string part_id = 1;
uint32 revision = 2;
DecimalValue mass = 3;
optional bool released = 4;
google.protobuf.Timestamp updated_at = 5;
repeated string tags = 6;
reserved 7;
reserved "legacy_code";
}
Partはmessage、part_idなどはfield、1から7はfield番号です。massのvalueは別messageのobjectであり、その内側にも独立したfield番号があります。tagsは同じtypeの値を複数持つrepeated fieldです。
質量245.50 gは、coefficient = 24550、exponent = -2、unit = "g"として保持します。単位とscaleが見えるため、CAD属性、BOM、DBへ変換しても意味を保てます。
field番号は変更できないIDである
Protobufのbinary messageには通常、part_idという名前は入りません。受信側はfield番号1とschemaを照合して、その値をpart_idとして解釈します。そのため、公開済みfieldの番号変更はrenameではなく、旧fieldの削除と新fieldの追加に相当します。
field番号には次の原則があります。
- 同じmessage内で一意にする
- 運用開始後は変更しない
- 削除した番号を別の意味へ再利用しない
- 頻出fieldには短くencodeできる1から15を優先する
- 19000から19999はProtobuf実装用なので使用しない
番号を見栄えのよい順へ並べ直すことも破壊的変更です。途中へfieldを追加しても既存番号は動かしません。
削除したfieldはreservedへ移す
fieldを削除するときは、番号を空き番号へ戻しません。番号と、JSONやText Formatで使われる可能性がある旧名称をreservedへ記録します。
message Part {
string part_id = 1;
uint32 revision = 2;
reserved 3, 7 to 9;
reserved "old_mass", "legacy_code";
}
番号のreservedと名称のreservedは別statementにします。将来、別の開発者が番号3を新しい意味へ割り当てようとすると、compilerがerrorにできます。
enumでも同じ考え方が必要です。削除したnumeric valueを再利用せず、最初の値は未指定を表す0にします。
enum LifecycleState {
LIFECYCLE_STATE_UNSPECIFIED = 0;
LIFECYCLE_STATE_DRAFT = 1;
LIFECYCLE_STATE_RELEASED = 2;
reserved 3;
reserved "LIFECYCLE_STATE_ARCHIVED_OLD";
}
安全な変更と危険な変更を分ける
| schema変更 | 基本判定 | 理由 |
|---|---|---|
| 未使用番号で新fieldを追加 | 安全 | 旧readerではunknown fieldになる |
| field名だけを変更 | binaryでは互換 | ProtoJSONや運用codeには影響する |
| field番号を変更 | 危険 | 別fieldとしてdecodeされる |
| 削除番号を再利用 | 危険 | 保存済みdataの意味が衝突する |
| scalarをrepeatedへ変更 | 危険 | cardinalityとwire解釈が変わる |
| int32をint64へ変更 | 条件付き | 旧readerの範囲を超えるとdataを失う |
| enum値を追加 | binaryでは通常安全 | 未知enum値の扱いは言語差を確認する |
| implicit scalarをoptionalへ変更 | 要test | presenceの意味と生成APIが変わる |
wire typeが同じ型同士でも、業務上安全とは限りません。例えばint32からint64への変更後、新writerが大きな値を書けば旧readerでは扱えません。schema上の互換性だけでなく、全clientの更新が完了するまで書き込む値を旧範囲へ制限するrollout設計が必要です。
unknown fieldが新旧versionの混在を支える
新schemaがfield 8を追加したmessageを旧readerが読むと、field 8はunknown fieldになります。Proto3のbinary messageはunknown fieldを保持し、同じmessageを再serializeするときにも含めます。これにより、中継serviceが新fieldの意味を知らなくてもdataを次へ渡せます。
flowchart TD A[新writer: field 8を追加] --> B[旧relay: field 8はunknown] B --> C[binary messageのまま保持] C --> D[新reader: field 8を復元] B --> E[JSON化またはfield別copy] E --> F[field 8を失う可能性]
unknown fieldを維持したい中継処理では、message全体をCopyFrom()やMergeFrom()で扱います。認識できるfieldだけを新objectへ手作業でcopyしたり、途中でProtoJSONへ変換したりするとunknown fieldは失われます。
binaryとProtoJSONは同じ互換性ではない
ProtoJSONは人間が確認しやすく、Protobuf非対応systemとも連携できますが、binary wire formatよりschema evolutionの保証が弱くなります。JSONにはfield名やenum名が入り、unknown fieldを保持できません。
binaryではfield名を変更しても番号が同じなら値を読めますが、JSON clientは旧名称を期待している可能性があります。binary APIの内部中継と、公開JSON APIを同じ変更規則で管理してはいけません。
変換境界では次を明示します。
- binaryとJSONのどちらを正式な保存形式にするか
- unknown JSON fieldを拒否するか無視するか
json_nameを固定するか- int64、enum、Timestamp、NULLのJSON表現
- JSON変換で失われるunknown fieldを許容するか
default値と未指定を混同しない
proto3のimplicit scalar fieldでは、fieldがwire上に存在しない場合もtypeごとのdefault値を返します。stringは空文字、numberは0、boolはfalseです。このため、implicitなbool released = 4;では「未確認」と「明示的に未公開」を区別できません。
区別が必要なscalarにはoptionalを使います。Python生成classではHasField()でpresenceを確認できます。
from part_pb2 import Part
part = Part(part_id="P-1001", revision=7)
assert part.released is False
assert not part.HasField("released")
part.released = False
assert part.HasField("released")
part.ClearField("released")
assert not part.HasField("released")
repeated fieldは未指定時に空collectionとなり、scalarのoptionalとは異なります。NULL、空文字、0、空配列、field欠落をどの業務状態へ対応させるか、DBやJSONへの変換ruleも含めて決めます。
Pythonで生成・serialize・検証する
.protoからPython moduleを生成します。生成fileは直接編集せず、schemaを変更して再生成します。
protoc --proto_path=. --python_out=. part.proto
生成されたclassを使い、型付きrecordをbinaryへ変換します。
from datetime import datetime, timezone
from part_pb2 import Part
part = Part(part_id="P-1001", revision=7, released=False)
part.mass.coefficient = 24550
part.mass.exponent = -2
part.mass.unit = "g"
part.updated_at.FromDatetime(
datetime(2026, 10, 30, 4, 30, tzinfo=timezone.utc)
)
part.tags.extend(["hydraulic", "drawing-reviewed"])
payload = part.SerializeToString()
decoded = Part()
decoded.ParseFromString(payload)
if not decoded.part_id or decoded.revision == 0:
raise ValueError("part_id and revision are required by the application")
if decoded.mass.unit not in {"g", "kg"}:
raise ValueError("unsupported mass unit")
if not decoded.HasField("released"):
raise ValueError("released decision is missing")
Protobufのparserが成功しても、必須ID、許容unit、revision範囲、日時、参照先の存在までは保証しません。生成classによる型検査と、applicationのvalidationを重ねます。
正常系・異常系・再実行をtestする
testでは現在schemaだけでなく、少なくとも一つ前の生成classとの組合せを確認します。
| test | 期待する結果 |
|---|---|
| 旧writerから新reader | 既存fieldを同じ意味で読める |
| 新writerから旧reader | 新fieldをunknownとして保持できる |
| 旧readerでparse後にbinary再serialize | 新fieldが残る |
| binaryをJSON経由で再構築 | unknown fieldが失われることを検出 |
| 削除番号を再利用 | schema検査またはreviewで拒否 |
released未指定 | HasField()がfalse |
released = falseを明示 | HasField()がtrue |
| 旧範囲を超える数値 | rollout完了前はwriterで拒否 |
messageのbyte列そのものをidempotency keyやcache keyにするのは避けます。Protobuf公式のbest practiceでは、serializationの安定性はbuild間で保証されません。業務上の重複判定にはpart_idとrevisionなどの安定したkeyを使い、必要なら論理fieldを正規化してhash化します。
securityと運用上の注意
Protobufは暗号化ではありません。token、password、個人情報をmessageへ入れれば、binary表示で読みにくいだけで保護されません。TLS、保存時暗号化、access controlを別に適用し、debug logやJSON変換時の秘密情報流出も防ぎます。
外部から受信するmessageには、全体byte数、stringやbytesの長さ、repeated件数、nesting depthの上限を設けます。Anyを使う場合は許可するtype URLをallowlist化し、受信した型から任意の処理を起動しません。
schemaはsource codeと同じrepositoryでversion管理し、生成code、互換性test、配布artifactを同じchangeとしてreviewします。clientとserverが同時に更新される前提を置かず、旧reader→新writer、新reader→旧writerの両方向をtestします。
DB・API・CAD連携へ再利用する
CAD属性やBOMでは、図番、revision、quantity、unit、更新日時、参照先IDをmessageとして定義できます。field番号を固定すれば、field名の表記変更や生成言語の違いがあってもbinary上の識別子を保てます。
DBへ保存するときは、検索・参照整合性に使うfieldをcolumnへ展開します。raw Protobufだけを保存すると、DB側でIDやrevisionを検査しにくくなります。raw payloadを残す場合は、完全修飾message名、schema artifactのversion、受信時刻、hashをmetadataとして関連付けます。
APIや自動化へ広げる場合も、schema registryまたは配布packageを正本にし、consumerが使用したschema versionを追跡します。field番号、presence、unit、ID、unknown fieldの扱いを契約化することで、設計dataをPython、DB、service間で再利用できます。
まとめ
Protocol Buffersのschema evolutionでは、field番号がdataの永続IDです。公開後は番号を変更・再利用せず、削除時は番号と名称をreservedへ移します。
新fieldは旧readerでunknown fieldとして扱えますが、ProtoJSON変換やfield単位のcopyでは失われる可能性があります。default値と未指定を区別するfieldにはoptionalを使い、生成classの型検査にapplication validationを重ねます。
新旧schemaを両方向でtestし、idempotencyやcacheには安定した業務keyを使うことで、CAD、BOM、DB、APIをまたぐschema変更を安全に継続できます。
