|
|||||||||||||||||||||
|
|||||||||||||||||||||
|
Announcements
Chapters
Services
Feature Zones
|
IntroductionThis article is the second of a multi-part series that will cover the architecture and implementation of components needed to create and maintain a robust, scalable, high performance, massive multiplayer online game server and game engine. The first article of the series focused on building a Scheduling Engine to drive organized, real-time change in a virtual world. The present article focuses on the design and implementation of a TCP/IP communication component, designed to efficiently handle communications between the game server and remote game clients (players). Background: BBS Games to MUDsBack in the 80's, I ran a modem-based bulletin board system (BBS) that let users dial in and leave messages for other users, share files, and play simple multi-player games. BBS-hosted games (anyone remember TradeWars?) were multiplayer in the sense of having a common, persistent virtual world with which multiple players could interact. Each day, players would dial in to the bulletin board system to use up their quota of daily strategic moves and to see what other players had done in/to the virtual world. Because only one person could play the game at a time, real-time player interaction was not possible.
When I headed off for college in the early 90's, I had to shut down the BBS. Just when I thought my days of online games were over, I found out my dorm room had an Ethernet outlet... A friend from high school, now living hundreds of miles way, introduced me to MUDs (online multiplayer games) as a way to meet up online and have fun. Players connected to MUDs using Telnet to connect to a server via TCP/IP, and then Telnet sent the player's typed commands to the game server, allowing interaction with the game. Best of all, my friend and I could both play and interact in the same virtual world at the same time.. Whereas BBS games involved multiple players taking turns, Internet MUDs involved multiple players all playing simultaneously. With Ethernet access in my room and his encouragement, it didn't take me long to get hooked. I started as a player, and when I became bored of playing, I moved on to world creation. When I became bored of world creation, I set up and hosted my own MUD from my dorm room Ethernet, and began making modifications and enhancements to standard MUD code. While working on MUDs, I spent a lot of time learning the ins and outs of the Berkeley sockets API which handled client communication with the game engine. I'd like to think that my experience with socket communication back in the 90's was not wasted, and in fact, when I started snooping around the modern .NET TCP/IP client- and server-related objects, I found the Berkeley sockets API skeleton hiding in the closet. So, without further ado, I present to you my own, modernized implementation of a high-performance TCP/IP server, complete with discussion of (and solutions to) various socket-related quirks I encountered along the way. Terminology - Sockets, and Outgoing / Incoming Connections
In my own words, a network socket is an object that, when connected, can send and receive data to/from another computer. A socket can be in a connected or disconnected state, and can send and receive data. Pretty straightforward... A socket object is created when your computer initiates a connection to a remote host. A connection request is sent to a specific port on a remote host. If the remote host accepts the connection, the socket you created enters a connected state, allowing you to transmit and receive data to/from the remote host. In this article, an Outgoing Connection refers to a socket object, created locally, used to initiate contact and communicate with a remote host. A networked computer also has the capability of listening for incoming connection requests. To listen for incoming connections, a socket is created and then bound (assigned) to a specific port / address. That socket is then instructed to listen for incoming connections. When a remote host requests a connection to the specified port on your computer (and your computer accepts the connection request), an additional, dedicated socket object is created on your computer to handle data transmission with that specific remote host. In this article, an Incoming Connection refers to a connected socket, created in response to fulfilling a remote host's request for a connection with your computer. Peer-to-Peer vs. Client/ServerIn peer-to-peer communication, a single machine acts as both a server (receiving incoming connections) and as a client (initiating outgoing connections to remote hosts). There are just enough differences between incoming and outgoing connections to make implementing peer-to-peer software annoying. Examples: Firewalls may prevent incoming connections from a remote host, but still allow connection to the same remote host via the use of an outgoing connection request. And, there are other small differences between outgoing and incoming connections: outgoing connections point to a remote host name and port, and if connectivity breaks, you may be able to use the same host name and port information to reestablish the outgoing connection. However, the sockets used to handle incoming connections are assigned their own port number when a listener/server socket accepts an incoming connection. Therefore, if connectivity with an incoming connection is lost, you cannot simply reconnect to the remote machine using the host name and port associated with the (now-broken) incoming connection. Okay, enough confusion. For my sake and yours, this is not a peer-to-peer project. It's a straightforward client-server TCP/IP implementation. Here is how it works:
Example UseBefore delving into the design and implementation details, I'd like to present several snippets of code that demonstrate how the communications library can be used: // Set up the server that will accept incoming connections
CommunicationsServer server =
new CommunicationsServer(IPAddress.Any, PortNum, new ServerMessageHandler());
server.BeginListening();
...
// Create an outgoing connection, used to connect with the server
OutgoingConnection connectionToServer =
new OutgoingConnection(connectionToServer_MessageReceived);
// Establish an outgoing connection to the server
bool success = connectionToServer.Connect("localhost", PortNum, true);
// Send some kind of initial hello message
HelloMessage message = new HelloMessage("Hello from client number " + n.ToString());
connectionToServer.IssueCommand(message, 1);
...
// Shut down the server
server.ShutdownServer();
// Show some transmission info
Console.WriteLine("Server sent " + server.BytesSent.ToString() + ", " +
"received " + server.BytesReceived.ToString() + " bytes.");
Design and ImplementationFor the sake of article length, I'm going to jump right into the design and implementation. The class Both The interface
(The interface also inherits from public interface ICommunicationsBase : ITrackBytes
{
ICommunicationsProtocol TcpClientConnection { get; set; }
event EventHandler<ObjectEventArgs> MessageReceived;
void SendMessage(object o, int transmissionType);
void CloseConnection();
}
Outgoing Client Connection to the ServerThe client's connectivity with the server is defined by the
Incoming Client Connection on the ServerOn the server side of things, an instance of the
A visual representation of the relationship between
Transmitting MessagesAt the heart of the client-server communication is the transmission and receipt of messages. A message is a complete parcel of information - when packaged up, sent, received, and then unwrapped, a message contains the same (complete) information as sent by the sender. The Send/Receive BufferThe thread-safe
When data is received from a remote client, it is enqueued into /// <summary>
/// Retrieves data waiting to be sent, and returns the data in a byte array ready
/// for transmission. The number of bytes is output via output parameter packetLength.
/// </summary>
internal byte[] ConstructOutgoingPacketFromByteChunks(out int packetLength)
{
byte[] outgoingPacket;
byte[] nextPacket;
int outgoingPacketLength = 0;
// Dequeue a waiting byte chunk
if (_outgoingByteChunks.Dequeue(out outgoingPacket))
{
outgoingPacketLength = outgoingPacket.Length;
if (outgoingPacketLength < JoinPacketsIfSizeBelow)
{
// If the length of the byte chunk is small, then combine multiple
// byte chunks that may be waiting
using (MemoryStream ms = new MemoryStream(JoinPacketsIfSizeBelow))
{
while (outgoingPacketLength < JoinPacketsIfSizeBelow &&
_outgoingByteChunks.Dequeue(out nextPacket))
{
ms.SetLength(0);
int nextPacketLength = nextPacket.Length;
// Append packet1 bytes with the bytes of packet2
ms.Write(outgoingPacket, 0, outgoingPacketLength);
ms.Write(nextPacket, 0, nextPacketLength);
outgoingPacket = ms.ToArray();
outgoingPacketLength += nextPacketLength;
}
}
}
}
packetLength = outgoingPacketLength;
return outgoingPacket;
}
Messages and FragmentationBlocking vs. Non-blocking SocketsIn blocking mode, sending data to a remote client halts the current thread's execution until all data is sent. Similarly, attempting to receive data from a remote client halts the current thread's execution until data is received. When sending data to a remote client in non-blocking mode, part or all of the data is transmitted, and the send operation immediately returns. When receiving data, if the client has not sent any data, We don't want the communications server waiting around for send and receive operations to complete; we want to read available data that has been sent from a client and then immediately move on to read available data from the next connected client. Similarly, we want to send as much waiting data as possible to a client, and then immediately move on to send waiting data to the next client. Therefore, the current communications library implementation uses non-blocking sockets. Dealing with Partial Data TransmissionA message is defined as a complete parcel of information - when packaged up, sent, received, and then unwrapped, a message contains the same (complete) information as sent by the sender. When data is transmitted via TCP/IP in non-blocking mode, complete messages may be broken apart (fragmented) into multiple packets of data for transmission. Therefore, messages may need to be reassembled into complete parcels of information when received by the recipient. The technique most frequently used to ensure complete receipt of a message (and to help identify the type of message being transmitted) is to prefix the message with a header. In the presented solution, a message header consists of two integers (each 4 bytes). The first integer specifies the size of the complete, transmitted message, and the second integer specifies the transmitted message type: 4 bytes 4 bytes n bytes
[Msg Size][Msg Type][Compressed Msg]
For flexibility, it would be nice to transmit any instance of any class across the wire. Although using binary serialization to serialize and then compress objects may not be the most efficient method of transmitting data between client and server, its flexibility and ease-of-use made it the method of choice for data transmission for this sample project. To maximize efficiency and speed in a real-world scenario, custom serialization should be implemented and used. The following method is used to convert a message (from any serializable object) into a byte array suitable for transmission. /// <summary>
/// Creates a byte array for an object (with appropriate header bytes),
/// allowing a message to be sent across the wire.
/// </summary>
internal byte[] CreateForSend(object o, int transmissionType)
{
byte[] commandBytes;
using (MemoryStream ms = new MemoryStream())
{
// Leave the first 8 bytes empty for now
ms.Seek(8, SeekOrigin.Begin);
// Serialize the object to the memory stream starting at position 8
o.SerializeToCompressedBinaryMemoryStream(ms);
// Determine the byte length of the serialized object
int bytes = (int)ms.Length - 8;
// Write the header to the beginning of the memory stream
ms.WriteHeader(bytes, transmissionType);
commandBytes = ms.ToArray();
}
return commandBytes;
}
Receiving Message Parts: MessageBufferBecause messages may be received in multiple pieces, the message header (the first four bytes of which contain the total size of the message) is used to determine when a full message has been received. The
The public void AppendNewIncomingBytePackets(ThreadSafeQueue<byte[]> incomingByteChunks)
{
byte[] nextBytes = null;
if (_incomingMemoryStream == null)
Interlocked.CompareExchange<MemoryStream>(ref _incomingMemoryStream,
new MemoryStream(), null);
// Go to the end of the incoming memory stream
// and append any bytes that have recently come in
_incomingMemoryStream.Seek(0, SeekOrigin.End);
while (incomingByteChunks.Dequeue(out nextBytes))
_incomingMemoryStream.Write(nextBytes, 0, nextBytes.Length);
}
The method public bool ReadHeader(out bool error)
{
// To read a header, we must have
// at least PacketLengthPrefixSize bytes available
if (_bytesAvailable - (int)_binaryReader.BaseStream.Position <=
ConnectionBase.PacketLengthPrefixSize)
return false;
// Read the header - bytes expected and transmission type
_bytesExpectedTemp = _binaryReader.ReadInt32();
_transmissionTypeTemp = _binaryReader.ReadInt32();
...
}
The method public bool ReadMessage(out object o, out int transmissionType)
{
o = null;
transmissionType = 0;
if ((int)_binaryReader.BaseStream.Position +
_bytesExpectedTemp > _bytesAvailable)
{
// The message is not complete (i.e. data may
// be coming in on the next incoming message).
// Rewind the stream back to the beginning of the message header
if (_binaryReader.BaseStream.Position != 0)
_binaryReader.BaseStream.Seek(-ConnectionBase.PacketLengthPrefixSize,
SeekOrigin.Current);
return false;
}
// If we're here, we have a full message.
// Read the message bytes and deserialize the message
byte[] messageBytes = _binaryReader.ReadBytes(_bytesExpectedTemp);
try
{
o = messageBytes.DeserializeFromCompressedBinary();
transmissionType = _transmissionTypeTemp;
}
catch (Exception ex)
{
o = null;
// let this fall through and return true.
// The next message might be good.
}
return true;
}
Full use of the /// <summary>
/// Checks to determine if a full message has been received. If so, raises the
/// MessageReceived event.
/// </summary>
internal bool GetNextFullMessage()
{
bool fullMessageReceived = false;
bool error = false;
// No new data received, so no need to check for a new message
if (_incomingByteChunks.Count <= 0) return false;
// Append the new data received to the incoming message buffer
_incomingMessageBuffer.AppendNewIncomingBytePackets(_incomingByteChunks);
// Records the number of bytes we have available
// and sets up the message buffer for reading
_incomingMessageBuffer.RewindAndCreateBinaryReader();
while (_incomingMessageBuffer.ReadHeader(out error))
{
object o;
int transmissionType;
// Try to read the complete message. If the message is incomplete,
// ReadMessage returns false and we exit the loop
if (!_incomingMessageBuffer.ReadMessage(out o, out transmissionType))
break;
fullMessageReceived = true;
// Raise an event to signal a full message was received
if (o != null && MessageReceived != null)
{ MessageReceived(this, new ObjectEventArgs(o, transmissionType)); }
}
if (error == true)
{
// Clear the incoming byte chunks - see if
// we can start over with a fresh slate
_incomingByteChunks.Clear();
}
_incomingMessageBuffer.RemoveReceivedMessagesFromBuffer();
return fullMessageReceived;
}
Receiving Messages
When a complete, incoming message is received by the server, the server uses an implementation of /// <summary>
/// Handles incoming messages received by the server
/// </summary>
public class ServerMessageHandler : IIncomingMessageHandler
{
public static int _messagesReceived = 0;
public void HandleIncomingMessage(ICommIncomingConnection connection,
ICommunicationsMessage message)
{
Interlocked.Increment(ref _messagesReceived);
// We could also use the "type" parameter integer and avoid reflection/etc
Type messageType = message.MessageContents.GetType();
if (messageType == typeof(HelloMessage))
HandleHelloMessage(message.MessageContents as HelloMessage);
else if (messageType == typeof(SomeGameCommand))
HandleSomeGameCommand(message.MessageContents as SomeGameCommand);
else
Console.WriteLine("Unknown command type: " + messageType.Name);
}
public void HandleSomeGameCommand(SomeGameCommand command)
{
Console.WriteLine(
"Server received game command number " +
command.CommandNumber.ToString() +
" with message " + command.Message +
" and " + command.AdditionalParameters.Count.ToString() +
" additional parameters");
}
public void HandleHelloMessage(HelloMessage hello)
{
Console.WriteLine("Server received hello message: " + hello.Message);
}
}
TCP/IP Tricks and TipsDetecting Dropped ConnectionsWhen writing a TCP/IP client and server, a frequently encountered problem is the issue of clients dropping their connections. Specifically, when a client drops its connection to the server in an abnormal manner, the server can still use the partially closed socket to "send data to the client". In other words, after a client drops his/her connection, the server may blissfully continue sending data into a black hole, seemingly unaware that the client is no longer fully connected. This actually isn't a bug; TCP/IP is a full-duplex network protocol, which means the connection can be closed "half way". Therefore, additional work is required to determine if the client's connection is fully connected (capable of both sending and receiving data to/from the server). In the presented communications library, the following (quick and dirty) code executes occasionally to determine which connections have been "dropped": List<Socket> sockets = new List<Socket>();
foreach (IncomingConnection connection in incomingConnections)
{
sockets.Add(connection.TcpClientConnection.Socket);
}
// Check read
Socket.Select(sockets, null, null, 10000);
if (sockets.Count > 0)
{
// Check write
Socket.Select(null, sockets, null, 10000);
if (sockets.Count > 0)
{
// For these sockets, both read
// and write are bad - client dropped connection
foreach (Socket s in sockets)
{
foreach (IncomingConnection connection in incomingConnections)
{
if (connection.TcpClientConnection.Socket.Handle.ToInt32() ==
s.Handle.ToInt32())
connection.NeedsClosing = true;
}
}
}
}
Demo Project
The demo/test project, TestCommunicationsWinforms, is a Windows Forms application that tests both the server and the client functionality of the communications library. The application performs the following:
Points of InterestI hope you've enjoyed the second installment in the series (or at least picked up some useful information or snippets of code). Upcoming article topics are listed below:
All comments/suggestions are welcome. | ||||||||||||||||||||