Get smart with proxies and RMI

Use dynamic class loading to implement smart proxies in RMI

Java Remote Method Invocation (RMI) gives clients access to objects in the server virtual machine (VM) in one of two ways: by reference or by value. To access a remote object by reference, the object must be an instance of a class that:

  • Implements an interface that extends java.rmi.Remote
  • Has a properly generated RMI stub class that implements the same interface
  • Is properly exported to allow incoming RMI calls

The interface implemented both by that class and its stub is referred to as the object's remote interface, and the methods declared in that interface can be invoked remotely. Clients only need to know about the remote interface. When they request a reference to a remote object that implements that interface, RMI substitutes an instance of the stub class and returns a copy of that stub to the client. Method calls on the RMI stub are forwarded to the remote object, which is still in the server VM. The substitution of a stub for the remote object happens automatically -- you just need to ensure that the server object implements the remote interface and is properly exported.

With pass-by-reference, all methods on the object are remote calls. That lets the remote object have access to the server's resources and services and, because multiple clients can talk to the same object on the server, changes made to that object's state by one client are visible to all clients. However, all the problems of network communications -- latency, disconnects, and time-outs, for example -- still apply.

Clients access an object by value when a remote call results in a return value of an object that implements java.io.Serializable instead of java.rmi.Remote. In that case, the returned object is serialized, sent to the client's VM, and deserialized, instantiating a copy of the object in the client's VM. Methods invoked on this copy are just like any other Java method -- they execute locally, without communicating with the server.

For pass-by-value, none of the network communication problems occur; however, the copy does not have easy access to server-side resources and services. Further, each time a client makes the remote method call to get the object, a new copy is created in the client VM. Since each copy maintains its own state, the changes made in one object cannot be seen by any other object.

Situations may arise that require a blend of those two strategies: an object in which some methods execute locally, without network latencies and other problems, and some methods that execute on the server. Ideally, some parts of the object's state could be shared, so that all clients could see changes, and other parts of the state kept private. In the CORBA world, that problem is solved with a construct called a smart proxy.

Smart proxies

A smart proxy is a class, instantiated in the client VM, that holds onto a remote object reference. It implements the object's remote interface and typically forwards most of the calls on the interface to the remote object, just like an RMI stub. However, a smart proxy is more useful than the RMI stub in that you can change the behavior of the remote interface to do more than forward calls to the remote object. For instance, a smart proxy can locally cache state from the remote object to avoid the network overhead on every method call.

CORBA implementations typically use a client-side object factory to instantiate smart proxies. The application calls a method of the factory to request a remote object reference. The factory gets the remote object reference, instantiates a smart proxy object that implements the same interface, and stores the remote reference in the proxy. The proxy is then returned to the caller.

That approach works in pure Java applications as well. It has a few drawbacks, however. Client-side code typically has to know about the implementation of the server's objects, for instance, in order to know which attributes are safe to cache and which need to be read from the server each time. Another problem is that, as the server application changes, some remote objects that were good candidates for smart proxies might no longer be appropriate; other classes that didn't need a smart proxy on the client might now need one. Each of those changes requires changes on the client code that may already be distributed.

A better solution would be to take advantage of RMI's ability to dynamically download classes that the client doesn't know about at runtime and implement the use of smart proxies in the server's code. That way, the client only knows that it is getting an object that implements the remote interface -- it doesn't know whether the object is a remote reference, a copy of a remote object, or a smart proxy. The server developer can, in fact, change the implementation from one of those to the other, and the client will continue to work without change.

To implement smart proxies in the server, it helps to understand how RMI passes object references:

  1. If the object returned from a remote method call implements an interface that extends java.rmi.Remote, the JVM believes that object should be a remote object and tries to construct an instance of the RMI stub for it. The stub is returned in place of the original object's reference. That is the pass-by-reference case as explained above; calls on the stub are forwarded to the server object.
  2. Otherwise, if the object implements java.io.Serializable, the object itself is serialized and sent to the client VM, which then creates a copy of the object. (Primitive types, such as byte, char, boolean, int, and float, are always serialized and passed by value.) That is the pass-by-value case.
  3. Finally, if neither of the above cases hold, an exception is thrown.

