ClientsWe will start by creating a minimal TCP client, and then we will tackle a minimal UDP client. A Minimal TCP ClientLet's take a close look at the client's perspective in any networked application. The client's perspective fundamentally involves one socket that we will use to explicitly connect to a game server whose address is known beforehand. Because we are using TCP for this first example, our communication protocol will thus consist of four fundamental operations:
ConnectionEstablishing a connection is a very simple process for a network client. All we need to do is retrieve the required connection information (IP address, port) and then execute the connect call using that information. A step-by-step analysis of the connection process follows: int sock = socket(AF_INET, SOCK_STREAM,IPPROTO_TCP); This first line creates a socket. As in typical UNIX file system programming, a socket is identified by a file descriptor, which is a nonnegative integer. The first parameter to the socket call specifies that we want the socket to use the Internet (other values such as AF_UNIX are used to perform communications that take place inside a single computer). The second parameter specifies that we want stream-based communications, which fundamentally means we want TCP to keep packets in sequence. Other values would be SOCK_DGRAM for datagram-based communications (more on this later) or SOCK_RAW, which is a low-level interface that dumps data as it is received by the socket, with no reordering or sequencing. The third parameter indicates we effectively request TCP as the transport protocol. Other values would be IPPROTO_UDP, which would initialize the socket in UDP mode. Once the socket is available, we need to make it target the server. Remember that we are using TCP, so we need a stable connection between the client and the server. This is a relatively complex process because we traditionally like to work with DNS addresses such as: gameserver.myprovider.com But computers need these addresses to be converted to numeric IP addresses. This conversion process is performed by the name server. So, the first step in the connection is to perform this translation, which is achieved by using the following code, where host is a character string containing the server's DNS address: struct hostent *H=gethostbyname(host); This call connects to the name server and retrieves addressing information for the DNS address passed as the parameter. The hostent structure returned by this call consists of the following members: struct hostent { char * h_name; char **h_aliases; int h_addrtype; int h_length; char **h_addr_list; }; The first member contains the official DNS address of the host. The second parameter is a list of null-terminated strings containing all the aliases (alternate DNS addresses) to the host. The third parameter contains a symbolic constant to identify which type of address we are using. Currently, it always holds the AF_INET constant. The h_length parameter contains the length of the address in bytes. The addr_list contains a list of IP addresses for the host. Additionally, a #define statement is used to define the member h_addr. This points to the first address in the h_addr_list. It is a convenient addition because the first IP address in the list is the one we will be using later in the process. So, gethostbyname allows us to retrieve address data for the specified server using the name server. Once we know the IP address, all we need to do is complete our connection using the socket and connection data. This is achieved by using another structure called sockaddr_in. Here is the required source code: struct sockaddr_in adr; adr.sin_family=AF_INET; adr.sin_port = htons(port); adr.sin_addr.s_addr=*((unsigned long *) H->h_addr); ZeroMemory(adr.sin_zero,8); This seems to be getting more complex, but it really isn't. Think of sockaddr_in as the address information on a postcard. The sin_family member must indicate that we want an Internet connection. The sin_port attribute must contain the port we need to open at the destination server. Notice that we use the htons call (Host-To-Network) to ensure that the port number is in network format. Remember that different computer architectures encode numbers in different ways (big-endian, little-endian, and so on), so making sure we encode these network-related numbers as a neutral format is a must. Once the port has been initialized, we load the s_addr attribute with the IP address, which we obtained from the h_addr attribute of the hostent structure. In addition, we use the ZeroMemory call because we must clear the sin_zero attribute. Now we have a new structure that holds all the connection data. All we have to do is effectively connect our socket to the server using it. This task is performed by using the following line: int error=connect(sock,(struct sockaddr *) &adr,sizeof(adr)); Here the connect call will attempt to connect the socket to the specified host. A return value of 0 will indicate success. Errors are indicated by –1, and the standard errno reporting mechanism is set to indicate the error cause. Once the connect call has been executed, we have an active line of communication with the other endpoint. Let's take a second to review the following encapsulated, fully functional client-side connection method, which tries to connect and returns the socket descriptor: int ConnectTCP(char *host,int port) { int sock= socket(AF_INET, SOCK_STREAM,IPPROTO_TCP); struct hostent *H=gethostbyname(host); struct sockaddr_in adr; adr.sin_family=AF_INET; adr.sin_port = htons(port); adr.sin_addr.s_addr=*((unsigned long *) H->h_addr); ZeroMemory(adr.sin_zero,8); int error=connect(sock,(struct sockaddr *) &adr,sizeof(adr)); if (error==0) return sock; else return error; } Data TransferOnce your communication pipeline is open, transferring data is just a matter of reading and writing from the socket as if it were a regular file descriptor. For example, the following line reads from an open socket: int result=recv(sock,buffer,size,0); Here we are attempting to read "size" bytes from the socket and then store them on the buffer. Notice that this buffer must have assigned memory to accommodate the incoming data. The ending parameters are flags that allow us to maintain sophisticated control over the socket. By default, sockets have a blocking nature. This is the source of many problems and fundamentally means a recv call will only return when the socket effectively holds as much data as we have requested (or if the other peer closes the connection). So, if we try to receive 256 bytes, but the socket only holds 128, the recv call will remain blocked until more data can be read. This can result in deadlocks and annoying situations, so we will need to add a bit more code to ensure that we handle this situation properly. This code will create nonblocking sockets, which will not block regardless of whether or not data is available. We will cover nonblocking sockets in the section "Preventing Socket Blocks" later in this chapter, because they are an advanced subject. But for our simple client, the preceding code should suffice. Once we know how to read from a socket, we can implement the following code, which performs the opposite operation, writing data so that the other peer can read it: int result=send(sock,buffer,strlen(buffer),0); Notice that the syntax is very similar. A word on result values must be provided, though. Both the recv and send calls return an integer value, which tells us how many bytes of data were actually sent or received. In a send command, this could be due to a network malfunction. Because data is sent sequentially, sending less than the whole message means that we will need a second call to send to make sure the remaining data makes it through the network. With a recv call, receiving less data than we are supposed to usually means that either the emitter closed the socket or we have implemented some nonblocking policy that allows us to receive shorter than expected messages. Remember that sockets should block if the amount of data available is less than expected. Closing SocketsAfter all the data has been transferred, it is time to close the socket. On a low-level, this involves making sure the other endpoint has received all the data, and then effectively shutting down the communication port. This is performed by using a very simple command: close(sock); A Minimal UDP ClientWorking with UDP is much easier than with TCP. As mentioned earlier, the downside is reduced reliability. But from our simplified look at the world of networking, all we need to know is that there is no connection establishment or shutdown, so all the networking takes place directly in the data transfer sequences. We will still need to create a socket, but other than that, it is pretty accurate to say that UDP really consists of two calls. Let's first create a UDP socket, which can easily be achieved through the following line of code: int sock= socket(AF_INET, SOCK_DGRAM,IPPROTO_UDP); Notice that we request a datagram socket and specify UDP as the desired transport protocol. Once this initial step is performed, we can directly send and receive data with our datagram socket. Notice that each data transfer call must explicitly specify the destination of the datagram because we don't have a "live" connection. Thus, our regular send primitive won't be of much use here. We will revert to its datagram counterpart: Int sendto(int socket, char *msg, int msglength, int flags, sockaddr *to, int tolen); Again, we initially specify the socket, message, message length, and flags. All these parameters are identical to a regular send call. Additionally, we must specify a sockaddr structure and its length, as in a connect call. As an example, here is the source code to send a message in datagram mode: Void SendUDP(char *msg, char *host, int port,int socket) { struct hostent *H=gethostbyname(host); struct sockaddr_in adr; adr.sin_family=AF_INET; adr.sin_port = htons(port); adr.sin_addr.s_addr=*((unsigned long *) H->h_addr); ZeroMemory(adr.sin_zero,8); Sendto(socket,msg,strlen(msg),0, (struct sockaddr *) &adr,sizeof(adr)); } Notice that we are accessing the name server for each send call, which is inefficient. Alternatively, we could store the sockaddr structure and reuse it for each call. Even better, we can use a variant of UDP called connected UDP, which maintains UDP's nonsequential, nonguaranteed performance while storing a connection to a server. To work with connected UDP, we will follow these simple steps:
Connected UDP is the protocol of choice for UDP applications working with a permanent, stable server (such as a game server). The sendto call provides greater flexibility because we can send messages to different servers easily. But for a one-to-one scenario, connected UDP has a lower overhead and thus offers better performance. Receiving data from a UDP socket is performed through the recvfrom call: int recvfrom(int socket, char *buffer, int buflen, int flags, sockaddr *from, int fromlen); Again, we need extra parameters to retrieve information about our peer. Because we are working in connectionless mode, we need the from parameter to tell us where the message comes from. Server programming under UDP is simpler than with TCP precisely for this reason. We can have a single socket serving many connectionless peers. As a summary of simple clients, Table 10.2 shows you the call sequence for our minimal TCP, connectionless UDP, and connected UDP clients.
|