-
Notifications
You must be signed in to change notification settings - Fork 5
Packets and Serialization
A message is three pieces: a Packet subclass holding the data, a
PacketSerializer that converts it to and from bytes, and a registration in a
Codec that maps the id to the serializer.
enum : PacketId { kChatMessage = 1 };
class ChatMessage : public Packet {
public:
ChatMessage() : Packet(kChatMessage) {}
std::string author;
std::string text;
};
class ChatMessageSerializer : public PacketSerializer<ChatMessage> {
public:
std::shared_ptr<Buffer> SerializeTyped(
std::shared_ptr<ChatMessage> packet,
std::shared_ptr<Buffer> buffer) override {
buffer->WriteString(packet->author);
buffer->WriteString(packet->text);
return buffer;
}
std::shared_ptr<ChatMessage> DeserializeTyped(
std::shared_ptr<Buffer> buffer) override {
auto packet = std::make_shared<ChatMessage>();
packet->author = buffer->ReadString();
packet->text = buffer->ReadString();
return packet;
}
};
std::shared_ptr<Codec> MakeCodec() {
auto codec = std::make_shared<Codec>();
codec->Add(kChatMessage, std::make_unique<ChatMessageSerializer>());
return codec;
}Read in the same order you wrote. Nothing checks the field layout for you: the codec verifies the length the serializer declared, not its contents.
Ids are yours to assign and must match on both ends. Zero is fine. PacketId is
uint64_t, and znet's own handshake packets sit at the top of that space,
(PacketId)(-2) for the handshake and (PacketId)(-3) for the connection-ready
packet, which is the only range worth avoiding. They never coexist with yours:
the handshake runs on a codec and a handler znet installs itself, and it clears
both before the session becomes ready, so the connected event finds the two
slots empty and fills them with yours.
Until it does, the session has neither, and a session with no codec or no handler drops what arrives: the buffer is decrypted and decompressed, then discarded without a word. Setting both inside the connected event closes that window, since nothing dispatches on the session until the event returns. See Events.
Serializers hold no per-connection state, so build the codec once and hand the
same shared_ptr to every session. Building one per connection works but
allocates a serializer set per client for nothing.
Write into the buffer you were given and return it. The codec has already
written the frame header into that buffer, and Buffer grows on write, so there
is no size to respect and no reason to allocate.
A serializer that already holds the bytes, a cached encoding or a payload being forwarded, may instead return a buffer of its own. The codec copies its readable range in behind the header, so the frame comes out identical either way. That costs one copy, which is why writing in place is still the default.
Returning nullptr refuses the packet: it is dropped, logged, and nothing goes
on the wire.
Buffer is a growable byte buffer with separate read and write cursors, so a
buffer being filled and one being drained use the same type without interfering.
WriteInt<T> / ReadInt<T>
|
Fixed-width integers |
WriteVarInt<T> / ReadVarInt<T>
|
Variable-length, smaller for small values |
WriteString / ReadString
|
Length-prefixed |
WriteBool, WriteFloat, WriteDouble, WriteChar
|
And their Read counterparts |
WriteBitset<N> / ReadBitset<N>
|
std::bitset |
WriteInetAddress / ReadInetAddress
|
Addresses; the read gives a unique_ptr<InetAddress>, null on a family it does not know |
WritePort / ReadPort
|
PortNumber |
Write(const T*, size_t) / Read(T*, size_t)
|
Raw arrays |
WriteVector / ReadVector
|
Count-prefixed, element written by a member you name |
WriteMap / ReadMap
|
Count-prefixed pairs |
WriteArray / ReadArray
|
Count-prefixed, into a unique_ptr<T[]> or a fixed std::array
|
A read past the end sets an error flag and returns a default value instead of throwing. Check it when the buffer came from the network:
auto value = buffer->ReadInt<uint32_t>();
if (buffer->GetAndClearLastError() != BufferError::None) {
return nullptr; // truncated or malformed, refuse it
}GetAndClearLastError() is the only thing that clears the flag; a later
successful read does not. One check after the last read of a DeserializeTyped
therefore catches any failure inside it. The buffer holds one value rather than
a set, so that check names the most recent failure, and checking after each read
is only worth it when you want to know which one.
A build that defines DEBUG asserts when a fixed-width read runs past the end
rather than returning quietly, unless it also defines
DISABLE_ASSERT_READABLE_BYTES. znet defines neither itself.
The codec limits each serializer to its own frame while deserializing, so a
serializer that reads too far hits the limit rather than the next packet, and
readable_bytes() reports what is left of that frame rather than of the whole
buffer. It still returns garbage for that message: the limit protects the
stream, not the message.
Every length in a message is chosen by whoever sent it. A string, vector, map or
array read is refused when the count it claims is larger than the bytes left in
the frame could possibly back: one byte an element at the very least, two for a
map entry since an entry is a key and a value, sizeof(T) for the
unique_ptr<T[]> array. A short packet asking for four billion of them is cheap
to reject, and the error is ReadOutOfBounds. Nothing configures this and
nothing turns it off. The fixed std::array<T, N> overload needs no such rule:
a count that is not exactly N is CorruptedFormat.
On top of that sits a ceiling on the count itself, in case a peer is willing to
pay the bytes. Exceeding it is ReadLimitExceeded.
| Default | ||
|---|---|---|
ZNET_MAX_READ_ELEMENTS |
65536 |
Vector, map and array counts |
ZNET_MAX_READ_STRING_LENGTH |
65536 |
String length in bytes |
Set either to 0 to remove that ceiling, which is reasonable on a trusted link
where a legitimate message really is larger. The bytes-on-hand check still
holds.
A payload is walked frame by frame, and what a bad frame costs depends on whether the framing after it can still be located.
| Unknown packet id | Warns, skips the declared length, carries on with the next frame. Not counted against the session: an id it does not know can be honest version skew |
Serializer returned nullptr
|
Rewinds to the start of the frame, skips its declared length, carries on. Counted |
| Serializer read less than it declared | Warns, skips to the end of the frame, and still delivers the packet. Not counted |
| Serializer read more than it declared | Drops that packet and the rest of the buffer. Counted |
| Unreadable header, or a declared size larger than what is left | Drops the rest of the buffer. Counted |
The session adds what was counted to invalid_frames() and closes once that
reaches max_invalid_frames; dump_on_decode_failure logs the bytes of the
first frame in a payload that fails. Both are in
Configuration Reference.
The under-read is the one worth watching, because it is the one that still delivers: the fields the serializer did not read keep their defaults, so a serializer out of step with the sender hands your handler plausible garbage rather than failing. This is what the version check under Versioning between builds avoids.
PacketHandler takes the handler type and the packet types it accepts, then one
OnPacket overload each:
class GameHandler : public PacketHandler<GameHandler, Ping, Pong> {
public:
explicit GameHandler(std::shared_ptr<PeerSession> session)
: session_(std::move(session)) {}
void OnPacket(std::shared_ptr<Ping> packet) {
(void)packet;
session_->SendPacket(std::make_shared<Pong>());
}
void OnPacket(std::shared_ptr<Pong> packet) { (void)packet; }
private:
std::shared_ptr<PeerSession> session_;
};void OnPacket(const Ping&) is accepted in place of the shared_ptr overload,
and a handler that defines both for one type has both called. Dispatch is one
lookup in a table built once per handler type, so listing more packet types
never makes a single message cost more.
CallbackPacketHandler is the same thing without a class per handler: register
one lambda per packet type with AddShared<T> or AddRef<T>, and a type with
no callback is dropped just the same.
A session's handler can be replaced from its own thread, which is what an
OnPacket or an event gives you, and that is the usual way to model connection
state: a login handler that accepts two packet types, swapped for a gameplay
handler once authenticated. A packet the current handler has no
OnPacket overload for is dropped, so an unauthenticated client cannot reach
gameplay messages.
That drop is silent. An id with no serializer registered on the codec warns, but a packet that deserialized fine and simply found no handler does not, so a handler missing an overload looks exactly like a peer that never sent it. If you are debugging a message that seems not to arrive, check the handler's type list before suspecting the network.
The handler above keeps a shared_ptr to the session it replies on, and the
session owns the handler, so the two keep each other alive and neither is ever
destroyed. znet breaks that cycle for the sessions it owns: a server calls
ReleaseHandler() on a session as it leaves the worker's map, and a client does
the same once its loop has joined. A session you own yourself, one kept past the
object that produced it, is yours to release, from the thread that drives it.
session->SendPacket(packet);SendPacket queues and returns; it does not block and it does not encode on
your thread. Check the return value. It is a Result, and only
Result::Success means the packet was queued. QueueFull is the backpressure
signal, which is how you learn you are producing faster than the link drains;
NotReady means the handshake has not settled yet, NotConnected that the
session is gone, and InvalidArgument that the packet was null. Every refusal
happens before anything is encoded, so the packet is still yours and retrying or
dropping it are both fine.
Queue depth is send_queue_capacity, 512 by default. See
Configuration Reference.
The optional second argument is a SendOptions, which controls delivery.
Its three options, reliable, ordered and channel, are read by ZDT
alone. TCP is a single reliable ordered stream with no channels and ignores
all three, silently. Build the combinations you need once as constants and
reuse them; both that pattern and the options themselves are covered in
Choosing a Transport.
Two builds that disagree about a packet's fields will misread each other, since nothing on the wire describes the layout. The multiversion example shows the usual fix: exchange a version during the handshake, then register a different serializer for the same id depending on what the peer reported.