Tutorial Implementing JSON RPC - liuli-neko/NekoProtoTools GitHub Wiki

This tutorial guides you through creating a simple client-server application using JSON-RPC 2.0 powered by NekoProtoTools. JSON-RPC is a lightweight protocol for executing procedures (functions/methods) on a remote system.

NekoProtoTools provides a high-level, template-based system to:

  1. Define RPC Services: Easily declare available remote methods and their signatures.
  2. Implement Servers: Bind C++ functions (including asynchronous ones using Ilias coroutines) to handle RPC requests.
  3. Implement Clients: Call remote methods almost like calling local functions.

The library handles the underlying JSON formatting, request/response matching, and network transport (typically TCP, using the ilias).

Prerequisites

  • Familiarity with asynchronous programming using C++ coroutines (co_await, ilias::Task).
  • NekoProtoTools installed with JSON-RPC and communication features enabled in xmake.lua:
    add_requires("neko-proto-tools", {
        configs = {
            -- Must be true for JSON-RPC
            enable_jsonrpc = true,
            -- JSON-RPC uses JSON, so enable a backend
            enable_rapidjson = true, -- Or enable_simdjson = true
            -- Optional, but helpful for logging, if has std::format can use default
            enable_fmt = true
        }
    })
  • The Ilias library is required (xmake handles this if enable_jsonrpc = true).

Includes

You'll need headers for JSON-RPC, any necessary NekoProtoTools types used as parameters/return values (like string, vector), and Ilias components.

// NekoProtoTools JSON-RPC
#include <nekoproto/jsonrpc/jsonrpc.hpp>
// Include NekoProtoTools type support for any types used in RPC methods
#include <nekoproto/proto/types/vector.hpp>   // For std::vector example
#include <nekoproto/proto/types/string.hpp>  // For std::string example

// Ilias Library
#include <ilias/platform.hpp>  // For PlatformContext
#include <ilias/task.hpp>        // For ilias::Task, ilias::IoTask, co_await, ilias_go

// Standard Library
#include <numeric> // For std::accumulate in example
#include <iostream>
#include <vector>
#include <string>
#include <cstdint> // For int types if needed

Step 1: Define the RPC Module and Methods

An RPC "Module" is a C++ struct that groups related RPC methods. Each method is declared using NekoProto::RpcMethod.

Syntax: RpcMethod<FunctionSignature, "json_rpc_method_name" [, json_rpc_params_name...]> member_name;

  • FunctionSignature: The C++ function signature (e.g., int(int, int), std::string(std::string), ilias::Task<double>(double)).
  • "json_rpc_method_name": The literal string name used to identify this method in the JSON-RPC protocol messages.
  • [json_rpc_params_name...]: Optional, names for the JSON-RPC parameters. If omitted, the parameter will be unnamed parameters in the JSON-RPC request. if provided, the number of names must match the number of parameters in the function signature.
  • member_name: The C++ variable name you'll use to access this method definition within the module struct.

Important: All parameter types and return types in the FunctionSignature must be serializable by the JSON serializer. This means they should either be basic types, supported STL containers (like std::vector, std::string, std::map), or custom structs marked with NEKO_SERIALIZER for which type support headers are included.

Let's define a simple CalculatorModule:

NEKO_USE_NAMESPACE // Use NekoProto namespace
using namespace ILIAS_NAMESPACE; // Use Ilias namespace

// Define the structure grouping our RPC methods
struct CalculatorModule {
    // Method "add": Takes two integers, returns an integer.
    RpcMethod<int(int, int), "add", "num1", "num2"> add;

    // Method "subtract": Takes two integers, returns an integer.
    RpcMethod<int(int, int), "subtract", "num1", "num2"> subtract;

    // Method "sum": Takes a vector of integers, returns an integer.
    // The implementation will be asynchronous (returning ilias::IoTask<int>).
    RpcMethod<int(std::vector<int>), "sum", "nums"> sum;

    // Method "greet": Takes a string, returns a string.
    RpcMethod<std::string(std::string), "greet"> greet;
};

Step 2: Implement the Server Logic

The server creates an instance of JsonRpcServer, binds implementations to the methods defined in the module, starts listening, and waits for requests.

