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;
}
}