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:
- Define RPC Services: Easily declare available remote methods and their signatures.
- Implement Servers: Bind C++ functions (including asynchronous ones using Ilias coroutines) to handle RPC requests.
- 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).
- 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
).
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
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;
};
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 theadd
method definition. The signature must match. - Async methods return an
ilias::Task
orilias::IoTask
and useco_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)
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 remoteadd
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.
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;
}
- Save the code (e.g.,
jsonrpc_tutorial.cpp
). - Ensure your
xmake.lua
target includes this file and has the correct NekoProtoTools configuration. - Compile:
xmake build jsonrpc_tutorial
- 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)
...
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.
- 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).