// Server logic as an Ilias coroutine Task
ilias::Task<> run_server(PlatformContext& context) {
    try {
        // Create a JSON-RPC server templated on our module type
        JsonRpcServer<CalculatorModule> rpc_server;

        std::cout << "Server: Binding RPC methods..." << std::endl;

        // --- Bind Implementations ---
        // Use lambdas, function pointers, or std::function.
        // The signature must match the RpcMethod definition.

        // Bind 'add' method
        rpc_server->add = [](int a, int b) -> int {
            std::cout << "Server: add(" << a << ", " << b << ") called." << std::endl;
            return a + b;
        };

        // Bind 'subtract' method
        rpc_server->subtract = [](int a, int b) {
            std::cout << "Server: subtract(" << a << ", " << b << ") called." << std::endl;
            return a - b;
        };

        // Bind 'sum' method (Asynchronous Implementation)
        // The lambda returns an ilias::IoTask<int> matching the expected return type
        // for async operations within the Ilias context.
        rpc_server->sum = [](std::vector<int> vec) -> ilias::IoTask<int> {
            std::cout << "Server: sum(...) called asynchronously." << std::endl;
            // Perform potentially long-running work here...
            // (In this case, it's fast, but demonstrates the concept)
            int result = std::accumulate(vec.begin(), vec.end(), 0);
            // You could 'co_await' other async operations inside here if needed.
            co_return result; // Use co_return for async task results
        };

        // Bind 'greet' method
        rpc_server->greet = [](std::string name) -> std::string {
            std::cout << "Server: greet(\"" << name << "\") called." << std::endl;
            return "Hello, " + name + "!";
        };

        // Start the server listening on a specific address and port
        // The address scheme (e.g., "tcp://") determines the transport.
        std::string listen_address = "tcp://127.0.0.1:12335";
        std::cout << "Server: Starting on " << listen_address << "..." << std::endl;
        auto start_result = co_await rpc_server.start(listen_address);
        if (!start_result) {
            std::cerr << "Server failed to start: " << start_result.error().message() << std::endl;
            co_return;
        }
        std::cout << "RPC Server listening on " << listen_address << std::endl;
        std::cout << "Server: Built-in methods available (e.g., rpc.get_method_list)." << std::endl;

        // Keep the server running until stopped (e.g., by Ctrl+C or another signal)
        co_await rpc_server.wait();
        std::cout << "RPC Server stopped." << std::endl;

    } catch (const std::exception& e) {
        std::cerr << "Server error: " << e.what() << std::endl;
    }
}

Key Points (Server):

  • JsonRpcServer<CalculatorModule>: Creates the server instance tied to your defined methods.
  • rpc_server->add = ...: Binds a callable (lambda here) to the add method definition. The signature must match.
  • Async methods return an ilias::Task or ilias::IoTask and use co_return to yield the result.
  • co_await rpc_server.start(address): Starts the underlying listener (TCP in this case).
  • co_await rpc_server.wait(): enter the coroutine loop until the server is shut down. if you want to do another task, you can enter this coroutine loop anywhere.
  • Built-in methods (rpc.get_method_list, rpc.get_method_info, etc.) are automatically handled by the server for introspection.
  • Note: must don't definition method with rpc prefix, because it is reserved for built-in methods.(e.g., JsonRPC2.0 specification)

Step 3: Implement the Client Logic

The client creates an instance of JsonRpcClient, connects to the server, and calls remote methods using the -> operator on the client object.

// Client logic as an Ilias coroutine Task
ilias::Task<> run_client(PlatformContext& context) {
    try {
        // Create a JSON-RPC client templated on the *same* module type
        JsonRpcClient<CalculatorModule> rpc_client;

        // Connect to the server address
        std::string server_address = "tcp://127.0.0.1:12335";
        std::cout << "Client: Connecting to " << server_address << "..." << std::endl;
        auto connect_result = co_await rpc_client.connect(server_address);
        if (!connect_result) {
            std::cerr << "Client failed to connect: " << connect_result.error().message() << std::endl;
            co_return;
        }
        std::cout << "Client connected to " << server_address << std::endl;

        // --- Call Remote Methods ---
        // Use 'co_await rpc_client->method_name(args...)'
        // The return type is ilias::Result<ExpectedReturnType>

        // Call 'add'
        std::cout << "Client: Calling add(10, 5)..." << std::endl;
        auto add_result = co_await rpc_client->add(10, 5);
        if (add_result) { // Check if the call was successful (no transport or RPC error)
            std::cout << "Client: add(10, 5) = " << add_result.value() << std::endl; // Access result with .value()
        } else {
            // Error occurred (e.g., connection closed, server returned JSON-RPC error)
            std::cerr << "Client: 'add' call failed: " << add_result.error().message() << std::endl;
        }

        // Call 'sum' (which is implemented asynchronously on the server)
        std::cout << "Client: Calling sum({1, 2, 3, 4, 5})..." << std::endl;
        std::vector<int> nums = {1, 2, 3, 4, 5};
        auto sum_result = co_await rpc_client->sum(nums);
        if (sum_result) {
            std::cout << "Client: sum({1,2,3,4,5}) = " << sum_result.value() << std::endl;
        } else {
            std::cerr << "Client: 'sum' call failed: " << sum_result.error().message() << std::endl;
        }

        // Call 'greet'
        std::cout << "Client: Calling greet(\"Neko\")..." << std::endl;
        auto greet_result = co_await rpc_client->greet("Neko");
        if (greet_result) {
            std::cout << "Client: greet(\"Neko\") = \"" << greet_result.value() << "\"" << std::endl;
        } else {
            std::cerr << "Client: 'greet' call failed: " << greet_result.error().message() << std::endl;
        }

        // --- Calling a non-existent method (Example of compile-time safety) ---
        // If you uncomment the following line, it will *fail to compile* because
        // 'multiply' is not defined in CalculatorModule.
        // auto multiply_result = co_await rpc_client->multiply(2, 3);

        // Close the connection
        rpc_client.close();
        std::cout << "Client disconnected." << std::endl;

    } catch (const std::exception& e) {
        std::cerr << "Client error: " << e.what() << std::endl;
    }
}

