Getting started (C#)
Getting Started with Bebop in C#
Bebop is a high-performance serialization framework designed for efficient data transfer. This guide will walk you through setting up Bebop in your C# project, creating schemas, and using the generated code.
Supported Runtimes
- .NET Framework 4.7.2
- .NET Framework 4.8
- .NET Core 3.1
- .NET 5+
Installation
First, let’s install the necessary packages:
Package | NuGet Stable | Downloads |
---|---|---|
bebop | ||
bebop-tools |
Install Bebop Runtime
dotnet add package bebop
Install Bebop Compiler Tools
dotnet add package bebop-tools
Project Configuration
To configure Bebop in your project, add the following ItemGroup
to your .csproj
file:
<ItemGroup> <Bebop Include="**/*.bop" OutputDir="./Models/" OutputFile="IpcModels.g.cs" Namespace="YourNamespace.Models" /></ItemGroup>
This configuration tells Bebop to:
- Include all
.bop
files in your project - Generate C# code
- Output the generated code to
./Models/IpcModels.g.cs
- Use the specified namespace for the generated code
Creating Bebop Schemas
Bebop uses its own schema language to define data structures. Create a new file with a .bop
extension (e.g., schemas.bop
) and define your schemas:
struct Person { string name; uint32 age;}
struct Team { string name; Person[] members;}
Generating C# Code
After defining your schemas, the C# code will be automatically generated when you build your project. Any issues encountered during compilation will be displayed in the error list.
Using Generated Code
Now you can use the generated code in your C# project. Here’s an example of how to create and encode a Person
object:
using YourNamespace.Models;using Bebop.Runtime;
// Create a new Person objectvar person = new Person{ Name = "Spike Spiegel", Age = 27};
// Encode the person object to a byte arraybyte[] encoded = BebopSerializer.Encode(person);
// Decode the byte array back to a Person objectPerson decoded = BebopSerializer.Decode<Person>(encoded);
Console.WriteLine(decoded.Name); // Output: Spike SpiegelConsole.WriteLine(decoded.Age); // Output: 27
Using the BebopSerializer
The BebopSerializer
class provides static methods for encoding and decoding Bebop records. Here are some examples:
using Bebop.Runtime;
// Encodingbyte[] encoded = BebopSerializer.Encode(person);byte[] encodedWithCapacity = BebopSerializer.Encode(person, 1024); // With initial capacityImmutableArray<byte> encodedImmutable = BebopSerializer.EncodeImmutably(person);
// DecodingPerson decodedFromArray = BebopSerializer.Decode<Person>(encoded);Person decodedFromSpan = BebopSerializer.Decode<Person>(new ReadOnlySpan<byte>(encoded));Person decodedFromMemory = BebopSerializer.Decode<Person>(new ReadOnlyMemory<byte>(encoded));Person decodedFromImmutable = BebopSerializer.Decode<Person>(encodedImmutable);
Working with Unions
Bebop supports unions, which allow you to define a type that can be one of several possible structures. Here’s an example of how to work with unions in C#.
Defining a Union in Bebop
First, let’s look at how a union is defined in a Bebop schema file (.bop
):
union Person { 1 -> struct John { int32 x; int32 y; } 2 -> struct Doe { int32 age; string name; }}
Using the Generated Union in C#
After generating the C# code from this Bebop schema, you can use the union like this:
using YourNamespace.Models; // Replace with your actual namespaceusing Bebop.Runtime;
// Creating instances of the unionvar john = new Person(new John { X = 10, Y = 20 });var doe = new Person(new Doe { Age = 30, Name = "Jane Doe" });
// Checking the type and accessing fieldsif (john.IsJohn){ Console.WriteLine($"John's coordinates: ({john.AsJohn.X}, {john.AsJohn.Y})");}
if (doe.IsDoe){ Console.WriteLine($"Doe's name: {doe.AsDoe.Name}, Age: {doe.AsDoe.Age}");}
// Using pattern matchingstatic void PrintPerson(Person person){ switch (person.Discriminator) { case 1: var john = person.AsJohn; Console.WriteLine($"John at ({john.X}, {john.Y})"); break; case 2: var doe = person.AsDoe; Console.WriteLine($"Doe named {doe.Name}, aged {doe.Age}"); break; }}
// Encoding and decoding unionsbyte[] encodedJohn = BebopSerializer.Encode(john);Person decodedJohn = BebopSerializer.Decode<Person>(encodedJohn);
// Using the Match method for type-safe handlingstring description = person.Match( john => $"John at ({john.X}, {john.Y})", doe => $"Doe named {doe.Name}, aged {doe.Age}");Console.WriteLine(description);
// Using the Switch method for side effectsperson.Switch( john => Console.WriteLine($"Processing John: ({john.X}, {john.Y})"), doe => Console.WriteLine($"Processing Doe: {doe.Name}, {doe.Age}"));
Key Points About Unions
- The union type (
Person
in this case) has properties likeIsJohn
andIsDoe
to check which variant it currently represents. - Use
AsJohn
andAsDoe
to access the specific fields of each variant. - The
Discriminator
property tells you which variant the union currently represents (1 for John, 2 for Doe in this case). - You can use pattern matching or the
Switch
andMatch
methods for type-safe handling of the union. - Encoding and decoding work seamlessly with unions, just like with regular Bebop structures.
Unions are particularly useful when you need to represent data that can be one of several types, providing a type-safe way to handle different cases in your application.
Advanced Usage: Zero-Allocation Network Writing with EncodeIntoBuffer
For high-performance scenarios, especially when writing to a network stream, you can use the EncodeIntoBuffer
method along with System.Buffers.ArrayPool<T>
to achieve zero-allocation encoding. This approach is particularly useful in situations where you’re sending many objects rapidly and want to avoid any overhead from memory allocations.
Concept: Using EncodeIntoBuffer for Network Writing
Here’s the core concept of how to use EncodeIntoBuffer
with ArrayPool<T>
to write directly to a network stream without allocations:
try{ if (_buffer == null || _buffer.Length < person.MaxByteCount) { if (_buffer != null) ArrayPool<byte>.Shared.Return(_buffer); _buffer = ArrayPool<byte>.Shared.Rent(person.MaxByteCount); }
int bytesWritten = person.EncodeIntoBuffer(_buffer); stream.Write(_buffer, 0, bytesWritten);}finally{ // Ensure the buffer is returned to the pool if an exception occurs // Note: In a real implementation, you'd want to be more careful about when to return the buffer ArrayPool<byte>.Shared.Return(_buffer);}
Key Concepts
-
Use of ArrayPool: We rent a buffer from
ArrayPool<byte>.Shared
instead of allocating a new array each time. -
Sizing with MaxByteCount: We use
person.MaxByteCount
to ensure our buffer is large enough for the current instance of the Person record. This value can vary between instances, especially for records with variable-length fields like strings or arrays. -
Zero-Allocation Encoding:
EncodeIntoBuffer
writes directly into the rented buffer, avoiding additional allocations. -
Direct Stream Writing: We write the exact number of bytes used in encoding directly to the stream.
-
Resource Management: The
finally
block ensures that the buffer is always returned to the pool, even if an exception occurs.
This pattern allows for efficient, zero-allocation serialization and network writing, which can significantly improve performance in high-throughput scenarios. It adapts to the specific size requirements of each record instance, ensuring optimal buffer usage.