Does protobuf-net support C# 9 positional record types?

Viewed 1192

I'm trying to use protobuf-net with C# positional record types, and I've encountered this exception:

10:18:48.048 [EROR] #010 (Microsoft.AspNetCore.Server.Kestrel) Connection id ""0HM4NDHMUB3C6"", Request id ""0HM4NDHMUB3C6:00000003"": An unhandled exception was thrown by the application.
Grpc.Core.RpcException: Status(StatusCode="Internal", Detail="Error starting gRPC call. ProtoException: No parameterless constructor found for Bidirectional.Demo.Common.Contracts.Server.GetServerProcessI
nfo.GetServerProcessInfoResponse", DebugException="ProtoBuf.ProtoException: No parameterless constructor found for Bidirectional.Demo.Common.Contracts.Server.GetServerProcessInfo.GetServerProcessInfoResp
onse
   at ProtoBuf.Internal.ThrowHelper.ThrowProtoException(String message, Exception inner) in /_/src/protobuf-net.Core/Internal/ThrowHelper.cs:line 70
   at ProtoBuf.Meta.TypeModel.ThrowCannotCreateInstance(Type type, Exception inner) in /_/src/protobuf-net.Core/Meta/TypeModel.cs:line 1666
   at proto_12(State& , GetServerProcessInfoResponse )
   at ProtoBuf.Internal.Serializers.SimpleCompiledSerializer`1.ProtoBuf.Serializers.ISerializer<T>.Read(State& state, T value)
   at ProtoBuf.ProtoReader.State.ReadAsRoot[T](T value, ISerializer`1 serializer)
   at ProtoBuf.ProtoReader.State.DeserializeRoot[T](T value, ISerializer`1 serializer)
   at ProtoBuf.Meta.TypeModel.Deserialize[T](ReadOnlySequence`1 source, T value, Object userState)
   at ProtoBuf.Grpc.Configuration.ProtoBufMarshallerFactory.ContextualDeserialize[T](DeserializationContext context)

This is what GetServerProcessInfoResponse looks like:

[ProtoContract]
public record GetServerProcessInfoResponse(
    [property: ProtoMember(1)] TimeSpan TotalProcessorTime,
    [property: ProtoMember(2)] TimeSpan UserProcessorTime,
    [property: ProtoMember(3)] TimeSpan PrivilegedProcessorTime,
    [property: ProtoMember(4)] string CurrentMemoryUsage,
    [property: ProtoMember(5)] string PeakMemoryUsage,
    [property: ProtoMember(6)] int ActiveThreads
);

The code works fine if I change GetServerProcessInfoResponse into a regular C# class with gettable and settable properties. I was hoping however that records could work too, because they avoid a whole lot of nullability warnings. System.Text.Json has support for deserializing records for example, which has to work against the same limitations.

I couldn't find anything in the docs, issues or here on StackOverflow, so maybe I'm bad at searching, or maybe the answer isn't out there yet. :-)

The protobuf-net repository also doesn't seem to have any unit tests that try to serialize / deserialize a c# record, it only contains "RecordTypeTests" which seems to be checking if records can be cloned?

2 Answers

Yes, in two different ways.

With your existing code, the simplest option is to tell protobuf-net to perform voodoo to get past the fact there is no available constructor; fortunately, this is simple:

[ProtoContract(SkipConstructor = true)]
public record GetServerProcessInfoResponse(
    [property: ProtoMember(1)] TimeSpan TotalProcessorTime,
    [property: ProtoMember(2)] TimeSpan UserProcessorTime,
    [property: ProtoMember(3)] TimeSpan PrivilegedProcessorTime,
    [property: ProtoMember(4)] string CurrentMemoryUsage,
    [property: ProtoMember(5)] string PeakMemoryUsage,
    [property: ProtoMember(6)] int ActiveThreads
);

However! With recent builds of the v3 code, it will also infer what you are trying to do specifically in the case of purely positional records, i.e. the following should work too, and mean more or less the same thing (as a minute detail: it will actually use the constructor in this case, instead of creating an instance via voodoo and then stomping the values):

public record GetServerProcessInfoResponse(
    TimeSpan TotalProcessorTime,
    TimeSpan UserProcessorTime,
    TimeSpan PrivilegedProcessorTime,
    string CurrentMemoryUsage,
    string PeakMemoryUsage,
    int ActiveThreads
);

Records will work, and as comments and Marc's answer here shows, the issue is with the constructor, not that it is a record type.

Record types are just classes, with compiler-generated code and attributes.

To show a different way to get to where you want, you could just add the parameterless constructor. Yes, it's a little bit more work than just the naive record declaration, but if you're stuck with a version of ProtoBuf that doesn't support parameterized constructors, and you don't want to bypass the constructors like Marc showed, here's a different option:

[ProtoContract]
public record GetServerProcessInfoResponse(
    [property: ProtoMember(1)] TimeSpan TotalProcessorTime,
    [property: ProtoMember(2)] TimeSpan UserProcessorTime,
    [property: ProtoMember(3)] TimeSpan PrivilegedProcessorTime,
    [property: ProtoMember(4)] string CurrentMemoryUsage,
    [property: ProtoMember(5)] string PeakMemoryUsage,
    [property: ProtoMember(6)] int ActiveThreads
)
{
    public GetServerProcessInfoResponse() : this(default, default, default, default, default, default) { }
}

This lets you use the basic positional record declaration, but allows you to add the parameterless constructor that ProtoBuf <3 wants.

Related