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.

Prerequisites

Make sure you understand:

  1. Basic C++ structs/classes.
  2. How to use NEKO_SERIALIZER (covered in the basic tutorial and Serializers Interface).
  3. 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>

Step 1: Define Your Data Structure

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;
};

Step 2: Mark Serializable Members

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);
};

Step 3: Declare as a Protocol (NEKO_DECLARE_PROTOCOL)

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 calling toData() or fromData() 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() and fromData() will use by default.

Step 4: Working with IProto

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;
    }
}

Step 5: Using the ProtoFactory

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.

Complete Example

#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;
}

Summary

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() and IProto::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.

Next Steps

⚠️ **GitHub.com Fallback** ⚠️