Joshua develops collaborative software at VocalTec Communications Ltd. He can be contacted at [email protected].
The Java Shared Data Toolkit (JSDT) is a freely available class library from Sun Microsystems designed to help you write collaborative applications. Distributed collaborative systems, sometimes referred to as groupware or multiuser applications, let groups of users work simultaneously on a common task. Typical collaborative apps include workflow management systems, distance learning, video conferencing, and the like. (At VocalTec, the company I work for, we are developing a system that lets you surf the Web with another person while talking with him/her over IP telephony.) At this writing, the current release is JSDT 1.5, although 2.0 is in Beta. The JSDT is not a standard Java extension (with a javax package name); instead, it is an independent toolkit from Sun (with a com.sun package name) -- one of the Java Media APIs. The JSDT works with Java 1.1.x, 1.2.x, and 1.3, and is available at http:// java.sun.com/products/ java-media/jsdt/ index.html. Note that, while the JSDT is free, source code is not currently available.
The JSDT's strength is in grouping participants according to dynamic criteria set by the participants themselves. For instance, how do you make sure that Alice plays chess with Bob (both are masters), while Carol plays checkers with Doug, without sending Bob's chess moves into the Carol-Doug face-off? And what if Alice wants to switch to checkers, or play a number of simultaneous chess matches? This is the sort of problem that the JSDT is designed to solve.
The JSDT helps you determine who talks with whom. Yet the very nature of collaboration, in which distributed systems are combined with multiple user interfaces, means that the JSDT cannot solve all the problems of collaboration for you. In this article, I'll discuss the problems that JSDT can solve for you, the problems it fails to address, and problems that remain as an inevitable part of developing collaborative systems.
JSDT Transport Layers
The JSDT runs over network transport layers that pass information between two participants, while the JSDT coordinates the participants. The JSDT comes with a number of these transport layers (called "implementations"), namely TCP and UDP sockets, HTTP, and the Lightweight Reliable Multicast Protocol (LRMP); see http://webcanal.inria.fr/lrmp/index.html for a description of this protocol, and to download the necessary binaries. Of these, the socket implementation is the most extensively tested and used, and thus is the only practical choice for most applications. In this article, assume that the socket implementation is being used unless otherwise specified.
The socket implementation will not penetrate firewalls. The HTTP implementation has the advantage of penetrating firewalls (HTTP connections can be made to a servlet on port 80, which gets through most firewalls), but is inherently less scaleable than the socket implementation. In earlier JSDT versions, JSDT clients repeatedly (on the order of once per second) opened a new HTTP connection to the server to get information with an HTTP request/response, degrading performance. JSDT 2.0 is slated to use persistent HTTP connections to solve this problem.
You can replace the transport layer. The JSDT has a set of interfaces that let you write your own implementation if you have some exotic protocol that you would like to use. A simpler way of providing your own transport layer is to provide a socket factory to the JSDT's socket implementation -- again, like RMI. You simply write a class that knows how to return a new instance of a subclass of java.net.Socket or java.net.ServerSocket. Because these Socket classes are designed for extension through the java.net.SocketImpl class, you can implement arbitrary transport layers with this hook. One built-in socket factory lets you use the Secure Socket Layer for encryption, although legal issues prevent a compatible library from being widely available. (There is an SSL library as part of the strong-encryption version of the Java Web Server, available in the United States and Canada only. This library needs to be extracted from the installed Java Web Server for that purpose, and distributed with all JSDT clients. Its size and legal difficulties make this impractical. Alternately, other SSL libraries could be adapted to the purpose of the JSDT Socket Factory.)
The Challenges of Collaborative Systems
Collaborative applications over the network combine the challenges of distributed systems with those of user- interface systems -- with multiple users involved. User interfaces require a fast reaction and instantaneous feedback -- no one wants to stare at the keyboard wondering where the keystroke went. Distributed systems, on the other hand, depend on a network of unknown size and reliability to transfer data. This poses two problems:
- Nondeterministic latency: not knowing how long data will take to traverse the network.
- Partial failure: A remote part of the system may fail or become disconnected, but other parts of the system should keep on working.
The phone system is a high-quality worldwide system with many similarities to collaborative software. The rare exceptions to the phone system's high level of quality provide some good examples of problems in such systems. When Alice doesn't hear Bob's voice for a few seconds, she is likely to say: "Hello, Bob, are you there?" In fact, there is no way for her to distinguish between a dead line and a taciturn Bob, other than asking him to speak up. Likewise, when Bob hears an echo of his voice, or a delay in Alice's voice, it becomes almost impossible to communicate. Not only in telephone conversations is instant feedback needed -- we need it in collaborative software as well. The Internet always poses problems of latency and partial failure, and the JSDT cannot eliminate them for you. Your application layer will have to work around them.
In any distributed system, a great many things can go wrong. JSDT provides a wide variety of exception types (24 at last count) to let you check what went wrong. In general, all you care about is that something went wrong, so you can just catch JSDTException, the superclass of all JSDT exceptions.
Any distributed system needs a way of checking and cleaning up a crashed connection; this should allow for trying to reconnect if that is warranted. Although TCP/IP sockets, for example, have some keep-alive functionality, the process of checking and restoring the connection is best conducted at the application level. (In fact, the JDK java.net.Socket class specifically excludes access to the TCP/IP keep-alive option, which you can set in C, for example. See the JDK 1.2 guide to Socket Options in Java; http://java.sun.com/products/jdk/1.2/docs/ guide/net/socketOpt.html.) An addition to the JSDT 2.0 is the Connection class that can let a ConnectionListener know when the connection has failed. This helps you implement your own application-level system for restoring connections over the JSDT; see Listing One.
The JSDT and RMI
There are a number of similarities between the JSDT and RMI. Both are Java protocols for exposing shared objects for use in distributed systems. Both have a registry, which can be independent of any specific server, for looking up shared objects. Other similarities include socket factory and fall-back firewall penetration using HTTP.
The difference between the two is in their purpose: RMI focuses on connecting two participants with method calls, while letting the participants find each other. The JSDT, on the other hand, focuses on controlling which participants talk with each other. In RMI, you link precisely two clients with a remote object, and the remote reference is accessed or discarded much like local object references, while in the JSDT, you can precisely monitor and control multiple clients as they join and leave shared objects. In RMI, you can make your own classes remotely accessible in the registry, while JSDT shared objects fall into only a few predefined types -- the Registry, Session, Channel, ByteArray, Token, and Client Listener.
Fully Distributed or Client/Server Architecture?
The architecture of distributed systems is always in tension between client/server and fully distributed systems. Does a central server route all data between clients? Does a server decide who talks with whom? As you design your JSDT-based system, you should realize that the JSDT imposes restrictions on the nature of the distribution in your design: In brief, there must be a central server, but the client applications must be in charge of their own state. You can avoid missteps if you fit your architecture to the JSDT-imposed design restrictions.
Some sort of server is unavoidable with the JSDT. The JSDT requires a registry -- a way for distributed participants to locate shared objects such as Sessions. In contrast, other technologies provide ways to get around the need for a central registry in a distributed architecture. Jini does it by letting users multicast their search for a lookup service. Still, this multicast is only practical in a small network -- you can't search the entire Internet for your lookup service. Likewise, Internet DNS lookup is not completely centralized; rather, each local zone has a lookup service that can turn to wider area -- and therefore more centralized -- lookup services as necessary. The JSDT also requires that the server start all Sessions if you want to work with unsigned applets, which may communicate only with the server from which they were downloaded. In addition, you will need to start your Sessions on a server, because once the client who starts a shared object exits it, other clients are unable to keep using it. Fortunately, after you have set up the shared objects, the JSDT does the data-passing work transparently, freeing you from writing code to pass data from client to server and then to the other clients. To do it yourself would require some challenging multithreading work to switch messages between dynamically changing configurations of clients.
Collaboration at its purest lets each participant control its own state, and the design of the JSDT encourages this -- each client calls the appropriate method to join a Session or Channel, rather than being placed there by an outside entity. A participant can learn another client's behavior, send invitations to change, or even compel change, but the logical center of each client's state is in that client.
Controlling Data Sharing
Beyond JSDT-imposed centralization requirements, your design logic might need a central controller to decide who talks to whom. You might want to require authorization for joining a Session or other shared object, or Clients might be requested to join Sessions based on some centralized logic. For example, two game players who connect to the system in sequence might be assigned a new Session to play against each other, or Clients with names that are known from a database might be assigned to the same Session each time they connect to the system. A number of features of the JSDT allow this sort of central control. In the JSDT API, these features are not reserved for a central server. A central controller in your system is, in JSDT terms, simply another participant for whom you have implemented these control features.
Monitoring and control of shared objects is provided by Managers, Listeners, and Client Listeners; see Table 1.
You can add a Manager to shared objects such as Sessions, Channels, and the registry. When an attempt is made to create, destroy, or join a managed shared object, the Manager issues a challenge in the form of a Java Object and the Client gives a response in the form of another Object, which Managers must approve. To make the authentication secure, you'll have to add encryption. For most applications, security will not need to be added.
Where the Managers let you control actions, shared-object Listeners let you monitor actions. Listeners such as RegistryListener and SessionListener are call-back interfaces that are notified when someone performs an action such as creating, destroying, joining, or leaving the shared object.
Managers and Listeners assume an active Client that attempts to join and leave the shared object on its own initiative. If you want Clients to receive requests to join or leave shared objects, the Clients can enter themselves in the registry as Client Listeners, a type of shared object that makes it possible to look up the Clients, then get requests. Another participant in the collaboration system can look up the Client Listener and request that it join or leave a shared object. A ClientListener object will receive an event through a call-back method and act accordingly.
Whatever degree of central control you put into your architecture, the Clients, not the central controller, still contain the information on the distributed objects they belong to. This can pose problems if you know what a Client should be doing, but it is not connected. For example, if Bob asks to play a game with Alice, and Alice has not yet connected (but might do so in a minute), you will have to store this information in your own data structure. The moment that Alice connects, you must move her Client to the Session. At that point, you have to erase this information from your own data structure, since it is now duplicating information stored in the JSDT. Also, if Alice's application is participating in one of the Sessions in your collaboration system, the fact that she is in that Session is stored in her client-side JSDT component. Unless you duplicate that information in your own code, when she closes and reopens her application, the information on what Session she was in will be lost.
When it joins shared objects, a central controller has to have a Client, and is merely another participant as far as the JSDT is concerned. To simply listen to events, it does not need to join. But to perform actions on a shared object (such as destroying it), it must join the shared object using a Client. You must take care in your design to distinguish controllers from participants who actually share data. For example, you often want to destroy a Session when it is empty -- when the last Client leaves it. Your controller could listen to events from the shared object. When clients leave, it would check that there are zero clients, join the shared object with a Client, then destroy it. To simplify this, a flag for distinguishing participants from controllers would be a welcome addition to the JSDT.
Port-Binding Problems
There is an unfortunate coupling between Client Listeners in the same physical machine. A java.net.ServerSocket is bound to the given port for Client Listeners as well as for Sessions. Therefore, ports for Client Listeners have to be unique per machine, and distinct from any Session ports. If you know how many Client Listeners there will be per machine, assign them well-known ports. If you cannot know in advance how many Client Listeners there will be in a machine, you can dynamically assign ports to Client Listeners to avoid collision: Catch PortInUseException, increment the port number, and try registering the Client Listener again (see Listing Two). This run-time port assignment makes it difficult to know how to find the Client Listener. The port is no longer well known. However, by using ClientFactory.listClients(), you can get a list of Client Listeners that you can iterate through, looking for the Client Listener with a given name, then ask for that Client Listener's port. The complexity of this procedure means that you will probably want to limit yourself to one Client Listener per application, but you will still have to account for the possibility of multiple virtual machines (VMs) on a hardware machine competing to bind their Client Listeners to a port.
Similar problems arise with binding multiple Sessions to the same port. For Sessions, the problem is with opening two Sessions with different names in different VMs, using the same port on the same physical machine. (When you open two Sessions on one port in the same VM, that's okay, since the two Sessions are multiplexed over the port.) So, when writing two distinct JSDT servers, be sure to use two distinct ports for any Sessions that they create.
The port-binding problems will arise more frequently in development, when you will often be running everything on one machine (localhost), than in production, where there is usually one application on each physical machine. Simply being aware of the port-binding issues as you design your system will solve many of the difficulties.
Entering and Leaving Sessions
The Session is the central feature of the JSDT. It represents a group of participants interested in communicating with each other. You get a reference to a Session object with SessionFactory.createSession() method, which creates the Session if it does not already exist, or alternatively returns a reference to an existing Session. Calling this method also lets you join the Session. The createSession() method is where you are likely to get more JSDTExceptions than in any other method call, since this is where a connection is opened in the underlying implementation.
The Channel and the ByteArray
While the Session is a grouping of Clients, the Channel and ByteArray are ways for those Clients to share data (Table 1). If the group of Clients is to share more than one type of data, you will want to use more than one Channel or ByteArray, rather than tagging the data with its type. These two distributed objects are quite similar: They both pass information in the form of byte arrays, strings, or objects. The difference is in the way data is received. When you transmit data over a Channel, it arrives actively at the other side, asynchronously through a call-back interface, or synchronously through a blocking method. On the other hand, when you place data in a shared ByteArray, the data just sits there waiting for someone to read it. However, because a ByteArray can produce an event indicating that its value has changed, the choice between Channel and ByteArray is mostly a matter of convenience.
Strings, Byte Arrays, or Objects?
Information transmitted in the JSDT is encapsulated either in shared ByteArray objects or in Data objects sent through a shared Channel. In either case, you can get or set the data as a byte array, string, or serializable Java object. Each of these provides a different way of encoding data in a protocol specific to your application: You can encode data in byte arrays with primitive types in a predetermined order, in strings with delimiters and keywords, or in objects that you define.
Unless your data is quite simple, in which case you can send it as a string or byte array, you will probably want to transmit objects. An object knows how to provide information about itself. Just as RMI, CORBA, and DCOM gain their power from their object-based protocols, you can gain the same advantages in your JSDT application. An object does not need external parsers to act on the basis of information it conveys -- it can carry out the action itself when it arrives at its target. (Compare the Command design pattern.) You will find it easy to change your design by adding fields and methods to an object. The compiler's type checking makes sure that both the sending and receiving side recognize the same encapsulation of the data. Moreover, objects allow random access to information: You can read fields or call methods without parsing your way through a string. Objects also provide the advantage of polymorphism for different types of data, so that different subclasses can have different effects on the receiving side. Just don't forget to make your class implement java.io.Serializable and to give it a public default constructor.
The only disadvantage of object serialization is that it is quite slow. In most cases, however, the rate-limiting factor is likely to be the human user or the network, not the serialization. If you intend to use the JSDT for high data-rate applications, such as multimedia streaming (the JSDT is fast enough to do this), then you will want to use byte arrays filled with primitive data types. A look at the "phone" and "sound" examples in the JSDT release makes for an instructive comparison between the two techniques for sending audio packets: In the phone example, audio is sent in SoundPacket objects, each of which encapsulates a byte array of audio data. But in the sound example, the AudioClick object (which encapsulates a byte array) is not sent over the JSDT Channel -- rather, the byte array of sound is sent directly.
The Token
Java made single-machine multithreading much easier by including synchronization in the language. With distributed systems, synchronization becomes much more difficult. The JSDT Token lets you synchronize client applications. A Token resembles a Java monitor in some ways: One Client grabs it, and other Clients cannot grab it until it is released. Just as local threads wait on a monitor for a synchronized block to exit, so JSDT Clients listen for the release of a Token with a TokenListener.
Other comparisons between local monitors and JSDT Tokens come to mind. When you use synchronization in a single Java VM, you want to avoid deadlock, in which one Thread is holding a monitor, waiting to release it until signaled by another Thread, while the other Thread is stuck waiting to receive the same monitor. Fear of deadlock is why the suspend() and resume() methods of Thread were deprecated in Java 2. On the other hand, waiting for a Token to be freed does not have to freeze a client (and the Token-release event arrives at the TokenListener in a separate thread). Thus, distributed deadlock can be avoided in JSDT applications. Still, you might have to wait a long time for a Token, given the latency inherent in distributed systems.
In single-machine applications, Threads should not exit without releasing monitors. This is why the destroy() method of Thread was never implemented. The JSDT avoids this problem by releasing any Tokens held by a Client that disconnects.
This can, however, cause problems. Tokens, like Java monitors, might typically be used to lock a resource while it is being modified. If an unexpected crash occurs, data might be left in an inconsistent state. The classic example is a bank account. When an application locks access to the account to make a deposit, and then loses its connection, how do you know if the deposit has been made? This problem was addressed for single-machine Threads in Java 2 by deprecating the stop() method of Thread, and relying on you to make Threads exit cleanly. In distributed applications, however, disconnection is often unpredictable and unavoidable. There is no built-in transaction mechanism in the JSDT, so you must supply your own application-level transactions on top of the JSDT Tokens.
JSDT and Applets
It's hard to use the JSDT with applets. In fact, it is difficult to use any nonstandard library, including the Java Foundation Classes, in an applet, because of the large JAR file that your users may have to download every time. Even the reduced client-side JAR file provided with the JSDT distribution is 168 KB, to which you must add your own code. One solution, applicable to the JSDT as to other large JAR files, is to unzip the JAR, remove the ARCHIVE tag from your HTML APPLET tag, put your applet through its paces, then examine your web server's log files. With a script to filter listings of *.class files served, you can create a new JAR file from the exact subset of the JAR that you use.
Another limitation on the use of applets with the JSDT is that you can't use Client Listeners in unsigned applets, since Client Listeners open a ServerSocket. You can get around this by signing your applets (separately for Netscape and Internet Explorer, of course; see "Creating Signed, Persistent Java Applets," by Paul Brigner, DDJ, February 1999), and getting user permission to open the ServerSocket.
Resource Management
When you write in Java, the garbage collector can make you forget your C++ discipline about managing memory. Even in Java, though, you are still responsible for the cleanup of nonmemory resources such as sockets, threads, and file handles. Likewise, JSDT client applications have to clean up their Session with close(). You must take care to do this when you no longer need the Session, but not to do it if you might still need it.
You should also be aware of variants in the close(boolean closeConnection) method of the class Session. If you call close(true), then all clients in your VM will lose their connection to that Session, not just the client for which you called close(). This is almost always harmful, except when you are exiting the application. To allow Clients to run independently, you must call close(false).
Conclusion
Although it is a new tool, the JSDT is being rapidly improved and debugged. It is useful for writing collaborative applications, particularly if they involve complex and dynamic groupings of participants that need to be controlled by a combination of a central server and the participants themselves.
Acknowledgment
Thanks to Rich Burridge, author of the JSDT, for reviewing this paper and for extensive help, and to Justin Couch, author of Java 2 Networking (McGraw-Hill, 1999), for advice in the JSDT discussion group. Responsibility for errors remains my own.
DDJ
Listing One
import com.sun.media.jsdt.Connection; import com.sun.media.jsdt.JSDTException; . . . public static void main (String [] args) { // . . . // Register a Connection Listener, which will receive // notification when the connection fails: try { Connection.addConnectionListener("www.my-jsdt-server.com", "socket", new KeepAlive()); } catch (JSDTException jsdte) { } // . . . } /* Class that cleans up and tries to reconnect when the connection is lost. */ import com.sun.media.jsdt.Connection; import com.sun.media.jsdt.JSDTException; import com.sun.media.jsdt.Session; import com.sun.media.jsdt.event.ConnectionEvent; import com.sun.media.jsdt.event.ConnectionListener; public class KeepAlive implements ConnectionListener { /* Call-back method from ConnectionListener interface. The connection has * failed--let's hope that it is restored eventually. Try to reconnect * at 20 second intervals. */ public void connectionFailed(ConnectionEvent event) { disconnect(); // clean up just in case boolean succeeded = false; while (!succeeded) { try { connect(); succeeded = true; } catch (JSDTException jsdte) { succeeded = false; } try { Thread.sleep(20 * 1000L); } catch (InterruptedException ie) { } } } private void connect() { // . . . } private void disconnect() { // . . . } // . . . }
Listing Two
/** This is a JSDT Client which registers a Client Listener, incrementing * its port number if the port it tries is already bound. */ import java.net.InetAddress; import com.sun.media.jsdt.AuthenticationInfo; import com.sun.media.jsdt.Client; import com.sun.media.jsdt.URLString; import com.sun.media.jsdt.ClientFactory; import com.sun.media.jsdt.PortInUseException; import com.sun.media.jsdt.event.ClientEvent; import com.sun.media.jsdt.event.ClientAdaptor; public class MyClient implements Client { private String myName; public int clientListenerPort = 5661; // ... private void registerClientListener(Client client) { while (true) { try { // The last parameter for the Client Listener // URLString MUST be the same as the name of the Client object. URLString clientListenerUrl= URLString.createClientURL( "www.my-jsdt-server.com", clientListenerPort, "socket", this.getName()); ClientFactory.createClient(this, clientListenerUrl, new MyClientAdaptor()); break; } catch (PortInUseException piue) { clientListenerPort++; // Retry after incrementing port number } catch (Exception e) { break; } } } public String getName() { return myName; } public Object authenticate(AuthenticationInfo ai){ return null; } /* Implementation of ClientListener. It listens for commands on behalf * of your client applications. Like the Swing Adaptor classes, the JSDT * Adaptors provide an empty implementation of all methods of Listener, * so that you can implement just those methods that interest you. */ private class MyClientAdaptor extends ClientAdaptor { /* Examples of commands that can be sent to the Client Listener. */ public void sessionInvited(ClientEvent event) { // Now that you've been invited, you'll // probably want to connect to the Session, // . . . } public void sessionExpelled(ClientEvent event) { // You've been expelled from the Session. Unlike // sessionInvited, you do not need to do anything // to leave the Session, since you have already been expelled. } } }