Tutorials Figures Installation Tutorials |
Tutorial 1: Creating exhelloApplies to GNE version 0.70. View version for 0.55. Those porting from GNE 0.55 to GNE 0.70 may wish to compare the two tutorials to get a feel for the major API changes between the two versions. Last updated: Tuesday, August 2, 2005 Contents
In this example we will walk through the process and the design behind the GNE exhello world example program. This "hello world" example shows the 'minimum' code to get a fully-functioning and correct program running in GNE. A first look at the example and it looks pretty complicated, but don't let that deceive you! A few reasons simply that is looks deceiving is that most of the code is in comments, but more importantly most of that code follows to be pretty obvious derivations of code that already exists in GNE, and this will be explained in the tutorial. It is also important to notice that the program is fully functioning and fleshed out. You may look at code using raw sockets or using low-level C network libraries and think it is simpler, but most are leaving out complete error checking and handling, and most only work for simple one-client connections. This complete example shows that programming with GNE has a high overhead but once you pass that overhead, adding more functionality is simple. All of this code has the following advantages:
Anyways, enough of the propaganda. I hope you find the extra initial effort worth the power and flexibility you gain over your communications. Let's dive into the code. The code is organized a little strangely in the exhello example, because the exhello example and the exsynchello example both implement the same thing, exhello with event-driven logic and exsynchello with syncronous logic. This will be explained later and in later tutorials. But for now you should know that the event-driven logic is the primary way of using GNE. The code that is shared between the two examples is in shared.h. Note that they share the doMain function which effectively implements "main". The entry point to this program is a little unorthodox as two examples share the same code. For now just ignore this. The main function calls the doMain functions providing the name of the example logic. #include <gnelib.h> #include <iostream> #include <string> using namespace std; using namespace GNE; using namespace GNE::Console; using namespace GNE::PacketParser; int main(int argc, char* argv[]) { doMain( "Event-Driven" ); return 0; } void doMain(const char* connType) { if ( initGNE( NL_IP, atexit ) ) { exit(1); } //sets our game info, used in the connection process by GNE to error check. //We give exhello and exsynchello the same name because they are compatable //with each other. setGameInformation( "hello examples", 1 ); initConsole(); setTitle( "GNE Basic Connections Example" ); } The first thing we do is include the gnelib header. We then import the std namespace (for endl) and the GNE namespaces we will be using, so that we won't need to fully qualify the class names (for example GNE::initGNE is now just initGNE). The first call we make is to initGNE. This function takes the network type as a parameter, and the atexit function. Use NL_IP for internet, and use NO_NET if you don't want to use the network (for example just to test the console functionality, as in the exinput example). The function returns true on error, and we exit the program if this is the case. Because GNE registers itself with atexit, GNE will automatically be shut down on program exit if you do not do so explicitly. We then call setGameInformation. The name of your game or program and the number is used to UNIQUELY identify your program to any program using GNE. In this way GNE automatically checks version numbers and program types to make sure you are both using the same version of YOUR protocol (GNE also checks against its internal protocol version transparently to you). The call to initConsole tells GNE we will be using the console code for text drawing and to initialize that. The setTitle call sets the title bar of your applications Window, if it is able to do so (currently MS Windows only). Part 2: Interacting with the Console After GNE is initialized we get input from the user, to determine whether we will run the server or client side of the code. The GNE console code is to be used over the standard C or C++ I/O classes, because of the multithreaded nature of GNE. GNE provides just enough console functionality to write simple text interfaces for servers (and also for simple examples as you can see):
The gout and gin objects correspond to the C++ objects cout and cin. gout and gin are exactly the same as their counterparts, except they use a custom streambuf. The GNE API reference explains many more features than those we will use in this tutorial. Also, the C-style (like printf) functions are also there, you can find them in the reference (see GNE::Console::mprintf). Both mprintf and gout are used in this tutorial. Unlike cout and printf, mprintf and gout can be interchanged and used at the same time without any abnormal behavior, but you must remember that gout is line-buffered, so the output from gout will only be shown on flush or on a newline character. Here is the rest of the code of the doMain function. gout << "GNE " << connType << " Basic Connections Example for " << GNE::VER_STR << endl; gout << "Local address: " << getLocalAddress() << endl; gout << "Should we act as the server, or the client?" << endl; gout << "Type 1 for client, 2 for server: "; int type; gin >> type; int port; if (type == 2) { setTitle("GNE Basic Connections Example -- Server"); gout << "Reminder: ports <= 1024 on UNIX can only be used by the superuser." << endl; port = getPort("listen on"); doServer(0, 0, port); } else { setTitle("GNE Basic Connections Example -- Client"); port = getPort("connect to"); doClient(0, 0, port); gout << "Press a key to continue." << endl; getch(); } As you can see, gout works in the same way as cout when only a single thread is active. All of the calls in this section are pretty self-explainatory -- you can always view them in the reference, though. The getPort function is a simple utility function defined in the example to return an integer port. We set the window title appropriately when the user picks client or server versions. Note that GNE provides the GNE::Console::getch() function -- this is NOT the function by the same name as found in in the popular headers/libraries conio.h or curses.h. Note that if multiple threads may be using gout, you need to use the acquire/release manipulators with gout, or use LockObject. mprintf is safe not to acquire/release. If you do not acquire gout the program will not crash, but GNE has no way of keeping a single gout statement together, thus if you use gout << 530 << " hello " << 5 << endl; you may see the 3 portions of that output ( 530, " hello ", and 5 ) intermixed with portions from other gout statements. The solution is to use acquire/release, or preferably LockObject, so that all information covered in the lock is treated as a single "output unit". { LockObject lock( gout ); gout << 530 << " hello " << 5 << endl; } When you use mprintf, all text produced by that call is treated as one "output unit." Because of the locking issues and the fact the sometimes C++-style output is better and sometimes C-style is better, it is explicitly allowed in GNE and acceptable to mix gout, mprintf, and the other GNE::Console output functions. Before we start doing anything over the network, let's define the types of data our program will use. Communication in GNE is always done with packets. You create your own packets by inheriting from the GNE::Packet class, and overriding the appropriate methods. Most of the time these methods are very straightforward. To create a packet, you will need to know what data the packet will hold, and we need to give the packet an ID. GNE IDs range from MIN_USER_ID to MAX_USER_ID, inclusive. It is suggested that give your packets IDs relatlve to MIN_USER_ID (for example MAX_USER_ID + x, where x is some number). For this example, we will only be defining one packet, and we'll be giving it the id MIN_USER_ID + 0, or just MIN_USER_ID. This packet will just contain a string containing the "hello" message. Note that the maximum GNE string length size is 255 bytes. Larger strings should be split up into different packets. Note also the max packet size is a little over 500 bytes. GNE expects you to make the smallest packets possible, and split up your data into pieces (one packet for each game object). Don't worry about sending too many small packets -- this is meant to be as GNE is designed to optimize the overhead of the connection by automatically combining small packets, so making small packets helps this process. The exception to the smallest packet possible rule is if you are sending something large such as a map data file over a Connection, and that is the only transfer currently taking place -- in this case you want to send as large of packets as possible. class HelloPacket : public Packet { public: typedef SmartPtr<HelloPacket> sptr; typedef WeakPtr<HelloPacket> wptr; public: HelloPacket() : Packet(ID) {} //copy ctor not needed, because message is copyable HelloPacket(string message2) : Packet(ID), message(message2) {} virtual ~HelloPacket() {} static const int ID; int getSize() const { return Packet::getSize() + Buffer::getSizeOf(message); } void writePacket(Buffer& raw) const { Packet::writePacket(raw); raw << message; } void readPacket(Buffer& raw) { Packet::readPacket(raw); raw >> message; } string getMessage() { return message; } private: string message; }; const int HelloPacket::ID = MIN_USER_ID; Note that HelloPacket is defined in shared.h as both the exhello and exsynchello examples share this packet type so they can communicate with each other. Let's look at this class in detail.
Now that we have made our custom packet, we must register it so GNE knows to parse it. If you try to send a HelloPacket without registering it, it will send correctly, by the other side will report that it received an illegal, unknown packet as a parser error. Add a line to doMain after initializing GNE: defaultRegisterPacket<HelloPacket>(); The default default packet registration template uses the ID member from your packet as its ID. After registration GNE can create our HelloPackets internally. Congratulations, you have just integrated a new packet type into GNE's library of packets! The default registration assumes the following this about your packet (called P):
Unless you want to use your memory allocator to replace the standard C++ new and delete operators, you are safe using defaultRegisterPacket. See the GNE documentation if you want to use your own memory allocators for the full registration procedure. Part 4: Creating the Server Listener Now that we have initialized GNE and have a packet to communicate with, we need to set up three things:
In this part we will create a ServerConnectionListener. To do this we derive from the ServerConnectionListener class: class OurListener : public ServerConnectionListener { protected: OurListener(int iRate, int oRate) : ServerConnectionListener(), iRate(iRate), oRate(oRate) { } public: typedef SmartPtr<OurListener> sptr; typedef WeakPtr<OurListener> wptr; static sptr create(int iRate, int oRate) { sptr ret( new OurListener( iRate, oRate ) ); ret->setThisPointer( ret ); return ret; } virtual ~OurListener() {} void onListenFailure(const Error& error, const Address& from, const ConnectionListener::sptr& listener) { mprintf("Connection error: %s\n", error.toString().c_str()); mprintf(" Error received from %s\n", from.toString().c_str()); } void OurListener::getNewConnectionParams(ConnectionParams& params) { params.setInRate(iRate); params.setOutRate(oRate); params.setUnrel(true); params.setListener( OurServer::create() ); } private: int iRate; int oRate; }; This class is usually fairly simple. We have set up an error handler in onListenFailure to report errors if an error occurs before a connection is even created (for example if another non-GNE program tried to connect to yours, or if the versions of the connecting programs differ). The getNewConnectionParams method is the important method, and it sets up important parameters for our connection. There are more parameters we could set up, but we choose the stick with the default setting on those, and instead we set:
Note that with this method, there may be many connections going at the same time -- each connection will have its own OurServer instance, as getNewConnectionParams is called for every incoming client. The ServerConnectionListener class can do a lot more than this. The example expong is currently the only example that uses ServerConnectionListener to its fullest extent. You can use it to distribute bandwidth amongst incoming connections, and you can use it only allow a certain number of connections at a time (expong uses it to only allow one ever enter the game). Sidenote: When high-level GNE is completed, there will be an actual Server class that will handle that functionality for you easily. Many of the major GNE classes are managed by smart pointers. Thus when we inherit from a GNE class we have to accomodate for that. In these cases, we define smart pointer typedefs, make the constructor protected, and provide a static creation function so that the only way to create the class gives you a SmartPtr. SmartPtr is reference counted so you never need to "delete" it. Read the Smart Pointers section of the API changes document (internet link) for more information about smart pointers. We will create the implementation of the server by deriving a custom class from ConnectionListener. Typical communication in GNE is event-based. Every time an event happens on a connection, one of the methods of your ConnectionListener will be called. You will then respond to the data, and then return from the function, and lie dormant until your next event. Only one event can be occuring for a single connection at any time -- but keep in mind your "main" thread will still continue to run, and other connections may be processing events at the same time in different threads, so you should use mutexes where needed. Fortunately for this sample the code is very simple and data is not shared between threads, so mutexes are not needed. Let's look at the OurServer class: class OurServer : public ConnectionListener { public: //typedefs typedef SmartPtr<OurServer> sptr; typedef SmartPtr<OurServer> wptr; protected: OurServer() : received(false) { mprintf("Server listener created\n"); } public: static sptr create() { return sptr( new OurServer() ); } virtual ~OurServer() { mprintf("Server listener killed\n"); } void onDisconnect( Connection& conn ) { //Call receivePackets one last time to make sure we got all data. //It is VERY possible for data still left unread if we get this event, //even though we read all data from onReceive. receivePackets( conn ); mprintf("ServerConnection just disconnected.\n"); if (!received) mprintf("No message received.\n"); } void onExit( Connection& conn ) { mprintf("Client gracefully disconnected.\n"); } void onNewConn( SyncConnection& conn2) { mprintf("Connection received; waiting for message...\n"); } void onReceive( Connection& conn ) { receivePackets( conn ); } void onFailure( Connection& conn, const Error& error ) { mprintf("Socket failure: %s\n", error.toString().c_str()); } void onError( Connection& conn, const Error& error ) { mprintf("Socket error: %s\n", error.toString().c_str()); conn.disconnect(); } //Tries to receive and process packets. //This function responds to any requests. Note that it is called in //onDisconnect and it is perfectly valid to send data from onDisconnect -- //it just won't ever be sent ;), but there is no reason to pass in a param //and check for disconnection just so we don't send the data. void receivePackets( Connection& conn ) { //This time we use the SmartPtr version of getNextPacket, to show both //ways. Using the SP version we don't need to call destroyPacket. Packet::sptr message; while ( (message = conn.stream().getNextPacketSp() ) ) { if ( message->getType() == HelloPacket::ID ) { HelloPacket::sptr helloMessage = static_pointer_cast<HelloPacket>( message ); mprintf("got message: \"%s\"\n", helloMessage->getMessage().c_str()); received = true; //Send Response mprintf(" Sending Response...\n"); HelloPacket response("Hello, client! I'm the event-driven server!"); conn.stream().writePacket(response, true); } else mprintf("got bad packet.\n"); } } private: bool received; }; This class includes some "fluff" code to display what's happening, so I've cut some of it out for this tutorial. You can see the extra code in the actual example file. It's pretty simple when you break it down, so let's do that now. Let's break it down by the order events in which events will occur for us.
Like our listener before, OurServer is to be managed by the smart pointer system in GNE so we don't have to worry about deleting it when the connection is closed -- we can "make and forget". As before we used the typedefs and static create function for this purpose. You will notice the usage of "conn.stream()" in the server code. This returns the GNE::PacketStream instance which represents a stream of packets. All of the data communication will happen through this class. Refer to the documentation to see what methods are available to you and how to use them. Now we need a client to connect to our newly created server class. First thing you will notice is that the client and server-side code is mostly the same. Sometimes it is so similar that in some examples there is only one ConnectionListener which does both client and server. I will only describe the differences between the client and server rather than break down the whole class again. Some of the code has been cut out to simplify the view: class OurClient : public ConnectionListener { public: //typedefs typedef SmartPtr<OurClient> sptr; typedef SmartPtr<OurClient> wptr; protected: OurClient() { mprintf("Client listener created.\n"); } public: static sptr create() { return sptr( new OurClient() ); } ~OurClient() { mprintf("Client listener destroyed.\n"); } void onDisconnect( Connection& conn ) { mprintf("Client just disconnected.\n"); } void onExit( Connection& conn ) { mprintf("Server gracefully disconnected.\n"); } void onConnect(SyncConnection& conn2) { mprintf("Connection to server successful.\n"); } void onReceive( Connection& conn ) { Packet* message = NULL; while ( (message = conn.stream().getNextPacket()) != NULL ) { if ( message->getType() == HelloPacket::ID ) { HelloPacket* helloMessage = (HelloPacket*)message; LockObject lock( gout ); gout << "got message: \"" << helloMessage->getMessage() << '\"' << endl; } else mprintf("got bad packet.\n"); destroyPacket( message ); } } void onFailure( Connection& conn, const Error& error ) { mprintf("Socket failure: %s\n", error.toString().c_str()); //No need to disconnect, this has already happened on a failure. } void onError( Connection& conn, const Error& error ) { mprintf("Socket error: %s\n", error.toString().c_str()); conn.disconnect();//For simplicity we treat even normal errors as fatal. } void onConnectFailure( Connection& conn, const Error& error) { mprintf("Connection to server failed.\n"); mprintf("GNE reported error: %s\n", error.toString().c_str()); } }; Again I will explain the client in terms of the order of events called on it:
The receivePackets method of OurClient uses the old getNextPacket method that returns a Packet*. This method requires a GNE::destroyPacket method to be called on that pointer when you are finished with the Packet. Both ways are available and shown but the smart pointer method used in OurServer is much safer and is the preferred method. Now that we have a listener, server, and client setup and ready to go, it's time for us to use them. We have two functions, doServer and doClient. We'll go over doServer first, then look at doClient. //For both types of exhello, starting the server is the same. void doServer(int outRate, int inRate, int port) { #ifdef _DEBUG //Generate debugging logs to server.log if in debug mode. initDebug(DLEVEL1 | DLEVEL2 | DLEVEL3 | DLEVEL4 | DLEVEL5, "server.log"); #endif OurListener::sptr server = OurListener::create(inRate, outRate); if (server->open(port)) errorExit("Cannot open server socket."); if (server->listen()) errorExit("Cannot listen on server socket."); gout << "Server is listening on: " << server->getLocalAddress() << endl; gout << "Press a key to shutdown server." << endl; getch(); //This is not strictly needed, as all listeners will be shutdown when GNE //shuts down. server->close(); } The first thing we do is if we are compiling in debug mode, we initialize the debug file. GNE will print out very verbose and helpful messages that will greatly aid in viewing the flow of your program. You can customize the contents of the logs by specifing which message levels you want. In this case, we have chosen to display ALL debugging content. For normal programs you should leave out DLEVEL4 and DLEVEL5 as they generate lots of text -- DLEVEL4 will generate a line of text for every event generated. Note that we had to start our logs late, so we can create separate server and client log files. Therefore we missed a couple of lines at the start of the log pertaining to GNE startup, but we won't worry about that in this tutorial. Now the important part is that we create an instance of our listener, to listen for incoming connections on our given port. The errorExit function is a small utility function in shared.h to display an error message and exit the program. Because we are using smart pointers, the OurListener created will always be deleted, even if we exit the program early with errorExit, without any work on our part. We then wait for a keypress to shutdown the server. When GNE is shutdown all listeners and connections are disconnected, so although the close call is not technically needed there, we provide it for completeness. Even if connections are active, GNE will wait (by default) up to 10 seconds to gracefully close all connections, end all threads, and stop all timers. Thus shutdowns with GNE are always clean and graceful since GNE internally keeps a list of all connections, threads, and timers. And because of the smart pointers, you don't need to worry about memory allocation either. void doClient(int outRate, int inRate, int port) { #ifdef _DEBUG initDebug(DLEVEL1 | DLEVEL2 | DLEVEL3 | DLEVEL4 | DLEVEL5, "client.log"); #endif string host; gout << "Enter hostname or IP address: "; gin >> host; Address address(host); address.setPort(port); if (!address) errorExit("Invalid address."); gout << "Connecting to: " << address << endl; ConnectionParams params( OurClient::create() ); params.setUnrel(true); params.setOutRate(outRate); params.setInRate(inRate); ClientConnection::sptr client = ClientConnection::create(); if (client->open(address, params)) errorExit("Cannot open client socket."); client->connect(); client->waitForConnect(); //Check if our connection was successful. if (client->isConnected()) { //Send our information HelloPacket message("Hello, server! I'm the event-driven client!"); client->stream().writePacket(message, true); client->stream().writePacket(message, false); //Wait a little for any responses. gout << "Waiting a couple of seconds for any responses..." << endl; Thread::sleep(2000); client->disconnectSendAll(); } //client will be destroyed here as its sptr will go out of scope and //because it is disconnected. } (Some code and comments cut out for visibility). Working with clients is much different -- it's a lot "closer" and personal than the server, as the client is proactive in making a connection while a server just "sits there." We first ask the user to input an address to connect to, then we validate it (note that not only can address take raw IP address such as 129.21.135.193, it can take names like "www.yahoo.com"). We set up a ConnectionParams object in the same way we did so in our implementation of the ServerConnectionListener. We then create the connection and open the port, and then connect. The connect operation is done in a separate thread, so we could go off an do other work, waiting for onConnect to fire and tell us to come back, but in this simple example we have nothing else to do, so we wish to wait to connect. To do this we call the waitForConnect method. After waiting for the connection thread to complete, we check to see if we connected. If we didn't connect, onConnectFailure was called and already reported an error message, so we just exit. If we did connect, we create our HelloPacket. For testing we send one packet reliably with true, and send one unreliably with false. Unreliable packets require less overhead, less bandwidth, and decrease your ping times, but have the disadvantage that might get lost, or arrive in a different order than you sent them in. Reliable packets always arrive and arrive in the same order as sent. Note that the waitForConnect method returns an Error object if the connection process failed. Instead of reporting the error on onConnectFailure, we could have just as easily done so where we called waitForConnect. Note that waitForConnection can be called even after the connection's attempt has completed. In that case, the waitForConnect method returns immediately with the Error that occured (the Error may have a NoError code if the connection was successful). Note that if the server refused our unreliable connection (by setting unreliable in their params to be false), both packets will be sent reliably instead. This is performed transparently to you, so you need not worry about the type of connection negotiated. We then wait for responses, then shut down the connection gracefully, sending any unsent buffered data, and exit. As in the server's case, the disconnect call is not strictly needed as GNE will request all connections to disconnect at shutdown and wait for up to 10 seconds by default for a graceful close. That was quite a lengthy tutorial, but the code that you have created in this example can serve as a template for future GNE code. In this tutorial we actually covered every basic aspect of the library, and created a complete, robust program without any missing error handling or memory leaks even in failure cases. |