ChatterBox, A Basic Multi-Player app

Now that we have the basics out of the way, let's get down to some real code. In this section, we'll build a working client/server app called ChatterBox. Although it is simple, it demonstrates how to put together what we've learned so far into something useful.

The Server: ChatterServer

ChatterServer is a multi-user chat app that allows for any number of users to connect to the server and send text messages to one another. The server needs to perform the following main functions:

  • Accept client connections
  • Read messages from clients
  • Write messages to clients
  • Handle disconnections (graceful or otherwise)
Initial Setup

Before getting into the work functions, there is a bit of setup to do. In the constructor for ChatterServer is the following:

clients = new LinkedList();
writeBuffer = ByteBuffer.allocateDirect(255);
readBuffer = ByteBuffer.allocateDirect(255);


The LinkedList is for keeping track of the connected clients. Additionally, we allocate two ByteBuffers, one for reading from the channel and one for writing to the channel. Both buffers are allocated as direct buffers. Because we'll be passing data between sockets and the buffers, this allows the Java VM to use optimized operating system calls and to avoid passing the data across the boundary between native and Java code, as discussed earlier. The server will listen on port 10997 for incoming client connections. The choice of port 10997 is arbitrary. You could, of course, set your server to communicate over any port you want, but it is good practice to respect the "well-known" port numbers (see www.iana.org/assignments/port-numbers). The port is set up in the initServerSocket() method, shown in Listing 6.1.

Listing 6.1 Initializing the Server
private static final int PORT = 10997;
private void initServerSocket() {
 try {
 sSockChan = ServerSocketChannel.open();
 sSockChan.configureBlocking(false);
 InetAddress addr = InetAddress.getLocalHost();
 sSockChan.socket().bind(new InetSocketAddress(addr, PORT));
 readSelector = Selector.open();
 }
 catch (Exception e) {
 log.error("error initializing server", e);
 }
}


First, we open a ServerSocketChannel and configure it for nonblocking operation. Then bind it to the IP address and port. Finally, a selector is opened, with which all new client channels will be registered.

The Main Loop

Now that everything is all set up, we can go ahead and start the run() method. For simplicity, your ChatterServer is implemented using a single thread, so we'll do all of our actions in one main loop. The run() method simply calls the acceptNewConnections() and readIncomingMessages() methods repeatedly.

Accepting Connections

Accepting incoming client connections is a simple matter of invoking the accept() method on the previously opened ServerSocketChannel. Listing 6.2 shows the acceptNewConnections()method from ChatterServer.

