ChatClient
基于WebSocket的聊天客户端核心类。负责实际的服务器连接及消息处理。
事件代码
| 代码 | 常量名 | 说明 |
|---|---|---|
| 0 | EventChannels | 频道列表 |
| 1 | EventSubscribe | 频道订阅 |
| 2 | EventUnSubscribe | 频道订阅 解除 |
| 3 | EventPublicMessage | 公开消息 |
| 4 | EventPrivateMessage | 私密消息 |
| 5 | EventPlayerOnline | 玩家 在线 状态 |
| 97 | EventNotifyMessage | 通知消息 |
| 99 | EventError | 错误 |
主要功能
| 方法 | 说明 |
|---|---|
| Connect | 连接WebSocket服务器 |
| Disconnect | 断开服务器连接 |
| Subscribe | 频道订阅 (上一 消息 数量 设置 可用) |
| Unsubscribe | 频道订阅 解除 |
| GetChannels | 频道列表 请求 |
| GetPlayersOnline | 玩家 在线 状态 查询 |
| SendPublicMessage | 公开消息 发送 |
| SendPrivateMessage | 发送私密消息 |
| SetUserName | 设置用户名 |
| Service | 主线程回调处理 |
属性
| 属性 | 类型 | 说明 |
|---|---|---|
| IsConnected | bool | 连接 状态 |
| CurrentChannel | FString | 当前订阅的频道 |
Unreal C++实现
头文件 (ChatClient.h)
// ChatClient.h
#pragma once
#include "CoreMinimal.h"
#include "IWebSocket.h"
#include "ChatModels.h"
class FChatClient
{
public:
// 이벤트 코드
static const uint8 EventChannels = 0;
static const uint8 EventSubscribe = 1;
static const uint8 EventUnSubscribe = 2;
static const uint8 EventPublicMessage = 3;
static const uint8 EventPrivateMessage = 4;
static const uint8 EventPlayerOnline = 5;
static const uint8 EventNotifyMessage = 97;
static const uint8 EventError = 99;
FChatClient(IChatListener* InListener, const FString& InUserName = TEXT(""));
~FChatClient();
// Public API
void Connect(const FChatServerItem& Server);
void Disconnect();
void Subscribe(const FString& Channel, int32 PrevMessageCount = 0);
void Unsubscribe(const FString& Channel);
void GetChannels();
void GetPlayersOnline(const TArray<FString>& UserIds);
void SendPublicMessage(const FString& Channel, const FString& Text);
void SendPrivateMessage(const FString& TargetUserId, const FString& Text);
void SetUserName(const FString& InUserName);
// Getters
bool GetIsConnected() const { return bIsConnected; }
FString GetCurrentChannel() const { return CurrentChannel; }
private:
void ConnectToServer(const FString& Url);
FString BuildServerUrl(const FString& BaseUrl);
void Send(uint8 Type, const FString& Channel, const FString& Message = TEXT(""),
const FString& PrivateToUserId = TEXT(""), int32 PrevMessageCount = 0);
void ProcessMessage(const FString& Json);
FString ComputeHash();
FString GetLocalIP();
// WebSocket 이벤트 핸들러
void OnConnected();
void OnConnectionError(const FString& Error);
void OnClosed(int32 StatusCode, const FString& Reason, bool bWasClean);
void OnMessage(const FString& Message);
IChatListener* Listener;
TSharedPtr<IWebSocket> WebSocket;
FString UserUniqueId;
FString UserName;
FString CurrentChannel;
bool bIsConnected;
};
实现文件 (ChatClient.cpp)
OpenSSL依赖
使用OpenSSL进行HMAC-SHA256计算。需要在项目的Build.cs文件中添加"OpenSSL"模块。
// ChatClient.cpp
#include "ChatClient.h"
#include "WebSocketsModule.h"
#include "PlayNANOOAuth.h"
#include "Misc/Base64.h"
#include "SocketSubsystem.h"
#include "IPAddress.h"
#if PLATFORM_WINDOWS
#include "Windows/AllowWindowsPlatformTypes.h"
#endif
#include <openssl/hmac.h>
#include <openssl/sha.h>
#if PLATFORM_WINDOWS
#include "Windows/HideWindowsPlatformTypes.h"
#endif
FChatClient::FChatClient(IChatListener* InListener, const FString& InUserName)
: Listener(InListener)
, UserName(InUserName)
, bIsConnected(false)
{
// UUID는 DataManager에서 가져온다고 가정
UserUniqueId = UGameDataManager::Get()->GetAccessToken(); // 또는 별도 UUID 관리
}
FChatClient::~FChatClient()
{
Disconnect();
}
void FChatClient::Connect(const FChatServerItem& Server)
{
if (bIsConnected) return;
FString Protocol = Server.Secure == TEXT("Y") ? TEXT("wss") : TEXT("ws");
FString BaseUrl = FString::Printf(TEXT("%s://%s:%d"), *Protocol, *Server.Addr, Server.Port);
ConnectToServer(BuildServerUrl(BaseUrl));
}
void FChatClient::Disconnect()
{
if (WebSocket.IsValid())
{
bIsConnected = false;
WebSocket->Close();
WebSocket.Reset();
}
}
void FChatClient::Subscribe(const FString& Channel, int32 PrevMessageCount)
{
CurrentChannel = Channel;
Send(EventSubscribe, Channel, TEXT(""), TEXT(""), PrevMessageCount);
}
void FChatClient::Unsubscribe(const FString& Channel)
{
Send(EventUnSubscribe, Channel);
}
void FChatClient::GetChannels()
{
Send(EventChannels, TEXT(""));
}
void FChatClient::GetPlayersOnline(const TArray<FString>& UserIds)
{
if (!WebSocket.IsValid() || !bIsConnected)
{
if (Listener)
Listener->OnError(TEXT("NOT_CONNECTED"), TEXT("WebSocket is not connected"));
return;
}
FChatMessageModel Msg;
Msg.mid = FGuid::NewGuid().ToString();
Msg.type = EventPlayerOnline;
Msg.gameId = FPlayNANOOAuth::GameId;
Msg.serviceKey = FPlayNANOOAuth::ServiceKey;
Msg.userUniqueId = UserUniqueId;
Msg.userName = UserName;
Msg.onlinePlayers = UserIds;
Msg.ipAddr = GetLocalIP();
Msg.createdAt = FDateTime::Now().ToString(TEXT("%Y-%m-%dT%H:%M:%S"));
WebSocket->Send(Msg.ToJson());
}
void FChatClient::SendPublicMessage(const FString& Channel, const FString& Text)
{
Send(EventPublicMessage, Channel, Text);
}
void FChatClient::SendPrivateMessage(const FString& TargetUserId, const FString& Text)
{
Send(EventPrivateMessage, CurrentChannel, Text, TargetUserId);
}
void FChatClient::SetUserName(const FString& InUserName)
{
UserName = InUserName;
}
FString FChatClient::BuildServerUrl(const FString& BaseUrl)
{
FString Hash = ComputeHash();
// Unreal WebSocket은 경로가 없으면 '//'를 요청하는 버그가 있음
// 명시적으로 '/' 경로 추가
return FString::Printf(TEXT("%s/?gameId=%s&uuid=%s&hash=%s"),
*BaseUrl,
*FPlayNANOOAuth::GameId,
*UserUniqueId,
*FGenericPlatformHttp::UrlEncode(Hash));
}
void FChatClient::ConnectToServer(const FString& Url)
{
if (!FModuleManager::Get().IsModuleLoaded(TEXT("WebSockets")))
{
FModuleManager::Get().LoadModule(TEXT("WebSockets"));
}
WebSocket = FWebSocketsModule::Get().CreateWebSocket(Url);
WebSocket->OnConnected().AddLambda([this]()
{
OnConnected();
});
WebSocket->OnConnectionError().AddLambda([this](const FString& Error)
{
OnConnectionError(Error);
});
WebSocket->OnClosed().AddLambda([this](int32 StatusCode, const FString& Reason, bool bWasClean)
{
OnClosed(StatusCode, Reason, bWasClean);
});
WebSocket->OnMessage().AddLambda([this](const FString& Message)
{
OnMessage(Message);
});
WebSocket->Connect();
}
void FChatClient::OnConnected()
{
bIsConnected = true;
if (Listener)
Listener->OnConnected();
}
void FChatClient::OnConnectionError(const FString& Error)
{
if (Listener)
Listener->OnError(TEXT("CONNECTION_FAILED"), Error);
}
void FChatClient::OnClosed(int32 StatusCode, const FString& Reason, bool bWasClean)
{
bIsConnected = false;
if (Listener)
Listener->OnDisconnected();
}
void FChatClient::OnMessage(const FString& Message)
{
ProcessMessage(Message);
}
void FChatClient::Send(uint8 Type, const FString& Channel, const FString& Message,
const FString& PrivateToUserId, int32 PrevMessageCount)
{
if (!WebSocket.IsValid() || !bIsConnected)
{
if (Listener)
Listener->OnError(TEXT("NOT_CONNECTED"), TEXT("WebSocket is not connected"));
return;
}
FChatMessageModel Msg;
Msg.mid = FGuid::NewGuid().ToString();
Msg.type = Type;
Msg.gameId = FPlayNANOOAuth::GameId;
Msg.serviceKey = FPlayNANOOAuth::ServiceKey;
Msg.channelId = Channel;
Msg.userUniqueId = UserUniqueId;
Msg.userName = UserName;
Msg.message = Message;
Msg.privateToUserUniqueId = PrivateToUserId;
Msg.prevMessageCount = PrevMessageCount;
Msg.ipAddr = GetLocalIP();
Msg.createdAt = FDateTime::Now().ToString(TEXT("%Y-%m-%dT%H:%M:%S"));
WebSocket->Send(Msg.ToJson());
}
void FChatClient::ProcessMessage(const FString& Json)
{
UE_LOG(LogTemp, Log, TEXT("[Chat] Received: %s"), *Json);
FChatMessageModel Msg;
if (!Msg.FromJson(Json))
{
UE_LOG(LogTemp, Error, TEXT("[Chat] ProcessMessage error: Failed to parse JSON"));
return;
}
FChatUserInfo User;
User.visitorId = Msg.userUniqueId;
User.visitorName = Msg.userName;
if (!Listener) return;
switch (Msg.type)
{
case EventChannels:
Listener->OnChannels(Msg.channels);
break;
case EventSubscribe:
Listener->OnSubscribed(User);
break;
case EventUnSubscribe:
Listener->OnUnSubscribed(User);
break;
case EventPublicMessage:
Listener->OnPublicMessage(User, Msg.message);
break;
case EventPrivateMessage:
Listener->OnPrivateMessage(User, Msg.message);
break;
case EventPlayerOnline:
Listener->OnPlayerOnline(Msg.players);
break;
case EventNotifyMessage:
if (Msg.userUniqueId != UserUniqueId)
Listener->OnNotifyMessage(User, Msg.message);
break;
case EventError:
Listener->OnError(FString::FromInt(Msg.error), Msg.message);
break;
}
}
FString FChatClient::ComputeHash()
{
// HMAC-SHA256 해시 생성
FString Message = FPlayNANOOAuth::GameId + FPlayNANOOAuth::ServiceKey + UserUniqueId;
// UTF-8로 변환
FTCHARToUTF8 KeyUtf8(*FPlayNANOOAuth::SecretKey);
FTCHARToUTF8 MessageUtf8(*Message);
// HMAC-SHA256 계산 (OpenSSL)
unsigned char Hash[SHA256_DIGEST_LENGTH];
unsigned int HashLen = 0;
HMAC(EVP_sha256(),
KeyUtf8.Get(), KeyUtf8.Length(),
(const unsigned char*)MessageUtf8.Get(), MessageUtf8.Length(),
Hash, &HashLen);
// Base64 인코딩
TArray<uint8> HashArray;
HashArray.Append(Hash, HashLen);
return FBase64::Encode(HashArray);
}
FString FChatClient::GetLocalIP()
{
bool bCanBindAll;
TSharedPtr<FInternetAddr> LocalAddr = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->GetLocalHostAddr(*GLog, bCanBindAll);
if (LocalAddr.IsValid())
{
return LocalAddr->ToString(false);
}
return TEXT("0.0.0.0");
}