kagan-draca 2024. 10. 28. 16:34

먼저, 

 

src 폴더에서 utils라는 폴더를 만들어주고 그 폴더 안에 parser라는 폴더를 만들어준다.

parser 폴더에는 packetParser.js라는 파일을 만들어준다.

 

packetParser.js 파일

export const packetParser = (data)=>{
  // data : 매개변수로 byte 배열을 매개 변수로 받아옵니다.
}

 

packetParser라는 함수를 만들어주고 (data)를 매개변수로 가져 옵니다.

그 후, 

import { getProtoMessages } from '../../init/loadProtos.js';
export const packetParser = (data)=>{
  // data : 매개변수로 byte 배열을 매개 변수로 받아옵니다.
  const protoMessages = getProtoMessages();
  // 프로토 메시지를 가져온다.
}

init 폴더에 있는 loadProtos.js의 getProtoMessages() 메서드를 통해

.proto라는 파일 내부 정보들을 가져옵니다.

 

우리가 제일 처음해줘야 할 내용은 공통 패킷 구조를 디코딩 해주는 것 입니다.

 

공통 패킷 구조란? .proto 파일에 있는

message Packet {
  uint32 handlerId = 1;      // 핸들러 ID (4바이트)
  string userId = 2;         // 유저 ID (UUID, 16바이트)
  string clientVersion = 3;  // 클라이언트 버전 (문자열)
  uint32 sequence = 4;       // 유저의 호출 수 (42억)
  bytes payload = 5;         // 실제 데이터
}

payload를 제외한 handlerId, userId, clientVersion, sequence를 의미 합니다.

 

공통 패킷의 구조가 

