JSON-RPC 심플 구현

C++을 사용하여 최대한 간단하게 자체 JSON-RPC를 심플하게 구현해 보았다.

머릿말

RPC(Remote Procedure Call)는 두 프로세스 간의 함수 호출을 뜻하는데, 소켓 통신과 같은 IPC(Inter-Process Communication)를 이용하여 구현할 수 있다.
예전에 JSON-RPC 정리 글에서 JSON-RPC를 소개하고, C/C++로 오픈소스 패키지를 이용한 JSON-RPC 사용법을 간단히 다루었다.
이번 글에서는 단순한 파라미터로 함수 몇 개만 호출하는 경우에 적합한 용도로, JSON-RPC 외부 패키지를 사용하지 않고, JSON 라이브러리만 사용하여 간단하게 JSON-RPC를 직접 구현해 보았다.

여기서는 AF_INET 소켓을 이용하여 JSON RPC를 구현하였다. AF_INET 소켓은 포트를 이용하므로, 시스템이 사용 중이지 않은 포트를 사용해야 한다. 만약에 AF_UNIX 소켓을 사용하려는 경우에는 포트 대신에 소켓 파일을 사용해야 하는데, 이 방법은 AF_INET 소켓을 이용하는 예제 이후에, 필요한 변경 사항만 간략히 언급하겠다.

JSON 라이브러리

C++ 코드에서 JSON을 사용하기 위하여 JsonCpp 라이브러리를 이용할 것이므로, 아래와 같이 소스를 받아서 빌드한 후에, 시스템에 설치한다.

$ git clone https://github.com/open-source-parsers/jsoncpp.git
$ cmake -B build
$ cd build/
$ make
$ sudo make install

이후 정상적으로 잘 설치되었는지 아래와 같이 확인해 볼 수 있다.

$ pkg-config --cflags --libs jsoncpp
-I/usr/local/include -L/usr/local/lib -ljsoncpp

Makefile 파일

아래와 같이 Makefile 파일을 작성한다. 즉, make 명령을 실행하면 예제 RPC client, server 프로그램이 빌드된다.

CXX = g++
CXXFLAGS = -Wall
LDFLAGS = -ljsoncpp

TARGET_CLIENT = client
TARGET_SERVER = server

CLIENT_SRCS = Client.cpp JsonRpc.cpp
CLIENT_OBJS = $(CLIENT_SRCS:%.c=%.o)

SERVER_SRCS = Server.cpp JsonRpc.cpp
SERVER_OBJS = $(SERVER_SRCS:%.c=%.o)

all: $(TARGET_CLIENT) $(TARGET_SERVER)

$(TARGET_CLIENT): $(CLIENT_OBJS)
	$(CXX) -o $@ $^ $(LDFLAGS)

$(TARGET_SERVER): $(SERVER_OBJS)
	$(CXX) -o $@ $^ $(LDFLAGS)

%.o: %.cpp
	$(CXX) $(CXXFLAGS) -o $@ -c $<

clean:
	@rm -rf *.o $(TARGET_CLIENT) $(TARGET_SERVER)

RPC server 헤더 파일

RPC server와 client가 공통으로 사용하는 JsonRpc.h 헤더 파일을 아래와 같이 작성한다. (예로 12345 포트 번호 사용)

#pragma once

#include <json/json.h>

#define PORT    12345

bool SendJsonRpc(int fd, const Json::Value &root);
bool RecvJsonRpc(int fd, Json::Value &outRoot);

JSON-RPC 구현

RPC server와 client가 공통으로 사용하는 JsonRpc.cpp 파일을 아래와 같이 작성한다.
JSON-RPC 수신은 입력 소켓 파일 디스크립터를 read 해서 JSON 데이터로 파싱하고, JSON-RPC 송신은 입력 JSON 데이터를 입력 소켓 파일 디스크립터로 write 한다.

#include <arpa/inet.h>
#include <unistd.h>

#include <json/json.h>

