이 코드는 NestJS를 사용하여 구현된 polls repository의 일부분입니다.
import {
Inject,
Injectable,
InternalServerErrorException,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Redis } from 'ioredis';
import { IORedisKey } from 'src/redis.module';
import { AddParticipantData, CreatePollData } from './types';
import { Poll } from 'shared';
import { error } from 'console';
@Injectable()
export class PollsRepository {
// to use time-to-live from configuration
private readonly ttl: string;
private readonly logger = new Logger(PollsRepository.name);
constructor(
configService: ConfigService,
@Inject(IORedisKey) private readonly redisClient: Redis,
) {
this.ttl = configService.get('POLL_DURATION');
}
/**
* Create a new poll in Redis and returns the newly created poll.
* @param {CreatePollData} data - The data required to create a poll.
* @param {number} data.votesPerVoter - The number of votes each voter is allowed to cast in the poll.
* @param {string} data.topic - The topic of the poll.
* @param {string} data.pollID - The ID of the poll.
* @param {string} data.userID - The ID of the user creating the poll.
* @returns {Promise<Poll>} - The newly created poll.
* @throws {InternalServerErrorException} - Throws an error if creating the poll in Redis fails.
*/
async createPoll({
votesPerVoter, // The number of votes each voter is allowed.
topic, // The topic of the poll.
pollID, // The ID of the poll.
userID, // The ID of the user creating the poll.
}: CreatePollData): Promise<Poll> {
// Create the initial poll object with the given data.
const initialPoll = {
id: pollID,
topic,
votesPerVoter,
participants: {},
adminID: userID,
};
// Log a message indicating that a new poll is being created with the given data.
this.logger.log(
`Creating new poll: ${JSON.stringify(initialPoll, null, 2)} with TTL ${
this.ttl
}`,
);
// Create a Redis key for the poll.
const key = `polls:${pollID}`;
try {
// Use Redis MULTI/EXEC to perform multiple commands atomically.
await this.redisClient
.multi([
// Set the value of the Redis key to the initial poll object.
['send_command', 'JSON.SET', key, '.', JSON.stringify(initialPoll)],
// Set the TTL for the Redis key to the configured value.
['expire', key, this.ttl],
])
.exec();
// Return the initial poll object.
return initialPoll;
} catch (error) {
// Log an error message if the poll could not be added to Redis.
this.logger.error(
`Failed to add poll ${JSON.stringify(initialPoll)}\n${error}`,
);
// Throw an internal server error exception to the calling function.
throw new InternalServerErrorException();
}
}
/**
* Get a poll from Redis and returns the poll object.
* @param {string} pollID - The ID of the poll to retrieve.
* @returns {Promise<Poll>} - The poll object corresponding to the given ID.
* @throws {Error} - Throws an error if retrieving the poll from Redis fails.
*/
async getPoll(pollID: string): Promise<Poll> {
// Log a message indicating that we are attempting to get the poll with the given ID.
this.logger.log(`Attempting to get poll with: ${pollID}`);
// Create a Redis key for the poll.
const key = `polls:${pollID}`;
try {
// Use Redis JSON.GET to retrieve the value of the Redis key as a JSON string.
const currentPoll = await this.redisClient.send_command(
'JSON.GET',
key,
'.',
);
// Log the retrieved poll object in verbose mode.
this.logger.verbose(currentPoll);
// Parse the retrieved poll object from JSON and return it.
return JSON.parse(currentPoll);
} catch (error) {
// Log an error message if the poll could not be retrieved from Redis.
this.logger.error(`Failed to get ${pollID}`);
// Throw the error to the calling function.
throw error;
}
}
/**
* Adds a participant to a poll in Redis and returns the updated poll.
* @param {AddParticipantData} data - The data required to add a participant to a poll.
* @param {string} data.pollID - The ID of the poll.
* @param {string} data.userID - The ID of the user to add as a participant.
* @param {string} data.name - The name of the user to add as a participant.
* @returns {Promise<Poll>} - The updated poll with the new participant added.
* @throws {Error} - Throws an error if adding the participant to the poll fails.
*/
async addParticipant({
pollID,
userID,
name,
}: AddParticipantData): Promise<Poll> {
this.logger.log(
`Attempting to add a participant with userID/name: ${userID}/${name} to pollID: ${pollID}`,
);
const key = `polls:${pollID}`;
const participantPath = `.participants.${userID}`;
try {
// Use the JSON.SET command to set the value of a specific JSON path within a Redis key.
// This command sets the name of the participant at the path ".participants.<userID>" within the poll object.
await this.redisClient.send_command(
'JSON.SET',
key,
participantPath,
JSON.stringify(name),
);
const pollJSON = await this.redisClient.send_command(
'JSON.GET',
key,
'.',
);
const poll = JSON.parse(pollJSON) as Poll;
this.logger.debug(
`Current Participants for pollID: ${pollID};)`,
poll.participants,
);
return poll;
} catch (error) {
this.logger.error(
`Failed to add a participant with userID/name: ${userID}/${name} to pollID: ${pollID}`,
);
throw error;
}
}
}
import {
Inject,
Injectable,
InternalServerErrorException,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Redis } from 'ioredis';
import { IORedisKey } from 'src/redis.module';
import {
AddParticipantData,
CreatePollData,
RemoveParticipantData,
} from './types';
import { Poll } from 'shared';
import { error } from 'console';
@Injectable()
export class PollsRepository {
// to use time-to-live from configuration
private readonly ttl: string;
private readonly logger = new Logger(PollsRepository.name);
constructor(
configService: ConfigService,
@Inject(IORedisKey) private readonly redisClient: Redis,
) {
this.ttl = configService.get('POLL_DURATION');
}
/**
Create a new poll in Redis with the specified topic, votes per voter, poll ID, and admin ID.
@param {CreatePollData} data - The data required to create a poll.
@param {number} data.votesPerVoter - The number of votes allowed per voter.
@param {string} data.topic - The topic of the poll.
@param {string} data.pollID - The ID of the poll.
@param {string} data.userID - The ID of the user creating the poll.
@returns {Promise<Poll>} - The newly created poll.
@throws {InternalServerErrorException} - Throws an error if creating the poll fails.
*/
async createPoll({
votesPerVoter,
topic,
pollID,
userID,
}: CreatePollData): Promise<Poll> {
// Create initial poll object with specified details
const initialPoll = {
id: pollID,
topic,
votesPerVoter,
participants: {},
adminID: userID,
hasStarted: false,
};
// Log creation of poll object
this.logger.log(
`Creating new poll: ${JSON.stringify(initialPoll, null, 2)} with TTL ${
this.ttl
}`,
);
// Set Redis key-value pair for the new poll with a specified time to live (TTL)
const key = `polls:${pollID}`;
try {
await this.redisClient
.multi([
['send_command', 'JSON.SET', key, '.', JSON.stringify(initialPoll)],
['expire', key, this.ttl],
])
.exec();
return initialPoll;
} catch (error) {
// Log and throw error if creation of poll fails
this.logger.error(
`Failed to add poll ${JSON.stringify(initialPoll)}\n${error}`,
);
throw new InternalServerErrorException();
}
}
/**
Get an existing poll from Redis.
@param {string} pollID - The ID of the poll.
@returns {Promise<Poll>} - The poll retrieved from Redis.
@throws {Error} - Throws an error if getting the poll from Redis fails.
*/
async getPoll(pollID: string): Promise<Poll> {
// Log attempt to retrieve poll
this.logger.log(`Attempting to get poll with: ${pollID}`);
// Retrieve poll from Redis using pollID
const key = `polls:${pollID}`;
try {
const currentPoll = await this.redisClient.send_command(
'JSON.GET',
key,
'.',
);
this.logger.verbose(currentPoll);
return JSON.parse(currentPoll);
} catch (error) {
// Log and throw error if retrieval of poll fails
this.logger.error(`Failed to get ${pollID}`);
throw error;
}
}
/**
* Add a participant to a poll.
*
* @param {AddParticipantData} data - Object containing the pollID, userID, and name of the participant to add.
* @returns {Promise<Poll>} The updated poll object with the added participant.
*/
async addParticipant({
pollID,
userID,
name,
}: AddParticipantData): Promise<Poll> {
// Log that we are attempting to add a participant to the poll with the provided userID and name.
this.logger.log(
`Attempting to add a participant with userID/name: ${userID}/${name} to pollID: ${pollID}`,
);
// Generate the Redis key and participant path strings for the poll and participant, respectively.
const key = `polls:${pollID}`;
const participantPath = `.participants.${userID}`;
try {
// Use the JSON.SET command to set the value of a specific JSON path within a Redis key.
// This command sets the name of the participant at the path ".participants.<userID>" within the poll object.
await this.redisClient.send_command(
'JSON.SET',
key,
participantPath,
JSON.stringify(name),
);
// Get an existing poll from Redis
return this.getPoll(pollID);
} catch (error) {
// If an error occurred while adding the participant, log the error and re-throw it.
this.logger.error(
`Failed to add a participant with userID/name: ${userID}/${name} to pollID: ${pollID}`,
);
throw error;
}
}
/**
Remove a participant from a poll in Redis and returns the updated poll.
@param {RemoveParticipantData} data - The data required to remove a participant from a poll.
@param {string} data.pollID - The ID of the poll.
@param {string} data.userID - The ID of the user to remove from the poll participants.
@returns {Promise<Poll>} - The updated poll with the participant removed.
@throws {Error} - Throws an error if removing the participant from the poll fails.
*/
async removeParticipant({
pollID,
userID,
}: RemoveParticipantData): Promise<Poll> {
// Log the removal of the participant.
this.logger.log(`removing userID: ${userID} from poll: ${pollID}`);
// Construct the Redis key and JSON path for the participant.
const key = `polls:${pollID}`;
const participantPath = `.participants.${userID}`;
try {
// Remove the participant from the poll using the Redis JSON.DEL command.
await this.redisClient.send_command('JSON.DEL', key, participantPath);
// Return the updated poll without the removed participant.
return this.getPoll(pollID);
} catch (error) {
// Log an error if the participant removal fails.
this.logger.error(
`Failed to remove userID: ${userID} from poll: ${pollID}`,
error,
);
}
}
}
1) 특정 poll의 특정 유저를 remove하기 위한 repository의 메서드입니다.
2) 파라미터는 객체로 넘겨 받으며 이를 object destructring을 통해 각각 pollID, userID로 처리하며 Promise Poll 객체를 반환하게 됩니다.
3) redisClient의 send_command 메서드를 이용하여 특정 poll의 키에 접근하고 더불어 특정 참가자를 제거 하기 위한 값을 seond_command메서드의 매개변수로 각각 넘겨주어 호출합니다.
4) 정상적으로 로직이 수행 완료되면 PollsRepository의 getPoll 메서드에 pollID를 호출하여 반환값인 Poll 객체를 반환하여줍니다.
5) 만약 오류가 발생하면 catch 구문에서 error로그로 남기도록 합니다.
/**
Remove a participant from a poll in Redis and returns the updated poll.
@param {RemoveParticipantData} data - The data required to remove a participant from a poll.
@param {string} data.pollID - The ID of the poll.
@param {string} data.userID - The ID of the user to remove from the poll participants.
@returns {Promise<Poll>} - The updated poll with the participant removed.
@throws {Error} - Throws an error if removing the participant from the poll fails.
*/
async removeParticipant({
pollID,
userID,
}: RemoveParticipantData): Promise<Poll> {
// Log the removal of the participant.
this.logger.log(`removing userID: ${userID} from poll: ${pollID}`);
// Construct the Redis key and JSON path for the participant.
const key = `polls:${pollID}`;
const participantPath = `.participants.${userID}`;
try {
// Remove the participant from the poll using the Redis JSON.DEL command.
await this.redisClient.send_command('JSON.DEL', key, participantPath);
// Return the updated poll without the removed participant.
return this.getPoll(pollID);
} catch (error) {
// Log an error if the participant removal fails.
this.logger.error(
`Failed to remove userID: ${userID} from poll: ${pollID}`,
error,
);
}
}
이미 구현된 getPoll 메서드를 사용하여 약 10줄의 코드를 1줄로 대체 할 수 있습니다.
async addParticipant
//생략
//변경전 --------------------
const pollJSON = await this.redisClient.send_command(
'JSON.GET',
key,
'.',
);
const poll = JSON.parse(pollJSON) as Poll;
this.logger.debug(
`Current Participants for pollID: ${pollID};)`,
poll.participants,
);
return poll;
// 변경 후 ---------------------
// Get an existing poll from Redis
return this.getPoll(pollID);
// ----------------------------
} catch (error) {
// 생략
// 이 인터페이스는 참가자 객체의 구조를 정의합니다. 참가자 객체는
// 각 키가 참가자 ID이고 각 값이 참가자의 이름인 객체입니다.
export interface Participants {
[participantID: string]: string;
}
// 이 인터페이스는 poll 객체의 구조를 정의합니다. poll 객체는 여러 속성을 갖습니다:
// - id: poll의 ID를 나타내는 문자열
// - topic: poll의 주제를 나타내는 문자열
// - votesPerVoter: 각 투표자가 행사할 수 있는 투표 수를 나타내는 숫자
// - participants: poll에 참가하는 참가자를 포함하는 객체
// - adminID: poll 관리자의 ID를 나타내는 문자열
// - hasStarted: poll이 시작되었는지 여부를 나타내는 bool 값
export interface Poll {
id: string;ㄹㄹ
topic: string;
votesPerVoter: number;
participants: Participants;
adminID: string;
hasStarted: boolean;
// 아래 속성은 현재 주석 처리되어 있지만, 나중에 추가될 수 있습니다:
// - nominations: poll의 후보자를 포함하는 객체
// - rankings: poll의 순위를 포함하는 객체
// - results: poll 결과를 포함하는 객체
}
async createPoll({
votesPerVoter,
topic,
pollID,
userID,
}: CreatePollData): Promise<Poll> {
// Create initial poll object with specified details
const initialPoll = {
id: pollID,
topic,
votesPerVoter,
participants: {},
adminID: userID,
hasStarted: false,
};
// 생략
export interface AddParticipantFields {
pollID: string;
userID: string;
name: string;
}
export interface RemoveParticipantFields {
pollID: string;
userID: string;
}
// 생략
polls.service.ts
// 생략
async addParticipant(addParticipant: AddParticipantFields): Promise<Poll> {
return this.pollsRepository.addParticipant(addParticipant);
}
async removeParticipant(
removeParticipant: RemoveParticipantFields,
): Promise<Poll | void> {
const poll = await this.pollsRepository.getPoll(removeParticipant.pollID);
if (!poll.hasStarted) {
const updatedPoll = await this.pollsRepository.removeParticipant(
removeParticipant,
);
return updatedPoll;
}
}
// 생략
이 코드는 @nestjs/websockets 모듈을 사용하여 WebSocket 게이트웨이를 구현한 TypeScript 코드입니다. PollsGateway 클래스는 OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect 인터페이스를 구현하고 있으며, @WebSocketGateway 데코레이터를 사용하여 "polls" 네임스페이스로 WebSocket 서버를 생성합니다.
ValidationPipe과 WsCatchAllFilter를 사용하여 WebSocket 요청을 유효성 검사하고 예외 처리합니다. PollsService를 주입받아 생성자에서 사용합니다.
handleConnection 메서드는 클라이언트가 WebSocket 서버에 연결되면 호출되며, 연결된 클라이언트의 ID, poll ID 및 이름을 로깅합니다. 이 메서드는 또한 연결된 모든 클라이언트에게 "hello" 메시지를 보냅니다.
handleDisconnect 메서드는 클라이언트가 WebSocket 서버에서 연결을 해제하면 호출됩니다. 이 메서드는 연결이 해제된 클라이언트의 ID, poll ID 및 이름을 로깅하고, 남아 있는 모든 클라이언트에게 "participants_updated" 메시지를 보냅니다.
test 메서드는 예외 처리를 위해 BadRequestException을 던지는 테스트용 WebSocket 메시지 핸들러입니다.
import { BadRequestException, Logger, OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer, Namespace, UseFilters, UsePipes, } from '@nestjs/websockets';
import { ValidationPipe } from '@nestjs/common';
import { WsCatchAllFilter } from './ws-catch-all.filter';
import { PollsService } from './polls.service';
import { SocketWithAuth } from './interfaces/socket-with-auth.interface';
// Use the ValidationPipe for validating incoming messages
@UsePipes(new ValidationPipe())
// Use the WsCatchAllFilter to catch all WebSocket exceptions
@UseFilters(new WsCatchAllFilter())
// Declare this class as a WebSocket Gateway with the namespace "polls"
@WebSocketGateway({
namespace: 'polls',
})
export class PollsGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
// Create a Logger instance for this class
private readonly logger = new Logger(PollsGateway.name);
constructor(private readonly pollsService: PollsService) {}
// Declare that the "io" property is a WebSocketServer instance
@WebSocketServer() io: Namespace;
/**
* Called when the gateway is initialized.
* Provides logging to indicate that the WebSocket Gateway has been initialized.
*/
afterInit(): void {
this.logger.log(`Websocket Gateway initialized.`);
}
/**
* Called when a client connects to the WebSocket Gateway.
* Logs the user ID, poll ID, and name of the connected socket, as well as the socket ID.
* Sends a "hello" message to all connected clients.
* @param client The connected socket client
*/
handleConnection(client: SocketWithAuth) {
const sockets = this.io.sockets;
this.logger.debug(
`Socket connected with userID: ${client.userID}, pollID: ${client.pollID}, and name: "${client.name}"`,
);
this.logger.log(`WS Client with id: ${client.id} connected!`);
this.logger.debug(`Number of connected sockets: ${sockets.size}`);
// Send a "hello" message to all connected clients
this.io.emit('hello', `from ${client.id}`);
}
/**
* Called when a client disconnects from the WebSocket Gateway.
* Logs the user ID, poll ID, and name of the disconnected socket, as well as the socket ID.
* Sends a "participants_updated" message to all remaining clients.
* @param client The disconnected socket client
*/
handleDisconnect(client: SocketWithAuth) {
const sockets = this.io.sockets;
this.logger.debug(
`Socket connected with userID: ${client.userID}, pollID: ${client.pollID}, and name: "${client.name}"`,
);
this.logger.log(`Disconnected socket id: ${client.id}`);
this.logger.debug(`Number of connected sockets: ${sockets.size}`);
// TODO - remove client from poll and send `participants_updated` event to remaining clients
}
/**
* A test WebSocket message handler that throws a BadRequestException.
* Used to test exception handling with the WsCatchAllFilter.
* @returns {Promise<void>}
*/
@SubscribeMessage('test')
async test() {
throw new BadRequestException('plain ol');
}
}
handleConnection 메서드는 클라이언트가 WebSocket에 연결될 때 호출됩니다. pollID를 기반으로 클라이언트를 방에 참여시키고, 클라이언트를 투표 참가자로 추가하고, 방의 모든 클라이언트에게 poll_updated 이벤트를 발생시킵니다.
handleDisconnect 메서드는 클라이언트가 WebSocket에서 연결을 해제 할 때 호출됩니다. 클라이언트를 투표에서 제거하고 방의 모든 클라이언트에게 poll_updated 이벤트를 발생시킵니다.
SubscribeMessage 데코레이터를 사용하여 'test' 메시지를 처리하고 BadRequestException을 throw합니다.
clinet1~3 각각 연결을 시도합니다.