カスタム通信プロトコルの設計と実装:TCP/IPを用いたネットワークサービス構築

はじめに

ソケットAPIを用いたネットワークプログラミングにおいて、データの読み書きは基本的にバイトストリーム(文字列)として扱われます。しかし、実際のアプリケーション開発では、構造化されたデータ(オブジェクトや構造体)を送受信する必要があります。この課題を解決するために、アプリケーション層での「カスタムプロトコル」の定義と、データを送信可能な形式に変換する「シリアライズ(直列化)」、そしてその逆の「デシリアライズ(逆直列化)」の処理が不可欠です。本記事では、これらの概念について深く掘り下げ、実際にC++を用いたネットワーク計算機の実装例を通じて具体的な手法を解説します。

概念の解説

カスタムプロトコル

カスタムプロトコルとは、特定のシステムやアプリケーションの要件に合わせて設計された通信規約のことです。既存のHTTPやFTPなどの汎用プロトコルではなく、独自のフォーマットを定義することで、データの効率的な転送や、特殊なビジネスロジックの実装が可能になります。

独自プロトコルが必要な理由:

  • 要件への適合: 特定のデータ構造や処理フローに最適化されたフォーマットを設計できるため、汎用的なプロトコルよりも無駄がありません。
  • パフォーマンスの最適化: 通信するデータの種類やネットワーク環境に合わせて、バイナリ形式やテキスト形式など最適なエンコーディングを選択できます。
  • セキュリティと拡張性: 独自の暗号化や圧縮ロジックをプロトコルレベルに組み込むことで、セキュリティを強化しやすく、将来的な機能拡張も柔軟に行えます。

シリアライズとデシリアライズ

シリアライズ(直列化)は、メモリ上にあるオブジェクトやデータ構造を、ネットワーク転送やファイル保存が可能なバイト列や文字列に変換するプロセスです。これにより、異なるプロセスやマシン間でデータの状態を共有できるようになります。

デシリアライズ(逆直列化)は、シリアライズされたバイト列を受け取り、元のオブジェクトやデータ構造を復元するプロセスです。受信側はこの処理によって、送信側の意図したデータを解釈し、アプリケーション内で利用可能な状態に戻します。

一般的なシリアライズ形式としては、可読性の高いJSONやXML、効率性の高いProtocol Buffersなどがあります。

具体例:オンラインゲームでの適用

オンラインゲームを例に考えてみましょう。プレイヤーがキャラクターを移動させる操作を行った際、クライアントは「移動命令」というオブジェクト(座標情報、キャラクターIDなど)を生成します。このオブジェクトはそのままではネットワークに流せないため、シリアライズされてJSONなどの文字列に変換され、サーバーへ送信されます。

サーバーは受信した文字列をデシリアライズし、元の「移動命令」オブジェクトに復元します。これにより、サーバーはどのプレイヤーがどこへ移動したいのかを正確に理解し、ゲームの状態を更新して他のプレイヤーにブロードキャストすることができます。この一連の流れがカスタムプロトコルとシリアライズ技術の典型的な応用例です。

実践:ネットワーク計算機の実装

ここからは、C++を用いてカスタムプロトコルを実装し、TCPソケット経由で計算要求を処理するネットワーク計算機を構築します。コードの構造や変数名はオリジナルのものを大幅に変更し、理解しやすく再利用可能な設計にします。

1. プロトコルとデータ処理 (NetProtocol.hpp)

このヘッダーファイルでは、TCPストリーム上でのメッセージ境界(デリミタ)を処理するためのエンコード/デコード機能と、計算要求・応答データのシリアライズロジックを定義します。今回はJSONライブラリ(JsonCpp)を使用した実装例を示します。

#pragma once
#include <iostream>
#include <string>
#include <json/json.h>
#include <cstring>

// プロトコル区切り文字(長さと本文のセパレータ)
const std::string PROTOCOL_DELIMITER = "\r\n";

