zig-protobuf

repository·master·Indexed 19 days ago

https://github.com/arwalk/zig-protobuf

A Protocol Buffers (proto3) implementation for the Zig programming language. It provides tools for generating Zig code from .proto definitions, including support for service interfaces using a VTable pattern and high-performance, zero-allocation streaming decoding via StreamDecoder.

Tokens
3.4K
Snippets
9
Records
11
Agent score
15%

What's inside zig-protobuf

  1. How service code generation works with the VTable pattern

    master

    zig-protobuf generates service interfaces using a VTable (virtual table) pattern. Instead of a single fixed implementation, the generator produces a function that takes two comptime parameters: UserDataType (your server's context/state) and ErrorSet (the errors your service can return).

    This function returns a struct type containing function pointers for each RPC method defined in your .proto file. This approach provides type safety for your custom state and errors while ensuring zero runtime overhead because polymorphism is resolved at compile time.

    Note: This only generates the service interface. You must implement your own transport layer (e.g., gRPC over HTTP/2) and server logic.

    // The generated function signature pattern
    pub fn MyService(comptime UserDataType: type, comptime ErrorSet: type) type {
        return struct {
            pub const service_name = "MyService";
    
            // Example method signature
            UnaryCall: *const fn(userdata: *UserDataType, request: Request) ErrorSet!Response,
        };
    }
  2. Use StreamDecoder for zero-allocation decoding

    master

    While MyMessage.decode materializes a full message in memory, every generated message also provides a StreamDecoder. This is a zero-allocation pull parser that iterates through a std.Io.Reader one wire field at a time.

    When to use it

    • Large or deeply nested messages.
    • Embedded systems with limited memory.
    • Multiplexed IO where you want to avoid buffering the entire message.

    How it works

    Call next() to receive an Event.

    • Scalars: Returned by value.
    • Oneof: Leaf cases are flattened into their own variants.
    • Length-delimited fields (string, bytes, submessages): Returned as a *std.Io.Reader bound to that field's bytes. You can recurse into these with another StreamDecoder or copy the bytes out.
    • Repeated fields: Emits one event per element.

    Critical Caveat

    Do not copy the StreamDecoder after initialization. The *std.Io.Reader returned for length-delimited fields points back into the decoder instance itself.

    var sd = MyMessage.StreamDecoder.init(&reader);
    while (try sd.next()) |item| switch (item) {
        .some_scalar => |v| { ... },                  // value, by value
        .some_string => |limited| {                   // limited: *std.Io.Reader
            var buf: [64]u8 = undefined;
            const n = try limited.readSliceShort(&buf);
            ...
        },
        .some_submessage => |limited| {               // recurse without allocating
            var inner = SubMessage.StreamDecoder.init(limited);
            while (try inner.next()) |x| switch (x) { ... };
        },
        // repeated fields (packed or not) emit one event per element
        .some_repeated => |v| { ... },
    };
  3. Wrap services with middleware

    master

    The VTable pattern allows you to wrap service implementations to add cross-cutting concerns like authentication, logging, or metrics. You create a function that takes an existing VTable and returns a new VTable where the method pointers call your middleware logic before delegating to the original implementation.

    fn withAuth(
        comptime VTable: type,
        impl: VTable,
        auth_token: []const u8,
    ) VTable {
        return .{
            .UnaryCall = struct {
                fn call(userdata: anytype, request: anytype) anyerror!@TypeOf(request) {
                    if (!isValidToken(auth_token)) return error.Unauthorized;
                    return impl.UnaryCall(userdata, request);
                }
            }.call,
        };
    }
  4. How Service Code Generation works

    master

    For Protocol Buffer service definitions, zig-protobuf generates code using the delegate pattern. This provides a type-safe interface for implementing gRPC-compatible services with custom server contexts.

    Important: The generated code only provides the service interfaces. It does not include a gRPC transport layer; you must implement your own server logic and transport mechanism.

  5. Implement a generated service

    master

    To implement a service, follow these steps:

    1. Define your context: Create a struct (UserDataType) to hold server state like allocators or database connections.
    2. Define your errors: Create an error set (ErrorSet) specific to your service logic.
    3. Instantiate the VTable type: Call the generated service function with your types.
    4. Implement methods: Create functions that match the required signatures and assign them to a VTable instance.
    5. Initialize the VTable: Create an instance of the struct with your implementations.

    Example implementation flow:

    const service = @import("example.pb.zig");
    const std = @import("std");
    
    const MyUserData = struct {
        allocator: std.mem.Allocator,
        connection_id: u64,
    };
    
    const MyErrors = error{
        InvalidRequest,
        ServiceUnavailable,
    };
    
    const MyServiceVTable = service.MyService(MyUserData, MyErrors);
    
    const unaryCallImpl = struct {
        fn call(userdata: *MyUserData, request: service.Request) MyErrors!service.Response {
            return service.Response{ .result = "success" };
        }
    }.call;
    
    const myServiceVTable = MyServiceVTable{
        .UnaryCall = unaryCallImpl,
        // ... other methods
    };
  6. Install zig-protobuf via zig fetch

    master

    To add zig-protobuf to your project, use the zig fetch command to save the dependency to your build.zig.zon file. Use the master branch for current developments compatible with the latest stable Zig release.

    zig fetch --save "git+https://github.com/Arwalk/zig-protobuf#master"
  7. Generate .zig files from .proto definitions

    master

    You can automate the generation of Zig code from .proto files by creating a custom build step in build.zig using protobuf.RunProtocStep. This step can be exposed as a command (e.g., zig build gen-proto).

    Key configuration options for RunProtocStep.create:

    • destination_directory: The LazyPath where generated files will be saved.
    • protoc: (Optional) A LazyPath to a specific protoc binary. If omitted, the library will attempt to download the official Google release.
    • source_files: An array of paths to your .proto files.
    • include_directories: An array of directories to include for imports.
    • preserve_unknown_fields: A boolean (defaults to false) that determines if unknown fields are preserved during binary encode/decode round trips.
    const protobuf = @import("protobuf");
    
    pub fn build(b: *std.Build) !void {
        const protobuf_dep = b.dependency("protobuf", .{
            .target = target,
            .optimize = optimize,
        });
        
        const gen_proto = b.step("gen-proto", "generates zig files from protocol buffer definitions");
    
        const protoc_step = protobuf.RunProtocStep.create(protobuf_dep.builder, target, .{
            .destination_directory = b.path("src/proto"),
            .source_files = &.{ 
                b.path("protocol/all.proto"),
            },
            .include_directories = &.{},
            .preserve_unknown_fields = false,
        });
    
        gen_proto.dependOn(&protoc_step.step);
    }
  8. Configure the protobuf module in build.zig

    master

    After fetching the dependency, you must register it as a module in your build.zig file. This allows you to use @import("protobuf") in your source code. Add the dependency as a module before calling b.installArtifact(exe).

    pub fn build(b: *std.Build) !void {
        // first create a build for the dependency
        const protobuf_dep = b.dependency("protobuf", .{
            .target = target,
            .optimize = optimize,
        });
    
        // and lastly use the dependency as a module
        exe.root_module.addImport("protobuf", protobuf_dep.module("protobuf"));
    }
  9. Implement Client and Server streaming

    master

    Server Streaming

    In a server streaming RPC, you receive a writer_queue. You loop through your data and call try writer_queue.write(response) for each item.

    Client Streaming

    In a client streaming RPC, you receive a reader_queue. You use a while (try reader_queue.read()) |request| loop to process all incoming messages before returning a single final response.

    // Server Streaming Example
    fn serverStreamImpl(
        userdata: *MyUserData,
        request: service.Request,
        writer_queue: *std.Io.Queue(service.Response)
    ) MyErrors!void {
        for (0..10) |i| {
            const response = service.Response{ .result = "item" };
            try writer_queue.write(response);
        }
    }
    
    // Client Streaming Example
    fn clientStreamImpl(
        userdata: *MyUserData,
        reader_queue: *std.Io.Queue(service.Request)
    ) MyErrors!service.Response {
        var count: usize = 0;
        while (try reader_queue.read()) |request| {
            count += 1;
        }
        return service.Response{ .result = "done" };
    }
  10. Use multiple implementations for testing

    master

    Because the service is a VTable generated from comptime parameters, you can instantiate the same service definition with different types. This is useful for swapping production logic with mocks during testing.

    Example: Using a ProdVTable for real database connections and a TestVTable for mock data.

    // Production
    const ProdVTable = service.MyService(ProdUserData, ProdErrors);
    const prodService = ProdVTable{ .UnaryCall = prodImpl };
    
    // Test/Mock
    const TestVTable = service.MyService(TestUserData, TestErrors);
    const testService = TestVTable{ .UnaryCall = testImpl };
  11. RPC streaming patterns and signatures

    master

    The generator supports all four gRPC streaming patterns. For streaming, it uses std.Io.Queue(T) to manage message flow.

    PatternSignature
    Unaryfn(userdata: *UserDataType, request: Request) ErrorSet!Response
    Server streamingfn(userdata: *UserDataType, request: Request, writer_queue: *std.Io.Queue(Response)) ErrorSet!void
    Client streamingfn(userdata: *UserDataType, reader_queue: *std.Io.Queue(Request)) ErrorSet!Response
    Bidirectional streamfn(userdata: *UserDataType, reader_queue: *std.Io.Queue(Request), writer_queue: *std.Io.Queue(Response)) ErrorSet!void

    Using Queues

    • Reader queue (*std.Io.Queue(Request)): Use .read() to receive incoming messages from the client.
    • Writer queue (*std.Io.Queue(Response)): Use .write(message) to send outgoing messages to the client.