Chapter 25

Client/Server Fundamentals

by George Reese


CONTENTS


A network connects two or more computers together to perform tasks individual computers cannot do on their own. In business, government, education, and research, the network has been a common way of extending the power of the computer. On the simplest level, a network enables multiple users to share the same work. For example, almost anywhere you see a network, you see people working on the same documents and spreadsheets.

On a more complex level, a network enables a single computer to share the hardware resources of other computers. One computer may be a powerful graphics machine and another may be a powerful number cruncher. If you put the two together, you can perform tasks that require both heavy graphics and heavy number crunching in an optimized fashion.

Over the past decade, the client/server architecture has grown to be the most common design for network applications. Client/server is based on the idea that one computer specializing in information presentation displays the data stored and processed on a remote machine.

Today, the Internet provides home computers with the same networking power institutions outside the home have traditionally used. Many of the Internet applications you have come to know are client/server applications: the Web, e-mail, ftp, telnet, and so on. Specifically, your home PC serves as the client side of the architecture. It displays information located on servers around the world.

This chapter takes a look at the basics of client/server programming and how Java supports the client/server architecture. The first step, of course, is to understand exactly what it is that the client/server architecture tries to accomplish. We then take a look at the basic building blocks of client/server programming: sockets. Finally, we take a look at ways we can extend that architecture to build powerful Internet applications.

Basic Client/Server Architecture

The most basic form of the client/server architecture involves two computers: one computer, the server, is responsible for storing some sort of data and handing it to the other computer, the client, for user interaction. The user can modify that data and save it back to the server. The Web implements this simple form of client/server architecture for multiple client machines. Your computer, the client, uses a Web browser to display HTML documents stored across the Internet on a Web server.

There are four software components to the Web system:

The diagram in Figure 25.1 shows how this architecture fits together.

Figure 25.1: The client/server architecture of the Web.

Dividing the Work

The client/server architecture provides us with a logical breakdown of application processing. In an ideal environment, the server side of the application handles all common processing, and the client side handles user-specific processing. With the Web, the server stores the HTML documents that are shown to all the clients. Each client, on the other hand, has different display needs. For example, a user at a dummy terminal is limited to using the character-mode Lynx client. A Windows user, on the other hand, can use a GUI browser to use the power of the graphical interface to display the document using full multimedia. The presentation of the HTML documents presented by the server is thus left up to the client.

Client/Server Communication

Internet applications communicate using Internet Protocol (IP) sockets. IP is a basic networking protocol on top of which other protocols exist to serve varying purposes. The details of how IP works are very boring and, outside of addressing, are fortunately unimportant to anyone who wants to write network applications.

Each computer in an IP network has an IP address, which is a 32-bit number usually broken into four 8-bit quads. An IP address looks like this: 206.11.201.18. The first two numbers (the high-order bits) form the network address. The low-order bits specify which computer on that network the address is for. Using this multinetwork addressing scheme, computers on different networks can communicate with each other.

IPv6: THE NEXT GENERATION
The current 32-bit scheme can address 4 billion hosts on 16.7 million networks. Optimistic projections suggest that we will run out of these addresses some time between 2005 and 2011. On November 17, 1994, the Internet Engineering Task Force (IETF) accepted a recommendation for a new version of IP called IPng (IP: Next Generation) to handle this problem and related issues. This new IP specification, also called IPv6, uses a 128-bit addressing scheme. Under pessimistic projections, IPng provides 1,564 IP addresses for each square meter of the planet Earth. Optimistic projections suggest that it will provide over 3 quintillion addresses for each square meter of the Earth's surface. This new specification is designed to interoperate with the existing IP standards so that each machine on the Internet can simply do a software upgrade when ready.

When I want to send some information from my machine to yours, my application uses your machine's IP address to send that data to your machine. If your machine has the address 199.199.181.120, for example, my machine first checks whether it knows where that specific IP address is. Because my machine is a simple client on the 206.11 network, it is very unlikely that it has any idea where your machine is. But it does know of a default gateway machine to which it sends data for all unknown computers.

