LoginSignup
3
1

More than 5 years have passed since last update.

C# Protocol Buffers メッセージモデルクラスの難点

Posted at

はじめに

gRPC の標準シリアライザは Protocol Buffers です。Grpc.Tools を使えば proto ファイルからソースコードを生成することができるのですが、この Grpc.Tools で生成される C# メッセージクラスの実装には癖があります。RPC メソッド呼び出しに限定して使うのであればよいかもしれませんが、アプリケーションロジックで使うにはやや難があると感じる点を挙げてみます。

メッセージモデルクラスの難点

Protobuf メッセージの定義の例

Grpc.Tools 1.16.0 を使ってこのような proto ファイルから C# ソースコードを生成しました。

sample.proto
import "timestamp.proto";
message Sample
{
    int32 ID = 1;
    string Name = 2;
    oneof AorB
    {
        string A = 3;
        string B = 4;
    }
    bytes Data = 5;
    google.protobuf.Timestamp CreateDateTime = 6;
}

String 型のプロパティに null を設定すると例外

Protobuf の string フィールドは C# では System.String 型のプロパティとして定義されますが、そのプロパティの setter には null を許可しないアサーションが組み込まれます。プロパティに null を設定すると、ArgumentNullException がスローされます。

生成されたソースコード
/// <summary>Field number for the "Name" field.</summary>
public const int NameFieldNumber = 2;
private string name_ = "";
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public string Name {
    get { return name_; }
    set {
        name_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
    }
}
Sample sample = new Sample();
// ArgumentNullException がスローされる。これをレビューとテストで取り除くことができるか?
sample.Name = null;

ByteString 型のプロパティに null を設定すると例外

Protobuf の bytes フィールドは C# では Google.Protobuf.ByteString 型のプロパティとして定義されます。この場合も String 型と同様のアサーションが組み込まれます。

生成されたソースコード
/// <summary>Field number for the "Data" field.</summary>
public const int DataFieldNumber = 5;
private pb::ByteString data_ = pb::ByteString.Empty;
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public pb::ByteString Data {
    get { return data_; }
    set {
        data_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
    }
}

日付を意味する値型がない

残念ながら Putorobuf では日付を意味するスカラー型はサポートされていません。Protobuf の公式リポジトリには google.protobuf.Timestamp という日付型があります。これを使用すると C# では Google.Protobuf.WellKnownTypes.Timestamp 型のプロパティとして定義されるのですが、この型は値型ではなく参照型です。参照型であると理解した上で利用するのであればよいのですが、やや危険な感じがします。

生成されたソースコード
/// <summary>Field number for the "CreateDateTime" field.</summary>
public const int CreateDateTimeFieldNumber = 6;
private global::Google.Protobuf.WellKnownTypes.Timestamp createDateTime_;
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public global::Google.Protobuf.WellKnownTypes.Timestamp CreateDateTime {
    get { return createDateTime_; }
    set {
        createDateTime_ = value;
    }
}
Sample a = new Sample() {
    CreateDateTime = Timestamp.FromDateTime(DateTime.Now.ToUniversalTime())
};
// a.CreateDateTime プロパティの値を b.CreateDateTime に代入(参照がコピーされる)
Sample b = new Sample() { CreateDateTime = a.CreateDateTime };
// a.CreateDateTime プロパティの値のクローンを c.CreateDateTime に代入
Sample c = new Sample() { CreateDateTime = a.CreateDateTime.Clone() };

Debug.WriteLine($"a.CreateDateTime={a.CreateDateTime}");
Debug.WriteLine($"b.CreateDateTime={b.CreateDateTime}");
Debug.WriteLine($"c.CreateDateTime={c.CreateDateTime}");

// a.CreateDateTime の値から1時間を引く
a.CreateDateTime.Seconds -= 60 * 60;

// 当然ながら b.CreateDateTime の値も1時間が引かれます
// 参照型と値型の違いを理解していない場合、わかりにくいバグを埋め込むリスクがあります
Debug.WriteLine($"a.CreateDateTime={a.CreateDateTime}");
Debug.WriteLine($"b.CreateDateTime={b.CreateDateTime}");
Debug.WriteLine($"c.CreateDateTime={c.CreateDateTime}");
a.CreateDateTime="2019-02-01T15:37:26.671817500Z"
b.CreateDateTime="2019-02-01T15:37:26.671817500Z"
c.CreateDateTime="2019-02-01T15:37:26.671817500Z"
a.CreateDateTime="2019-02-01T14:37:26.671817500Z"
b.CreateDateTime="2019-02-01T14:37:26.671817500Z"
c.CreateDateTime="2019-02-01T15:37:26.671817500Z"

Nullを許容する値型がない

Nullable に相当する値型はサポートされていません。次のようなメッセージを定義すれば見た目だけは Nullable のような型を作ることはできますが、これも値型ではなく参照型になります。

sample.proto
message NullableInt32
{
    int32 Value = 1;
}
message Sample
{
    // 割愛

    // nullを許容するInt32として表すことはできるものの…
    NullableInt32 ParentID = 7;
}
Sample a = new Sample();
Sample b = new Sample();

// このように記述しなくてはいけません。partialファイルでコンストラクタや暗黙型変換を実装する必要あり?
a.ParentID = new NullableInt32() { Value = 1 };
// null を代入することはできます。
b.ParentID = null;

// このようなコーディングは危険。
b.ParentID = a.ParentID;
a.ParentID.Value = 5;

等値判定の実装に癖がある

proto ファイルに記述した Message はクラスとして定義されますが、Equals や GetHashCode などのメソッドには一般的な値型に近い内容が実装されています。Grpc.Tools で生成される C# ソースコードの解説 にも書いている内容です。

Equals メソッド