// メッセージのエンコード(長さ + 区切り + 本文 + 区切り)
std::string PackMessage(const std::string& payload) {
    std::string lengthStr = std::to_string(payload.size());
    return lengthStr + PROTOCOL_DELIMITER + payload + PROTOCOL_DELIMITER;
}

// メッセージのデコード(受信バッファから本文を抽出)
bool UnpackMessage(std::string& buffer, std::string* payload) {
    size_t delimPos = buffer.find(PROTOCOL_DELIMITER);
    if (delimPos == std::string::npos) return false; // 長さ情報が不完全

    std::string lenStr = buffer.substr(0, delimPos);
    size_t contentLen = std::stoi(lenStr);
    size_t totalHeaderLen = delimPos + PROTOCOL_DELIMITER.size();
    size_t totalPacketLen = totalHeaderLen + contentLen + PROTOCOL_DELIMITER.size();

    if (buffer.size() < totalPacketLen) return false; // データがまだ到着していない

    *payload = buffer.substr(totalHeaderLen, contentLen);
    buffer.erase(0, totalPacketLen); // 処理済みデータをバッファから削除
    return true;
}

// 計算リクエスト構造体
struct CalculationRequest {
    int operandA;
    int operandB;
    char operatorSymbol;

    bool ToJson(std::string* outStr) const {
        Json::Value root;
        root["a"] = operandA;
        root["b"] = operandB;
        root["op"] = std::string(1, operatorSymbol);
        
        Json::StreamWriterBuilder builder;
        *outStr = Json::writeString(builder, root);
        return true;
    }

    bool FromJson(const std::string& inStr) {
        Json::Value root;
        Json::CharReaderBuilder builder;
        std::unique_ptr<Json::CharReader> reader(builder.newCharReader());
        std::string errors;
        
        if (!reader->parse(inStr.c_str(), inStr.c_str() + inStr.size(), &root, &errors)) {
            return false;
        }
        
        operandA = root["a"].asInt();
        operandB = root["b"].asInt();
        std::string op = root["op"].asString();
        operatorSymbol = op.empty() ? '?' : op[0];
        return true;
    }
};

// 計算レスポンス構造体
struct CalculationResponse {
    int result;
    int statusCode; // 0: 成功, 1: ゼロ除算エラー, 2: 不正な演算子

    bool ToJson(std::string* outStr) const {
        Json::Value root;
        root["res"] = result;
        root["code"] = statusCode;
        
        Json::StreamWriterBuilder builder;
        *outStr = Json::writeString(builder, root);
        return true;
    }

    bool FromJson(const std::string& inStr) {
        Json::Value root;
        Json::CharReaderBuilder builder;
        std::unique_ptr<Json::CharReader> reader(builder.newCharReader());
        std::string errors;
        
        if (!reader->parse(inStr.c_str(), inStr.c_str() + inStr.size(), &root, &errors)) {
            return false;
        }
        
        result = root["res"].asInt();
        statusCode = root["code"].asInt();
        return true;
    }
};

2. 計算ロジック (ComputeEngine.hpp)

デシリアライズされたリクエストデータを実際に処理し、結果を生成するエンジン部分です。

#pragma once
#include "NetProtocol.hpp"

enum ErrorCode {
    SUCCESS = 0,
    ERR_DIV_ZERO,
    ERR_MOD_ZERO,
    ERR_INVALID_OP
};

class ComputeEngine {
public:
    std::string ProcessRequest(const std::string& jsonStr) {
        CalculationRequest req;
        if (!req.FromJson(jsonStr)) {
            return ""; // パースエラー
        }

        CalculationResponse resp;
        resp.statusCode = SUCCESS;
        resp.result = 0;

        switch (req.operatorSymbol) {
            case '+':
                resp.result = req.operandA + req.operandB;
                break;
            case '-':
                resp.result = req.operandA - req.operandB;
                break;
            case '*':
                resp.result = req.operandA * req.operandB;
                break;
            case '/':
                if (req.operandB == 0) resp.statusCode = ERR_DIV_ZERO;
                else resp.result = req.operandA / req.operandB;
                break;
            case '%':
                if (req.operandB == 0) resp.statusCode = ERR_MOD_ZERO;
                else resp.result = req.operandA % req.operandB;
                break;
            default:
                resp.statusCode = ERR_INVALID_OP;
                break;
        }

        std::string responsePayload;
        resp.ToJson(&responsePayload);
        return PackMessage(responsePayload);
    }
};

