Tutorial Defining Protocol Messages - liuli-neko/NekoProtoTools GitHub Wiki
In the Basic Serialization Tutorial, you learned how to serialize and deserialize simple C++ objects using NEKO_SERIALIZER
and direct InputSerializer
/OutputSerializer
instances.
This tutorial introduces the concept of Protocol Messages in NekoProtoTools. By declaring your data structures as "protocols," you unlock several benefits:
-
Protocol Management: Use a
ProtoFactory
to create and manage different message types by name or type ID. -
Polymorphism: Work with different message types through a common interface (
IProto
). - Default Serialization: Associate a default serialization format (e.g., JSON, Binary) with each message type, simplifying serialization/deserialization calls.
- Reflection: Access basic metadata like the protocol name at runtime.
This is particularly useful for network communication where you might send and receive various types of messages.
Make sure you understand:
- Basic C++ structs/classes.
- How to use
NEKO_SERIALIZER
(covered in the basic tutorial and Serializers Interface). - You have NekoProtoTools installed and configured in your project (see Installation).
You'll need these includes for the examples:
#include <nekoproto/proto/proto_base.hpp> // Core header for IProto, ProtoFactory
#include <nekoproto/proto/serializer_base.hpp> // For NEKO_SERIALIZER
#include <nekoproto/proto/json_serializer.hpp> // Choose a default serializer (JSON here)
// Include headers for types used in your message members:
#include <nekoproto/proto/types/string.hpp>
#include <nekoproto/proto/types/vector.hpp> // If using vectors, maps, etc.
#include <iostream>
#include <string>
#include <vector>
Start with a regular C++ struct
or class
that holds the data for your message.
struct UserProfile {
int userId;
std::string username;
std::vector<std::string> roles;
};
struct LoginRequest {
std::string username;
std::string password_hash;
};
Just like before, use NEKO_SERIALIZER
to specify which members should be included.
struct UserProfile {
int userId;
std::string username;
std::vector<std::string> roles;
// Mark members for serialization
NEKO_SERIALIZER(userId, username, roles);
};
struct LoginRequest {
std::string username;
std::string password_hash;
// Mark members for serialization
NEKO_SERIALIZER(username, password_hash);
};
This is the key step to make your struct a managed protocol message. Add the NEKO_DECLARE_PROTOCOL
macro inside your struct/class definition, after NEKO_SERIALIZER
.
Syntax: NEKO_DECLARE_PROTOCOL(YourClassName, DefaultSerializerNamespace)
-
YourClassName
: The name of your struct or class. -
DefaultSerializerNamespace
: The namespace of the serializer to be used by default when callingtoData()
orfromData()
on this protocol type (e.g.,NekoProto::JsonSerializer
,NekoProto::BinarySerializer
).
#include <nekoproto/proto/proto_base.hpp>
#include <nekoproto/proto/json_serializer.hpp> // We choose JSON as the default
#include <nekoproto/proto/types/string.hpp>
#include <nekoproto/proto/types/vector.hpp>
struct UserProfile {
int userId;
std::string username;
std::vector<std::string> roles;
NEKO_SERIALIZER(userId, username, roles);
// Declare UserProfile as a protocol using JsonSerializer by default
NEKO_DECLARE_PROTOCOL(UserProfile, NekoProto::JsonSerializer);
};
struct LoginRequest {
std::string username;
std::string password_hash;
NEKO_SERIALIZER(username, password_hash);
// Declare LoginRequest as a protocol using JsonSerializer by default
NEKO_DECLARE_PROTOCOL(LoginRequest, NekoProto::JsonSerializer);
};
Adding NEKO_DECLARE_PROTOCOL
does several things internally:
- Makes your class inherit from necessary base classes.
- Registers the protocol with the internal registry used by
ProtoFactory
. - Provides static helper functions like
emplaceProto
,createEmptyProto
,protocolName
. - Defines which serializer
toData()
andfromData()
will use by default.
Once declared, you can interact with your protocols through the NekoProto::IProto
interface. IProto
provides a polymorphic way to handle different protocol types.
Creating Instances:
You can create an IProto
wrapper around a new instance using the static emplaceProto
method added by the macro:
using namespace NekoProto;
// Create a UserProfile and wrap it in IProto
// Pass constructor arguments to emplaceProto
IProto user_iproto = UserProfile::emplaceProto(123, "Alice", std::vector<std::string>{"admin", "editor"});
Serialization and Deserialization:
IProto
provides toData()
and fromData()
methods which use the default serializer you specified.
// Serialize using the default serializer (JsonSerializer in this case)
std::vector<char> user_data = user_iproto.toData(); // user_data now contains JSON bytes
std::cout << "Serialized UserProfile: " << std::string(user_data.begin(), user_data.end()) << std::endl;
// --- Deserialization ---
// 1. Create an empty protocol object of the correct type
IProto received_user_iproto = UserProfile::createEmptyProto();
// 2. Deserialize the data into the empty object
if (received_user_iproto.fromData(user_data)) {
std::cout << "Successfully deserialized UserProfile." << std::endl;
// Now received_user_iproto holds the deserialized data
} else {
std::cerr << "Failed to deserialize UserProfile." << std::endl;
}
Accessing the Concrete Type (cast<T>
):
Since IProto
is an interface, you often need to get back a pointer to your original type (UserProfile*
, LoginRequest*
) to access its members directly. Use the cast<T>()
method for safe downcasting. It returns nullptr
if the IProto
doesn't actually hold an object of type T
.
if (received_user_iproto.fromData(user_data)) {
// Safely cast the IProto back to UserProfile*
UserProfile* received_user_ptr = received_user_iproto.cast<UserProfile>();
if (received_user_ptr) {
// Cast successful, access members directly
std::cout << "Deserialized User ID: " << received_user_ptr->userId << std::endl;
std::cout << "Deserialized Username: " << received_user_ptr->username << std::endl;
// Output: Deserialized User ID: 123
// Output: Deserialized Username: Alice
} else {
// This shouldn't happen if fromData succeeded and types match
std::cerr << "Cast to UserProfile failed!" << std::endl;
}
}
The ProtoFactory
is essential when you need to create protocol instances based on runtime information, like a type name or ID received over the network. All types declared with NEKO_DECLARE_PROTOCOL
are automatically registered with the factory.
using namespace NekoProto;
// Create a factory instance. You can optionally specify a version.
ProtoFactory factory(1, 0, 0); // Major=1, Minor=0, Patch=0
// --- Create by Name ---
// Useful when you know the type name (e.g., from network data)
std::string received_type_name = "LoginRequest"; // Example
IProto login_iproto_from_factory = factory.create(received_type_name);
if (login_iproto_from_factory) { // Check if creation succeeded
std::cout << "Created instance of: " << login_iproto_from_factory.name() << std::endl;
// Cast to the expected type to populate it
LoginRequest* login_req_ptr = login_iproto_from_factory.cast<LoginRequest>();
if (login_req_ptr) {
login_req_ptr->username = "Bob";
login_req_ptr->password_hash = "some_secure_hash";
// Serialize it
std::vector<char> login_data = login_iproto_from_factory.toData();
std::cout << "Serialized LoginRequest: " << std::string(login_data.begin(), login_data.end()) << std::endl;
// --- Deserialize using the factory-created instance ---
// Imagine receiving login_data from the network
IProto received_login_iproto = factory.create("LoginRequest"); // Create empty shell by name
if (received_login_iproto && received_login_iproto.fromData(login_data)) {
LoginRequest* received_login_ptr = received_login_iproto.cast<LoginRequest>();
if (received_login_ptr) {
std::cout << "Deserialized Login Username: " << received_login_ptr->username << std::endl;
// Output: Deserialized Login Username: Bob
}
} else {
std::cerr << "Failed to deserialize LoginRequest data." << std::endl;
}
}
} else {
std::cerr << "Failed to create protocol instance for name: " << received_type_name << std::endl;
}
The factory automatically knows about UserProfile
and LoginRequest
because they used NEKO_DECLARE_PROTOCOL
.
#include <nekoproto/proto/proto_base.hpp>
#include <nekoproto/proto/serializer_base.hpp>
#include <nekoproto/proto/json_serializer.hpp> // Using JSON serializer
#include <nekoproto/proto/types/string.hpp>
#include <nekoproto/proto/types/vector.hpp>
#include <iostream>
#include <string>
#include <vector>
// Define protocol message structures
struct UserProfile {
int userId;
std::string username;
std::vector<std::string> roles;
// 1. Declare serialization members
NEKO_SERIALIZER(userId, username, roles);
// 2. Declare as a protocol with a default serializer
NEKO_DECLARE_PROTOCOL(UserProfile, NekoProto::JsonSerializer);
};
struct LoginRequest {
std::string username;
std::string password_hash;
NEKO_SERIALIZER(username, password_hash);
NEKO_DECLARE_PROTOCOL(LoginRequest, NekoProto::JsonSerializer);
};
int main() {
using namespace NekoProto;
// --- Using emplaceProto ---
IProto user_iproto = UserProfile::emplaceProto(123, "Alice", std::vector<std::string>{"admin", "editor"});
std::vector<char> user_data = user_iproto.toData(); // Serialize using default (JSON)
std::cout << "UserProfile Serialized: " << std::string(user_data.begin(), user_data.end()) << std::endl;
// --- Using ProtoFactory ---
ProtoFactory factory(1, 0, 0);
// Create LoginRequest by name
IProto login_iproto = factory.create("LoginRequest");
if (!login_iproto) {
std::cerr << "Failed to create LoginRequest!" << std::endl;
return 1;
}
// Cast and populate
auto login_req = login_iproto.cast<LoginRequest>();
if (login_req) {
login_req->username = "Bob";
login_req->password_hash = "some_hash";
} else {
std::cerr << "Cast to LoginRequest failed!" << std::endl;
return 1;
}
std::vector<char> login_data = login_iproto.toData(); // Serialize using default (JSON)
std::cout << "LoginRequest Serialized: " << std::string(login_data.begin(), login_data.end()) << std::endl;
// --- Deserialization Example (using factory) ---
std::string received_type = "UserProfile"; // Assume this info came from network, etc.
std::vector<char> received_data = user_data; // Use the previously serialized data
IProto received_proto = factory.create(received_type); // Create empty instance by name
if (received_proto && received_proto.fromData(received_data)) { // Deserialize
std::cout << "Successfully deserialized data into " << received_proto.name() << std::endl;
auto received_user = received_proto.cast<UserProfile>(); // Cast back
if (received_user) {
std::cout << "Received User ID: " << received_user->userId << std::endl;
// Output: Received User ID: 123
}
} else {
std::cerr << "Failed to deserialize received data for type: " << received_type << std::endl;
}
return 0;
}
By using NEKO_DECLARE_PROTOCOL
:
- You elevate a simple data structure to a managed Protocol Message.
- You associate a default serializer used by
IProto::toData()
andIProto::fromData()
. - You can work with messages polymorphically via the
IProto
interface. - You can create instances by name using the
ProtoFactory
, crucial for handling varied incoming messages. - You use
cast<T>()
for safe access to the underlying concrete type.
This approach provides a more robust and flexible way to handle complex data exchange compared to basic serialization alone.
- Dive deeper into the factory: Protocol Management (
IProto
&ProtoFactory
) - See how to send/receive these protocols over the network: Communication Layer
- Explore all supported data types: Supported Types & Formats