今日はdappの運用にprotobufを使うこと、のお話なんですがその前に
動機:ゲーム向けのサーバーフレームワークとしてのEthereum
前提
- コントラクトのコードはimmutable
前提
- コントラクトのコードはimmutable
- コントラクトのデータ構造はコードでしか定義できない
前提
- コントラクトのコードはimmutable
- コントラクトのデータ構造はコードでしか定義できない
=> コントラクトのデータ構造はimmutable
懸念:最初のコントラクトに無いデータが必要になったらどうすんの?
データのマイグレーション
メインネット上のゲームのコードを読んでみた
丸コピー
- ロジックとデータを同じコントラクトに置くと否応無しにこうなる
- データを新しいコントラクトにコピーするタイミングでカラムを変更する
- FooServiceに対して新しいデータ構造を持つFooServiceV2を作る
- FooServiceからFooServiceV2にgas limitの範囲内で少しずつデータをコピーするFooServiceMigraterを作成し実行する
ストレージコントラクトとその拡張
- ロジックとデータを扱うコントラクトを分離する
- データだけを保持するFooServiceStorageをつくりFooServiceからそれを使う
- 追加データはFooServiceStorageExtraを作りそこにおく
- FooServiceV2はFooServiceStorageとFooServiceStorageExtra両方を見るようにする
- Extra2, Extra3, ... (ToT)
データのマイグレーション:そんなものはなかった
protobufならまともなマイグレーションができるのでは?
protobuf
- googleが開発したIDL
- 複数のアプリで共有するデータ構造をmessageと呼び、以下のような感じで定義する
- =の後の番号が重要
message Foo {
uint64 id = 1;
Nested nested = 2;
message Nested {
string text = 1;
bytes data = 2;
}
}
protobuf
- 定義されたmessageをコンパイルして各種プログラム言語向けのコードができる
- バイナリにシリアライズすることで効率的にデータの移動が行える
- このフォーマットがナイス
p := pb.Foo{
Id: 1234,
Nested: *pb.Foo_Nested{
Text: "hoge",
Data: []byte{2, 3, 5},
},
}
out, err := proto.Marshal(p) //out is []byte
p := &pb.Foo{}
if err := proto.Unmarshal(in, p); err != nil { //in is []byte
log.Fatalln("Failed to parse Foo:", err)
}
protobuf(重要)
- データに割り当てた番号を再利用しない
- 例えば
uint64 id = 1;
の1は、他のデータの番号としては二度と割当てないようにする
- 例えば
- その条件下で一旦シリアライズされたデータはどのバージョンのコードとも読み込みの互換性があることが保証される
message Foo {
uint64 id = 1; //他のデータで1を使わない
Nested nested = 2;
message Nested {
string text = 1;
bytes data = 2;
}
}
protobuf
- 例)
message Foo
にfloat f = 3;
を追加した場合 - 古いコードで新しいデータを読む
- fに設定されたデータは単に無視される
- 新しいコードで古いデータを読む
- fは自明なデフォルト値(0.0f)で初期化されている
message Foo {
uint64 id = 1;
Nested nested = 2;
float f = 3;
message Nested {
string text = 1;
bytes data = 2;
}
}
まともなマイグレーション
- データスキーマをprotobufで定義する。contractにはbytesとして保存
- versionというカラムを各スキーマに持たせておくのがミソ
message Record {
uint32 version = 1;
uint32 id = 2;
//実際のデータカラムたちが続く
}
まともなマイグレーション
- FooServiceは各データスキーマの現バージョンを持っておく
- スキーマのバージョンを上げる時にはFooServiceの新しいバージョンをデプロイ
-
public mapping(uint => bytes) entries
とかを持つFooServiceStorageをつくりFooServiceからそれを使うようにしておく
contract FooService {
using pb_Record for pb_Record.Data;
uint const CURRENT_RECORD_VERSION = 1;
FooServiceStorage storage;
...
}
まともなマイグレーション
- データを新しく作るとき、versionカラムには今のバージョンをセットしてFooServiceStorageに保存する
function newRecord() public returns (uint) {
pb_Record.Data memory data;
r.id = newId();
r.version = CURRENT_RECORD_VERSION;
storage.entries[r.id] = r.encode();
}
まともなマイグレーション
- データを読み出すとき、versionカラムと現在のFooServiceが持っているバージョンが一致していなければ、その差分について必要な初期化を行う
- まともにマイグレーションできそうな感じがしてくる
function getRecord(uint id) internal returns (pb_Record.Data r) {
bytes bs = storage.entries[id];
r.decode(bs); //バージョンがどう変わっていても問題が生じない
if (r.version != CURRENT_RECORD_VERSION) {
//ここで新しいレコードに値をセットしたりする
r.version = CURRENT_RECORD_VERSION; //最新版になった
storage.entries[r.id] = r.encode(); //結果を再度保存
}
}
さらなるメリット
- コントラクトからstructを返すのと同等なことがポータブルに実現できる
- protobufでserializeされたbytesを返す関数を作り、それを受け側でdeserializeする
- ABIEncoderV2不要&すでに他の実用的な大部分のプログラム言語で可能
- ABIEncoderV2がprotobufレベルでサポートされるのは時間がかかるだろう(現状はethers.jsぐらい?)
- ABIEncoderV2に対してかなりの間優位性を保てるのでは
問題は
solidityでprotobufって使えるの?
solidityでprotobufって使えるの?
- protobuf verion 2用のコンパイラがあった
- しかしprotobufの現バージョンは3
- メンテナンスされてない上にhaskellで書かれている
- 2を使う場合、他の部分で苦労が予想される
- 3年半ほど新しいリリースはされてない
- protobuf3推奨のものも多い(grpcなど)
作りました
pb3sol
-
https://github.com/umegaya/pb3sol
- pythonでのprotobuf3コンパイラ実装 +
- addressなど、solidityのnative typeを利用可
- jsで上記のnative typeを使いやすくするnpmを用意(soltype-pb)
- よろしければ試してフィードバックください