message Packet {

패킷었기 때문에,

 

  // 공통 패킷 구조를 디코딩
  const Packet = protoMessages.common.Packet;

 

Packet이라는 변수를 만들어주고 protoMessages.common.Packet으로 공통 패킷 구조를 가져옵니다.

 

protoMessages.common.Packet으로 가져올 수 있는 이유는

packetNames.js으로 

export const packetNames = {
  common: {
    Packet: 'common.Packet',
    Ping: 'common.Ping',
  },
  response: {
    Response: 'response.Response',
  },
};

위와 같이 

packetNames 객체에 key로 common을 만들고 해당 key에 대한 value로 다시 객체를 만들어 Packet이라는 키 안에 'common.Packet'으로 만들어놨기 때문에 

 

 

init 폴더 아래에 있는 loadProtos.js에서

import { packetNames } from '../protobuf/packetNames.js';

 를 바탕으로,

// 로드된 프로토 메시지들을 저장할 객체
const protoMessages = {};

위의 객체에 

export const loadProtos = async () => {
  try {
    const root = new protobuf.Root();

    await Promise.all(protoFiles.map((file) => root.load(file)));

    for (const [packetName, types] of Object.entries(packetNames)) {
      protoMessages[packetName] = {};
      for (const [type, typeName] of Object.entries(types)) {
        console.log(types);
        protoMessages[packetName][type] = root.lookupType(typeName);
      }
    }

    console.log('Protobuf 파일이 로드되었습니다.');
  } catch (error) {
    console.error('Protobuf 파일 로드 중 오류가 발생했습니다.', error);
  }
};

의 코드를 바탕으로 모든 .proto 파일을 Root 객체로 불러오고, 이를 통해 생성된 메시지 타입을 protoMessages에 저장하는 비동기 함수입니다.

 

Promise.all로 .proto 파일을 비동기로 한 번에 읽어오고, packetNames 객체에서 정의한 네임스페이스와 타입명을 기반으로 protoMessages 객체에 각 타입을 등록해주었기 때문에 

 

  // 공통 패킷 구조를 디코딩
  const Packet = protoMessages.common.Packet;

위와 같이 사용할 수 있었습니다.

 

그 후,

 

  let packet;

  try {
    packet = Packet.decode(data);
  } catch (error) {
    console.error(error);
  }

으로, 함수의 매개변수로 전달 받은 data를

디코딩해줍니다.

 

이때, 오류가 발생할 수 있기 때문에, try-catch 문으로 애러가 발생할 경우 애러를 출력할 수 있게 만들어줍니다.

 

위의 코드를 바탕으로,

 

  let packet;

let packet 안에는 

message Packet {
  uint32 handlerId = 1;      // 핸들러 ID (4바이트)
  string userId = 2;         // 유저 ID (UUID, 16바이트)
  string clientVersion = 3;  // 클라이언트 버전 (문자열)
  uint32 sequence = 4;       // 유저의 호출 수 (42억)
  bytes payload = 5;         // 실제 데이터
}

payload를 제외한 

  uint32 handlerId = 1;      // 핸들러 ID (4바이트)
  string userId = 2;         // 유저 ID (UUID, 16바이트)
  string clientVersion = 3;  // 클라이언트 버전 (문자열)
  uint32 sequence = 4;       // 유저의 호출 수 (42억)

내용물이 담기게 될 것 입니다.


  const handlerId = packet.handlerId;
  const userId = packet.userId;
  const clientVersion = packet.clientVersion;
  const payload = packet.payload;
  const sequence = packet.sequence;

  console.log(`clientVersion : ${clientVersion}`);
  return { handlerId, packet, payload, sequence };

그래서, packet.~~~~~~로 해당 키에 대한 value를 변수에 담아주고,

 

clientVersion을 제외한 나머지 내용을 return 객체로 묶어 return 해줍니다.

 

clientVersion을 보내지 않는 이유는 클라이언트가 가지고 있는 clinetVersion과 서버가 가지고 있는 clientVersion이 다를 경우 Error를 발생시켜 주기 위해서 입니다.

 

이렇게 만들어진

export const packetParser = (data) => {
  // data : 매개변수로 byte 배열을 매개 변수로 받아옵니다.
  const protoMessages = getProtoMessages();
  // 프로토 메시지를 가져온다.

  // 공통 패킷 구조를 디코딩
  const Packet = protoMessages.common.Packet;
  let packet;

  try {
    packet = Packet.decode(data);
  } catch (error) {
    console.error(error);
  }

  const handlerId = packet.handlerId;
  const userId = packet.userId;
  const clientVersion = packet.clientVersion;
  const payload = packet.payload;
  const sequence = packet.sequence;

  console.log(`clientVersion : ${clientVersion}`);
  return { handlerId, packet, payload, sequence };
};

packetParser는 가장 최초로 데이터를 받는 onData.js 파일의 onData에 호출이 돼야 합니다.

 

export const onData = (socket) => async (data) => {
  // 기존 버퍼에 새로 수신된 데이터를 추가
  socket.buffer = Buffer.concat([socket.buffer, data]);

  // 패킷의 총 헤더 길이 (패킷 길이 정보 + 타입 정보)
  const totalHeaderLength = config.packet.totalLength + config.packet.typeLength;

  // 버퍼에 최소한 전체 헤더가 있을 때만 패킷을 처리
  while (socket.buffer.length >= totalHeaderLength) {
    // 1. 패킷 길이 정보 수신 (4바이트)
    const length = socket.buffer.readUInt32BE(0);

    // 2. 패킷 타입 정보 수신 (1바이트)
    const packetType = socket.buffer.readUInt8(config.packet.totalLength);
    // 3. 패킷 전체 길이 확인 후 데이터 수신
    if (socket.buffer.length >= length) {
      // 패킷 데이터를 자르고 버퍼에서 제거
      const packet = socket.buffer.slice(totalHeaderLength, length);
      socket.buffer = socket.buffer.slice(length);
    } else {
      // 아직 전체 패킷이 도착하지 않음
      break;
    }
  }
};

해당 부분은 패킷을 수신하고, 패킷이 모두 수신 됐는지 확인 후 패킷의 정보를 가진 Header 부분을 잘라

Body에 있는 Message만을 추출하는 코드 입니다.

 

해당 코드의 

    if (socket.buffer.length >= length) {
      // 패킷 데이터를 자르고 버퍼에서 제거
      const packet = socket.buffer.slice(totalHeaderLength, length);

    }

if문 안에서 

 

    if (socket.buffer.length >= length) {
      // 패킷 데이터를 자르고 버퍼에서 제거
      const packet = socket.buffer.slice(totalHeaderLength, length);
      socket.buffer = socket.buffer.slice(length);

      switch(packetType)
      {
        case PACKET_TYPE.PING:
        break;
        case PACKET_TYPE.NORMAL:
        break;
      }

    }

switch(packetType)으로  PACKET_TYPE.PING과 PACKET_TYPE.NORMAL인 경우를 가져옵니다.

그렇게 할 수 있는 이유는 우리가 이전에

export const TOTAL_LENGTH = 4;
export const PACKET_TYPE_LENGTH = 1;
export const PACKET_TYPE = {
  PING: 0,
  NORMAL: 1,
  GAME_START: 2,
  LOCATION: 3,
};

위와 같이 PING과 NORMAL을 PACKET_TYPE 객체안에 KEY와 VALUE로 정의해 놨기 때문입니다.

 

다시 돌아와

        case PACKET_TYPE.NORMAL:
          const { handlerId, userId, payload, sequence } = packetParser(packet);
          console.log(`handlerId : ${handlerId}`);
          console.log(`userId : ${userId}`);
          console.log(`payload : ${payload}`);
          console.log(`sequence : ${sequence}`);
          break;

PACKET_TYPE.NORMAL 의 경우 안에

 

const { handlerId, userId, payload, sequence } = packetParser(packet);

으로 packetParser(packet)을 가져와 객체분하할당으로 packetParser의 return 값을 다 가져옵니다.

 

이제 클라이언트의 접속에 따른 서버의 동작을 확인하기 위해,

 

client.js 파일에 아래의 내용을 붙어넣어줍니다.

import net from 'net';
import { getProtoMessages, loadProtos } from './src/init/loadProtos.js';

const TOTAL_LENGTH = 4; // 전체 길이를 나타내는 4바이트
const PACKET_TYPE_LENGTH = 1; // 패킷타입을 나타내는 1바이트

const readHeader = (buffer) => {
  return {
    length: buffer.readUInt32BE(0),
    packetType: buffer.writeUInt8(TOTAL_LENGTH),
  };
};

const sendPacket = (socket, packet) => {
  const protoMessages = getProtoMessages();
  const Packet = protoMessages.common.Packet;
  if (!Packet) {
    console.error('Packet 메시지를 찾을 수 없습니다.');
    return;
  }

  const buffer = Packet.encode(packet).finish();

  // 패킷 길이 정보를 포함한 버퍼 생성
  const packetLength = Buffer.alloc(TOTAL_LENGTH);
  packetLength.writeUInt32BE(buffer.length + TOTAL_LENGTH + PACKET_TYPE_LENGTH, 0); // 패킷 길이에 타입 바이트 포함

  // 패킷 타입 정보를 포함한 버퍼 생성
  const packetType = Buffer.alloc(PACKET_TYPE_LENGTH);
  packetType.writeUInt8(1, 0); // NORMAL TYPE

  // 길이 정보와 메시지를 함께 전송
  const packetWithLength = Buffer.concat([packetLength, packetType, buffer]);

  socket.write(packetWithLength);
};

// 서버에 연결할 호스트와 포트
const HOST = 'localhost';
const PORT = 5555;

const client = new net.Socket();

client.connect(PORT, HOST, async () => {
  console.log('Connected to server');
  await loadProtos();

  const message = {
    handlerId: 2,
    userId: 'xyz',
    payload: {},
    clientVersion: '1.0.0',
    sequence: 0,
  };

  sendPacket(client, message);
});

client.on('data', (data) => {
  const buffer = Buffer.from(data); // 버퍼 객체의 메서드를 사용하기 위해 변환

  const { handlerId, length } = readHeader(buffer);
  console.log(`handlerId: ${handlerId}`);
  console.log(`length: ${length}`);

  const headerSize = TOTAL_LENGTH + PACKET_TYPE_LENGTH;
  // 메시지 추출
  const message = buffer.slice(headerSize); // 앞의 헤더 부분을 잘라낸다.

  console.log(`server 에게 받은 메세지: ${message}`);
});

client.on('close', () => {
  console.log('Connection closed');
});

client.on('error', (err) => {
  console.error('Client error:', err);
});

 

서버를 실행하고 클라리언트를 동작시키면

위와 같이 

 

클라이언트의 이밴트에 따른 handlerId와 ,userId, payload, sequence가 정상적으로 잘 넘어온 것을 볼 수 있습니다.