Tutorials Figures Installation Tutorials |
Tutorial 1: Creating exhelloApplies to GNE version 0.55. 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 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 main function. 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(atexit); setTitle("GNE Basic Connections Example"); } The first thing we do is include the gnelib header. You will notice this is different from the actual code in the example, because the example is coded to use the header part of the source to let you compile the examples before installing GNE. 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. 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. As with initGNE it takes the atexit function so GNE will shutdown automatically when main() ends. 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 HTML 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). mprintf is used in this tutorial. Unlike cout and printf, mprintf and gout can be interchanged and used at the same time. 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. 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. 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, the load and write packet methods are the most complicated. 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. class HelloPacket : public Packet { public: HelloPacket() : Packet(MIN_USER_ID) {} HelloPacket(string message2) : Packet(MIN_USER_ID), message(message2) {} virtual ~HelloPacket() {} Packet* makeClone() const { return new HelloPacket(*this); } int getSize() const { return Packet::getSize() + RawPacket::getStringSize(message); } void writePacket(RawPacket& raw) const { Packet::writePacket(raw); raw << message; } void readPacket(RawPacket& raw) { Packet::readPacket(raw); raw >> message; } static Packet* create() { return new HelloPacket(); } string getMessage() { return message; } private: string message; }; 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: registerPacket(MIN_USER_ID, HelloPacket::create); We pass the registerPacket function the ID of our packet, and its creation function, so GNE can create our HelloPackets internally. Congratulations, you have just integrated a new packet type into GNE's library of packets! 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 { public: OurListener(int iRate2, int oRate2) : ServerConnectionListener(), iRate(iRate2), oRate(oRate2) { } virtual ~OurListener() {} void onListenFailure(const Error& error, const Address& from, ConnectionListener* listener) { mprintf("Connection error: %s\n", error.toString().c_str()); mprintf(" Error received from %s\n", from.toString().c_str()); delete listener; } void getNewConnectionParams(ConnectionParams& params) { params.setInRate(iRate); params.setOutRate(oRate); params.setUnrel(true); params.setListener(new OurServer()); } 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. 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: OurServer() : conn(NULL), received(false) { mprintf("Server listener created\n"); } virtual ~OurServer() { mprintf("Server listener killed\n"); } void onDisconnect() { //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(); mprintf("ServerConnection just disconnected.\n"); if (!received) mprintf("No message received.\n"); delete conn; delete this; } void onExit() { mprintf("Client gracefully disconnected.\n"); } void onNewConn(SyncConnection& conn2) { conn = conn2.getConnection(); } void onReceive() { receivePackets(); } void onFailure(const Error& error) { mprintf("Socket failure: %s\n", error.toString().c_str()); } void onError(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() { Packet* message = NULL; while ( (message = conn->stream().getNextPacket() ) != NULL) { if (message->getType() == MIN_USER_ID) { HelloPacket* helloMessage = (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"); delete message; } } private: Connection* conn; bool received; }; This class is pretty long-looking in the example, but a lot of the code is in comments, blank lines, and messages to help us debug. This class includes a LOT of "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.
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: OurClient() : conn(NULL) { mprintf("Client listener created.\n"); } ~OurClient() { mprintf("Client listener destroyed.\n"); } void onDisconnect() { mprintf("Client just disconnected.\n"); delete this; } void onExit() { mprintf("Server gracefully disconnected.\n"); } void onConnect(SyncConnection& conn2) { conn = conn2.getConnection(); } void onReceive() { Packet* message = NULL; while ( (message = conn->stream().getNextPacket()) != NULL ) { if (message->getType() == MIN_USER_ID) { HelloPacket* helloMessage = (HelloPacket*)message; mprintf("got message: \""); mprintf(helloMessage->getMessage().c_str()); mprintf("\"\n"); } else mprintf("got bad packet.\n"); delete message; } } void onFailure(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(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(const Error& error) { mprintf("Connection to server failed.\n"); mprintf("GNE reported error: %s\n", error.toString().c_str()); } private: Connection* conn; }; Again I will explain the client in terms of the order of events called on it:
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. 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 server(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(); //When the server class is destructed, it will stop listening and shutdown. } 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 server and client logs. Therefore we missed a couple of lines at the start of the log, 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. We then wait for a keypress to shutdown the server. When the server object is destroyed at the end of the function, it will automatically close the ports and shutdown. While this is simple, this isn't the best way to program. If there are current connections running we will forcefully terminate them in a non-graceful and ditry way when the program exits. Since our connections are so short, the user will have opportunity to press a key at an appropriate time. A more robust server would keep track of current connections, and then handle them gracefully. This is about the only error checking code that was left out of this program to make it completely robust. 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(new OurClient()); params.setUnrel(true); params.setOutRate(outRate); params.setInRate(inRate); ClientConnection* client = new ClientConnection(); if (client->open(address, params)) { delete client; errorExit("Cannot open client socket."); } client->connect(); client->join(); //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(); } delete client; } (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 join method. If instead we didn't want to wait, we would have called client->detach(false) instead. (Note: once you detach a thread, you can't join on it, and you must either detach or join on a thread before the objects are destroyed). 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 if the server refused our unreliable connection (by setting unreliable in their params to be false), both packets will be sent reliably instead. We then wait for responses, then shut down the connection gracefully, sending any unsent buffered data, and exit. Note that because we call "delete client" we don't need "delete conn" in the OurClient class. 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 with the exception of the server shutdown. |