When a serializable object is sent to the client VM, each of the object's non-transient fields goes through the same scrutiny. That is, if the field's type is a class that implements an interface that extends java.rmi.Remote (and it's not declared transient), an RMI stub is generated, sent to the client, and substituted for the field in the client's copy of the remote object. If the field is a reference to a serializable object, a copy of the field's data is serialized and sent to the client. In that case, the field in the client's VM will refer to that deserialized copy. That occurs recursively until all of the object graph's nontransient fields have been examined and sent appropriately.

A smart proxy must, therefore, implement java.io.Serializable, while not implementing an interface that extends java.rmi.Remote. At the same time, the proxy must implement the same interface as the server object. That allows the client to use the proxy as if it was the server object. Achieving all three of those goals might appear a little tricky, however. How does the proxy implement the server object's interface and not implement java.rmi.Remote? The answer lies in refactoring the way remote objects are typically implemented.

Conventional remote objects

"Beg your pardon, sir, but your excuse, 'We've always done it this way,' is the most damaging phrase in the language."

-Rear Admiral Grace Hopper, Ret.

Suppose that you want remote access to a Door object that contains methods, which return the door location and detect if the door is open. To implement that in Java RMI, you need to define an interface that extends java.rmi.Remote. That interface would also declare the methods that comprise the object's remote interface. Likewise, you need to define a class that implements that interface and can be exported as a remote object. The easiest way to define that class is to extend java.rmi.server.UnicastRemoteObject. That leads to the design shown in the UML class diagram below.

Figure 1. Door and DoorImpl class diagram

The remote interface, Door, extends java.rmi.Remote and declares the interface you need for Door objects. DoorImpl is the class that actually implements the Door interface. DoorImpl also extends java.rmi.server.UnicastRemoteObject so that instances of it can be accessed remotely.

Below is the code that you could use to implement that design:

/**
* Define the remote interface of a Door.
* @author M. Jeff Wilson
* @version 1.0
*/
public interface Door extends java.rmi.Remote
{
    String getLocation() throws java.rmi.RemoteException;
    boolean isOpen() throws java.rmi.RemoteException;
}
/**
* Define the remote object that implements the Door interface.
* @author M. Jeff Wilson
* @version 1.0
*/
public class DoorImpl extends java.rmi.server.UnicastRemoteObject
   implements Door
{
    private final String name;
    private boolean open = false;
    public DoorImpl(String name) throws java.rmi.RemoteException
    {
       super();
       this.name = name;
    }
    // in this implementation, each Door's name is the same as its
    // location.
    // we're also assuming the name will be unique.
    public String getLocation() { return name; }
    public boolean isOpen() { return open; }
    // assume the server side can call this method to set the
    // state of this door at any time
    void setOpen(boolean open) { this.open = open; }
    // convenience method for server code
    String getName() { return name; }
    // override various Object utility methods
    public String toString() { return "DoorImpl:["+ name +"]"; }
    // DoorImpls are equivalent if they are in the same location
    public boolean equals(Object obj)
    {
       if (obj instanceof DoorImpl)
       {
          DoorImpl other = (DoorImpl)obj;
          return name.equals(other.name);
       }
       return false;
    }
    public int hashCode() { return toString().hashCode(); }
}

Now that you've defined and implemented the Door interface, the next step is to allow remote clients to access Door's various instances. One way to do that is to bind each instance of DoorImpl to the RMI registry. The client would then have to construct a URL that contained the name of each Door it wanted, and do a naming service lookup on each Door to retrieve its RMI stub. That not only clutters up the RMI registry with a lot of names (one for each Door), but it is unnecessary work for the client as well. A better approach is to have one object bound in the RMI registry that keeps a collection of all the Doors in the server. Clients can look up the name of that object in the registry, then make remote method calls on the object to retrieve specific Doors. The design of such a DoorServer is shown in Figure 2. Notice that DoorServer and DoorServerImpl looks a lot like Door and DoorImpl because you are defining another remote interface (DoorServer) and the class that implements it (DoorServerImpl). One difference is that DoorServerImpl hangs on to a collection of DoorImpl. It will use that collection to fulfill its public Door.getDoor(String location) method.

Figure 2. DoorServer class diagram

Here's one possible implementation of the DoorServer design:

/**
* We need a class to serve Door objects to clients.
* First, create the server's remote interface.
* @author M. Jeff Wilson
* @version 1.0
*/
public interface DoorServer extends java.rmi.Remote
{
    Door getDoor(String location) throws java.rmi.RemoteException;
}
/**
* Define the class to implement the DoorServer interface.
* @author M. Jeff Wilson
* @version 1.0
*/
public class DoorServerImpl extends
java.rmi.server.UnicastRemoteObject implements DoorServer {
    /**
    * HashMap used to store instances of DoorImpl. The map will be keyed
    * by each DoorImpl's name attribute, so it is implied that two Doors
    * with the same name are equivalent.
    */
    private java.util.Hashtable hash = new java.util.Hashtable();
    public DoorServerImpl() throws java.rmi.RemoteException
    {
       // add a door to the hashmap
       DoorImpl impl = new DoorImpl("location1");
       hash.put(impl.getName(), impl);
    }
    /**
    * @param location - String value of the Door's location
    * @return an object that implements Door, given the location
    */
    public Door getDoor (String location)
    {
       return (Door)hash.get(location);
    }
    /**
    * Bootstrap the server by creating an instance of DoorServer and
    * binding its name in the RMI registry.
    */
    public static void main(String[] args)
    {
       System.setSecurityManager(new java.rmi.RMISecurityManager());
       // make the remote object available to clients
       try
       {
          DoorServerImpl server = new DoorServerImpl();
          java.rmi.Naming.rebind("rmi://host/DoorServer", server);
       }
       catch (Exception e)
       {
          e.printStackTrace();
          System.exit(1);
       }
    }
}

Finally, to wrap things up, the client code that gets an instance of Door might look like this:

try
{
    // get the DoorServer from the RMI registry
    DoorServer server = (DoorServer)Naming.lookup("rmi://host/DoorServer");
    // Use DoorServer to get a specific Door
    Door theDoor = server.getDoor("location1");
    // invoke methods on the returned Door
    if (theDoor.isOpen())
    {
       // handle the door-open case ...
    }
}
catch (Exception e)
{
    e.printStackTrace();
}

In that implementation, the client has to find the DoorServer by asking the RMI registry for it (via the call to Naming.lookup(URL)). Once the DoorServer is found, the client can ask for specific Door instances by calling DoorServer.getDoor(String), passing the Door's location.

Most tutorials and books on RMI suggest you create the Door interface and the DoorImpl class in this way, so that Door extends java.rmi.Remote, and DoorImpl extends java.rmi.server.UnicastRemoteObject and implements Door. To add a smart proxy for DoorImpl, however, you need to create a class that implements java.io.Serializable, and also implements Door, without also implementing java.rmi.Remote. However, since Door extends java.rmi.Remote, that is impossible.

Factoring out the remoteness

What is needed is a way to separate the server object's interface from its remoteness. The solution depends on the fact that Java, while it doesn't allow multiple inheritance of classes, does allow interfaces to extend more than one parent interface. Therefore, you can refactor the design for Door and DoorImpl to look like this:

Figure 3. The refactored class diagram

Notice that Door does not extend java.rmi.Remote. Instead, I've added a new interface, DoorRemote, that extends both Door and java.rmi.Remote. DoorImpl implements that new interface.

The following code shows the implementation of the new design:

/**
* Define the Door interface.
* @author M. Jeff Wilson
* @version 1.1
*/
public interface Door /* don't extend java.rmi.Remote */
{
    String getLocation() throws java.rmi.RemoteException;
    boolean isOpen() throws java.rmi.RemoteException;
}
/**
* Add the 'remoteness' to Door. Notice that you don't have to
* redeclare the methods in interface Door, just inherit both
* from Door and java.rmi.Remote.
* @author M. Jeff Wilson
* @version 1.0
*/
public interface DoorRemote extends java.rmi.Remote, Door
{
}
/**
* Define the remote object that implements the Door interface.
* @author M. Jeff Wilson
* @version 1.1
*/
public class DoorImpl extends java.rmi.server.UnicastRemoteObject
implements DoorRemote
{
    private final String name;
    private boolean open = false;
    public DoorImpl(String name) throws java.rmi.RemoteException
    {
       super();
       this.name = name;
    }
    // in this implementation, each Door's name is the same as its location.
    // we're also assuming the location will be unique
    public String getLocation() { return name; }
    public boolean isOpen() { return open; }
    // assume the server side can call this method to set the
    // state of this door at any time
    void setOpen(boolean open) { this.open = open; }
    // convenience method for server code
    String getName() { return name; }
    // override various Object utility methods
    public String toString() { return "DoorImpl:["+ name +"]"; }
    // DoorImpls are equivalent if they are in the same location
    public boolean equals(Object obj)
    {
       if (obj instanceof DoorImpl)
       {
          DoorImpl other = (DoorImpl)obj;
          return name.equals(other.name);
       }
       return false;
    }
    public int hashCode() { return toString().hashCode(); }
}

Defining the proxy

Now Door is not a remote interface (that is, it doesn't extend java.rmi.Remote). The remoteness is added by the DoorRemote interface, since it extends both Door and java.rmi.Remote. The semantics and behavior of DoorImpl haven't changed; when DoorServer.getDoor(String) is called, the RMI stub for DoorImpl is returned to the client. But splitting the interfaces that way lets you add a new class that implements Door, is serializable, but isn't remote. Let's add that class and call it DoorProxy.

/**
* Define a proxy for Door. Currently, the implementation of Door's
* methods are stubbed out.
* @author M. Jeff Wilson
* @version 1.0
*/
public class DoorProxy implements java.io.Serializable, Door
{
    public String getLocation() throws java.rmi.RemoteException
    {
       return null;
    }
    public boolean isOpen() throws java.rmi.RemoteException
    {
       return false;
    }
}

Since DoorProxy implements java.io.Serializable, a remote call can return a copy of it. DoorProxy also implements Door, so it appears the same to a client as a remote reference to a DoorImpl object.

For DoorProxy to be a true proxy, however, it needs to be able to store a reference to another object -- in this case, DoorImpl -- and forward method calls to the reference. You can easily accomplish this:

Figure 4. DoorProxy class diagram
/**
* Define a proxy for Door. In this version, all methods are
* delegated to the remote object.
* @author M. Jeff Wilson
* @version 1.1
*/
public class DoorProxy implements java.io.Serializable, Door
{
    // store a copy of the remote interface to a DoorImpl
    private DoorRemote impl = null;
    /**
    * Construct a DoorProxy.
    * @param impl - the remote reference to delegate to.
    */
    DoorProxy(DoorRemote impl)
    {
       this.impl = impl;
    }
    public String getLocation() throws java.rmi.RemoteException
    {
       // delegate to impl
       return impl.getLocation();
    }
    public boolean isOpen() throws java.rmi.RemoteException
    {
       // delegate to impl
       return impl.isOpen();
    }
}

A constructor has been added to DoorProxy to ensure that every instance created has a reference to a DoorRemote object. That reference will really be an instance of DoorImpl but, since the client doesn't need to know about anything more than the remote interface (DoorRemote), the details of DoorImpl are hidden from it. (It's also necessary to get the code to work because, when RMI serializes a DoorProxy, it's going to instantiate an RMI stub and replace the reference to DoorImpl with a reference to that stub. To do that, the impl field must be able to hold either a DoorImpl or its stub.)

Now if the client makes a remote method call that returns a DoorProxy, the DoorProxy's data is serialized and a copy of the object is instantiated in the client VM. As RMI serializes the DoorProxy, it notices that the impl field is a reference to an object that should be replaced by its RMI stub, so it replaces it. The result is that the DoorProxy instantiated in the client contains a remote reference to a DoorImpl object and can delegate its calls via RMI to that server object.

Now that you have the smart proxy, you need to make sure there's a way the client can get one. The easiest way to accomplish that is to change DoorServerImpl.getDoor(String) to return a properly constructed DoorProxy instead of a DoorImpl:

/**
* Define the class to implement the DoorServer interface.
* @author M. Jeff Wilson
* @version 1.1
*/
public class DoorServerImpl extends java.rmi.server.UnicastRemoteObject
    implements DoorServer
{
    /**
    * HashMap used to store instances of DoorImpl. The map will be keyed
    * by each DoorImpl's name attribute, so it is implied that two Doors
    * with the same name are equivalent.
    */
    private java.util.Hashtable hash = new java.util.Hashtable();
    public DoorServerImpl() throws java.rmi.RemoteException
    {
       // add a door to the hashmap
       DoorImpl impl = new DoorImpl("location1");
       hash.put(impl.getName(), impl);
    }
    /**
    * Changed to return the proxy.
    * @param location - String value of the Door's location
    * @return an object that implements Door, given the location
    */
    public Door getDoor (String location)
    {
       DoorImpl impl = (DoorImpl)hash.get(location);
       return new DoorProxy(impl);
    }
    /**
    * Bootstrap the server by creating an instance of DoorServer and
    * binding its name in the RMI registry.
    */
    public static void main(String[] args)
    {
       System.setSecurityManager(new java.rmi.RMISecurityManager());
       // make the remote object available to clients
       try
       {
          DoorServerImpl server = new DoorServerImpl();
          java.rmi.Naming.rebind("rmi://host/DoorServer", server);
       }
       catch (Exception e)
       {
          e.printStackTrace();
          System.exit(1);
       }
    }
}

The client code doesn't change; it still requests and gets a reference to an object that implements Door. Originally, it got the RMI stub to a DoorImpl object; now it gets a DoorProxy object. But since the client doesn't know about anything other than the Door interface, that substitution is invisible to it.

Making the proxy smart

Looking at DoorImpl, it's obvious that the name field will never change for a given instance, as it has been declared final. If it's not going to change, why pay the cost of a network call on every invocation? A better solution would be to cache that field in the proxy itself, so that a call to DoorProxy.getLocation() is a local call:

/**
* Define a proxy for Door. In this version, the name field
* is cached in the proxy.
* @author M. Jeff Wilson
* @version 1.2
*/
public class DoorProxy implements java.io.Serializable, Door
{
    // store a copy of the remote interface to a DoorImpl
    private DoorRemote impl = null;
    private final String name; 
    /**
    * Construct a DoorProxy.
    * @param impl - the remote reference to delegate to.
    */
    DoorProxy(DoorRemote impl) throws java.rmi.RemoteException
    {
       this.impl = impl;
       this.name = impl.getLocation();
    }
    public String getLocation() throws java.rmi.RemoteException
    {
       // return the cached value
       return name;
    }
    public boolean isOpen() throws java.rmi.RemoteException
    {
       // delegate to impl
       return impl.isOpen();
    }
}

Likewise, you could change the behavior of any DoorProxy method, without affecting any client code.

Making the proxy efficient

Since a copy of the DoorProxy object is returned from DoorServerImpl.getDoor(String), a given client could quite possibly have several DoorProxy copies that hold remote references to the same DoorImpl object. That may not be desirable -- at the very least, it is a waste of memory in the client VM.

The same problem exists with normal RMI remote objects: the client can get more than one RMI stub to the same server object. The designers of RMI solved that problem by overriding Object.equals(Object) and Object.hashCode() in the stub classes generated by the RMI compiler (rmic). Those methods have been overridden so that two different stub instances that refer to the same remote object are equivalent. That is, if stubs objA and objB refer to the same remote object, then objA.equals(objB) returns true and objA.hashCode() returns the same value as objB.hashCode(). Once that is done, you can see if two instances of a stub are equivalent, discarding one if they are. Another approach would be to store each remote reference in a hashtable; if an equivalent stub is put into the table, it will replace the original copy.

You can do the same thing with smart proxies. In the DoorProxy code, you could define Object.equals(Object) and Object.hashCode in the same way you defined those methods in the DoorImpl class. However, a more general solution would be to copy what the RMI designers did and make sure that two smart proxies that delegate to the same remote object are equivalent. Since you have access to the RMI stubs, that becomes a trivial delegation:

/**
* Define a proxy for Door. In this version, the name field
* is cached in the proxy, and I've overridden equals() and
* hashCode().
* @author M. Jeff Wilson
* @version 1.3
*/
public class DoorProxy implements java.io.Serializable, Door
{
    // store a copy of the remote interface to a DoorImpl
    private DoorRemote impl = null;
    private final String name;
    /**
    * Construct a DoorProxy.
    * @param impl - the remote reference to delegate to.
    */
    DoorProxy(DoorRemote impl) throws java.rmi.RemoteException
    {
       this.impl = impl;
       name = impl.getLocation();
    }
    public String getLocation() throws java.rmi.RemoteException
    {
       // return the cached value
       return name;
    }
    public boolean isOpen() throws java.rmi.RemoteException
    {
       // delegate to impl
       return impl.isOpen();
    }
    public boolean equals(Object obj)
    {
       if (obj instanceof DoorProxy)
       {
          return impl.equals(((DoorProxy)obj).impl);
       }
       return false;
    }
    public int hashCode()
    {
       return impl.hashCode();
    }
}

A complete example

I've taken the Door and DoorServer examples above and turned them into complete server and client applications. The .class files and the source code are both in the zip file found in Resources. In that example, the DoorServerImpl creates a hashtable of 100 DoorImpl objects. The client connects to the DoorServerImpl, gets all 100 Door objects as remote references (RMI stubs), sorts them, and finally inserts them into a JList component on the left-hand side of a window. Selecting a Door in the list results in a call to Door.getLocation() and Door.isOpen(), the values of which are displayed in a panel on the right-hand side of the window. There's also a menu, Server, that lets you refresh the list with either RMI stubs (Get DoorRemote) or proxies (Get DoorProxy). Figure 5 illustrates what the client window looks like.

Figure 5. The running client

The DoorImpl class has been changed to write a string to System.out showing the total number of times getLocation() has been called. DoorImpl also starts a thread to randomly change the value of a Door's isOpen field once every second.

Run the example and watch the console window in which DoorServerImpl was started. If the client is using the RMI stubs (the initial case), you will see a large number of remote calls to DoorImpl.getLocation(), caused mostly by sorting the Door objects. Scroll the list of Doors, resize the window, or minimize and restore the window. Notice that every time the list needs to be updated more remote calls are generated.

Using the Server menu, select Get DoorProxy. That replaces the list of DoorImpl_Stubs with DoorProxy objects in the client. Repeat your experimentation with the Door list or window. Since DoorProxy objects cache the location property, repainting the list doesn't generate calls to DoorImpl.getLocation().

To run the example

Download the zip file into a new directory and unzip it. The following files and directories should be created: doorserver.properties, Door.data, and com/.

The com/ directory contains both the source code of the applications and the already compiled class files. To run the example, you first need to edit the doorserver.properties file. Look for the following lines in that file: doorserver.host=0.0.0.0 and java.rmi.server.codebase=file:/C:\\rmi\\example/.

Change the value of the doorserver.host property to the IP number or hostname of the computer on which you will be running those programs. The java.rmi.server.codebase property should contain the file URL location of the com/ directory. Make those changes and save the file.

To run the example, first start the RMI registry:

Win32:

     unset CLASSPATH
     start rmiregistry

Unix:

     unsetenv CLASSPATH
     rmiregistry &

Once the registry is running, start the server. First, cd to the directory where you copied the zip file. Enter this command:

Win32:

     start java com.mjeffwilson.smartproxies.server.DoorServerImpl

Unix:

     java com.mjeffwilson.smartproxies.server.DoorServerImpl &

You should see the following messages on the console:

Figure 6. Windows console running the DoorServer

At that time, the DoorServerImpl has been bound to the RMI registry and is ready for client connections. Now you can start the client with the following command:

Win32:

     start java com.mjeffwilson.smartproxies.client.DoorClient

Unix:

     java com.mjeffwilson.smartproxies.client.DoorClient &

"But what... is it good for?"

"But what... is it good for?" --Engineer at the Advanced Computing Systems Division of IBM, 1968, commenting on the microchip.

The example illustrates the benefits of using the smart proxy approach in caching frequently read, seldom-updated data of remote objects. But the possibilities are not limited to that scenario. You might use smart proxies to monitor the performance of RMI calls (logging the time at invocation and return from a method call) or to handle remote exceptions in a standard way on the client.

Another good idea is to use smart proxies to prevent returning multiple copies of the same remote object to client code. A smart proxy could create a singleton object that wrapped a hashtable. Whenever a smart proxy method is called that would return a remote object (either a stub or a proxy) to the client, the hashtable could be checked for an identical object. If an equivalent object existed, it could be returned to the client instead of a new copy of the object. If an identical object didn't exist, the smart proxy could store it in the hashtable.

Obviously, a great number of other uses for smart proxies exist -- you can now start dreaming them up!

M. Jeff Wilson develops network management systems in Java for BellSouth Telecommunications' FastAccess Internet services. A Sun-certified Java programmer, he has more than 16 years of experience in software development, coming to Java by way of C++ and Objective-C. Jeff is currently working on his Master's degree in software systems at Atlanta's Mercer University.

Learn more about this topic

Join the discussion
Be the first to comment on this article. Our Commenting Policies