-
Notifications
You must be signed in to change notification settings - Fork 26
Mapping schema to generated types
Schema enums are mapped to C# enums.
Example schema:
enum TestEnum {
foo @0;
bar @1;
baz @2;
qux @3;
quux @4;
corge @5;
grault @6;
garply @7;
}
Example output:
public enum TestEnum : ushort
{
foo,
bar,
baz,
qux,
quux,
corge,
grault,
garply
}
Every generated C# enum defines ushort
as its base type because Cap'n Proto enums are always encoded with 16 bits.
For a struct definition like this:
struct SomeStruct {
...
}
the generated code looks like this:
public class SomeStruct : ICapnpSerializable
{
public const UInt64 typeId = 0xa0a8f314b80b63fdUL;
void ICapnpSerializable.Deserialize(DeserializerState arg_)
{
...
}
public void serialize(WRITER writer)
{
...
}
public void applyDefaults()
{
...
}
void ICapnpSerializable.Serialize(SerializerState arg_)
{
serialize(arg_.Rewrap<WRITER>());
}
public struct READER
{
readonly DeserializerState ctx;
public READER(DeserializerState ctx)
{
this.ctx = ctx;
}
public static READER create(DeserializerState ctx) => new READER(ctx);
public static implicit operator DeserializerState(READER reader) => reader.ctx;
public static implicit operator READER(DeserializerState ctx) => new READER(ctx);
...
}
public class WRITER : SerializerState
{
...
}
}
SomeStruct
is the domain class. It is independent of any serialization/deserialization context and provides somewhat better usability compared to the reader/writer classes. It is especially used for capability interfaces. However, using it costs some serialization/deserialization overhead compared to using the reader/writer classes directly. The ICapnpSerializable
implementation does exactly that: It is for deserializing from DeserializerState
or serializing to SerializerState
.
The applyDefaults
method sets every members to its schema-defined default value (in case it is specified). This does not happen automatically inside the constructor for performance reasons: Let's say you actually want to initialize the instance from a DeserializerState
. Then, first applying default values would be a waste of time.
The nested READER
struct is for reading a Cap'n Proto message. As opposed to the domain class, it is just a view of raw message data. Hence, it is a very lightweight wrapper around its underlying DeserializerState
. The implicit conversion operators make it even appear equivalent to the DeserializerState
.
The nested WRITER
class is for building a Cap'n Proto message. Again, it is just a view. The WRITER
class inherits from SerializerState
. You may convert any SerializerState s
into any specialization T
by calling s.Rewrap<T>()
(but ensure that the intended conversion actually makes sense).
Unions are generated pretty much like structs, but there are some particularities. Let's consider this example:
struct TestUnion {
union0 :union {
...
}
The generated code looks like this:
public class TestUnion
{
...
public TestUnion.union0 Union0
{
get;
set;
}
...
public class union0 : ICapnpSerializable
{
public const UInt64 typeId = 0xfc76a82eecb7a718UL;
public enum WHICH : ushort
{
....
undefined = 65535
}
private WHICH _which = WHICH.undefined;
private object _content;
public WHICH which
{
get => _which;
set
{
...
}
}
...
public struct READER...
...
public class WRITER : SerializerState
...
}
}
Like a struct, a union produces a domain class, a reader, and a writer. The domain class name starts with a lower-case letter to make it different from the property Union0
, which gives access to the union. There is a union discriminator enum WHICH
and a related property which
. This property is also present in the READER
and WRITER
classes. When you construct a union, the intended usage pattern between domain class and the WRITER
class is slightly different:
- When using the
WRITER
class, you should setwhich
first, then use the member property. This way around, theWRITER
implementation can initialize that property accordingly (might be a pointer). - When using the domain class, you should just set the according member property.
which
is automatically synchronized to the content you just set.
All value-type members of a union are generated as nullable types, simply because they might be undefined. And Void
-type members don't need a C# property.
Again, like a struct, a group produces a domain class, a reader, and a writer. It can be accessed by a property of its embedding type.
Lists are usually represented by .NET's IReadOnlyList<T>
. Hence, a List-typed field produces a C# property of type IReadOnlyList<T>
(or a subtype of it). The details vary across domain classes, readers, and writers. Usually means that there are exceptions:
-
Text
- from a Cap'n Proto view nothing but a List of bytes - is represented by .NETstring
. - List(Void) is simply represented by an Integer (for element count). Because the elements themselves are
Void
, there is not much to say about them. Especially, we don't need indexers, getters, or setters.
The generated code for, let's say list0: List(Struct0)
, looks like this:
public IReadOnlyList<Struct0>? List0
{
get;
set;
}
Thus, you may set the property to object which implements IReadOnlyList<Struct0>
, maybe a List<Struct0>
or an array Struct0[]
. You may also keep/set null
, in which case the field will be serialized as a null pointer.
Generated code looks like this:
public IReadOnlyList<Struct0.READER>? List0 => ...;
So you get a list of readers, and the setter is missing (simply because it's a reader class, not intended for writing).
Generated code looks like this:
public ListOfStructsSerializer<Struct0.WRITER> List0
{
get => BuildPointer<ListOfStructsSerializer<Struct0.WRITER>>(0);
set => Link(0, value);
}
Depending on the list's element type, you may also see a ListOfBitsSerializer
, ListOfCapsSerializer<T>
, ListOfPointersSerializer<T>
, ListOfPrimitivesSerializer<T>
, or ListOfTextSerializer<T>
. Of course, all of them implement the IReadOnlyList<T>
interface. But there's more:
- An
Init(int count)
method initializes the list with a certain element count. Once the list is initialized, there is no way back. The size cannot be changed afterwards. This is because list storage is allocated directly in the underlying message frame. Allowing the size to be changeable would not only add complexity to the allocator: We'd also risk fragmentation, making the frame bigger than necessary. If you want the list to be encoded as null pointer, just never callInit()
. - Most list serializers expose setter methods for modifying the list's elements.
ListOfStructsSerializer<T>
doesn't because a Cap'n Proto List of Structs is always allocated in-place. Hence, the structs already exist with allocation. You may modify their members, but not the structs themselves.
It is important to understand that in 99.99% of all cases you will never need the List0
property setter. The writer object automatically initializes a list serializer, and normally, you would use just that. The setter is for linking a serializer which was allocated independently. This enables advanced use cases where you might want to reuse the same list instance twice.
Let this schema definition be our running example:
struct Foo {
foo @0: UInt8;
cap @1: MyInterface;
}
interface MyInterface {
foo @0 (arg: Text) -> (cap: MyInterface);
bar @1 (arg: Text, cap: MyInterface);
baz @2 () -> (t1: Text, t2: Text);
foobar @3 () -> (result: Foo);
}
The generated code looks like this:
[Proxy(typeof(MyInterface_Proxy)), Skeleton(typeof(MyInterface_Skeleton))]
public interface IMyInterface : IDisposable
{
Task<CapnpGen.IMyInterface> Foo(string arg, CancellationToken cancellationToken_ = default);
Task Bar(string arg, CapnpGen.IMyInterface cap, CancellationToken cancellationToken_ = default);
Task<(string, string)> Baz(CancellationToken cancellationToken_ = default);
Task<CapnpGen.Foo> Foobar(CancellationToken cancellationToken_ = default);
}
public class MyInterface_Proxy : Proxy, IMyInterface
{
...
}
public class MyInterface_Skeleton
{
...
}
public static class MyInterface
{
...
}
public static partial class PipeliningSupportExtensions_test
{
...
}
IMyInterface
serves a two-fold purpose:
- When you provide the capability, implement this interface.
- When you consume the capability, the proxy implements this interface.
IMyInterface
implements IDisposable
to enable proper capability lifecycle management (deserves an extra page).
All methods are conceptually asynchronous, which becomes obvious from the Task
s being returned. Each method accepts an additional CancellationToken
. Any proxy monitors the passed token and sends a FINISH
message to the other party upon cancellation. The other party then attempts to cancel the ongoing operation. A capability implementation is free to evaluate the token and react accordingly to it.
MyInterface_Proxy
and MyInterface_Skeleton
provide the necessary serialization and glue code to make IMyInterface
work in RPC context. You will usually not instantiate those classes directly, but instead rely on the runtime code to handle that for you.
The static class MyInterface
contains parameter and result structs. Again, you'll usually not bother about that. PipeliningSupportExtensions_test
provides extension methods for promise pipeling (which deserves its own page).
Generics are supported for both type definitions (structs, interfaces) and methods. The generator translates them straightforward into C# generics. But they unfold their beauty only in domain classes. The reader and writer classes represent generically-typed fields by a raw DeserializerState
or SerializerState
, respectively. The reason is that C# lacks template metaprogramming. Thus, choosing the right generic type arguments would rather be a brain-twist and probably not behave as expected.
Although Cap'n Proto supports generic method parameters (and so does capnproto-dotnetcore), there is no protocol-built-in way for the callee-side to determine the actual type arguments which were passed. Hence, the runtime will always substitute object
.
Constant definitions are currently ignored, so do not produce any code.