3. ソケットラッパー (TcpSocket.hpp)

低レベルなソケット操作をカプセル化し、使いやすくします。

#pragma once
#include <iostream>
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <fcntl.h>

class TcpSocket {
public:
    TcpSocket() : _fd(-1) {}
    ~TcpSocket() { Close(); }

    bool Create() {
        _fd = socket(AF_INET, SOCK_STREAM, 0);
        return _fd != -1;
    }

    bool Bind(uint16_t port) {
        struct sockaddr_in addr;
        memset(&addr, 0, sizeof(addr));
        addr.sin_family = AF_INET;
        addr.sin_addr.s_addr = INADDR_ANY;
        addr.sin_port = htons(port);
        return bind(_fd, (struct sockaddr*)&addr, sizeof(addr)) != -1;
    }

    bool Listen(int backlog = 5) {
        return listen(_fd, backlog) != -1;
    }

    int Accept(std::string* clientIp, uint16_t* clientPort) {
        struct sockaddr_in clientAddr;
        socklen_t len = sizeof(clientAddr);
        int newFd = accept(_fd, (struct sockaddr*)&clientAddr, &len);
        if (newFd >= 0) {
            char ipBuf[64];
            inet_ntop(AF_INET, &clientAddr.sin_addr, ipBuf, sizeof(ipBuf));
            *clientIp = ipBuf;
            *clientPort = ntohs(clientAddr.sin_port);
        }
        return newFd;
    }

    bool Connect(const std::string& ip, uint16_t port) {
        struct sockaddr_in addr;
        memset(&addr, 0, sizeof(addr));
        addr.sin_family = AF_INET;
        addr.sin_port = htons(port);
        inet_pton(AF_INET, ip.c_str(), &addr.sin_addr);
        return connect(_fd, (struct sockaddr*)&addr, sizeof(addr)) != -1;
    }

    ssize_t Send(const std::string& msg) {
        return send(_fd, msg.c_str(), msg.size(), 0);
    }

    ssize_t Recv(char* buf, size_t len) {
        return recv(_fd, buf, len, 0);
    }

    void Close() {
        if (_fd != -1) {
            close(_fd);
            _fd = -1;
        }
    }

    int GetFd() const { return _fd; }

private:
    int _fd;
};

4. サーバー実装 (ServerMain.cpp)

クライアントからの接続を受け付け、計算要求を処理するサーバーのエントリーポイントです。

#include <iostream>
#include <sys/wait.h>
#include <signal.h>
#include "TcpSocket.hpp"
#include "ComputeEngine.hpp"

void HandleSigChild(int sig) {
    while (waitpid(-1, nullptr, WNOHANG) > 0);
}

int main(int argc, char* argv[]) {
    if (argc != 2) {
        std::cout << "Usage: " << argv[0] << " <port>" << std::endl;
        return 1;
    }

    signal(SIGCHLD, HandleSigChild);
    signal(SIGPIPE, SIG_IGN);

    uint16_t port = std::stoi(argv[1]);
    TcpSocket listenSock;
    
    if (!listenSock.Create() || !listenSock.Bind(port) || !listenSock.Listen()) {
        std::cerr << "Server initialization failed." << std::endl;
        return 1;
    }

    std::cout << "Server started on port " << port << std::endl;

    ComputeEngine engine;

    while (true) {
        std::string clientIp;
        uint16_t clientPort;
        int clientFd = listenSock.Accept(&clientIp, &clientPort);
        
        if (clientFd < 0) continue;

        std::cout << "New connection from " << clientIp << ":" << clientPort << std::endl;

        pid_t pid = fork();
        if (pid == 0) {
            // 子プロセス
            listenSock.Close();
            TcpSocket clientSock;
            // ディスクリプタ設定を簡略化するためにラッパー経由で処理する場合があるが、
            // ここでは簡易的に受信ループを記述
            char buffer[1024];
            std::string streamBuffer;
            
            while (true) {
                ssize_t n = recv(clientFd, buffer, sizeof(buffer), 0);
                if (n <= 0) break;

                buffer[n] = '\0';
                streamBuffer += buffer;

                std::string payload;
                while (UnpackMessage(streamBuffer, &payload)) {
                    std::string response = engine.ProcessRequest(payload);
                    if (!response.empty()) {
                        send(clientFd, response.c_str(), response.size(), 0);
                    }
                }
            }
            exit(0);
        } else {
            // 親プロセス
            close(clientFd);
        }
    }
    return 0;
}

