Skip to content

Commit

Permalink
Merge pull request #3 from boostcampwm-2024/refactor/chatgateway-SoC
Browse files Browse the repository at this point in the history
♻️ refactor: chatgateway의 관심사 분리, 채팅 비즈니스 로직을 chatservice로 구현
  • Loading branch information
CodeVac513 authored Jan 8, 2025
2 parents 2e9bf2c + 7ac2560 commit f872d6e
Show file tree
Hide file tree
Showing 3 changed files with 132 additions and 93 deletions.
102 changes: 10 additions & 92 deletions server/src/chat/chat.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,8 @@ import {
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { RedisService } from '../common/redis/redis.service';
import { Injectable } from '@nestjs/common';
import { getRandomNickname } from '@woowa-babble/random-nickname';
import { Cron, CronExpression } from '@nestjs/schedule';

const CLIENT_KEY_PREFIX = 'socket_client:';
const CHAT_HISTORY_KEY = 'chat:history';
const CHAT_HISTORY_LIMIT = 20;
const CHAT_MIDNIGHT_CLIENT_NAME = 'system';
const MAX_CLIENTS = 500;
import { ChatService } from './chat.service';

type BroadcastPayload = {
username: string;
Expand All @@ -33,45 +25,21 @@ type BroadcastPayload = {
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private dayInit: boolean = false;

constructor(private readonly redisService: RedisService) {}

@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
private midnightInitializer() {
this.dayInit = true;
}

private async emitMidnightMessage() {
const broadcastPayload: BroadcastPayload = {
username: CHAT_MIDNIGHT_CLIENT_NAME,
message: '',
timestamp: new Date(),
};

await this.saveMessageToRedis(broadcastPayload);

this.server.emit('message', broadcastPayload);
}
constructor(private readonly chatService: ChatService) {}

async handleConnection(client: Socket) {
const userCount = this.server.engine.clientsCount;
if (userCount > MAX_CLIENTS) {
if (this.chatService.isMaxClientExceeded(userCount)) {
client.emit('maximum_exceeded', {
message: '채팅 서버의 한계에 도달했습니다. 잠시후 재시도 해주세요.',
});
client.disconnect(true);
return;
}

const ip = this.getIp(client);
const clientName = await this.getOrSetClientNameByIp(ip);
const recentMessages = await this.redisService.redisClient.lrange(
CHAT_HISTORY_KEY,
0,
CHAT_HISTORY_LIMIT - 1,
);
const chatHistory = recentMessages.map((msg) => JSON.parse(msg)).reverse();
const clientName = await this.chatService.getClientNameByIp(client);
const chatHistory = await this.chatService.getChatHistory();

client.emit('chatHistory', chatHistory);

Expand All @@ -89,69 +57,19 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {

@SubscribeMessage('message')
async handleMessage(client: Socket, payload: { message: string }) {
const ip = this.getIp(client);
const redisKey = CLIENT_KEY_PREFIX + ip;
const clientName = await this.redisService.redisClient.get(redisKey);
const clientName = await this.chatService.getClientNameByIp(client);

const broadcastPayload: BroadcastPayload = {
username: clientName,
message: payload.message,
timestamp: new Date(),
};

await this.saveMessageToRedis(broadcastPayload);

if (this.dayInit) {
this.dayInit = false;
await this.emitMidnightMessage();
const midnightMessage = await this.chatService.handleDateMessage();
if (midnightMessage) {
this.server.emit('message', midnightMessage);
}

await this.chatService.saveMessageToRedis(broadcastPayload);
this.server.emit('message', broadcastPayload);
}

private getIp(client: Socket) {
const forwardedFor = client.handshake.headers['x-forwarded-for'] as string;
const ip = forwardedFor
? forwardedFor.split(',')[0].trim()
: client.handshake.address;

return ip;
}

private async getOrSetClientNameByIp(ip: string) {
const redisKey = CLIENT_KEY_PREFIX + ip;
let clientName: string = await this.redisService.redisClient.get(redisKey);

if (clientName) {
return clientName;
}

clientName = this.generateRandomUsername();
await this.redisService.redisClient.set(
redisKey,
clientName,
'EX',
3600 * 24,
);
return clientName;
}

private generateRandomUsername(): string {
const type = 'animals';

return getRandomNickname(type);
}

private async saveMessageToRedis(payload: BroadcastPayload) {
await this.redisService.redisClient.lpush(
CHAT_HISTORY_KEY,
JSON.stringify(payload),
);

await this.redisService.redisClient.ltrim(
CHAT_HISTORY_KEY,
0,
CHAT_HISTORY_LIMIT - 1,
);
}
}
3 changes: 2 additions & 1 deletion server/src/chat/chat.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { ChatGateway } from './chat.gateway';
import { ScheduleModule } from '@nestjs/schedule';
import { ChatService } from './chat.service';

@Module({
imports: [ScheduleModule.forRoot()],
providers: [ChatGateway],
providers: [ChatGateway, ChatService],
})
export class ChatModule {}
120 changes: 120 additions & 0 deletions server/src/chat/chat.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { Injectable } from '@nestjs/common';
import { Socket } from 'socket.io';
import { RedisService } from '../common/redis/redis.service';
import { getRandomNickname } from '@woowa-babble/random-nickname';
import { Cron, CronExpression } from '@nestjs/schedule';

const MAX_CLIENTS = 500;
const CLIENT_KEY_PREFIX = 'socket_client:';
const CHAT_HISTORY_KEY = 'chat:history';
const CHAT_HISTORY_LIMIT = 20;
const CHAT_MIDNIGHT_CLIENT_NAME = 'system';

type BroadcastPayload = {
username: string;
message: string;
timestamp: Date;
};

@Injectable()
export class ChatService {
private dayInit: boolean = false;

constructor(private readonly redisService: RedisService) {}

isMaxClientExceeded(userCount: number) {
return userCount > MAX_CLIENTS;
}

private getClientIp(client: Socket) {
const forwardedFor = client.handshake.headers['x-forwarded-for'] as string;
const ip = forwardedFor
? forwardedFor.split(',')[0].trim()
: client.handshake.address;

return ip;
}

async getClientNameByIp(client: Socket) {
const ip = this.getClientIp(client);
const redisKey = CLIENT_KEY_PREFIX + ip;
const clientName: string = await this.getClientName(redisKey);
if (clientName) {
return clientName;
}
const createdClientName = await this.setClientName(redisKey);
return createdClientName;
}

private async getClientName(redisKey: string) {
return await this.redisService.redisClient.get(redisKey);
}

private async setClientName(redisKey: string) {
const clientName = this.generateRandomUsername();
await this.redisService.redisClient.set(
redisKey,
clientName,
'EX',
3600 * 24,
);
return clientName;
}

private generateRandomUsername(): string {
const type = 'animals';

return getRandomNickname(type);
}

async getChatHistory() {
return (await this.getRecentChatMessages())
.map((msg) => JSON.parse(msg))
.reverse();
}

private async getRecentChatMessages() {
return await this.redisService.redisClient.lrange(
CHAT_HISTORY_KEY,
0,
CHAT_HISTORY_LIMIT - 1,
);
}

async saveMessageToRedis(payload: BroadcastPayload) {
await this.redisService.redisClient.lpush(
CHAT_HISTORY_KEY,
JSON.stringify(payload),
);

await this.redisService.redisClient.ltrim(
CHAT_HISTORY_KEY,
0,
CHAT_HISTORY_LIMIT - 1,
);
}

async saveDateMessage() {
const broadcastPayload: BroadcastPayload = {
username: CHAT_MIDNIGHT_CLIENT_NAME,
message: '',
timestamp: new Date(),
};

await this.saveMessageToRedis(broadcastPayload);

return broadcastPayload;
}

async handleDateMessage() {
if (this.dayInit) {
this.dayInit = false;
return await this.saveDateMessage();
}
}

@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
private midnightInitializer() {
this.dayInit = true;
}
}

0 comments on commit f872d6e

Please sign in to comment.