Need help and Suggestions Idiomatic Way of serialize/deserialize json accross the network TCP

Hi everyone,

TLDR; What’s the idiomatic way to deserialize json structs in Zig, when the type to be deserialized into is only known at runtime ?

Full Context

Warning : I’m not asking for a full code solution, as this is a school project, I just want some advice and suggestions on patterns, or critiques about the design decisions I’ve made.

For some context, I’m working on a group project at school, where we have to implement a single page website frontend + backend + db + pong. The project is modular, meaning you pick and choose some “Features” and with enough module, the project is valid. I picked the server side pong module.

I’ve chosen to implement this server side pong in Zig, One because I like it, second because I want it to be fast, and reliable, and lastly because it has good support for json, and my mates work with languages that also have strong support for json (python, js, etc…).

The way I’ve designed it, is I think relatively straightforward. The Frontend wants to play a game, it querry the backend, the backend then do it’s thing, and when everything is ok. It will launch my server with some cli flags, describing the kind of game this is (local vs ai, local vs local, remove vs remote), and some more options.

The server creates a socket (non blocking), bind listen, poll yadi yada. then it polls for events, accepts incoming connections, and store them/register them to poll.

My initial idea was that Clients management and communication would be state based. accepted → connected → authentificated → playing → … → done.

each of this state correspond to a struct whose definition is shared for both the client and the server. The client is expected to send a Request, the server reads the client until it finds the Protocol Delimiter. then it goes to json to be reinterpreted and the appropriate Response is sent back. The client does the same things and me transition the state accordingly.

The problem I have is with the Reification. It’s extremely tedious, and hard to reason about (current implementation before my refactor was 4k loc). Because Zig doesn’t have builtin Runtime Polymorphism, You have to implement it yourself.

Stripped down version of the refactor
const blt = @import("builtin");
const std = @import("std");
const json = std.json;
const Protocol = @This();

const DebugFmt: json.StringifyOptions = .{ .whitespace = .indent_4 };
pub const Delimiter: []const u8 = &[_]u8{0x1E};
pub const Formatting = if (blt.mode == .Debug) DebugFmt else .{};

pub const Host = enum { none, server, client };
pub const Message = enum { none, auth, update, signal };
pub const Status = enum { none, ok, err, later, confirm };

pub const Header = struct {
    host: Host = .none,
    tag: Message = .none,
    status: Status = .none,
    timestamp: i64 = 0,

    pub const init: Header = .{
        .host = .none,
        .tag = .none,
        .status = .none,
        .timestamp = 0,
    };

    pub fn format(
        self: @This(),
        comptime fmt: []const u8,
        options: std.fmt.FormatOptions,
        writer: anytype,
    ) !void {
        _ = fmt;
        _ = options;
        try json.stringify(self, Formatting, writer);
    }
};

pub const Authentification = struct {
    pub const Request = struct {
        header: Header = .init,
        pass: []const u8 = "test",

        pub fn format(
            self: @This(),
            comptime fmt: []const u8,
            options: std.fmt.FormatOptions,
            writer: anytype,
        ) !void {
            _ = fmt;
            _ = options;
            try json.stringify(self, Formatting, writer);
        }
    };

    pub const Response = struct {
        header: Header = .init,
        token_id: u64 = 0xBAAAAAAB,

        pub fn format(
            self: @This(),
            comptime fmt: []const u8,
            options: std.fmt.FormatOptions,
            writer: anytype,
        ) !void {
            _ = fmt;
            _ = options;
            try json.stringify(self, Formatting, writer);
        }
    };

    pub const testing_request: Request = .{
        .header = .init,
        .pass = "test",
    };

    pub const testing_response: Response = .{
        .header = .init,
        .token_id = 0xBAAAAAAB,
    };
};

Stripped down version of the protocol
const blt = @import("builtin");
const std = @import("std");
const json = std.json;
const Protocol = @This();

const DebugFmt: json.StringifyOptions = .{ .whitespace = .indent_4 };
pub const Delimiter: []const u8 = &[_]u8{0x1E};
pub const Formatting = if (blt.mode == .Debug) DebugFmt else .{};

pub const Host = enum { none, server, client };
pub const Message = enum { none, auth, update, signal };
pub const Status = enum { none, ok, err, later, confirm };

pub const Header = struct {
    host: Host = .none,
    tag: Message = .none,
    status: Status = .none,
    timestamp: i64 = 0,

    pub const init: Header = .{
        .host = .none,
        .tag = .none,
        .status = .none,
        .timestamp = 0,
    };

    pub fn format(
        self: @This(),
        comptime fmt: []const u8,
        options: std.fmt.FormatOptions,
        writer: anytype,
    ) !void {
        _ = fmt;
        _ = options;
        try json.stringify(self, Formatting, writer);
    }
};

pub const Authentification = struct {
    pub const Request = struct {
        header: Header = .init,
        pass: []const u8 = "test",

        pub fn format(
            self: @This(),
            comptime fmt: []const u8,
            options: std.fmt.FormatOptions,
            writer: anytype,
        ) !void {
            _ = fmt;
            _ = options;
            try json.stringify(self, Formatting, writer);
        }
    };

    pub const Response = struct {
        header: Header = .init,
        token_id: u64 = 0xBAAAAAAB,

        pub fn format(
            self: @This(),
            comptime fmt: []const u8,
            options: std.fmt.FormatOptions,
            writer: anytype,
        ) !void {
            _ = fmt;
            _ = options;
            try json.stringify(self, Formatting, writer);
        }
    };

    pub const testing_request: Request = .{
        .header = .init,
        .pass = "test",
    };

    pub const testing_response: Response = .{
        .header = .init,
        .token_id = 0xBAAAAAAB,
    };
};

Ok the best I’ve found is to have something like that.

        pub fn jsonDeserialize(self: *Request, comptime T: type, into: *T) !void {
            const allocator = self.arena.allocator();
            const slice = self.buffer.items;
            const delimiter = mem.indexOf(u8, slice, root.protocol.Delimiter);

            if (delimiter) |index| {
                const json_object = slice[0..index];
                into.* = try json.parseFromSliceLeaky(T, allocator, json_object, .{});
            }
        }

it seems to work fine. But if anyone has a better suggestion feel free.

the json parse api doesn’t document this for some reason, but if you have this

pub fn jsonParse(a: Allocator, src: anytype, opt: json.ParseOptions)json.ParseError(@TypeOf(source.*))!@This()

on your type than the normal json parse api will use it,
the allocator is an arena, the source is either a json.Reader or a json.Scanner, better to look at Scanner.Token it has better docs for the api

and options are to configure the behaviour of your parsing, you can ignore them if you’re not making a library and don’t need slightly different behaviour sometimes.

the to serialise there is

pub fn jsonStringify(self: @This(), jw: anytype) !void

jw is json.WriteStream which actually does document this except its easy to miss

2 Likes

Thanks for your answer this helps a lot, I’ve managed to get it to work, so now I’m able to send and receive some json structs over the network in non blocking mode both ways. This multiplayer thing is really tough, the solution you provided helps make it easier. I’ll dig into your suggestion

1 Like