Earlier in this chapter, we showed a hypothetical conversation in which a client and server exchanged some primitive data and a serialized Java object. Passing an object between two programs may not have seemed like a big deal at the time, but in the context of Java as a portable byte-code language, it has profound implications. In this section, we’ll show how a protocol can be built using serialized Java objects.
Before we move on, it’s worth considering network protocols. Most programmers would consider working with sockets to be “low-level” and unfriendly. Even though Java makes sockets much much easier to use than many other languages, sockets still provide only an unstructured flow of bytes between their endpoints. If you want to do serious communications using sockets, the first thing you have to do is come up with a protocol that defines the data you’ll be sending and receiving. The most complex part of that protocol usually involves how to marshal (package) your data for transfer over the Net and unpack it on the other side.
As we’ve seen, Java’s DataInputStream
and DataOuputStream
classes solve this problem for
simple data types. We can read and write numbers,
String
s, and Java primitives in a recognizable
format that can be understood on any other Java platform. But to do
real work, we need to be able to put simple types together into
larger structures. Java object serialization solves this problem
elegantly, by allowing us to send our data just as we use it, as the
state of Java objects. Serialization can even pack up entire graphs
of interconnected objects and put them back together at a later time,
possibly in another Java VM.
In the following example, a client will send a serialized object to the server, and the server will respond in kind. The client object represents a request and the server object represents a response. The conversation ends when the client closes the connection. It’s hard to imagine a simpler protocol. All the hairy details are taken care of by object serialization, so we can keep them out of our design.
To start we’ll define a class, Request
, to
serve as a base class for the various kinds of requests we make to
the server. Using a common base class is a convenient way to identify
the object as a type of request. In a real application, we might also
use it to hold basic information like client names and passwords,
timestamps, serial numbers, etc. In our example,
Request
can be an empty class that exists so
others can extend it:
//file: Request.java public class Request implements java.io.Serializable {}
Request
implements
Serializable
, so all of its subclasses will be
serializable by default. Next we’ll create some specific kinds
of Request
s. The first,
DateRequest
, is also a trivial class. We’ll
use it to ask the server to send us a
java.util.Date
object as a response:
//file: DateRequest.java public class DateRequest extends Request {}
Next, we’ll create a generic WorkRequest
object. The client sends a WorkRequest
to get the
server to perform some computation for it. The server calls the
WorkRequest
object’s execute( )
method and returns the resulting object as a response:
//file: WorkRequest.java public abstract class WorkRequest extends Request { public abstract Object execute( ); }
For our application, we’ll subclass
WorkRequest
to create
MyCalculation
, which adds code that performs a
specific calculation; in this case, we will just square a number:
//file: MyCalculation.java public class MyCalculation extends WorkRequest { int n; public MyCalculation( int n ) { this.n = n; } public Object execute( ) { return new Integer( n * n ); } }
As far as data content is concerned, MyCalculation
really doesn’t do much; it only transports an integer value for
us. But keep in mind that a request object could hold lots of data,
including references to many other objects in complex structures like
arrays or linked lists. An interesting part here is that
MyCalculation
also contains behavior—the
execute( )
operation. In our discussion of RMI
below, we’ll see how Java’s ability to dynamically
download bytecode for serialized objects makes both the data content
and behavior portable over the network.
Now that we have our protocol, we need the server. The following
Server
class looks a lot like the
TinyHttpd
server that we developed earlier in this
chapter:
//file: Server.java import java.net.*; import java.io.*; public class Server { public static void main( String argv[] ) throws IOException { ServerSocket ss = new ServerSocket( Integer.parseInt(argv[0]) ); while ( true ) new ServerConnection( ss.accept() ).start( ); } } // end of class Server class ServerConnection extends Thread { Socket client; ServerConnection ( Socket client ) throws SocketException { this.client = client; setPriority( NORM_PRIORITY - 1 ); } public void run( ) { try { ObjectInputStream in = new ObjectInputStream( client.getInputStream( ) ); ObjectOutputStream out = new ObjectOutputStream( client.getOutputStream( ) ); while ( true ) { out.writeObject( processRequest( in.readObject( ) ) ); out.flush( ); } } catch ( EOFException e3 ) { // Normal EOF try { client.close( ); } catch ( IOException e ) { } } catch ( IOException e ) { System.out.println( "I/O error " + e ); // I/O error } catch ( ClassNotFoundException e2 ) { System.out.println( e2 ); // unknown type of request object } } private Object processRequest( Object request ) { if ( request instanceof DateRequest ) return new java.util.Date( ); else if ( request instanceof WorkRequest ) return ((WorkRequest)request).execute( ); else return null; } }
The Server
services each request in a separate
thread. For each connection, the run( )
method
creates an ObjectInputStream
and an
ObjectOutputStream
, which the server uses to
receive the request and send the response. The
processRequest( )
method decides what the
request means and comes up with the
response. To figure out what kind of request we have, we use the
instanceof
operator to look at the object’s
type.
Finally, we get to our Client
, which is even
simpler:
//file: Client.java import java.net.*; import java.io.*; public class Client { public static void main( String argv[] ) { try { Socket server = new Socket( argv[0], Integer.parseInt(argv[1]) ); ObjectOutputStream out = new ObjectOutputStream( server.getOutputStream( ) ); ObjectInputStream in = new ObjectInputStream( server.getInputStream( ) ); out.writeObject( new DateRequest( ) ); out.flush( ); System.out.println( in.readObject( ) ); out.writeObject( new MyCalculation( 2 ) ); out.flush( ); System.out.println( in.readObject( ) ); server.close( ); } catch ( IOException e ) { System.out.println( "I/O error " + e ); // I/O error } catch ( ClassNotFoundException e2 ) { System.out.println( e2 ); // unknown type of response object } } }
Just like the server, Client
creates the pair of
object streams. It sends a DateRequest
and prints
the response; it then sends a MyCalculation
object
and prints the response. Finally, it closes the connection. On both
the client and the server, we call the flush( )
method
after each call to writeObject( )
.
This method forces the system to send any
buffered data; it’s important because
it ensures that the other side sees the entire request before we wait
for a response. When the client closes the connection, our server
catches the
EOFException
that is thrown and ends the session. Alternatively, our client could
write a special object, perhaps null
, to end the
session; the server could watch for this item in its main loop.
The order in which we construct the
object streams is important. We create the output streams first
because the constructor of an ObjectInputStream
tries to read a header from the stream to make sure that the
InputStream
really is an object stream. If we
tried to create both of our input streams first, we would deadlock
waiting for the other side to write the headers.
Finally, we can run the example. Run the Server
,
giving it a port number as an argument:
% java Server 1234
Then run the Client
, telling it the server’s
hostname and port number:
% java Client flatland 1234
The result should look like this:
Sun Jul 11 14:25:25 PDT 1999 4
All right, the result isn’t that impressive, but it’s easy to imagine more substantial applications. Imagine that you needed to perform some complex computation on many large data sets. Using serialized objects makes maintenance of the data objects natural and sending them over the wire trivial. There is no need to deal with byte-level protocols at all.
There is one catch in this scenario: both the client and server need
access to the necessary classes. That is, all of the
Request
classes—including
MyCalculation
, which is really the property of the
Client
—have to be in the class path on both
the client and the server machines. As we hinted earlier, in the next
section we’ll see that it’s possible to send the Java
bytecode along with serialized objects to allow completely new kinds
of objects to be transported over the network dynamically. We could
create this solution on our own, adding to the earlier example using
a network class loader to load the classes for us. But we don’t
have to: Java’s RMI facility automates that for us. The ability
to send both serialized data and class definitions over the network
makes Java
a powerful tool for developing
advanced
distributed applications.
Get Learning Java now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.