-
Notifications
You must be signed in to change notification settings - Fork 4
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. znet's own
handshake runs on a codec it installs itself and which yours replaces once the
session is ready, so the two never coexist; its packets also sit at the top of
the id space (PacketId(-2) and PacketId(-3)), which is the only range worth
avoiding.
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 |
Write(const 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() clears as it reads, so call it once per thing you want
to check rather than once at the end.
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. 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 element costs at least one byte, so 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.
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.
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_;
};Dispatch is by type, resolved once at construction, so adding packet types does not add per-message branching.
A session's handler can be replaced at any time, which 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.
session->SendPacket(packet);SendPacket queues and returns; it does not block and it does not encode on
your thread. Check the return value. false means the queue was full, which
is how you learn you are producing faster than the link drains. The packet is
still yours, so 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.