bool RecvJsonRpc(int fd, Json::Value &outRoot) {
    Json::Reader reader;
    uint32_t payloadLen = 0;

    if (read(fd, &payloadLen, sizeof(payloadLen)) != sizeof(payloadLen)) {
        return false;
    }

    uint32_t len = ntohl(payloadLen);
    std::string payload(len, '\0');
    if (read(fd, &payload[0], len) != (size_t)len) {
        return false;
    }
    if (!reader.parse(payload, outRoot, false)) {
        return false;
    }
    return true;
}

bool SendJsonRpc(int fd, const Json::Value &root) {
    Json::FastWriter w;
    std::string payload = w.write(root);
    uint32_t len = htonl(static_cast<uint32_t>(payload.size()));

    if (len == 0) {
        return false;
    }
    if (write(fd, &len, sizeof(len)) != sizeof(len)) {
        return false;
    }
    if (write(fd, payload.data(), payload.size()) != payload.size()) {
        return false;
    }
    return true;
}

RPC server

RPC server 프로그램으로 Server.cpp 파일을 아래와 같이 작성한다.
이 server 프로그램은 AF_INET 소켓을 생성한 후에 소켓으로 들어온 입력 JSON 데이터를 파싱하여 해당 메쏘드를 처리하고, 그 처리 결과를 JSON 데이터로 만들어서 다시 client로 보낸다.
아래에서는 메쏘드 예로 “add”, “multiply”, “hello”를 구현하였다. (여기서 “hello” 메쏘드는 의도적으로 response가 없도록 구현하였다)

#include <arpa/inet.h>
#include <stdio.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <unistd.h>

#include "JsonRpc.h"

static Json::Value ProcessMethod(const Json::Value &req) {
    Json::Value resp(Json::objectValue);

    if (!req.isMember("method") || !req["method"].isString()) {
        resp["ok"] = false;
        resp["error"] = "no_method";
        return resp;
    }
    const std::string method = req["method"].asString();
    const Json::Value params = req.get("params", Json::Value(Json::objectValue));

    if (method == "add") {
        int arg1 = params.get("arg1", 0).asInt();
        int arg2 = params.get("arg2", 0).asInt();
        printf("server: method=%s, arg1=%d, arg2=%d\n", method.c_str(), arg1, arg2);
        resp["result"] = arg1 + arg2;
        resp["ok"] = true;
        return resp;
    } else if (method == "multiply") {
        int arg1 = params.get("arg1", 0).asInt();
        int arg2 = params.get("arg2", 0).asInt();
        printf("server: method=%s, arg1=%d, arg2=%d\n", method.c_str(), arg1, arg2);
        resp["result"] = arg1 * arg2;
        resp["ok"] = true;
        return resp;
    } else if (method == "hello") {
        std::string msg = params.asString();
        printf("server: method=%s, msg=%s\n", method.c_str(), msg.c_str());
    }

    resp["ok"] = false;
    resp["error"] = "unknown_method";
    return resp;
}

int JsonRpcServer() {
    struct sockaddr_in addr;

    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) {
        printf("%s(): Failed to create socket\n", __func__);
        return -1;
    }

    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);
    if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        printf("%s(): Failed to bind\n", __func__);
        close(server_fd);
        return -1;
    }

    if (listen(server_fd, 1) < 0) {
        printf("%s(): Failed to listen\n", __func__);
        close(server_fd);
        return -1;
    }

    int client_fd = accept(server_fd, nullptr, nullptr);
    if (client_fd < 0) {
        printf("%s(): Failed to accept\n", __func__);
        close(server_fd);
        return -1;
    }

    for (;;) {
        Json::Value req, resp;
        if (!RecvJsonRpc(client_fd, req)) {
            break;
        }
        resp = ProcessMethod(req);
        if (!SendJsonRpc(client_fd, resp)) {
            printf("%s(): Failed to send JSON RPC\n", __func__);
            break;
        }
    }

    close(client_fd);
    close(server_fd);
    return 0;
}

int main() {
    JsonRpcServer();

    return 0;
}

RPC client

