C言語によるWebSocketサーバーの構築とチャットプロトコルの実装

WebSocketサーバーの基礎とハンドシェイクの実装

標準的なTCPソケット通信とWebSocketの最大の違いは、接続開始時に行われる「ハンドシェイク」プロセスにあります。WebSocketはHTTP/1.1のプロトコルアップグレードを利用して確立されます。クライアントから送られてくるSec-WebSocket-Keyに対し、特定のGUIDを結合した上でSHA-1ハッシュ化し、さらにBase64エンコードを施した値をSec-WebSocket-Acceptヘッダーとして返却する必要があります。

以下は、C言語でSHA-1とBase64エンコーディングを組み合わせ、ハンドシェイク用のレスポンスキーを生成するロジックの実装例です。

#include <openssl/sha.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>

char *generate_ws_accept_key(const char *client_key) {
    const char *WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
    char combined_str[256];
    sprintf(combined_str, "%s%s", client_key, WS_GUID);

    unsigned char sha1_result[SHA_DIGEST_LENGTH];
    SHA1((unsigned char *)combined_str, strlen(combined_str), sha1_result);

    const char base64_chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    int input_len = SHA_DIGEST_LENGTH;
    int output_len = 4 * ((input_len + 2) / 3);
    char *encoded_data = (char *)malloc(output_len + 1);

    for (int i = 0, j = 0; i < input_len;) {
        uint32_t octet_a = i < input_len ? sha1_result[i++] : 0;
        uint32_t octet_b = i < input_len ? sha1_result[i++] : 0;
        uint32_t octet_c = i < input_len ? sha1_result[i++] : 0;

        uint32_t triple = (octet_a << 16) | (octet_b << 8) | octet_c;

        encoded_data[j++] = base64_chars[(triple >> 18) & 0x3F];
        encoded_data[j++] = base64_chars[(triple >> 12) & 0x3F];
        encoded_data[j++] = base64_chars[(triple >> 6) & 0x3F];
        encoded_data[j++] = base64_chars[triple & 0x3F];
    }

    // パディングの処理
    for (int i = 0; i < (3 - input_len % 3) % 3; i++) {
        encoded_data[output_len - 1 - i] = '=';
    }
    encoded_data[output_len] = '\0';
    return encoded_data;
}

select関数によるI/O多重化の実装

多数のクライアントを同時に処理する場合、スレッドやプロセスを生成する手法もありますが、リソース消費を抑えるためにselect関数を用いたI/O多重化が有効です。selectは複数のファイル記述子(FD)を監視し、読み取りや書き込みが可能になったタイミングで通知を行います。

メインループ内での基本的な処理フローは以下の通りです。

#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>

void run_chat_server(int listen_fd) {
    fd_set active_fds, read_fds;
    FD_ZERO(&active_fds);
    FD_SET(listen_fd, &active_fds);
    int max_fd = listen_fd;

    while (1) {
        read_fds = active_fds;
        if (select(max_fd + 1, &read_fds, NULL, NULL, NULL) < 0) {
            perror("select error");
            break;
        }

        for (int i = 0; i <= max_fd; i++) {
            if (FD_ISSET(i, &read_fds)) {
                if (i == listen_fd) {
                    // 新規接続の受付
                    struct sockaddr_in client_addr;
                    socklen_t addr_len = sizeof(client_addr);
                    int new_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &addr_len);
                    FD_SET(new_fd, &active_fds);
                    if (new_fd > max_fd) max_fd = new_fd;
                } else {
                    // 既存クライアントからのデータ受信
                    char buffer[1024];
                    ssize_t nbytes = recv(i, buffer, sizeof(buffer), 0);
                    if (nbytes <= 0) {
                        close(i);
                        FD_CLR(i, &active_fds);
                    } else {
                        // WebSocketフレームの解析とデータ処理(デコード処理へ)
                    }
                }
            }
        }
    }
}

WebSocketデータフレームのデコード

WebSocketプロトコルでは、データは「フレーム」単位で送受信されます。特にクライアントからサーバーへのデータには「マスク(Masking)」が施されており、4バイトのマスクキーを使用して元のデータを取り出す必要があります。フレーム構造の最初の数バイトを解析し、ペイロード長とマスクキーを特定します。

void decode_ws_payload(unsigned char *frame, size_t frame_len, char *output) {
    size_t payload_len = frame[1] & 127;
    unsigned char *mask_key;
    unsigned char *payload_data;

    if (payload_len == 126) {
        payload_len = (frame[2] << 8) | frame[3];
        mask_key = frame + 4;
    } else if (payload_len == 127) {
        // 64bit長は今回の実装では省略
        return;
    } else {
        mask_key = frame + 2;
    }

    payload_data = mask_key + 4;
    for (size_t i = 0; i < payload_len; i++) {
        output[i] = payload_data[i] ^ mask_key[i % 4];
    }
    output[payload_len] = '\0';
}

クライアントサイドの実装 (JavaScript)

フロントエンドでは、ブラウザ標準のWebSocket APIを使用します。JSON形式でメッセージタイプ(個人チャット、グループチャット、ログインなど)を定義し、サーバー側でこのタイプを判別してルーティングを行います。

const socket = new WebSocket("ws://localhost:6001");

socket.onopen = () => {
    const loginData = {
        type: 1,
        name: "User_" + Math.floor(Math.random() * 1000)
    };
    socket.send(JSON.stringify(loginData));
};

socket.onmessage = (event) => {
    const data = JSON.parse(event.data);
    switch(data.type) {
        case 2:
            console.log(`[Private] ${data.name}: ${data.msg}`);
            break;
        case 6:
            console.log(`[Group ${data.gid}] ${data.name}: ${data.msg}`);
            break;
        default:
            console.log("System:", data);
    }
};

function sendMessage(targetId, message, isGroup = false) {
    const payload = {
        type: isGroup ? 6 : 2,
        s_uid: currentUserId,
        [isGroup ? 'gid' : 'r_uid']: targetId,
        msg: message,
        name: currentUserName
    };
    socket.send(JSON.stringify(payload));
}

ユーザー管理とグループ機能のデータ構造

サーバー内部では、接続中のユーザーをリンクリストや配列で管理します。各ユーザー構造体には、ファイル記述子、ユーザーID、ユーザー名、および所属しているグループのIDリストを保持させます。メッセージが届いた際、送信先IDに基づいてリンクリストを走査し、該当するファイル記述子に対してデータを転送します。

typedef struct UserNode {
    int fd;
    int uid;
    char name[64];
    int groups[10];
    int group_count;
    struct UserNode *next;
} UserNode;

typedef struct {
    UserNode *head;
    int count;
} UserManager;

// 指定したUIDにメッセージを送信
void forward_to_user(UserManager *mgr, int target_uid, const char *json_data) {
    UserNode *curr = mgr->head;
    while (curr) {
        if (curr->uid == target_uid) {
            // WebSocketエンコードを施して送信
            send_ws_frame(curr->fd, json_data);
            break;
        }
        curr = curr->next;
    }
}

タグ: c-language websocket Network-Programming Socket-Programming javascript

7月27日 03:46 投稿