5. クライアント実装 (ClientMain.cpp)

ランダムな計算問題を生成し、サーバーに送信して結果を受け取るクライアントです。

#include <iostream>
#include <string>
#include <cstdlib>
#include <ctime>
#include <unistd.h>
#include "TcpSocket.hpp"
#include "NetProtocol.hpp"

int main(int argc, char* argv[]) {
    if (argc != 3) {
        std::cout << "Usage: " << argv[0] << " <server_ip> <server_port>" << std::endl;
        return 1;
    }

    std::string serverIp = argv[1];
    uint16_t serverPort = std::stoi(argv[2]);

    TcpSocket sock;
    if (!sock.Create() || !sock.Connect(serverIp, serverPort)) {
        std::cerr << "Failed to connect to server." << std::endl;
        return 1;
    }

    srand(time(nullptr));
    const std::string operators = "+-*/%";
    std::string recvBuffer;

    for (int i = 1; i <= 10; ++i) {
        std::cout << "--- Test Round " << i << " ---" << std::endl;
        
        CalculationRequest req;
        req.operandA = rand() % 100 + 1;
        req.operandB = rand() % 100;
        req.operatorSymbol = operators[rand() % operators.size()];

        std::cout << "Sending: " << req.operandA << " " << req.operatorSymbol << " " << req.operandB << std::endl;

        std::string jsonStr;
        req.ToJson(&jsonStr);
        std::string packet = PackMessage(jsonStr);

        sock.Send(packet);

        // 応答待ち
        bool needRecv = true;
        while (needRecv) {
            char buf[1024];
            ssize_t n = sock.Recv(buf, sizeof(buf) - 1);
            if (n <= 0) {
                std::cerr << "Connection lost." << std::endl;
                return 1;
            }
            buf[n] = '\0';
            recvBuffer += buf;

            std::string payload;
            if (UnpackMessage(recvBuffer, &payload)) {
                CalculationResponse resp;
                resp.FromJson(payload);
                
                if (resp.statusCode == SUCCESS) {
                    std::cout << "Server Result: " << resp.result << std::endl;
                } else {
                    std::cout << "Server Error (Code " << resp.statusCode << ")" << std::endl;
                }
                needRecv = false;
            }
        }
        sleep(1);
    }

    sock.Close();
    return 0;
}

ビルドファイル (Makefile)

.PHONY: all clean

all: server client

LIBS = -ljsoncpp

server: ServerMain.cpp TcpSocket.hpp NetProtocol.hpp ComputeEngine.hpp
	g++ -o $@ ServerMain.cpp -std=c++11 $(LIBS)

client: ClientMain.cpp TcpSocket.hpp NetProtocol.hpp
	g++ -o $@ ClientMain.cpp -std=c++11 $(LIBS)

clean:
	rm -f server client

上記のコードは、プロトコルの設計、シリアライゼーション、およびTCP通信の基礎を統合したものです。構造体を用いてデータを明確に定義し、JSON形式で変換することで、拡張性と可読性を両立させています。また、TCPのストリーム性を考慮し、バッファリングとパース処理を適切に行うことで、データの欠損やパケットの混在を防いでいます。

タグ: C++ NetworkProgramming TCP socket Serialization

6月30日 19:45 投稿