RPC client 프로그램으로 Client.cpp 파일을 아래와 같이 작성한다.
이 client 프로그램은 AF_INET 소켓을 사용하여 RPC server에 연결한 후에, “add”와 “multiply” 메쏘드를 파라미터와 함께 JSON RPC로 보내고, server로부터 응답을 받아서 결과를 출력한다. 또, 리턴값이 필요없는 void 함수를 “hello” 메쏘드로 테스트한다.

#include <arpa/inet.h>
#include <stdio.h>
#include <sys/socket.h>
#include <unistd.h>

#include "JsonRpc.h"

int ConnectRpcServer(void) {
    struct sockaddr_in addr;
    int sock_fd;

    sock_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (sock_fd < 0) {
        printf("%s(): Failed to create socket\n", __func__);
        return -1;
    }

    addr.sin_family = AF_INET;
    addr.sin_port = htons(PORT);
    inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
    if (connect(sock_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        printf("%s(): Failed to connect\n", __func__);
        close(sock_fd);
        return -1;
    }

    return sock_fd;
}

bool JsonRpcCall(int fd, Json::Value req, Json::Value &resp) {
    if (!req.isMember("method") || !req["method"].isString()) {
        resp["error"] = "no_method";
        return false;
    }
    if (!SendJsonRpc(fd, req)) {
        printf("%s(): Failed to send json frame\n", __func__);
        return false;
    }
    if (!RecvJsonRpc(fd, resp)) {
        printf("%s(): Failed to receive json frame\n", __func__);
        return false;
    }
    return resp.isMember("ok") && resp["ok"].asBool();
}

bool JsonRpcNotify(int fd, Json::Value req) {
    if (!req.isMember("method") || !req["method"].isString()) {
        resp["error"] = "no_method";
        return false;
    }
    if (!SendJsonRpc(fd, req)) {
        printf("%s(): Failed to send json frame\n", __func__);
        return false;
    }
    return true;
}

bool JsonRpc_add(int fd, int arg1, int arg2, int &result) {
    Json::Value req, resp;

    req["method"] = "add";
    req["params"]["arg1"] = arg1;
    req["params"]["arg2"] = arg2;
    if (JsonRpcCall(fd, req, resp) == false) {
        printf("%s(): Failed, error=%s\n", __func__, resp["error"].asString().c_str());
        return false;
    }
    result = resp["result"].asInt();
    return true;
}

bool JsonRpc_multiply(int fd, int arg1, int arg2, int &result) {
    Json::Value req, resp;

    req["method"] = "multiply";
    req["params"]["arg1"] = arg1;
    req["params"]["arg2"] = arg2;
    if (JsonRpcCall(fd, req, resp) == false) {
        printf("%s(): Failed, error=%s\n", __func__, resp["error"].asString().c_str());
        return false;
    }
    result = resp["result"].asInt();
    return true;
}

bool JsonRpc_hello(int fd, std::string msg) {
    Json::Value req;

    req["method"] = "hello";
    req["params"] = msg;
    if (JsonRpcNotify(fd, req) == false) {
        printf("%s(): Failed\n", __func__);
        return false;
    }
    return true;
}

int main(void) {
    int result;
    int fd = ConnectRpcServer();
    if (fd == -1) {
        printf("Failed to connect RPC server\n");
        return 1;
    }

    if (JsonRpc_add(fd, 2, 3, result)) {
        printf("client: add(2, 3) = %d\n", result);
    } else {
        printf("Failed to call JSON-RPC add\n");
    }

    if (JsonRpc_multiply(fd, 4, 5, result)) {
        printf("client: multiply(4, 5) = %d\n", result);
    } else {
        printf("Failed to call JSON-RPC multiply\n");
    }

    if (JsonRpc_hello(fd, "Notify test")) {
        printf("client: hello()\n");
    } else {
        printf("Failed to call JSON-RPC hello\n");
    }

    close(fd);
    return 0;
}

테스트 실행

먼저 server 프로그램을 실행시키고, 터미널을 1개 더 열어서 client를 실행시킨다.
결과로 아래와 같이 정상적인 결과가 출력된다.

  • server 로그
    server: method=add, arg1=2, arg2=3
    server: method=multiply, arg1=4, arg2=5
    server: method=hello, msg=Notify test
    
  • client 로그
    client: add(2, 3) = 5
    client: multiply(4, 5) = 20
    client: hello()
    

AF_UNIX 소켓 사용하기

만약에 AF_INET 소켓 대신에 AF_UNIX 소켓을 사용하고자 하는 경우에는 다음과 같은 내용들만 변경하면 된다.

JsonRpc.h 파일에서 PORT 대신에 아래 예와 같이 소켓 파일의 경로를 지정한다.

#define SOCKET_PATH "/tmp/ipc.sock"


Server.cpp 파일에서 <arpa/inet.h> 헤더는 제거하고, 아래 헤더 파일을 include 한다.

#include <sys/un.h>

또, JsonRpcServer() 함수는 아래와 같이 변경한다.

int JsonRpcServer() {
    struct sockaddr_un addr;

    int server_fd = socket(AF_UNIX, SOCK_STREAM, 0);
    if (server_fd < 0) {
        printf("%s(): Failed to create socket\n", __func__);
        return -1;
    }

    if (access(SOCKET_PATH, F_OK) == 0) {
        unlink(SOCKET_PATH);
    }

    memset(&addr, 0, sizeof(addr));
    addr.sun_family = AF_UNIX;
    strncpy(addr.sun_path, SOCKET_PATH, sizeof(addr.sun_path) - 1);
    if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        printf("%s(): Failed to bind\n", __func__);
        close(server_fd);
        return -1;
    }
    chmod(SOCKET_PATH, 0660);

    if (listen(server_fd, 1) < 0) {
        printf("%s(): Failed to listen\n", __func__);
        close(server_fd);
        return -1;
    }

    int client_fd = accept(server_fd, nullptr, nullptr);
    if (client_fd < 0) {
        printf("%s(): Failed to accept\n", __func__);
        close(server_fd);
        return -1;
    }

    for (;;) {
        Json::Value req, resp;
        if (!RecvJsonRpc(client_fd, req)) {
            break;
        }
        resp = ProcessMethod(req);
        if (!SendJsonRpc(client_fd, resp)) {
            printf("%s(): Failed to send JSON RPC\n", __func__);
            break;
        }
    }

    close(client_fd);
    close(server_fd);
    unlink(SOCKET_PATH);
    return 0;
}