一般的な値型の実装に近い内容でオーバーライドされています。ValueEquals メソッドのような名前で実装されていてほしい内容です。

生成されたソースコード
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public bool Equals(Sample other) {
    if (ReferenceEquals(other, null)) {
        return false;
    }
    if (ReferenceEquals(other, this)) {
        return true;
    }
    if (ID != other.ID) return false;
    if (Name != other.Name) return false;
    if (A != other.A) return false;
    if (B != other.B) return false;
    if (Data != other.Data) return false;
    if (!object.Equals(CreateDateTime, other.CreateDateTime)) return false;
    if (AorBCase != other.AorBCase) return false;
    return true;
}
  • 同じ参照である場合は true を返します。
  • 異なる参照であっても、全てのプロパティの値が等しければ true を返します。
Sample a1 = new Sample() { Name = "a" };
Sample a2 = new Sample() { Name = "a" };
Debug.WriteLine(a1.Equals(a2));
True

== 演算子

オーバーロードされていません。そのため、Equals メソッドとは異なる挙動になります。

  • 同じ参照である場合は true を返します。
  • 参照が異なれば false を返します。
Sample a1 = new Sample() { Name = "a" };
Sample a2 = new Sample() { Name = "a" };
Debug.WriteLine(a1 == a2);
False

GetHashCode メソッド

一般的な値型の実装に近い内容でオーバーライドされています。

生成されたソースコード
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public override int GetHashCode() {
    int hash = 1;
    if (ID != 0) hash ^= ID.GetHashCode();
    if (Name.Length != 0) hash ^= Name.GetHashCode();
    if (aorBCase_ == AorBOneofCase.A) hash ^= A.GetHashCode();
    if (aorBCase_ == AorBOneofCase.B) hash ^= B.GetHashCode();
    if (Data.Length != 0) hash ^= Data.GetHashCode();
    if (createDateTime_ != null) hash ^= CreateDateTime.GetHashCode();
    hash ^= (int) aorBCase_;
    return hash;
}
  • 各プロパティの値のハッシュコードの XOR を返します。
Sample a1 = new Sample() { Name = "a" };
Sample a2 = new Sample() { Name = "a" };

// a2 は重複していると扱われて格納されません
HashSet<Player> hash = new HashSet<Player>();
hash.Add(a1);
hash.Add(a2);

// a2 は重複していると扱われて ArgumentException がスローされます
Dictionary<Player, bool> dic = new Dictionary<Player, bool>();
dic.Add(a1, true);
dic.Add(a2, true);

どのような対策があるか

いずれの方法も現実味は薄いです。

ラッパークラスを介してアクセス

ProtoBuf モデルクラスを内包するラッパーを定義し、アプリケーションロジックからはラッパーを介してアクセスするようにします。全てのプロパティに対するアクセス手段を実装する必要があり、ラッパーのソースコードを自動生成する仕組みを用意する必要があると思います。ProtoBuf を採用するメリットの一つに proto ファイルからのソースコード自動生成があると思いますが、さらに自動生成の仕組みが必要になるというのは本末転倒な気がします。

public class SampleWrapper
{
    private Sample m_ProroObject;
    public DateTime CreateDateTime
    {
        get { return m_ProroObject.CreateDateTime.ToDateTime(); }
        set { m_ProroObject.CreateDateTime =
                Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(value.ToUniversalTime());
        }
    }
}

インターフェースを介してアクセス

アプリケーションロジックからアクセスするメンバをインターフェースとして定義し、ProtoBuf モデルクラスに実装します。アプリケーションロジックからはインターフェースを介してアクセスするようにします。ラッパークラスに比べれば実装量は減るものの、これもソースコードを自動生成する仕組みを用意する必要があると思います。実体は ProtoBuf モデルクラスそのものですので、ProtoBuf モデルクラスにキャストされて直接アクセスされる可能性も考慮する必要があります。

public interface ISample
{
    public int ID { get; set; }
    public DateTime CreateDateTime { get; set; }
}
partial class Sample : ISample
{
    // ProtoBuf モデルクラスとシグネチャが一致するメンバは明示的に実装する必要はありません。

    DateTime ISample.CreateDateTime
    {
        get { return CreateDateTime.ToDateTime(); }
        set { CreateDateTime =
                Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(value.ToUniversalTime());
        }
    }
}

RPC 呼び出しのみ利用

ProtoBuf モデルクラスの利用を RPC メソッド呼び出しに限定します。アプリケーションロジックで使用するモデルクラスは別途定義し、RPC メソッドを呼び出すときに ProtoBuf モデルクラスと値を受け渡します。アプリケーションロジックから ProtoBuf モデルクラスの存在が見えると誤用や意図しない場面での利用が発生しますので、RPC メソッドをラップしたほうが安全と言えます。モデルクラスに加えてラッパーメソッドも実装しなくてはならず、実装量はかなり多くなります。

// アプリケーションロジックから使用するメソッド
private SampleServiceClient m_Client;
public SampleData GetSample(RequestData request, CallOptions options)
{
    // RPCメソッドを呼び出す
    return SampleData.FromRpcModel(m_Client.GetSample(request.ToRpcModel(), options));
}
// アプリケーションロジックから使用するモデルクラス
public class SampleData
{
    // 割愛
    public static SampleData FromRpcModel(Sample rpcModel) {}
}
public class RequestData
{
    // 割愛
    public Request ToRpcModel() {}
}

おわりに

以上の点から、proto ファイルから生成されたモデルクラスをアプリケーションロジックで積極的に利用するのは、私は難しいと思います。異なる言語間でデータのやり取りを行わなければならず、proto ファイルによる IDL 共有が有効であるシナリオに限って使うことになりそうです。
ソースコードを自動生成する仕組みが必要であるなら、ProtoBuf にこだわる必要はありません。

3
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1