Listing 6.2 Accepting Client Connections
private void acceptNewConnections() {
 try {
 SocketChannel clientChannel;
 while ((clientChannel = sSockChan.accept()) != null) {
 addNewClient(clientChannel);
 sendBroadcastMessage("login from: " +
 clientChannel.socket().getInetAddress(),
 clientChannel);
 sendMessage(clientChannel, "\n\nWelcome to ChatterBox,
 there are " + clients.size() + " users online.\n");
 sendMessage(clientChannel, "Type 'quit' to exit.\n");
 }
 }
 catch (IOException ioe) {
 log.warn("error during accept(): ", ioe);
 }
 catch (Exception e) {
 log.error("exception in acceptNewConnections()", e);
 }
}
private void addNewClient(SocketChannel chan) {
 clients.add(chan);
 try {
 chan.configureBlocking( false);
 SelectionKey readKey = chan.register(readSelector,
 SelectionKey.OP_READ, new StringBuffer());
 }
 catch (ClosedChannelException cce) {}
 catch (IOException ioe) {}
}


The ServerSocketChannel is configured for nonblocking operation, so all calls to accept() will return immediately, regardless of whether a connection is pending. This avoids blocking the one and only server thread. If no connections are pending, this method will end quickly; otherwise, it will loop until all new connections are processed. For each connection, we first call addNewClient(), also shown in Listing 6.2, which does a few housekeeping tasks. The client channel is configured as nonblocking and then is registered with the selector. We are interested in knowing only when the channel has data available for reading, so SelectionKey.OP_READ is passed as the interest set. The last parameter is the attachment for the SelectionKey assigned to the channel. Here, a simple StringBuffer is used as the attachment that will hold partially read messages for this client. Finally, a welcome message is sent to the new client and a broadcast message is sent to all connected clients notifying them of the new connection.

Writing Messages

ChatterServer has a sendMessage() method that is used to write messages to individual ChatterClients, as well as sendBroadcastMessage() for sending to all clients. Listing 6.3 shows the implementation along with the helper methods, prepWriteBuffer() and channelWrite().

Listing 6.3 Writing Messages
private void sendMessage(SocketChannel channel, String mesg) {
 prepWriteBuffer(mesg);
 channelWrite(channel, writeBuffer);
}
private void sendBroadcastMessage(String mesg, SocketChannel from) {
 prepWriteBuffer(mesg);
 Iterator i = clients.iterator();
 while (i.hasNext()) {
 SocketChannel channel = (SocketChannel)i.next();
 if (channel != from)
 channelWrite(channel, writeBuffer);
 }
}
private void prepWriteBuffer(String mesg) {
 writeBuffer.clear();
 writeBuffer.put(mesg.getBytes());
 writeBuffer.putChar('\n');
 writeBuffer.flip();
}
private void channelWrite(SocketChannel channel,
 ByteBuffer writeBuffer) {
 long nbytes = 0;
 long toWrite = writeBuffer.remaining();
 try {
 while (nbytes != toWrite) {
 nbytes += channel.write(writeBuffer);
 try {
 Thread.sleep(CHANNEL_WRITE_SLEEP);
 }
 catch (InterruptedException e) {}
 }
 }
 catch (ClosedChannelException cce) {}
 catch (Exception e) {}
 writeBuffer.rewind();
}


In prepWriteBuffer(), we start by clearing the writeBuffer. Then fill it with data from the String that is provided using the ByteBuffer.put() method. Then we call writeBuffer.flip() to prepare the buffer for writing to the channel. Recall that the flip() method sets the buffer's limit to the current position, which is at the end of the data just inserted, and resets the position to 0. ChannelWrite() consists of a loop around the Channel.write() call. The reason for the loop is that the nonblocking SocketChannel will not necessarily write the entire contents of the given buffer in one shot. It can write only as many bytes as are available in the socket's output buffer. Note that you can change the size of the socket buffers using Socket.setSendBufferSize() and Socket.setReceiveBufferSize(). The defaults are operating system–dependent. In this ChatterBox app, it is likely that a message will get sent in one call to the write() method, but this is good practice and will definitely be required for the full client and server apps in which message sizes and volumes can be much larger. Note at the end of channelWrite() that writeBuffer.rewind() is called. This sets up the buffer for subsequent channel writes by moving the buffer's position back to 0 while keeping the limit—and the buffer's contents—intact. sendBroadcastMessage()calls sendMessage() for each client in the list, with the exception of the from client. There is no need for the originator of the message to be sent a copy.

Reading Messages

Receiving messages from clients is a bit more complicated than sending them. The complication arises not from pulling bytes from the channel, but from the need to determine when a full message has arrived. Listing 6.4 shows the full read() method of ChatterServer.

Listing 6.4 ChatterServer.readIncomingMessages()
private void readIncomingMessages() {
 try {
 readSelector.selectNow();
 Set readyKeys = readSelector.selectedKeys();
 Iterator i = readyKeys.iterator();
 while (i.hasNext()) {
 SelectionKey key = (SelectionKey) i.next();
 i.remove();
 SocketChannel channel = (SocketChannel) key.channel();
 readBuffer.clear();
 long nbytes = channel.read(readBuffer);
 // check for end-of-stream
 if (nbytes == -1) {
 log.info("disconnect: " +
 channel.socket().getInetAddress() +
 ", end-of-stream");
 channel.close();
 clients.remove(channel);
 sendBroadcastMessage("logout: " +
 channel.socket().getInetAddress() , channel);
 }
 else {
 StringBuffer sb = (StringBuffer)key.attachment();
 readBuffer.flip( );
 String str = asciiDecoder.decode( readBuffer).toString( );
 readBuffer.clear( );
 sb.append( str);
 String line = sb.toString();
 if ((line.indexOf("\n") != -1) ||
 (line.indexOf("\r") != -1))
 line = line.trim();
 log.info ("got mesg: " + line);
 if (line.startsWith("quit")) {
 log.info("got quit msg, closing channel: " +
 channel.socket().getInetAddress());
 channel.close();
 clients.remove(channel);
 sendBroadcastMessage("logout: " +
 channel.socket().getInetAddress(), channel);
 }
 else {
 log.info("broadcasting: " + line);
 sendBroadcastMessage(
 channel.socket().getInetAddress() +
 ": " + line, channel);
 sb.delete(0, sb.length());
 }
 }
 }
 }
 }
 catch (IOException ioe) {
 log.warn("error during select(): ", ioe);
 }
 catch (Exception e) {
 log.error("exception in run()", e);
 }
}