Client.cpp 파일에서도 마찬가지로 <arpa/inet.h> 헤더는 제거하고, 아래 헤더 파일을 include 한다.

#include <sys/un.h>

또, ConnectRpcServer() 함수는 아래와 같이 변경한다.

int ConnectRpcServer(void) {
    struct sockaddr_un addr;
    int sock_fd;

    sock_fd = socket(AF_UNIX, SOCK_STREAM, 0);
    if (sock_fd < 0) {
        printf("%s(): Failed to create socket\n", __func__);
        return -1;
    }

    memset(&addr, 0, sizeof(addr));
    addr.sun_family = AF_UNIX;
    strncpy(addr.sun_path, SOCKET_PATH, sizeof(addr.sun_path) - 1);
    if (connect(sock_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        printf("%s(): Failed to connect\n", __func__);
        close(sock_fd);
        return -1;
    }

    return sock_fd;
}


빌드해서 실행해보면 이전의 AF_INET 소켓을 사용했던 결과와 동일한 결과를 얻을 수 있다.
참고로 server에서 생성한 소켓 파일의 속성을 보면, 아래와 같이 socket으로 나옴을 확인할 수 있다.

$ file /tmp/ipc.sock
/tmp/ipc.sock: socket

맺음말

위와 같이 기본 형태만 구현하여, 두 프로세스 간의 함수 호출을 구현해 보았다. 여기서는 간소화하기 위하여 2개의 프로세스를 1개는 server, 다른 1개는 client 역할을 하도록 나누었는데, 물론 필요에 따라서 각각의 프로세스가 server와 client 역할을 동시에 하도록 구현할 수도 있다.
또한 서버에서의 RPC 함수 호출과 클라이언트에서의 함수 부분을 구조화할 수도 있겠으나, 여기서는 의도적으로 가장 간단한 형태로 구현하여, 추후 참조용으로 남긴다.

카테고리:

업데이트: