Edited at

protobufによるフレキシブルなコントラクトデータの管理

More than 1 year has passed since last update.


今日は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 Foofloat 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に対してかなりの間優位性を保てるのでは


g



問題は



solidityでprotobufって使えるの?

g



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)



  • よろしければ試してフィードバックください



Thank you!!