First, we do a selectNow() on the readSelector to check whether any channels have activity that are of interest—in this case, any keys with OP_READ set. The Selector.selectNow() method returns immediately, regardless of how many (possibly zero) keys are ready. Again, the nonblocking call is used to avoid holding up the only thread. If we called select() instead, the call would block until data was incoming on the channel, and any pending outgoing messages would be stuck. Later, for the full server implementation, we'll dedicate a thread to socket reading and others for writing, so the blocking select() call will be used instead. When we have a ready key, we get a reference to the SocketChannel and call its read() method, which fills the readBuffer with as many bytes as are available in the socket's input buffer. Before you can process that data, you first need to check for an end-of-stream condition. The SocketChannel.read() method returns -1 to indicate that the end-of-stream has been reached, which basically means that the socket was closed. Now that we know that some bytes are available, we grab the StringBuffer, previously stored as the SelectionKey's attachment. Then append the contents of the readBuffer into the StringBuffer and check to see if there is a complete message. The app terminates each message with the new line character \n, so the String.indexOf() method is used to check for the presence of that character. We talk more about message formats later in the "GameEvents" section of this chapter; for now, this tactic suffices.

Disconnections

Clients eventually close their connections to the server. This can happen for many reasons, both intentional and accidental, and in a variety of ways. We must take care to cover all cases and act accordingly. A client might close its connection by sending a "quit" command to the server. The server checks for a "quit" message inside the readIncomingMessages() method and closes the client's channel. This is the preferred method because it allows both the client and the server to do any required cleanup. Client connections can be terminated forcefully by the client app closing the channel or exiting abruptly, or because of network connectivity failures. The server's first indication is either in readIncomingMessages(), where the channel.read() returns the end-of-stream indicator as discussed earlier, or by catching a ChannelClosedException or IOException in the sendMessage() or readIncomingMessages() methods. In either case, you need to be sure to close the channel, remove the client from the client list, and perform any other necessary cleanup. NOTE It is not necessary to unregister the channel from the selector. The channel implementation cancels its SelectionKey when it is closed, which causes it to be removed from the selector's key set. See the Selector API documentation for further details.


Building and Running the Server

Ah, our first real server program is complete. Let's get it built and fire it up for some testing. What's that, you say? We haven't written the client yet? No problem. As you'll see in a minute, because of our choice of a simple message format (new line terminated Strings), you can use any old Telnet client to talk to the server. First, grab a copy of the code and then use Ant to build the ChatterBox app:

$ cd chap06
$ ant


The code is compiled and ready for testing. For this test, you need to have three separate terminal windows open (or use screen, if you fancy: www.gnu.org/software/screen/). In the first terminal, run the server with the provided shell script:

$./bin/chatter_server.sh


You should see some initial log messages print to the screen.

Connecting with Telnet

