멀티플레이 게임 파일 구조 이해
server.js ⇒ 서버 띄우는 용
constants ⇒ 상수 ,패킷타입 정의 폴더
- env.js ⇒ 선언한 환경변수를 한번에 관리하는 파일
//환경변수 관리
import dotenv from "dotenv";
dotenv.config();
//서버정보
//왼쪽은 .env데이터 오른쪽은 defalut값
export const HOST = process.env.HOST || "localhost";
export const PORT = process.env.PORT || 5555;
export const CLIENT_VERSION = process.env.CLIENT_VERSION || "1.0.0";
//DB정보
export const DB_NAME = process.env.DB_NAME || "user_db";
export const DB_USER = process.env.DB_USER || "root";
export const DB_PASSWORD = process.env.DB_PASSWORD || "1234";
export const DB_HOST = process.env.DB_HOST || "127.0.0.1";
export const DB_PORT = process.env.DB_PORT || "3306";
/**********************************************************/
//.env에서 환경변수 선언
HOST =127.0.0.1
PORT=5555
CLIENT_VERSION = 1.0.0
##DB정보
DB_NAME = user_db
DB_USER = root
DB_PASSWORD=1234
DB_HOST=127.0.0.1
DB_PORT=3306
2. header.js ⇒ 상수와 패킷타입 정의
//상수 선언
export const TOTAL_LENGTH = 4;//패킷의 전체 길이를 측정하는 패킷의 길이
export const PACKET_TYPE_LENGTH = 1;//패킷타입의 길이
//패킷타입 정의
export const PACKET_TYPE = {
PING: 0, //왕복 레이턴시를 측정하기 위한 패킷타입
NORMAL: 1, //데이터 처리가 필요한 패킷타입
LOCATION: 3,//위치에 관련한 업데이트에 필요한 패킷타입
};
3. handlerIds.js ⇒ 클라이언트에서 온 요청을 처리할 핸들러ID를 관리하는 파일 클라이언트에서 요청한 핸들러ID에따라 어떤 작업을 수행할지 결정한다.
export const RESPONSE_SUCCESS_CODE = 0;//요청이 성공했다면 클라이언트에 0을 보내준다.
//특별한 요청을 식별하기 위한 상수들이다.
//각 ID는 클라와 서버간의 통신에서 어떤 작업을 수행할지를 명확히한다.
export const HANDLER_IDS = {
INITIAL: 0,
LOCATION_UPDATE: 2,
};
events ⇒ 모든 소켓 이벤트를 관리하는 파일
- onConnection.js ⇒ server.js에서 서버를 생성할경우 실행되는 함수이다. 여러가지 이벤트를 가지고 있으며 서버 실행시 그 이벤트들을 대기시키는 함수
import { onData } from "./onData.js";
import { onEnd } from "./onEnd.js";
import { onError } from "./onError.js";
//server.js에 createServer로 서버가 실행되면 가장 최상위에 보내준다.
export const onConnection = (socket) => {
console.log(`Client connected from : ${socket.remoteAddress} : ${socket.remotePort}`);
//소켓에는 클라이언트의 정보가 들어있음
socket.buffer = Buffer.alloc(0); //아무 크기가 없는 버퍼 객체를 각 클라이언트 소켓에 넣어줌 여기에 데이터를 넣었다 뻇다
//대기중인 소켓 이벤트
socket.on("data", onData(socket));
socket.on("end", onEnd(socket));
socket.on("error", onError(socket));
};
//서버 실행시 모든 이벤트들 무한정 대기
//server.js
const server = net.createServer(onConnection);
- onDate.js ⇒ 클라이언트에서 받아온 데이터를 처리하는 파일
init ⇒ 서버가 켜짐과 동시에 로드가 되는 내용들을 담은파일
- index.js ⇒ 서버가 켜지기전 메모리에 모든 파일을 로드하는 함수를 작성한 파일
//서버 실행시 불러올 데이터를 여기 추가
import { addGameSession } from "../sessions/game.session.js";
import { testConnection } from "../utils/db/testConnection.js";
import { loadProtos } from "./loadProto.js";
import { v4 as uuidv4 } from "uuid";
//이 함수가 실행된 후에 서버가 실행되야 하므로 비동기 처리를 해준다.
const initServer = async () => {
try {
// 프로토타입 파일을 로드한다.
await loadProtos();
//게임생성시 인자로 넣어줄 gameId를 UUID로 생성
const gameId = uuidv4();
// 생성된 게임 ID를 사용하여 새로운 게임 세션을 추가
const gameSession = addGameSession(gameId);
// 데이터베이스 연결을 테스트
await testConnection();
} catch (err) {
console.error(err);
//오류 발생시 프로세스를 종료
process.exit(1);
}
};
export default initServer;
//server.js에서 호출
//서버실행시 필요한 데이터들이 전부 들어왔다면 그 이후 서버를 실행한다.
initServer()
.then(() => {
//포트 , 호스트주소 ,백로그
server.listen(PORT, HOST, () => {
console.log(`서버 켜짐${HOST} : ${PORT}`);
});
})
.catch((err) => {
console.error(err);
process.exit(1);
});
protobuf ⇒ 여러가지 proto파일들을 담아놓은 폴더
- proto 파일 ⇒ 프로토콜 버퍼 파일, 즉 .proto파일은 데이터의 구조와 형식을 정의하는데 사용되는 파일이다. 간단하게 말해 클라이언트에서 받아올 데이터의 형식을 작성하는 파일이다.
- 데이터 구조 정의 .proto 파일에서는 메시지 형식을 정의하며 ,각 메시지의 필드와 타입을 지정한다.
- 타입 안정성 정의된 메시지 타입을 사용하여 클라이언트와 서버간의 데이터 교환 시 타입 안정성을 보장한다.
- 자동 코드 생성 프로토콜 버퍼 컴파일러를 사용하여 .proto파일로부터 다양한 프로그래밍 언어에 맞는 코드 (JS,Python,Java등)를 자동으로 생성할 수 있다. 이를 통해 서버와 클라이언트 간의 데이터 처리 방식을 일관되게 유지할 수 있다.
- 버전관리 프로토콜 버퍼는 데이터 구조의 버전 관리를 용이하게 하여 이전 버전과의 호환성을 유지하면서 새로운 필드를 추가하거나 기존 필드를 변경할 수 있다.
syntax ="proto3";
package common;
message Packet {
uint32 handlerId = 1;
string userId = 2;
string version =3;
bytes payload =4;
}
- packetNames.js ⇒ 패킷을 어떤 패킷으로 설정했는지 작성하는 파일 proto 파일에서 작성한값을 편하게 맵핑해주는 용도의 파일이다.
//앞에 선언한 common,Packet등등은 맵핑용
export const packetNames = {
common: {
Packet: "common.Packet", //common.Packet:protobuf를 사용할때 사용할 이름
Ping: "common.Ping",
},
initial: {
InitialPayload: "initial.InitialPayload",
},
game: {
LocationUpdatePayload: "game.LocationUpdatePayload",
},
gameNotification: {
LocationUpdate: "gameNotification.LocationUpdate",
},
response: {
Response: "response.Response",
},
};
/*예시
common = 패킷이름
Packet = 패킷타입
common.Pakcet = 타입이름
*/
- loadProto.js ⇒ .proto파일을 읽어와서 메모리에 로드하는 로직을 작성한 파일 프로토타입 파일을 동적으로 찾고,로드하며,이를 통해 생성된 메시지 타입을 관리한다.
- 모듈 임포트
import fs from "fs"; // 파일 시스템 모듈
import path from "path"; // 경로 관련 모듈
import { fileURLToPath } from "url"; // URL 모듈의 함수
import protobuf from "protobufjs"; // protobufjs 라이브러리
import { packetNames } from "../protobuf/packetNames.js"; // 패킷 이름 정의
- 설명
fs: 파일 시스템에 접근하여 파일과 디렉토리를 읽기 위한 모듈입니다. path: 파일 경로를 조작하기 위한 모듈입니다. fileURLToPath: ES 모듈에서 현재 모듈의 파일 경로를 가져오는 데 사용됩니다. protobuf: 프로토콜 버퍼를 다루기 위한 라이브러리입니다. packetNames: 프로토타입 파일에서 정의한 패킷 이름을 가져옵니다.
- 파일 경로 설정
const __filename = fileURLToPath(import.meta.url); //nodejs에서 ES모듈을 사용할때 현재 모듈의 파일 경로를 가져오는 코드
const __dirname = path.dirname(__filename); //filename으로 찾은 경로에있는 디렉토리의 이름을 반환함
const protoDir = path.join(__dirname, "../protobuf"); //현재위치:init 폴더 이므로 ../로 상위폴더의 protobuf파일을 찾아 가져온다.
현재 파일의 경로를 가져와서 __dirname을 설정한다.
protoDir = 프로토타입 파일이 위치한 경로를 넣어준다.
- 프로토파일 검색 함수
//최초 실행시 빈 배열을 넘겨줌
const getAllProtoFiles = (dir, fileList = []) => {
const files = fs.readdirSync(dir); //주어진 디렉토리의 현재 경로를 읽어오는 부분
files.forEach((file) => {
const filePath = path.join(dir, file); //파일의 전체 경로를 구함
const stat = fs.statSync(filePath); //현재 파일 상태 확인
//현재 파일의 상태가 디렉토리일 경우 getAllProtoFiles를 재귀호출
if (stat.isDirectory()) {
//현재 파일의 상태가 디렉토리 일 경우 배열을 다시 넘겨줌
getAllProtoFiles(filePath, fileList);
} else if (path.extname(file) === ".proto") {
//현재 file의 확장자명이 .proto일 경우 fileList에 파일을 추가한다.
fileList.push(filePath);
}
});
//마지막 값을 반환
return fileList;
};
주어진 디렉토리 내의 모든 .proto파일을 재귀적으로 찾아서 그 경로를 배열에 저장하는 함수다.
디렉토리 내의 파일을 읽고 각 파일의 상태를 확인하여 디렉토리일 경우 재귀적으로 호출하며 .proto파일을 발견하면 fileList에 추가한다.
간단하게 말해서 최상위 프로토파일 디렉토리 로들어가 그파일안에 있는게 .proto파일이라면 배열에 저장하고 파일이아닌 디렉토리가 들어있다면 그 디렉토리안으로 들어가 .proto파일을 찾는다.
함수 실행시 예상도
📦protobuf = 디렉토리
┣ 📂notification = getAllProtoFiles = 파일로 들어감
┃ ┗ 📜game.notification.proto = .proto 이므로 배열에 저장
┣ 📂request = getAllProtoFiles = 파일로 들어감
┃ ┣ 📜common.proto = .proto 이므로 배열에 저장
┃ ┣ 📜game.proto = .proto 이므로 배열에 저장
┃ ┗ 📜Initial.proto = .proto 이므로 배열에 저장
┣ 📂response = getAllProtoFiles = 파일로 들어감
┃ ┗ 📜response.proto = .proto 이므로 배열에 저장
┗ 📜packetNames.js = .proto 이므로 배열에 저장
- 프로토파일 로드 및 메시지 타입 정의 loadProtos 함수는 프로토파일을 비동기적으로 로드한다. protobuf.Root()를 생성하여 프로토타입 메시지의 루트 객체를 만든다. Promise.all을 사용하여 모든 프로토파일을 동시에 로드한다. 로드가 완료되면 packetNames에 정의된 각 패킷 이름에 대해 protoMessages 객체에 메시지타입 저장
//protoFIles배열은 .proto파일들의 전체 경로가 담겨있는 배열이다.
const protoFiles = getAllProtoFiles(protoDir);
//완성된 파일이 들어갈 객체
const protoMessages = {}; //읽어온 파일들을 저장할 객체 생성
export const loadProtos = async () => {
try {
/*protobufjs에서 제공하는 클래스이다. 새로운Protocol Buffers의 루트 객체를 생성하는 코드이다.
이 루트 객체는 메시지 타입을 정의하고 관리할 수 있는 최상위 컨테이너이다.*/
const root = new protobuf.Root(); //row한 파일을 Root메서드를 사용해서 읽는다.
//Promise.all을 사용하여 portoFiles의 배열의 각 파일경로에 대해 비동기적으로 root.load(file)를 호출하여 모든 파일을 동시에 로드한다.
//이 작업이 끝나면 root객체는 모든 .proto파일에 정의된 메시지 타입과 구조체를 메모리에 올린상태가 된다.
await Promise.all(
protoFiles.map(
(file) => root.load(file), //각 파일을 읽고 파싱하여 root객체에 포함시키는 함수다.
),
);
for (const [packetName, types] of Object.entries(packetNames)) {
//packetName: packetNames.js에서 작성해둔 이름
protoMessages[packetName] = {};
for (const [type, typeName] of Object.entries(types))
protoMessages[packetName][type] = root.lookupType(typeName);
}
console.log(`Protobuf파일 로드 성공`);
} catch (err) {
console.error(`Protobuf파일 로드 중 오류 발생`, err);
}
};
- 메시지 타입 접근 함수 getProtoMessages 함수는 protoMessages객체의 얕은 복사본을 반환하여 원본 객체의 변조를 방지한다.
export const getProtoMessages = () => {
return { ...protoMessages };
};