When a gateway receives data addressed for a specific IP, it in turn checks to see whether it knows about the specific computer in question. In this case, my default gateway is likely a router for my local network. It probably knows about the existence of machines only on the local network. It thus forwards my data onto its default gateway, which is responsible for knowing about a lot of networks. Although this router also does not know where the 199.199.181.120 machine is, it does have a specific gateway for the 199.199 network. It therefore forwards the data to that gateway. After traveling through a series of gateways, the data eventually reaches a machine that knows exactly where your machine can be found. Figure 25.2 shows the flow of how a machine handles each set of IP data (also referred to as a packet).

Figure 25.2: How a computer handles an IP packet.

On any given machine, you may have a lot of applications communicating with other computers on the network. The packet I sent you now has to be able to tell your machine exactly which application it is destined for. It does this using the final piece to IP addressing: the port number. Just as an apartment number tells the post office which apartment in a building a specific letter should be sent to, a port number tells a computer which application should receive an IP packet.

Note
With all this talk about IP numbers, you may be wondering how IP names fit into the picture. IP names are actually aliases placed on top of IP through a system called DNS (Domain Name Service). DNS provides a system for turning names such as byzantium.imaginary.com into numbers like 206.11.201.18 and then reversing the process. It is important to note that IP itself has no knowledge of machines names; such processing is handled at a higher level.

However, all you really need to know about IP specifically is how to address the data you want to send. In fact, the port number just described is not even part of the IP. IP simply describes how to get a packet from point A to point B. Any network application you write will instead be coded against higher level protocols which, in turn, handle IP management. The two most common protocols are these:

TCP/IP, referred to in older texts as DARPA Internet Protocols, is actually a suite of protocols that provides applications with a reliable data communication layer. When you send a TCP/IP packet, you know either that the packet will reach its destination or that you will be informed of any problems with the transmission of the data. All this is done by encapsulating packet transmission inside a network session. When you want to communicate with another computer using TCP/IP, you create a connection that allows you to send multiple packets. The target application is listening to the network on a target port. Your client application connects to that listen port and negotiates a new private port through which data can be transmitted for as long as the connection is open.

The downside to TCP/IP is the overhead required to manage all that error handling as well as the need to take up ports on both machines to maintain a constant connection. UDP/IP, on the other hand, is a protocol for transmitting packets across the network without the reliability overhead incurred by TCP/IP. If you use UDP/IP, your application sends individual packets (called datagrams) to the target computer's listen port and hopes for the best. Sometimes the packets get to their destination, sometimes they do not. For a detailed discussion of socket programming, take a look at Chapter 26, "Java Socket Programming."

Using TCP/IP

Our client/server application starts with the server. Java provides a ServerSocket class that listens to a port and waits for clients to connect. When an application creates a ServerSocket, it passes a port number to the constructor. The application then repeatedly calls the ServerSocket accept() method. That method blocks application processing until a client connects. Once a client does connect, accept() returns a Java Socket object representing the connection to the client machine. The server then creates a new thread for the processing of data related to this connection. The listen thread calls accept() to wait for the next connection. Listing 25.1 shows the basic flow of server processing.


Listing 25.1. Basic server processing using the Java TCP/IP classes.

import java.net.ServerSocket;
import java.net.Socket;

public class Server implements Runnable {
  private Socket client;

  public Server(Socket socket) {
    Thread thread;

    client = socket;
    thread = new Thread(this);
    thread.start();
  }

  static public void main(String args[]) {
    ServerSocket listen_socket;

    try {
      listen_socket = new ServerSocket(10000);
    }
    catch( java.io.IOException e ) {
      System.err.println("Failed to create listen socket.");
      e.printStackTrace();
      System.exit(-1);
      return;
    }
    while( true ) {
      Socket socket;
      try {
        socket = listen_socket.accept();
        new Server(socket);
      }
      catch( java.io.IOException e ) {
        e.printStackTrace();
      }
    }
  }

