기존엔 데이터 디자이너님께서 엑셀파일에서 매크로를 통해 매 시트마다 json파일을 뽑던 작업을
xlsx파일만 올리면 서버에서 json으로 변환 후 S3버킷에 업로드 하는 기능을 추가하게 됐다.
@Post('upload')
@ApiOperation({ summary: 'Upload Game Data Json File' })
@UseInterceptors(FilesInterceptor('files'))
async upload(
@Body('serverType') serverType: string,
@Body('dataVersion') dataVersion: string,
@Body('clientKeyVersion') clientKeyVersion: string,
@Body('clientKey') dataDoor: string,
@Body('clientSecret') dataHive: string,
@UploadedFiles(new ParseFilePipe())
files: Express.Multer.File[]
) {
await this.dataService.dataSave(
serverType,
dataVersion,
clientKeyVersion,
clientKey,
clientSecret,
files
);
}
async dataSave(
serverType: string,
dataVersion: string,
clientKeyVersion: string,
clientKey: string,
clientSecret: string,
files: Express.Multer.File[]
) {
// AWS S3 에 저장
const awsFileKey = `${serverType}/${dataVersion}`;
const updatedFiles = await this.s3Service.uploadFile(serverType, dataVersion, files);
이때 수정된 파일만 S3에 업로드 후 Slack 웹훅을 통해 알려야 하므로 수정된 파일을 return 받고 data service에 return한다.
async uploadFile(serverType: string, dataVersion: string, files: Express.Multer.File[]) {
let updatedFiles = [];
try {
for (const file of files) {
const fileUpdatedPaths = await this.convertExcelToJsonAndSave(
`${this.uploadFolderPath}/${file.filename}`,
dataVersion
);
updatedFiles = updatedFiles.concat(fileUpdatedPaths);
await this.uploadConvertedFiles(serverType, dataVersion, updatedFiles);
await this.uploadConvertedFiles(serverType, dataVersion, updatedFiles);
}
this.invalidateCache(serverType, dataVersion);
this.logger.log(`Successfully uploaded to S3`);
return updatedFiles;
} catch (err) {
this.logger.error('Error', err);
}
}
4-1. 맨 뒤에 #이 붙어있는 시트는 제외한다.
4-2. 각 시트의 row 수를 구한다. 기존에 사용하던 json과 같은 형식으로 파일을 만들어야 하므로 엑셀의 매크로인 VBA코드를 참고했다.
4-3. 엑셀파일의 5번째 줄엔 server, client, all 이 적혀있다. server는 서버에만 필요한 json파일, client는 클라이언트에만 필요한 json파일, all은 둘 다에게 필요한 json파일이다.
4-4. 엑셀파일의 6번째 줄엔 각 value의 타입이 적혀있다. 타입에 맞추어 json파일을 생성한다. 이때 주의해야 할 점은 int이면서 null일 경우 json은 null로, string이면서 null일 경우 json은 '' 빈 문자열로 변환해주어야 한다.
4-5. 생성된 파일들을 돌면서 기존의 데이터와 다른지 확인하기 위해 JSON.stringify를 사용했다. 컴퓨터가 생성하는 파일은 항상 같은 형식이므로 조금이라도 데이터가 바뀐다면 fileChanged가 true를 반환할 것이다.
4-6. 파일이 변했다면 updatedFile에 넣고 처음에 생성했던 serverData배열과 clientData배열에 있는 값들로 json파일을 만든다.
async convertExcelToJsonAndSave(filePath, dataVersion) {
const workbook = xlsx.readFile(filePath);
const updatedFiles = [];
workbook.SheetNames.forEach(sheetName => {
if (sheetName.endsWith('#')) {
return;
}
const worksheet = workbook.Sheets[sheetName];
const rawData: any = xlsx.utils.sheet_to_json(worksheet, { header: 1 });
const headers = rawData[6];
const types = rawData[5];
const fileTypes = rawData[4];
const startLine = 7;
let actualRowCount = 0;
for (let i = startLine; i < rawData.length; i++) {
if (rawData[i].some(cell => cell !== null && cell !== undefined)) {
actualRowCount++;
} else {
break;
}
}
const serverData = [],
clientData = [];
rawData.slice(startLine, startLine + actualRowCount).forEach(row => {
const serverRow = {},
clientRow = {};
const indexColumnExists = headers.includes('index');
headers.forEach((header, index) => {
if (types[index] !== 'ignore') {
let value = row[index];
if (value !== undefined) {
if (types[index] === 'int') {
value = parseInt(value);
} else if (types[index] === 'string') {
value = String(value);
}
} else {
value = types[index] === 'string' ? '' : null;
}
if (fileTypes[index] === 'server' || fileTypes[index] === 'all') {
serverRow[header] = value;
}
if (fileTypes[index] === 'client' || fileTypes[index] === 'all') {
clientRow[header] = value;
}
}
});
if (!indexColumnExists || (indexColumnExists && serverRow['index'] != null)) {
if (Object.keys(serverRow).length > 0) serverData.push(serverRow);
}
if (!indexColumnExists || (indexColumnExists && clientRow['index'] != null)) {
if (Object.keys(clientRow).length > 0) clientData.push(clientRow);
}
});
const createJsonFile = (type, data, sheetName) => {
const dirPath = path.join(this.uploadFolderPath, `${type}/${dataVersion}`);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
const jsonFilePath = path.join(dirPath, `${sheetName}_${type}.json`);
const jsonData = JSON.stringify({ items: data }, null, 2);
let fileChanged = false;
try {
if (fs.existsSync(jsonFilePath) && fs.lstatSync(jsonFilePath).isFile()) {
const existingData = fs.readFileSync(jsonFilePath, 'utf8');
if (jsonData !== existingData) {
fs.writeFileSync(jsonFilePath, jsonData);
fileChanged = true;
}
} else {
fs.writeFileSync(jsonFilePath, jsonData);
fileChanged = true;
}
} catch (err) {
console.error('Error reading or writing file:', err);
}
if (fileChanged) {
this.logger.log(`File updated: ${jsonFilePath}`);
updatedFiles.push(jsonFilePath);
}
};
if (serverData.length > 0) {
createJsonFile('Server', serverData, sheetName);
}
if (clientData.length > 0) {
createJsonFile('Client', clientData, sheetName);
}
});
return updatedFiles;
}
_Server.json은 server버킷에, _Client.json은 client 버킷에 버전폴더 아래에 저장한다.
async uploadConvertedFiles(
serverType: string,
dataVersion: string,
updatedFiles: string[]
): Promise<void> {
for (const filePath of updatedFiles) {
if (fs.lstatSync(filePath).isFile()) {
const filename = path.basename(filePath);
let bucket = this.s3ClientBucket;
if (filename.endsWith('_Server.json')) {
bucket = this.s3ServerBucket;
} else if (!filename.endsWith('_Client.json')) {
continue;
}
const Key = `${serverType}/${dataVersion}/${filename}`;
const stream = fs.createReadStream(filePath);
await this.s3.send(new PutObjectCommand({ Bucket: bucket, Key, Body: stream }));
this.logger.log(`[ ${bucket} ] Successfully uploaded ${filename}`);
}
}
}
현재 개발중인 게임의 경우 글로벌 서비스이기 때문에 하나의 리전에서 데이터를 가져가려면 시간이 오래걸린다. 따라서, AWS의 Cloud Front를 사용하여 사용자에게 가까운 엣지 로케이션에서 데이터를 제공받을 수 있게 했다.
캐싱된 데이터는 기본적으로 24시간동안 기존 버킷의 객체를 유지하기 때문에 데이터가 바뀌었다면 캐시를 무효화하여 각 엣지 로케이션에서 새로운 데이터를 갖고있게 해야한다.
async invalidateCache(serverType: string, dataVersion: string): Promise<void> {
const paths = [`/${serverType}/${dataVersion}/*`];
const command = new CreateInvalidationCommand({
DistributionId: this.cloudFrontDistributionId,
InvalidationBatch: {
Paths: {
Quantity: paths.length,
Items: paths
},
CallerReference: new Date().getTime().toString()
}
});
try {
await this.cloudFront.send(command);
this.logger.log(`Cache invalidated`);
} catch (error) {
this.logger.error('Error invalidating cache', error);
throw error;
}
}
const updatedClientFiles = updatedFiles
.filter(f => f.endsWith('_Client.json'))
.map(f => path.basename(f))
.join(', ');
const updatedServerFiles = updatedFiles
.filter(f => f.endsWith('_Server.json'))
.map(f => path.basename(f))
.join(', ');
const message = `
- Server Type : ${serverType.toUpperCase()}
- Data Version : ${dataVersion}
- Client Modified Files: ${updatedClientFiles}
- Server Modified Files: ${updatedServerFiles}`;
console.log(message);
if (serverType !== 'local') {
const url = this.configService.get('SLACK_WEBHOOK_URL');
await SlackService.send(url, 'Game Data Upload Complete!', message);
}
데이터 디자이너님께 테스트를 부탁드렸다.
정상적으로 수정된 파일을 감지하고 CDN에서 캐시까지 무효화 한 로그를 확인할 수 있었다.
여기까진 확인했지만 혹시나 클라이언트에서 제대로 작동하지 않는 부분이 있을 수 있어 클라이언트 팀장님께도 업데이트 한 내용을 말씀드렸다.
클라이언트에도 새로 올라간 데이터를 사용하는데 문제가 없다고 하셨고, 이후 QA 테스트까지 잘 진행했다. 이번에도 자그마한 자동화 기능을 만들었다.
그동안 했던 기능들과는 달리 출시가 얼마 남지 않은 게임이기 때문에 생성되는 json파일이 기존의 파일과 한글자라도 다르면 모든 코드가 동작하지 않을 수 있어 사소한 로직까지 모두 신경써야 했다.
쉽진 않았지만 데이터 디자이너님께서 파일 업로드 하는 부분이 엄청 간편해졌다며 좋아하셨다. 누군가에게 도움이 되는 일은 항상 보람차다. 🥳