「Protocol Buffersのschema evolution設計|field番号・reserved・unknown field・Python」の内容を表す技術イラスト
UNIT 00LAB NOTES FIG.00 · EYECATCH

Protocol Buffersのschema evolution設計|field番号・reserved・unknown field・Python

Protocol Buffersのschema evolutionを安全に進める方法を解説。field番号、reserved、unknown field、optional、ProtoJSON、Python実装、新旧schema検証、CAD・BOM・DB連携まで整理します。

DOC NO.EKP-00-1271
DATE
REVB
READ約 12 分
目次

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へ変更要testpresenceの意味と生成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変更を安全に継続できます。

参考情報

参考になったらシェアしてください
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

機械設計・油圧・CAD・Python・AIなど、ものづくりに関わる技術を扱っています。工学知識を整理・構造化し、設計や自動化に再利用できる形へ変えていくことを目指しています。

目次