  public void run() {
    // Handle all processing for a specific client here
  }
}

On both the client and server ends, your application sends and receives data through Socket objects. The run() method in the Server in Listing 25.1 is used to get and receive information from the client. For each instance of the Server class created from the static main() method, the application has a corresponding instance of the Socket class to communicate with a specific client.

As with other forms of I/O, socket I/O is managed with the Java streams. Listing 25.2 shows how we can implement the run() method of a Server class to simply echo information the client sends back to it.


Listing 25.2. The run() method from the Server class.

public void run() {
  java.io.DataInputStream input;
  java.io.PrintStream output;
  String data;

  try {
    input = new java.io.DataInputStream(client.getInputStream());
    output = new java.io.PrintStream(client.getOutputStream());
  }
  catch( java.io.IOException e ) {
    e.printStackTrace();
    return;
  }
  while( true ) {
    try {
      data = input.readLine();
      output.println("Received (" + data.length() + "): " + data);
    }
    catch( java.io.IOException e ) {
      break;
    }
  }
}

You can test this program using the telnet client to connect to port 10000 on the machine running the Server application. To do this, type telnet localhost 10000 at your UNIX or DOS command line. When using the telnet application for testing, however, you should keep in mind that telnet is a protocol on top of TCP/IP. It actually sends more characters than you type as part of its protocol. Because the Server application does not know the telnet protocol, it ends up sending back to you what you typed plus all the telnet protocol information.

Of course, it may be more useful to try out the Server application using an application that shows how a client uses Java sockets. Listing 25.3 shows such a client application.


Listing 25.3. A Java client application.

import java.net.Socket;

public class Client {
  static public void main(String args[]) {
    Socket socket;

    try {
      java.io.DataInputStream input;
      java.io.PrintStream output;

      socket = new Socket("localhost", 10000);
      while( true ) {
        try {
          String tmp;
          java.io.DataInputStream user_input =
            new java.io.DataInputStream(System.in);

          input = new java.io.DataInputStream(socket.getInputStream());
          output = new java.io.PrintStream(socket.getOutputStream());
          tmp = user_input.readLine();
          output.println(tmp);
          tmp = input.readLine();
          System.out.println(tmp);
        }
        catch( java.io.IOException e ) {
          e.printStackTrace();
          return;
        }
      }
    }
    catch( java.io.IOException e ) {
      e.printStackTrace();
    }
  }
}

Because of the way Java sockets are engineered, client and server operations are nearly identical. The major differences are that the server communicates with multiple clients but the client communicates with only a single server, and that the server has to create a listen port for initial connections.

What we have developed in this chapter so far simply takes raw data from one end and sends it right back. In a real application, we probably want something more like a dialog to occur between the client and server applications-we want to interpret the data. Once you have the basic blocks for communicating between the client and server, you need to build your own customer protocol on top of that communication layer to give that data meaning.

Using UDP/IP

You may wonder at first why you would use an unreliable communications protocol like UDP. After all, if you are sending data, can't you assume that you want it to get to its destination? Not necessarily. Sometimes, an application sends information, and the arrival of individual packets is unimportant. For example, a server repeatedly broadcasting sports scores 24 hours a day does not really care whether a given score arrives at its destination. It does care, however, about the overhead any error correction might introduce. Such an application is a perfect situation for UDP/IP.

Listing 25.4 shows a DatagramServer class that performs two main tasks:

  1. Listens for incoming datagram packets from clients that request scores to be sent to them.
  2. Sends out scores to all clients who have shown interest.

Listing 25.4. A simple datagram server.

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;

public class DatagramServer implements Runnable {
  private java.util.Hashtable listeners = new java.util.Hashtable();

  public DatagramServer() {
    Thread thread = new Thread(this);
    thread.start();
  }