Key Points (Client):

  • JsonRpcClient<CalculatorModule>: Creates the client instance, also tied to the module definition. This provides type safety.
  • co_await rpc_client.connect(address): Establishes the connection.
  • co_await rpc_client->add(10, 5): Calls the remote add method. This looks like a local call but performs the JSON-RPC request/response cycle over the network.
  • The return value is ilias::Result<T>, which encapsulates either the successful result (.value()) or an error (.error()). Always check the result before accessing .value().
  • Trying to call a method not defined in the CalculatorModule struct (rpc_client->multiply(...)) results in a compile-time error, preventing typos and mismatches.
  • rpc_client.close(): Disconnects from the server.

Step 4: Run the Client and Server

The main function sets up the Ilias PlatformContext and launches the server and client tasks.

int main() {
    PlatformContext context; // Ilias event loop and execution context

    std::cout << "Starting JSON-RPC server and client tasks..." << std::endl;

    // Launch the server coroutine
    ilias_go run_server(context);

    // Launch the client coroutine (give server a moment to start)
    // In a real app, you might add retry logic or wait for a signal
    ilias_go run_client(context);

    // Run the Ilias event loop until all tasks complete
    context.run();

    std::cout << "All tasks finished." << std::endl;
    // Note: In this simple example, the server might not shut down gracefully
    // unless interrupted (Ctrl+C). Real applications need proper shutdown mechanisms.
    return 0;
}

Compile and Run

  1. Save the code (e.g., jsonrpc_tutorial.cpp).
  2. Ensure your xmake.lua target includes this file and has the correct NekoProtoTools configuration.
  3. Compile: xmake build jsonrpc_tutorial
  4. Run: xmake run jsonrpc_tutorial

You should see output reflecting the server starting, client connecting, method calls being logged on the server, and results printed by the client:

Starting JSON-RPC server and client tasks...
Server: Binding RPC methods...
Server: Starting on tcp://127.0.0.1:12335...
Client: Connecting to tcp://127.0.0.1:12335...
RPC Server listening on tcp://127.0.0.1:12335
Server: Built-in methods available (e.g., rpc.get_method_list).
Client connected to tcp://127.0.0.1:12335
Client: Calling add(10, 5)...
Server: add(10, 5) called.
Client: add(10, 5) = 15
Client: Calling sum({1, 2, 3, 4, 5})...
Server: sum(...) called asynchronously.
Client: sum({1,2,3,4,5}) = 15
Client: Calling greet("Neko")...
Server: greet("Neko") called.
Client: greet("Neko") = "Hello, Neko!"
Client disconnected.
(Server likely still running until interrupted)
...

Summary

NekoProtoTools' JSON-RPC implementation offers a type-safe and relatively simple way to build RPC services in C++. By defining a module struct with RpcMethod, you clearly specify the service interface. The JsonRpcServer and JsonRpcClient handle the protocol details, allowing you to focus on implementing the server logic and calling remote methods on the client-side, leveraging the power of Ilias coroutines for asynchronous operations.

Next Steps

  • Explore using custom structs (marked with NEKO_SERIALIZER) as parameters or return values in your RPC methods.
  • Investigate error handling strategies for RPC calls.
  • Learn about the built-in server methods (rpc.get_method_list etc.) for service discovery.
  • Look into securing your RPC communication (e.g., using TLS, potentially via an Ilias stream wrapper if available).
⚠️ **GitHub.com Fallback** ⚠️