Φτιάχνοντας έναν Game Server σε C++ #2: Και εγένετο Κώδικας
Στο προηγούμενο άρθρο συγκέντρωσα κάποια πράγματα σχετικά με τη μορφή που θέλω να έχει ο game server μου. Σε αυτό, θα προσπαθήσω να μεταφέρω όλα αυτά (ή τα περισσότερα) σε κώδικα. Σε αυτή τη φάση, ο στόχος είναι να φτιάξω μα πολύ βασική έκδοση η οποία:
- “ακούει” και αποδέχεται συνδέσεις μέσω TCP
- δημιουργεί client threads που απλά στέλνουν πίσω το περιεχόμενο που λαμβάνουν
- διατηρεί μια βασική λίστα δεδομένων για τους clients
Προθέρμανση
Ούτως ειπείν, ας ξεκινήσουμε με τη main():
int main(int argc,char *argv[]) {
return Server(getParams(argc,argv)).run();
}
Αρκετά σαφές: δημιουργούμε ένα αντικείμενο Server με τις κατάλληλες παραμέτρους και επιστρέφουμε το αποτέλεσμα της μεθόδου run() του. Η συνάρτηση getParams() θεωρητκά θα κάνει όλη τη σκληρή δουλειά σχετικά με τις παραμέτρους εκτέλεσης, αλλά για την ώρα ας τη βγάλουμε στα γρήγορα απ’ τη μέση:
struct serverParams {
int port;
string dbHost,dbName,dbUser,dbPassword;
};
serverParams getParams(int argc,char *argv[]) {
serverParams params={
9999,
"localhost","gamesrv","user","opensessame",
};
return params;
}
Τώρα ας επικεντρωθούμε στο αντικείμενο Server. Από όσα ξέρουμε ως τώρα, ο ορισμός του θα πρέπει να είναι κάπως έτσι:
class Server: public SessionInterface, public GameInterface {
public:
Server(serverParams);
int run();
};
Αφήνοντας στην άκρη τα δύο interfaces για την ώρα, ο constructor θα πρέπει να αρχικοποιήσει όλο το πλαίσιο. Και λέγοντας πλαίσιο, εννοώ τα δύο backends (logger και βάση δεδομένων) και τα δύο sockets (TCP και UDP):
#define VERSION (string) "0.1.0"
Server::Server(serverParams p) {
log=new Logger();
log->info("Game Server v"+VERSION);
storage=new Storage(p.dbHost,p.dbName,p.dbUser,p.dbPassword);
sockaddr_in addr{};
addr.sin_family=AF_INET;
addr.sin_addr.s_addr=INADDR_ANY;
addr.sin_port=htons(p.port);
tcp=sockBind(SOCK_STREAM,addr);
udp=sockBind(SOCK_DGRAM,addr);
listen(tcp,5);
log->info("Server listening at port "+to_string(p.port));
}
Η δημιουργία και το binding των δύο sockets είναι παρόμοια, οπότε αποφάσισα να φτιάξω μια μέθοδο sockBind() για αυτόν το σκοπό:
int Server::sockBind(int protocol,sockaddr_in address) {
int sock=socket(AF_INET,protocol,0);
int opt=1;
setsockopt(sock,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
bind(sock,(sockaddr*)&address,sizeof(address));
return sock;
}
Τα υπόλοιπα πεδία που λείπουν είναι τα log, storage, tcp και udp, οπότε ας τα προσθέσουμε στον ορισμό της κλάσης:
class Server: public SessionInterface, public GameInterface {
private:
Storage *storage;
Logger *log;
int tcp,udp,sockBind(int,sockaddr_in);
public:
Server(serverParams);
int run();
};
Φυσικά, τώρα θα πρέπει να ορίσουμε τα δύο backends. Δεν θέλω να ασχοληθώ με το κομμάτι της αποθήκευσης τώρα:
class Storage {
public:
Storage(string,string,string,string);
};
Storage::Storage(string h,string d,string u,string p) { }
Όσον αφορά τον logger, θέλω να παράγω τρεις διαφορετικούς τύπους μηνυμάτων, ανάλογα με την κρισιμότητα — info, warning και error. Όμως και οι τρεις τρόποι θα χρησιμοποιούν έναν κοινό μηχανισμό, που στην περίπτωσή μας μεταφράζεται στη μετάδοση των μηνυμάτων στο stdout ή στο stderr:
class Logger {
private:
void message(string,ostream&);
public:
Logger();
void info(string);
void warning(string);
void error(string);
};
void Logger::message(string msg,ostream& s=cout) {
auto t=time(nullptr);
stringstream ss;
ss<<"["<<put_time(localtime(&t),"%Y-%m-%d %H:%M:%S")<<"] "<<msg<<endl;
s<<ss.str();
}
Logger::Logger() { }
void Logger::info(string msg) { message(msg); }
void Logger::warning(string msg) { message("\033[33m[WRN] "+msg+"\033[0m",cerr); }
void Logger::error(string msg) { message("\033[31;1m[ERR] "+msg+"\033[0m",cerr); }
Κι έτσι, τελειώνουμε με τον constructor και όλη τη φασαρία της αρχικοποίησης.
Εκτελώντας τον server
Επιτέλους ήρθε η ώρα να ασχοληθούμε με την run(), όπου συμβαίνουν όλα τα ενδιαφέροντα πράγματα:
int Server::run() {
fd_set fds;
int max_fd=max(tcp,udp)+1;
while(!shutdown) {
FD_ZERO(&fds);
FD_SET(tcp,&fds);
FD_SET(udp,&fds);
int activity=select(max_fd,&fds,nullptr,nullptr,nullptr);
if(activity<0) continue;
// Handle new TCP client
if(FD_ISSET(tcp,&fds)) handleTCP();
// handle UDP communication
if(FD_ISSET(udp,&fds)) handleUDP();
}
return 0;
}
Εδώ λοιπόν έχουμε ένα loop που τρέχει έως ότου το shutdown γίνει true, το οποιο είναι ένα νέο πεδίο της κλάσης μας. Μέσα εκεί, παρακολουθούμε τη δραστηριότητα των δύο sockets χρησιμοποιώντας τη select(). Η εισερχόμενη δραστηριότητα εξυπηρετείται από τις δύο νέες μεθόδους, handleTCP() και handleUDP(), ανάλογα με το socket. Θέλω να ξεκινήσω με τη δεύτερη, μιας και είναι η πιο απλή σε αυτή τη φάση:
void Server::handleUDP() {
char buffer[1024];
sockaddr_in client_addr{};
socklen_t len=sizeof(client_addr);
ssize_t bytes=recvfrom(udp,buffer,sizeof(buffer)-1,0,(sockaddr*)&client_addr,&len);
if(bytes<=0) return;
buffer[bytes]='\0';
string msg(buffer);
log->info("[UDP] "+msg);
}
Το μόνο που κάνει αυτή η μέθοδος για την ώρα είναι να μαζεύει εισερχόμενα πακέτα από το socket και να τα μεταφέρει στο log. Μελλοντικά, αυτό θα είναι το σημείο στο οποίο θα υλοποιήσουμε το handshaking και τη δρομολόγηση πακέτων στα παιχνίδια.
Πριν προχωρήσουμε στην handleTCP(), θέλω να ορίσω τη λίστα των clients. Από τις παραδοχές του προηγούμενου άρθρου, δύο πεδία που πρέπει να συσχετιστούν με το αναγνωριστικό της συνεδρίας είναι η αντίστοιχη διεύθυνση UDP του client και το όνομα χρήστη:
struct sessionEntry {
sockaddr_in udpAddr{};
string username;
};
Επιπλέον, θεωρώντας ότι (1) το sessions είναι ένα map που αντιστοιχεί session ids με session entries και (2) το sessionMutex είναι ένας shared mutex που προστατεύει τη λίστα. Θέλω να ορίσω μερικές άλλες σχετικές μεθόδους, παρόλο που δεν θα τις χρησιμοποιήσουμε σε αυτό το πλαίσιο:
string Server::getSessionUsername(int id) {
lock_guard<recursive_mutex> lock(sessionMutex);
if(sessions[id]) return sessions[id]->username;
return "";
}
sockaddr_in Server::getSessionUDPAddr(int id) {
lock_guard<recursive_mutex> lock(sessionMutex);
if(sessions[id]) return sessions[id]->udpAddr;
return sockaddr_in{};
}
Έχοντας καλύψει όλα αυτά, είμαστε πλέον έτοιμοι να προχωρήσουμε στην εξυπηρέτηση του TCP. Όταν έρχεται μια νέα σύνδεση πρέπει να την αποδεχτούμε, να παράγουμε το id της, να δημιουργήσουμε μία εγγραφή στη λίστα των clients και να δημιουργήσουμε ένα thread που θα χειρίζεται την περαιτέρω επικοινωνία:
void Server::handleTCP() {
int conn=accept(tcp,nullptr,nullptr);
if(conn>=0) {
lock_guard<recursive_mutex> lock(sessionMutex);
int id=nextId++;
sessions[id]=new sessionEntry;
thread(sessionThread,conn,id,this).detach();
log->info("[TCP] Client "+to_string(id)+" connected");
}
}
Στον παραπάνω κώδικα, το nextId είναι ένα πεδίο που αρχικοποιείται σε 1. Πέρα από αυτό, το μόνο που μένει είναι η συνάρτηση του thread.
Συνεδρίες, threads και το SessionInterface
Στην αρχή του άρθρου προϋποθέσαμε ότι η κλάση Server θα υλοποιεί δύο interfaces, το ένα από τα οποία αφορά την επικοινωνία με τα παιχνίδια, και θα το αφήσουμε άδειο για την ώρα:
class GameInterface { };
Το άλλο θα το χρησιμοποιούν τα threads, τα οποία θα καλούν τις δύο μεθόδους που προβλέπει. Ας το ορίσουμε, μαζί με τη συνάρτηση των threads:
struct cmdResult {
bool disconnect=false;
string msg;
};
class SessionInterface {
public:
virtual cmdResult clientCommand(int,string)=0;
virtual void clientDisconnect(int)=0;
};
void sessionThread(int connection,int id,SessionInterface *si) {
string welcome="id "+to_string(id)+"\n";
send(connection,welcome.c_str(),welcome.size(),0);
string input;
char buffer[1024];
bool disconnect=false;
ssize_t bytes;
while(!disconnect&&(bytes=read(connection,buffer,sizeof(buffer)-1))) {
buffer[bytes]='\0';
input+=string(buffer);
size_t pos;
string delimiter="\n";
while((pos=input.find(delimiter))!=string::npos) {
string command=input.substr(0,pos);
input.erase(0,pos+delimiter.length());
if(!command.length()) continue;
cmdResult reply=si->clientCommand(id,command);
send(connection,reply.msg.c_str(),reply.msg.size(),0);
if(disconnect=reply.disconnect) break;
}
}
close(connection);
si->clientDisconnect(id);
}
Προφανώς, το thread χρειάζεται να ξέρει τη νέα σύνδεση και το αναγνωριστικό της συνεδρίας, αλλά επίσης και έναν pointer στο αντικείμενο του Server, μέσω του SessionInterface, προκειμένου να μπορέσει να επικοινωνήσει μαζί του. Η πρώτη του δουλειά είναι να στείλει το αναγνωριστικό πίσω στον client, χρησιμοποιώντας ένα αναγνωρίσιμο πακέτο (id {id}). Έπειτα, μπαίνει σε ένα loop έως ότου ο client αποσυνδεθεί, οπότε και εκτελεί την παρακάτω clientDisconnect() πριν τερματίσει οριστικά:
void Server::clientDisconnect(int id) {
lock_guard<recursive_mutex> lock(sessionMutex);
delete sessions[id];
sessions.erase(id);
log->info("[TCP] Client "+to_string(id)+" disconnected.");
}
Πισω στο thread loop τώρα, τα εισερχόμενα πακέτα μαζεύονται στο input. Παρόλο που είναι σχετικά νωρίς για να ορίσω το πρωτόκολλο της εφαρμογής, ξέρω σίγουρα ότι θα είναι text-based και κάθε “εντολή” θα πρέπει να τελειώνει με έναν χαρακτήρα τέλους γραμμής (newline). Ο client θα πρέπει να μπορεί να στέλνει κατακερματισμένα πακέτα όπως και πολλές εντολές με μιας. Η δουλειά του thread είναι να τα συναρμολογεί, να τα χωρίζει αύμφωνα με τα newlines και να αγνοεί τις κενές εντολές. Έπειτα, ο server επεξεργάζεται την κάθε εντολή ξεχωριστά, χρησιμοποιώντας την clientCommand(), η οποία για την ώρα απλά θα επιστρέφει την εντολή στον client:
cmdResult Server::clientCommand(int id,string command) {
log->info("[TCP] ("+to_string(id)+"): "+command);
return {false,"ECHO "+command+"\n"};
}
Η παραπάνω μέθοδος επιστρέφει ένα cmdResult, το οποίο περιέχει ένα πεδίο bool μαζί με το μήνυμα της απάντησης στον client. Αυτό το bool πεδίο σηματοδοτεί αν η τελευταία εντολή πρέπει να προκαλέσει την αποσύνδεση του client.
Αυτό ήταν και το τελευταίο κομμάτι του πάζλ, οπότε ας δούμε πως διαμορφώνεται τελικά η κλάση Server:
class Server: public SessionInterface, public GameInterface {
private:
Storage *storage;
Logger *log;
int tcp,udp,sockBind(int,sockaddr_in);
void handleTCP(),handleUDP();
bool shutdown=false;
int nextId=1;
map<int,sessionEntry*> sessions;
recursive_mutex sessionMutex;
string getSessionUsername(int);
sockaddr_in getSessionUDPAddr(int);
public:
Server(serverParams);
void clientDisconnect(int);
cmdResult clientCommand(int,string);
int run();
};
Δοκιμές
Αφού έκανα compile και έτρεξα τον server, ξεκίνησα ένα nc από ένα άλλο τερματικό:
$ nc localhost 9999 id 1 test ECHO test Hello World! ECHO Hello World! ^C
Όσο για τη λειτουργικότητα του UDP, τη δοκίμασα από ακόμα ένα τερματικό:
$ nc -u localhost 9999 Hello from UDP ^C
Και αυτά είναι τα αποτελέσματα από το τερματικό του server:
[2025-11-04 01:01:30] Game Server v0.1.0 [2025-11-04 01:01:30] Server listening at port 9999 [2025-11-04 01:01:36] [TCP] Client 1 connected [2025-11-04 01:01:41] [TCP] (1): test [2025-11-04 01:01:50] [TCP] (1): Hello World! [2025-11-04 01:02:11] [UDP] Hello from UDP
Λοιπόν, αποστολή εξετελέσθη, αλλά ο game server μας είναι ακόμα …χωρίς παιχνίδια. Αυτό θα το λύσουμε στο επόμενο άρθρο, όπου θα υλοποιήσουμε ένα απλό παιχνίδι Πέτρα Ψαλίδι Χαρτί.

One Comment