  static public void main(String args[]) {
    DatagramSocket socket;
    DatagramPacket packet;
    DatagramServer server;
    byte[] buffer = new byte[255];

    server = new DatagramServer();
    packet = new DatagramPacket(buffer, buffer.length);
    try {
      socket = new DatagramSocket(10000);
    }
    catch( java.net.SocketException e ) {
      e.printStackTrace();
      return;
    }
    while( true ) {
      String tmp;

      try {
        socket.receive(packet);

        tmp = new String(buffer, 0, 0, packet.getLength());
        if( tmp.equals("close") ) {
          server.removeListener(packet.getAddress());
        }
        else {
          server.addListener(packet.getAddress());
        }
      }
      catch( java.io.IOException e ) {
        e.printStackTrace();
      }
    }
  }

  public void run() {
    while(true) {
      synchronized(listeners) {
        java.util.Enumeration addresses = listeners.keys();

        // Send a score to the current list
        while( addresses.hasMoreElements() ) {
          InetAddress addr = (InetAddress)addresses.nextElement();
          DatagramPacket packet;
          DatagramSocket socket;
          String str = "a score";
          byte[] msg = new byte[str.length()];

          try {
            socket = new DatagramSocket();
          }
          catch( java.net.SocketException e ) {
            e.printStackTrace();
            break;
          }
          str.getBytes(0, msg.length, msg, 0);
          try {
            packet = new DatagramPacket(msg, msg.length, addr, 11000);
            socket.send(packet);
          }
          catch( java.io.IOException e ) {
            e.printStackTrace();
          }
        }
      }
      // unsynchronize the listeners and allow any new additions
      try {
        Thread.sleep(500);
      }
      catch( InterruptedException e ) {
      }
      // remove old listeners
      synchronized(listeners) {
        java.util.Enumeration addresses = listeners.keys();

        while(addresses.hasMoreElements()) {
          InetAddress addr = (InetAddress)addresses.nextElement();
          int count = ((Integer)listeners.get(addr)).intValue();

          if( count > 1200 ) {
            listeners.remove(addr);
          }
          else {
            listeners.put(addr, new Integer(count + 1));
          }
        }
      }
    }
  }

  public void addListener(InetAddress ip) {
    listeners.put(ip, new Integer(0));
  }

  public void removeListener(InetAddress ip) {
    listeners.remove(ip);
  }
}

For simplicity's sake, this example simply sends the string a score out repeatedly. For a real application, you would, of course, want to provide the server with some way of retrieving real scores instead. Listing 25.5 is equally simple. It connects to the server and displays the scores as they come across.


Listing 25.5. A corresponding datagram client for displaying scores from the server.

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;

public class DatagramClient {
  static public void main(String args[]) {
    DatagramSocket socket;
    DatagramPacket packet;
    byte[] buffer = new byte[255];
    int count = 0;

    packet = new DatagramPacket(buffer, buffer.length);
    try {
      socket = new DatagramSocket(11000);
    }
    catch( java.net.SocketException e ) {
      e.printStackTrace();
      return;
    }
    try {
      DatagramPacket p;
      DatagramSocket s;
      String str = "keep alive";
      byte[] msg = new byte[str.length()];

      count = 0;
      try {
        s = new DatagramSocket();
        str.getBytes(0, msg.length, msg, 0);
        p = new DatagramPacket(msg, msg.length,
                               InetAddress.getByName("localhost"), 10000);
        s.send(packet);
      }
      catch( java.net.SocketException e ) {
        e.printStackTrace();
      }
    }
    catch( java.io.IOException e ) {
      e.printStackTrace();
    }
    while( true ) {
      String tmp;

      count++;
      try {
        socket.receive(packet);

        tmp = new String(buffer, 0, 0, packet.getLength());
        System.out.println(tmp);
      }
      catch( java.io.IOException e ) {
        e.printStackTrace();
      }
      if( count > 5000 ) {
        DatagramPacket p;
        DatagramSocket s;
        String str = "keep alive";
        byte[] msg = new byte[str.length()];

        count = 0;
        try {
          s = new DatagramSocket();
        }
        catch( java.net.SocketException e ) {
          e.printStackTrace();
          break;
        }
        str.getBytes(0, msg.length, msg, 0);
        try {
          p = new DatagramPacket(msg, msg.length,
                                 InetAddress.getByName("localhost"), 10000);
          s.send(packet);
        }
        catch( java.io.IOException e ) {
          e.printStackTrace();
        }
      }
    }
  }
}

