Let's quickly summarize what we've learned in parts 1 and 2 of this series:
In Part 2 we explored a custom-rolled solution and began to see some of the traits of an FSM. Then we brought machina.js into the picture to give us a good utilitarian foundation for building FSMs in JavaScript. However, our "ConnectivityManager" FSM still had problems – the biggest of which being the fact that it was managing both the "single source of truth" of connectivity state, as well as how the app should handle HTTP requests based on that state. This blurring of concerns will tempt us to bake even more behavior into our FSM that shouldn't be there - things like UI changes based on connectivity state. I'm eager to skip ahead to the clean code examples, but I think it's important for us to explore why these responsibilities should be separated from our ConnectivityManager by seeing what happens when we don't do the right thing. This will help you recognize down the road when your FSMs are doing too much.
Since we're talking state machines, let's introduce a useful tool to help you plan what an FSM should do: the State Transition Table. It's a straightforward way to lay out how the FSM should react to input while in a given state. In the context of our connectivity example, you'd use a state transition table to say "If I'm offline, and I get a 'window.online' input, then transition to 'online' and make a change in the UI" (and yes, I'm intentionally making our FSM handle some concerns it shouldn't… you'll see why).
Take this, for example:
State | Input | Next State | Output |
---|---|---|---|
Offline | window.online | Online | UI Change to Reflect Online |
Offline | applicationCache.downloading | Online | UI Change to Reflect Online |
Offline | sendHttpRequest | Offline | Queue Up HTTP Request |
Online | window.offline | Offline | UI Change to Reflect Offline |
Online | applicationCache.error | Offline | UI Change to Reflect Offline |
Online | sendHttpRequest | Online | Send HTTP Request |
The above table helps us to:
To put that in plain English, let's take the first row of data in the table: If we're in the offline
state and we get a window.online
event, then our FSM will transition to online
and the FSM will cause a change in the UI to indicate that we're now online.
But wait - remember how the boss said the users wanted a "Go Offline/Go Online" button? Here's what gets added:
State | Input | Next State | Output |
---|---|---|---|
Offline | go online | Online | UI Change to Reflect Online |
Online | go offline | Offline | UI Change to Reflect Offline |
Not a bad change, right? We just added two new inputs. However, wait until the boss's next conversation. Our blurred concerns are about to be painfully obvious…
Enter, your boss:
Boss: "Hey remember how we wanted to keep our users' requests from being ignored when they were offline - so we built this fancy date machine..."
You: "State machine."
Boss: "Right, state machine. Anyway, so we were queueing up their requests and were just resubmitting them when they came back online, right?"
You: "Riiiight….?"
Boss: Well. Marketing wants to be able to say that this app is real time, so we need to add websockets to the picture.
You: …
Boss: …
You: …
Boss: …
You: …
Boss: "Well, I can see you might need a minute to process this. You did a great job adding websockets to our last app, so this should be simple, right?"
You: …
"Stay calm. BREATHE!" (you tell yourself). We can just update our state transition table, right? Sure, but keep in mind that the app is technically online if the websocket backend is available, but the HTTP services aren't, and vice versa. As you try to untangle all the possibilities, your state transition table begins to look like this:
State | Input | Next State | Output |
---|---|---|---|
Offline | socket.connect | OnlineWithWebSocket | UI Change to Reflect Online With WebSockets |
Offline | window.online | OnlineWithHTTP? | UI Change to Reflect Online (but HTTP or WebSockets?) |
Offline | applicationCache.downloading | OnlineWithHTTP? | UI Change to Reflect Online (but HTTP or WebSockets?) |
Offline | sendRequest | Offline | Queue Up Request |
OnlineWithHTTP | window.offline | Offline | UI Change to Reflect Offline |
OnlineWithHTTP | applicationCache.error | Offline | UI Change to Reflect Offline |
OnlineWithHTTP | sendRequest | OnlineWithHTTP | Send Request Over HTTP |
OnlineWithHTTP | socket.connect | ?! | ??! |
OnlineWithWebSocket | socket.error | Offline | UI Change to Reflect Offline |
OnlineWithWebSocket | sendRequest | OnlineWithWebSocket | Send Request Over Socket |
The need for a websocket vs HTTP heartbeat check becomes apparent. Even worse, you realize that this table doesn't even help you determine where the heartbeat checks should happen. Are they polling continually? Should there be a specific state for running heartbeat checks? What "online" state is the preferred one - HTTP or websockets? Once we're online with HTTP or websockets, when - if ever - should we switch to the other "online" state? Oh, and you forgot for a moment that this was a Cordova app - so you need to add input for the deviceready
and resume
events - but this could result in six new rows being added to the table. Why? If these events need to be handled in all three states, then it's one row for deviceready
and one for resume
in each state that exists. Each row we add to the table means more code in the FSM - not necessarily a bad thing on its own of course - but if it's behavior that shouldn't be in the FSM to begin with, then we have a recipe for disaster in the making!
The nice thing about using state transition tables is that they can reveal problems with an FSM before you write any code. When your FSM is concerned with managing more than one kind of state, adding new input or output can cause an exponential growth in complexity (as well as code). Conclusion? This FSM is doing too much.
After you recover from the migraine casued by the effort above, you realize that the concept of "online/offline" is separate from "what should the app do with a request?". AND - what the app does with a request is separate from "how should the UI report connectivity state?". So, let's start with what we know: we need an FSM that reports to us what the connectivity state is, and it needs to take our heartbeat check(s) into account. For now, let's focus on HTTP connectivity and leave websockets out (we'll add it later):
State | Input | Next State | Output |
---|---|---|---|
Probing | heartbeat | Online | Event Indicating We're Online |
Probing | no-heartbeat | Disconnected | Event Indicating We're Disconnected |
Probing | go.offline | Offline | Event Indicating We're Offline |
Online | window.offline | Probing | Event Indicating We're Probing |
Online | appCache.error | Probing | Event Indicating We're Probing |
Online | request.timeout | Probing | Event Indicating We're Probing |
Online | go.offline | Offline | Event Indicating We're Offline |
Disconnected | go.online | Probing | Event Indicating We're Probing |
Disconnected | window.online | Probing | Event Indicating We're Probing |
Disconnected | appCache.downloading | Probing | Event Indicating We're Probing |
Disconnected | go.offline | Offline | Event Indicating We're Offline |
Offline | go.online | Probing | Event Indicating We're Probing |
Using the above table, you can see:
probing
(which, ironically, is something Developer Advocates become more familiar with as they travel and interact more with the TSA, but I digress…). Anytime we enter the probing
state, we'll have an "entry action" that kicks off the heartbeat check (more on this in a moment).offline
(for when the user deliberately chooses to work offline) and disconnected
(for "Oops, we lost connectivity!").offline
and online
events. Before we were just blindly trusting an offline
event to mean that we were really-undoubtedly-one-hundred-percent-I-promise offline. However, what if the user is on a commuter train that just passed through a tunnel? They might have lost connectivity for only a few seconds or less! With the introduction of the probing
state, we can now express our well-founded paranoia about inherently unreliable APIs in code! No more blind trust - if we are in an online
state and our FSM receives input that indicates we might be offline, the FSM transitions to probing
to find out for real. In the same way, if we are in the disconnected
state and our FSM receives input that indicates we might be online, the FSM transitions to probing
to really find out.So let's see our freshly minted HTTP-focused connectivity FSM using machina.js (be sure to read the comments in the snippet below as well!):
// We're getting a customized FSM constructor which we // can use later to create an instance. Please note that // I'm leaving off any "wrapper methods" at the top of // the FSM (methods that would wrap the "handle" call). // For the sake of keeping this example somewhat simple, // we'll just use the handle call directly... var HttpConnectivityFsm = machina.Fsm.extend({ // we'll assume we're offline and let the app // determine when to try and probe for state initialState : "offline", // The initialize method is invoked as soon as the // constructor has completed execution. Here we // set up listeners to the various events that // could indicate changes in connectivity initialize : function () { var self = this; // The "stethoscope" is simply an object that can make a // request to a pre-determined HTTP endpoint and emit a // heartbeat event if the request is successful, or a // no-heartbeat event if it fails. _.each( ['heartbeat', 'no-heartbeat'], function ( eventName ) { self.stethoscope.on( eventName, function () { self.handle.call( self, eventName ); } ); } ); $( window ).bind( "online", function () { self.handle( "window.online" ); }); $( window ).bind( "offline", function () { self.handle( "window.offline" ); }); $( window.applicationCache ).bind( "error", function () { self.handle( "appCache.error" ); }); $( window.applicationCache ).bind( "downloading", function () { self.handle( "appCache.downloading" ); }); $( document ).on( "resume", function () { self.handle( "device.resume" ); }); }, states : { probing : { // the "_onEnter" handler is a special machina // feature that gets executed as soon as you // enter the state. This is our "entry action". _onEnter : function () { this.stethoscope.checkHeartbeat(); }, // We're using a shortcut feature of machina here. // If the only action of an input handler is to // transition to a new state, then the value of the // handler can be the string name of the state to // which we should transition, instead of a function. heartbeat : "online", "no-heartbeat" : "disconnected", "go.offline" : "offline", // the "*" is a special handler in machina that will // be invoked for any input that's not explicitly // handled by another handler in the same state. "*" : function () { this.deferUntilTransition(); } }, online : { "window.offline" : "probing", "appCache.error" : "probing", // the request.timeout event could be hooked into some // customization of $.ajax that causes this input to be // passed to the FSM when *any* HTTP request times out. "request.timeout" : "probing", "device.resume" : "probing", "go.offline" : "offline" }, disconnected : { "window.online" : "probing", "appCache.downloading" : "probing", "go.online" : "probing", "device.resume" : "probing", "go.offline" : "offline" }, offline : { "go.online" : "probing" } } }); // somewhere in your code, when you need it (assuming // you already have an httpStethoscope instance): var httpConnectivity = new HttpConnectivityFsm({ stethoscope: httpStethoscope });
OK - so the highlights from above:
offline
, and we'll let our app decide when to invoke httpConnectivity.handle('go.online');
. This could be done when the deviceready
event (or a DOM ready event) fires.initialize
, so the vast majority of the input to this FSM will be automatically driven by events! The user can still manually trigger go.online
and go.offline
, etc._onEnter
handler is emitting an event matching the name of the state, so it's easy for consumers to subscribe via: httpConnectivity.on('online', callback);
, etc.By the way...wondering what an event emitter is? If you're familiar with the Observer pattern (a.k.a. - "events"), the "emitter" is the subject - the thing capable of generating events to which other components can subscribe. Event emitters typically have an API with calls likeon()
,off()
andtrigger()
(oremit()
). If you've ever subscribed a callback to listen to, for example, a click event in the DOM, then you've used an event emitter...
this.transition("someState");
, machina provides a shortcut syntax where the handler value is just the string name of the state to which you want to transition, instead of a function. You can see this, for example, above in the probing
state – the heartbeat
input handler value is the string online
, rather than a function that causes a transition to online
. This helps reduce function boilerplate, making the code a bit more readable & terse where possible.The httpConnectivity
FSM above can now act as a "single source of truth" for our HTTP connectivity state. Since it's an event emitter, it's quite simple to have other components in your app subscribe and react to state changes. Let's pretend our app is using a Backbone view to manage a piece of the DOM where the user is presented with connection status information:
var StatusView = Backbone.View.extend({ el: "#conn-status", initialize: function() { var self = this; httpConnectivity.on("transition", function( data ) { self.$el.removeClass( data.fromState ).addClass( data.toState ); }); }, // render implemention, other methods, etc… });
In Part 2 of this series, we had our "ConnectivityManager" queueing up HTTP requests while we were offline, and then replaying them when we were online. Of course, the real world is hardly ever so naive, but this simplicity makes it easier to convey the key concepts. We're going to take that behavior - which used to be part of our connectivity FSM - and place it in its own "AjaxManagement" FSM. The "AjaxManagement" FSM will then be able to subscribe to the httpConnectivity FSM's events and adjust how it manages HTTP requests as the connectivity state changes. Despite the naïveté, this will give you an idea of how you could begin to tackle sophisticated communications management for offline vs online.
// helper function used by the FSM below var sendAction = function(options) { $.ajax(options).done( function () { this.emit( "http-success", options); }).fail( function () { this.emit( "http-error", options); }); }; var AjaxManagementFsm = machina.Fsm.extend({ initialize: function() { var self = this; // we cause this FSM to transition based on the // state reported by the httpConnectivityFsm self.httpConnectivityFsm.on("transition", function( data ) { if( data.toState === "online" ) { self.handle( "transportAvailable" ); } else if ( data.toState === "offline" || data.toState === "probing" || data.toState === "disconnected" ) { self.handle( "transportUnavailable" ); } }); self.initialState = (self.httpConnectivityFsm.state === "online") ? "sending" : "queueing"; }, initialState: "queueing", states: { queueing: { sendHttpRequest: function() { this.deferUntilTransition('sending'); }, transportAvailable: "sending" }, sending: { sendHttpRequest: function( options ) { sendAction.call(this, options); }, transportUnavailable: "queueing" } }, mayJax : function( options ) { this.handle( "sendHttpRequest", options ); } }); // And later, when you need it: var ajaxManager = new AjaxManagementFsm({ httpConnectivityFsm: httpConnectivityFsm }); // make a 'safe' AJAX request ajaxManager.mayJax({ type : "GET", url : "some/API", dataType : "json", timeout : 5000 });
From the above code, you can see that our ajaxManager
is listening to the events emitted by our httpConnectivityFsm
. If the httpConnectivityFsm
reports that we're online
, then the ajaxManager
allows requests to be sent via $.ajax();
, otherwise, it transitions to queueing
and queues up any sendHttpRequest
input until it transitions into sending
again.
We've gone from a thorny problem and bunch of disparate APIs (discussed in Part 1), to a spaghetti-code-nightmare-yet-beginnings-of-a-state-machine (in Part 2), finally arriving at a place where we have one clean FSM monitoring HTTP connectivity state and another managing how HTTP requests get handled. Let's examine some of the benefits we've picked up:
httpConnectivityFsm
can easily be extended to take new events into account that could indicate a change in connectivity stateStatusView
react to changes in connectivity state without ever touching the httpConnectivityFsm (& vice versa)httpConnectivityFsm
is an event emitter, other components in the app (or future ones) can consume the events it produces. No need to litter the app with connectivity-focused code."WAIT A SECOND! WHAT ABOUT ADDING WEBSOCKETS INTO THIS?!"
Right, you caught me. Because I just had to go and open that can of worms, I'm going to save that for Part 4 - and then try to not add any more parts to this series…