Skip to content

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:

PackageNuGet StableDownloads
bebopbebopbebop
bebop-toolsbebop-toolsbebop-tools

Install Bebop Runtime

Terminal window
dotnet add package bebop

Install Bebop Compiler Tools

Terminal window
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 object
var person = new Person
{
Name = "Spike Spiegel",
Age = 27
};
// Encode the person object to a byte array
byte[] encoded = BebopSerializer.Encode(person);
// Decode the byte array back to a Person object
Person decoded = BebopSerializer.Decode<Person>(encoded);
Console.WriteLine(decoded.Name); // Output: Spike Spiegel
Console.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;
// Encoding
byte[] encoded = BebopSerializer.Encode(person);
byte[] encodedWithCapacity = BebopSerializer.Encode(person, 1024); // With initial capacity
ImmutableArray<byte> encodedImmutable = BebopSerializer.EncodeImmutably(person);
// Decoding
Person 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 namespace
using Bebop.Runtime;
// Creating instances of the union
var 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 fields
if (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 matching
static 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 unions
byte[] encodedJohn = BebopSerializer.Encode(john);
Person decodedJohn = BebopSerializer.Decode<Person>(encodedJohn);
// Using the Match method for type-safe handling
string 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 effects
person.Switch(
john => Console.WriteLine($"Processing John: ({john.X}, {john.Y})"),
doe => Console.WriteLine($"Processing Doe: {doe.Name}, {doe.Age}")
);

Key Points About Unions

  1. The union type (Person in this case) has properties like IsJohn and IsDoe to check which variant it currently represents.
  2. Use AsJohn and AsDoe to access the specific fields of each variant.
  3. The Discriminator property tells you which variant the union currently represents (1 for John, 2 for Doe in this case).
  4. You can use pattern matching or the Switch and Match methods for type-safe handling of the union.
  5. 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

  1. Use of ArrayPool: We rent a buffer from ArrayPool<byte>.Shared instead of allocating a new array each time.

  2. 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.

  3. Zero-Allocation Encoding: EncodeIntoBuffer writes directly into the rented buffer, avoiding additional allocations.

  4. Direct Stream Writing: We write the exact number of bytes used in encoding directly to the stream.

  5. 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.