June 17, 2008, 11:43 a.m.
posted by oxy
Item 18: Prefer context-complete communication stylesContext-complete communication offers a number of advantages that context-incomplete calls don't. The notion of context turns out to be a major component in distributed design as a whole, so it makes sense to first examine the notion of context in communications. Imagine for a moment a conversation between yourself and a friend over the phone.[2]
When viewed this way, the conversation is easy to follow because the context of the conversation is easily visible and understood. But think about what would happen if you wandered into the middle of this conversation, at about the fifth line. You know they're meeting at the pizza place downtown, but not what time or what day. You might have a vague idea that this is a lunch or dinner meeting, but they could be choosing the location as a simple gathering point for a convoy trip to a distant state. In short, the context of the conversation establishes important elements about the outcome that aren't visible unless you were there from the beginning. Worse, in the case of two friends who have been to lunch together on a regular basis (as Stu and Justin have), the conversation could have been far more cryptic:
Now the context stretches across a much longer chronological window, and unless you happen to know their eating habits, there's absolutely no way to follow this conversation. Many distributed object designs take this same approach, often without meaning to: Context rootContext = new InitialContext(); CartoonCharacter cc = (CartoonCharacter)rootContext.lookup(PATH); cc.setFirstName("Fred"); cc.setLastName("Flintstone"); cc.setHometown("Bedrock"); String catchphrase = cc.utterCatchPhrase(); // Returns this character's catch phrase as a string By the time we get to the utterCatchPhrase call, the context has already been established by the series of calls prior to it, implicitly establishing the context of the call by setting state on the object instance itself. But what would happen if that context were lost? Or confused? Many distributed system architects make the major mistake of not accounting for the various ways that remote objects can fail, even in a short period of time. Let's spin out a simple scenario: the call to setLast Name throws a RemoteException, and we catch it. What now? Is the entire object itself no longer good? Is that call the only thing that failed? Can we call setLastName again and continue? Worse yet, consider the code written by the college intern over in the corner; we all know that you'd never do this, but what's to stop him from writing something like:
Context rootContext = new InitialContext();
CartoonCharacter cc =
(CartoonCharacter)rootContext.lookup(PATH);
try { cc.setFirstName("Fred"); } catch (Exception x) { }
try { cc.setLastName("Flintstone"); } catch (Exception x) { }
try { cc.setHometown("Bedrock"); } catch (Exception x) { }
String catchphrase = cc.utterCatchPhrase();
// Returns this character's catch phrase as a string
I know, it just makes you weep to think that somebody might actually write something like this and turn it loose in a production system, but it happens, and the author of CartoonCharacter's implementation needs to code defensively in order to be robust in the face of failure (see Item 7). This, then, implies that the utterCatchPhrase method should probably check to make sure its internal state is good before continuing forward:
public class CartoonCharacterImpl extends UnicastRemoteObject
implements CartoonCharacter
{
private String firstName = null;
private String lastName = null;
private String hometown = null;
private boolean goodness = false;
public String utterCatchPhrase()
{
if (this.goodness)
{
// Do database lookup to get character's catch phrase;
// only do this if we have good firstName, lastName,
// and hometown values
//
}
else
throw new IllegalArgumentException(
"Invalid arguments!");
}
}
Now we get into an interesting conundrum: How do we set, or unset, the goodness flag? Clearly the easiest thing to test is that all three of the private values aren't null; that is, put a test into the end of each mutator method that looks something like this:
if (firstName != null && lastName != null &&
hometown != null)
goodness = true;
else
goodness = false;
Note that the else clause is a necessary part of the test; without it, simple code like the following would easily confuse the goodness bit:
cc.setFirstName("Fred");
cc.setLastName("Flintstone");
cc.setHometown("Bedrock");
// At this point, goodness turns 'true'
cc.setFirstName(null);
// Without else clause, goodness remains 'true'
In fact, things are even more insidious than this, even with that else clause—what happens when the following code runs?
cc.setFirstName("Fred");
cc.setLastName("Flintstone");
cc.setHometown("Bedrock");
// Goodness turns 'true'
cc.setFirstName("Thundarr");
// Is it still good?
What should take place now when we call utterCatchPhrase? Obviously, "Thundarr Flintstone" isn't exactly a valid combination, but because initialization has taken place in a piecemeal fashion, there's no way for the language to catch and enforce this. Things can also get really hairy when only parts of the state are necessary and the rest remains optional. Those old enough to remember the cartoon series will remember that unlike the Flintstones, Thundarr didn't have a last name, nor did he have a hometown. Still, his first name was enough to identify him, so the following code should be sufficient:
cc.setFirstName("Thundarr");
String catchphrase = cc.utterCatchPhrase();
Meanwhile, He-Man didn't have a last name, but he did have a hometown (his home planet of Eternia—hey, it's a stretch, but so is an enterprise Java system built around cartoon characters, so bear with me):
cc.setFirstName("He-Man");
cc.setHometown("Eternia");
String catchphrase = cc.utterCatchPhrase();
Consider how you might set the goodness bit based on these requirements, then think about what happens in the event of this chronological scenario:
cc.setFirstName("Fred");
cc.setLastName("Flintstone");
cc.setHometown("Bedrock");
String catchphrase1 = cc.utterCatchPhrase();
cc.setFirstName("He-Man");
cc.setHometown("Eternia");
String catchphrase2 = cc.utterCatchPhrase();
cc.setFirstName("Thundarr");
String catchphrase3 = cc.utterCatchPhrase();
By the time of the third call, we've got leftover initialization data from the previous two calls because the programmer didn't clear out the old data by doing a complete initialization all over again—a fairly easy mistake to make, particularly if the CartoonCharacter class used to just require firstName, with lastName and hometown being added later. In formal terms, two steps need to take place for every object created: instantiation, in which the object itself is spun out of nothingness, and initialization, in which the object is given the initial state required in order to begin carrying out meaningful work. Normally, this is done via the constructor by defining constructors that require certain parameters and not providing a default constructor. If the parameters passed in aren't valid, we can throw an exception and effectively invalidate the object itself. In the case of a remote object, however, instantiation is often done in an entirely different timeline than initialization, so we lose the opportunity during instantiation to validate initialization parameters. In classic object-orientation, we never want to use an object that's not ready for use (i.e., an object that hasn't been initialized properly), so languages go to great lengths to make sure initialization occurs at the same time as instantiation, usually via constructors. If the parameters are invalid, the constructor can throw an exception and effectively kill the object. If the necessary parameters aren't passed in at all, the compiler will complain. In short, we have linguistic support to make sure that an object is given what it needs in order to perform its job. Remote objects have a tendency to screw this up, however. Because the instantiation of a remote object usually has to happen before the client can pass initialization in, the remote object has to separate initialization from instantiation, which is where we start to get into trouble. In keeping with classic Java design patterns, we tend to make such initialization calls individual to the property level, leading us to need multiple calls to establish context (state) within the object in order to carry out some kind of useful behavior. Which brings us full circle to the problem established earlier: What happens if one of those context-establishing method calls fails? There's no linguistic way to handle this gracefully. Let's take context out of the picture, or perhaps more accurately, let's remove the possibility of context getting lost by including it as part of all communications back and forth, starting with our lunchtime conversation again:
Nobody ever talks this way. It's awkward, it's redundant, and it's entirely unnecessary between two people who have any sort of short-term memory whatsoever. But bear in mind, that's exactly the point: we don't want the two conversationalists, objects in this case, to have to remember anything—that's what will enable the system to scale better, because now any object at any time can participate in this conversation: each back-and-forth exchange between the two principals is context-complete. Putting this idea into code means that instead of establishing context as part of an API that's removed from the call, we include it as part of each "do something" call:
CartoonCharacter cc =
(CartoonCharacter)rootContext.lookup(...);
String catchphrase =
cc.utterCatchPhrase("Fred", "Flintstone", "Bedrock");
In this case, instead of passing the context via three separate API calls before being allowed to call utterCatchPhrase, we're doing it as part of the call itself. This also makes it easier for the utterCatchPhrase method to ignore any state from previous calls:
CartoonCharacter cc =
(CartoonCharacter)rootContext.lookup(...);
String catchPhrase1 =
cc.utterCatchPhrase("Fred", "Flintstone", "Bedrock");
String catchPhrase2 =
cc.utterCatchPhrase("He-Man", null, "Eternia");
String catchPhrase3 =
cc.utterCatchPhrase("Thundarr", null, null);
This works because usually the context is stored in local variables and/or method parameters:
public class CartoonCharacterImpl extends UnicastRemoteObject
implements CartoonCharacter
{
public String utterCatchPhrase(String first,
String last, String ht)
{
// Do our lookup here
}
}
In fact, this looks a lot like what the stateless session bean does for us: because the stateless session bean can hold no state across method calls, it implicitly requires context-complete communications. And this is partly why context-complete communications are preferable: because any object in the cluster can answer the request. There's no implicit identity due to the context established on any particular object (which is the case with both stateful session beans and entity beans). One other useful benefit arises out of context-complete calls, in that you have everything you need to recreate the call later if it cannot be carried out at this time. This is useful in the event of a failure in the call's processing: shunt the context off into some kind of intermediate storage, and pull it back later to try again. In fact, if that intermediate storage is a messaging layer, we can obtain asynchronicity for free when we need it, since now we can take the context, push it into a Message, and put the Message onto a Queue for later processing, presumably when load decreases or the outage is recovered. Transactional considerations factor into this discussion as well—under the context-complete approach, it becomes pretty clear when and where the transaction should begin and end. In the getter/setter approach, it's much less clear. Do we begin with the first call to set and end the transaction only when the utterCatchPhrase call is invoked? That leaves transactions open between method calls, a clear violation of Item 30. If we want to avoid leaving the transaction open, each call needs to operate under its own transaction, which then leaves us open to semantic corruption, in that other clients could conceivably call other set methods on this same object, interleaved with the sequence above. It just gets uglier from there. Interestingly enough, this model fits in well with the Web Services notion of document-literal communications (as opposed to rpc-encoded), since document-literal communications require the complete set of parameters to be present as part of the packet. Servlets, too, can benefit from this; it becomes easier for forms to be reused through the Web application (or even across Web applications) if there aren't any implicit dependencies on context stored in HttpSession. Avoid context as much as you can, and you'll build better scalable systems. In doing so, almost by accident, you'll tend to build remote APIs that require fewer trips across the network (thus obeying Item 17), since no "initialization methods" need be invoked to set context into place before the call itself. |
- Comment