The Telnet protocol is defined in RFC 854 (www.faqs.org/rfcs/rfc854.html), dating back to 1983. It was primarily designed to facilitate communication between local and remote terminals, usually for interactive shell sessions. Due to security concerns, this functionality has largely been replaced by the secure shell (ssh) protocols, but Telnet isn't dead yet. By having the server communicate with a very rudimentary subset of the Telnet protocol, we can take advantage of the ubiquity of Telnet clients on modern operating systems and use it as the test client. From another terminal, Telnet to the server host IP (10.0.0.1, in this example), also providing the port number to connect to:

$ telnet 10.0.0.1 10997
Trying 10.0.0.1...
Connected to 10.0.0.1.
Escape character is '^]'.
Welcome to ChatterBox, there are 1 users online.
Type 'quit' to exit.


You are greeted by the welcome message and told how many users are online. In the server terminal, you should see log messages indicating that it has received the connection. Now do the same from the third terminal. This time, after connecting, type a message and press Return. You should see the message echoed to the other client terminal. Go ahead, talk to yourself—it's okay.

$ telnet 10.0.0.1 10997
Trying 10.0.0.1...
Connected to 10.0.0.1.
Escape character is '^]'.
Welcome to ChatterBox, there are 2 users online.
Type 'quit' to exit.
hello there
/10.0.0.1: wassup?
quit Connection closed by foreign host.
$


There you have it, a multi-user server app! I know, it's not very fun, is it? But we're getting there. The next thing to do is construct a real client app.

The Client: ChatterClient

ChatterClient is a relatively simple app and shares much with ChatterServer. It needs to perform only three tasks:

  • Connect to the server.
  • Send messages.
  • Receive messages.
Setup

As with the server, we need to do a bit of initialization first. We set up two ByteBuffers just as we did for ChatterServer, one each for reading and writing. Additionally, before dropping into the main loop, a console input thread is started. This thread is responsible for reading user input from STDIN.

Making the Connection

First let's look at making a connection to the server. As discussed earlier, we'll use a SocketChannel to implement the socket connection. Listing 6.5 shows the connect() method.

Listing 6.5 ChatterServer.connect()
private void connect(String hostname) {
 try {
 readSelector = Selector.open();
 InetAddress addr = InetAddress.getByName(hostname);
 channel = SocketChannel.open(new InetSocketAddress(addr, PORT));
 channel.configureBlocking(false);
 channel.register(readSelector, SelectionKey.OP_READ,
 new StringBuffer());
 }
 catch (UnknownHostException uhe) {}
 catch (ConnectException ce) {}
 catch (Exception e) {}
}


First we open a new selector and get the IP address of the server using InetAddress.getByName(). Then we open the SocketChannel with that address and the port number we are using (10997). Next, the channel is configured to be in nonblocking mode. The last thing to do is register the channel with the selector, again using a StringBuffer as the attachment. Note that in the ChatterBox example, we largely ignore exceptions for clarity. In production code, you must be careful to catch all exceptions and deal with them appropriately by trying to recover gracefully, logging error messages, alerting the user, or any combination of these.

The Main Loop

The client uses two threads, one for console input/writing (InputThread) and the main execution thread. The main loop, in the run() method, simply calls readIncomingMessages() repetitively to communicate with the server. Outgoing messages to the server are initiated from within InputThread.

Sending and Receiving Messages

As you'll see from the code, sending and receiving messages is nearly identical in both the client and the server. Later, in the game server framework, we'll separate common code such as this into a utility class, to avoid the redundancy.

Building and Running the Client

As with the server, use Ant to build the code:

$ cd chap06
$ ant


Then call the provided shell script to start the client. ChatterClient takes the host IP and port as parameters, so invoke it just like the previous Telnet example:

$ ./bin/chatter_client.sh 10.0.0.1 10997


The interaction is nearly identical as when using Telnet:

$ ./bin/chatter_client.sh 10.0.0.1 10997
Welcome to ChatterBox, there are 1 users online.
Type 'quit' to exit.
>
login from: /10.0.0.7
> hello
/10.0.0.7: wassup?
>


That's all for our first multi-user app. Now we can get on to bigger and better things!



   
Comments