UDP/IP sockets require a lot more base manipulation than do TCP/IP sockets because you have to make a new connection for every single packet you send. The payoff is enhanced performance for communication, which does not depend on any one socket actually arriving at its destination.

Two-Tier versus Three-Tier Design

Now that you understand how to make computers talk to one another on the Internet with Java, it helps to understand how to design any client/server application you might build. As discussed earlier, the client/server architecture assigns processing responsibility where it logically belongs. A simple system can be broken into two layers: a server where data and common processing occurs and a client where user-specific processing occurs. This kind of architecture is more commonly known as a two-tier architecture. For the types of applications we have discussed, a simple two-tier breakdown works well.

Business applications-and, increasingly, Internet applications-are generally much more complex than the applications discussed in this chapter. These kinds of applications can involve relational databases and complex server-side processing. Client machines are becoming increasingly powerful. At the same time, the benefit of client/server development has enabled applications to move processing off the server and onto the client to facilitate the use of cheaper servers. This trend has led to what is known as the problem of the fat client.

A fat client in a client/server system is a client that has absorbed an inordinate amount of the system's processing needs. Although a fat client architecture is as capable as any other client/server configuration, it is harder to scale as your application grows over time. Using a common client/server tool such as PowerBuilder, your client application has direct knowledge of exactly how your data is stored and what it looks like in the data store (usually a database). If you ever change where that data is stored or how it is stored, you have to do significant rework of your client application.

The solution to the problem of the fat client is a three-tier client/server architecture that creates another layer of processing across the network. In Figure 25.3, you can see how the three-tier design divides application work into the following three tasks:

Figure 25.3: The three-tier client/server architecture.

One of the primary advantages of a three-tier architecture is that, as your data storage needs grow, you can change the way data is stored without affecting your clients. The middle layer of the system, commonly referred to as the application server, can thus concentrate on centralizing business rule processing. (Business rule processing is the processing of data going to and from clients in a way that is common to all clients.)

Beyond Sockets

This chapter has outlined the nuts and bolts of how communication between machines works in a client/server environment and how you might construct client/server applications to best perform the tasks you need them to perform. Much of the code you have seen in this chapter, unfortunately, really has little to do with the central job your application is doing. It is simply about making two or more computers talk to each other.

New technologies are on the horizon to help deliver you from the tedium of socket programming in a client/server environment. The most exciting of these technologies is distributed objects. A distributed application is a single application that has individual objects located on many machines. In an ideal world, these objects communicate with one another through simple method calls. Unfortunately, the ideal world is not here yet.

With the 1.1 release, Java provides a new API designed to allow you to distribute your Java applications. This new API, called Remote Method Invocation (RMI), enables a program on one machine to communicate with a program on another machine using simple Java method calls. Instead of writing a complex socket interface and application-specific communication protocol, your application acts as if all the separate pieces were part of a single program on one machine. You call methods in any object, no matter where they exist, just as you do any other Java method.

A discussion of RMI is beyond the scope of this chapter. Nevertheless, as a seamless method-based communication API, RMI does provide an attractive alternative to writing socket code. Unfortunately, RMI works only when all the pieces of your application are Java pieces. In a hybrid system, sockets provide the best method of enabling communication among networked machines.

Summary

The client/server architecture is a very powerful design used by almost every application you use on the Internet. At its core, it is simply about making an application on one computer talk with an application on another computer. On the Internet, this is always done using the keystone of the Internet: IP.

In most of the applications you build today, you use Java's socket objects to do TCP/IP and UDP/IP communication. Understanding how these protocols work is not only critical to writing client/server applications that use the Java socket code, but it is also key to understanding the problems that can occur using any protocols built on top of them-such as RMI.