Two refactorings to remedy Cairngorm Model Locator Singletonitis May 29, 2009
Posted by Allen Manning in : Cairngorm, Flex, Refactoring, Unit Testing , 9commentsIt is a common misconception that Cairngorm prescribes over-use of Singletons. Cairngorm doesn’t suffer from Singletonitis inherently, but it is easy to use the framework in a way that sprinkles Singletons everywhere- in particular Model Locators. Presented here are two refactorings (Bind Model Into View and Use Callback in Command) to help reduce the symptoms.
For Flex developers, Singletonitis can lead to an acute onset of Tight Coupling and real complications when writing Unit Tests. Singletons for this reason should be used judiciously.
Bind Model into View
Views bind to Models via data binding and then update themselves as the model changes. Many Models may have no direct coupling with the View at all, and may in fact have a closer representation to the business domain than the application itself.
One common cause of Cairngorm Singletonitis is how Views are tightly coupled to the Model Locator. Here is an example of this below. Imagine a simple Customer Editor form that pre-populates itself based upon a Model.
1 2 3 4 5 6 7 8 | <mx:form> <mx:formitem label="First Name"> <mx:textinput text="{AppModelLocator.getInstance().selectedUser.firstName}" /> </mx:formitem> <mx:formitem label="Last Name"> <mx:textinput text="{AppModelLocator.getInstance().selectedUser.lastName}" /> </mx:formitem> </mx:form> |
We can see here that the View is in very close collusion with its Model Locator collaborator. Imagine an application that has hundreds of Views used in this way and that we wanted to rename or move a bound property. Get out the find and replace tool, because we are going to be updating lots of files.
This makes testing especially difficult, because if we want to inject a mock of the model into the View for the purposes of our tests we are stuck.
These objects are not only tightly bound to the structure of their collaborating Models but also to their type. AppModelLocator is the only class that is accepted here.
Let’s create a little bit more room to breathe by binding the Views to local properties, which represent their contract, and binding in the views in some sort of Main application space above.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | <!-- ******************************************************************************************** SCRIPT ******************************************************************************************** --> <mx:script> /** The user we want to pre-populate */ [Bindable] public var user:User; </mx:script> <!-- ******************************************************************************************** VIEW ******************************************************************************************** --> <mx:form> <mx:formitem label="First Name"> <mx:textinput text="{user.firstName}" /> </mx:formitem> <mx:formitem label="Last Name"> <mx:textinput text="{user.lastName}" /> </mx:formitem> </mx:form> |
1 2 3 4 5 6 | <!-- ******************************************************************************************** VIEW ******************************************************************************************** --> <userform id="userForm" user="{AppModelLocator.getInstance().selectedUser}" /> |
Now we can re-jigger the structure of our models to our hearts content and only be required to change one place that we are injecting this in. In addition, we can create Model Mocks in our unit tests and inject those in for testing purposes. The Views have not concept of a ModelLocator, and there they can be Singleton free.
Use Callback in Command
One of the main uses of Responding Commands are to update local models with data updated in remote systems- essentially syncing remote persisted objects with their local Flex cache representations.
It can seem only natural to ‘get a handle’ on the Model data through a Singleton invocation from inside of the Command itself.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | /** * Execute this command - proxy a remote call through the business delegate * * @param event the CairngormEvent which was dispatched to invoke this command */ public function execute(cairngormEvent:CairngormEvent):void { var event:UpdateUserEvent = (cairngormEvent as UpdateUserEvent); var delegate:IMetadataDelegate = new AccountDelegate(this); delegate.updateUser(event.UserToUpdate); } /** * Called as part of a successfull result from the delegate- update the local model with the * result * * @parm data the data passed back from the server */ public function onResult(event : * = null):void { var updatedUser:User = (event.result as User); AppModelLocator.getInstance().selectedUser = updatedUser; } |
Alas, again we find ourselves stuck in the overly-tight coupling of Singletonitis. Refactoring this Command to take a callback rather than performing the update work itself breaks the fever.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | /** * Cairngorm event broadcast to update a user account * * @author amanning */ public class UpdateUserEvent extends CairngormEvent { //------------------------------------------------------------------------------------------ // Member Variables //------------------------------------------------------------------------------------------ /** The unique name or type for this event */ public static const NAME:String = "UPDATE_USER_EVENT"; /** The user to update */ private var _userToUpdate:UserDTO; public function get userToUpdate():UserDTO { return _userToUpdate; } /** The callback function for updating the user */ private var _updateUserCallback:Function; public function get updateUserCallback():Function { return _updateUserCallback; } //------------------------------------------------------------------------------------------ // Constructors //------------------------------------------------------------------------------------------ /** * Class constructor for this event, initialize parameters * * @param userToUpdate The user account to update * @param updateUserCallback The callback function for updating the user- * this function takes a single userDTO as * it's only argument: * * updateUserCallback(user:UserDTO) */ public function UpdateUserEvent( user:UserDTO, updateUserCallback:Function) { super(NAME); _userToUpdate = userToUpdate; _updateUserCallback = updateUserCallback; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | /** * Execute this command - proxy a remote call through the business delegate * * @param event the CairngormEvent which was dispatched to invoke this command */ public function execute(cairngormEvent:CairngormEvent):void { var event:UpdateUserEvent = (cairngormEvent as UpdateUserEvent); _event = event; var delegate:IMetadataDelegate = new AccountDelegate(this); delegate.updateUser(event.UserToUpdate); } /** * Called as part of a successfull result from the delegate- update the local model with the * result * * @parm data the data passed back from the server */ public function onResult(event : * = null):void { var updatedUser:User = (event.result as User); _event.updateUserCallback(updatedUser); } |
Here a callback function ‘updateUser’ is passed into the event that dispatched the Command. This callback references a method on a known Model- for example a Presentation Model associated with the View that dispatched it. The Command is only required to call it with the returned data.
The Command has no knowledge of the existence of a Model Locator, or anything about the View for that matter.
Calling getInstance() only once, if at all
With these strategies in place, it should be possible to inject Model dependencies only in the top-level parent View. This injection can happen through either direct data binding or by an Inversion of Control approach like Spring Actionscript. The binding of these Models can then cascade throughout the rest of the application.
Dependency Injection in Flex Applications - Part I - Spring ActionScript and Cairngorm December 18, 2008
Posted by Allen Manning in : Cairngorm, Flex, Refactoring , 9commentsAfter layering many new features into our applications we see complexity emerge, and as complexity emerges we need to find techniques to keep our objects clean and testable. Complexity, as we looked at earlier, is a major challenge for large applications. Object complexity is increased as object collaboration increases; the more collaboration, the more complexity. This is complexity not only in objects performing their work, doing what they were designed to do, but also in testing.
We looked to some approaches in Unit Testing to help keep Command complexity under control and give our applications more robust testing coverage. Now we are going to upgrade these approach with some discussion and refactoring based on Dependency Injection.
Dependency Injection in a Nutshell
Eric Feminella has a great post on this that is worth reading. Dependency Injection basically describes separating configuration from implementation. Swapping in and out different object dependencies in different application contexts hasn’t been implemented very cleanly in traditional Object Orientated languages. In light of this, frameworks like the Spring Framework have risen in popularity over the last few years.
Christophe Herreman and Spring ActionScript
Many thanks to Christophe Herreman for releasing “Spring ActionScript” (formerly known as Prana) a Spring-like IoC container for Actionscript 3. The project has been out for a while and has already gone through a few iterations. Christophe has updated the Cairngorm Application Blueprint (the Cairngorm Store) with a Spring ActionScript Enabled version. We will draw upon these ideas in Spring ActionScript-enabling an example application from a previous post.
Injecting Command Dependencies
Rather than declaring our two Command dependencies (the Model and a Delegate) as internal fields that unit tests in the same package can override (inject) with their Mocks, we will let Spring ActionScript do that work for us. This results in a cleaner implementation where the Commands are completely de-coupled from our form of injection.
Let’s take a look at the refactoring:
UpdatePreferencesCommand.as (Before)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 | package com.allenmanning.exampleflexapp.account.commands { import com.adobe.cairngorm.commands.Command; import com.adobe.cairngorm.control.CairngormEvent; import com.allenmanning.exampleflexapp.account.business.IAccountDelegate; import com.allenmanning.exampleflexapp.account.event.UpdatePreferencesEvent; import mx.rpc.IResponder; /** * Command that updates application UserPreferences for a particular User- collaborates with * the Account Delegate for persistence, and updates the local Account Model * * @author amanning */ public class UpdatePreferencesCommand implements Command, IResponder { //------------------------------------------------------------------------------------------ // Member variables //------------------------------------------------------------------------------------------ /** The remote account management proxy */ internal var _accountDelegate:IAccountDelegate; //------------------------------------------------------------------------------------------ // Constructors //------------------------------------------------------------------------------------------ /** * Class constructor, initialize collaborators */ public function UpdatePreferencesCommand():void { //once the account delegate has been created we will initialize it it here // _accountDelegate = new AccountDelegate(this); } //------------------------------------------------------------------------------------------ // Methods //------------------------------------------------------------------------------------------ /** * Execute this command - update the Preferences and wait for response * * @param cairngormEvent the CairngormEvent which was dispatched to invoke this command */ public function execute(cairngormEvent:CairngormEvent):void { var event:UpdatePreferencesEvent = (cairngormEvent as UpdatePreferencesEvent); // Consider using an Assert? if(event.user == null || event.userPreferences == null) { throw new Error("Invalid event data"); } _accountDelegate.updatePreferences(event.user.id, event.userPreferences); } /** * Event listner for the successful result from the delegate- update the local model with * the results * * @param data The data passed back from the server. While <code>data</code> is typed as * Object, it is often (but not always) an mx.rpc.events.ResultEvent. * @see mx.rpc.IResponder#result */ public function result(data:Object):void { } /** * Event listener for the error result from the delegate- log the error * * @param info The application fault. While <code>info</code> is typed as Object it is * often (but not always) an mx.rpc.events.FaultEvent. * */ public function fault(info:Object):void { } } } |
UpdatePreferencesCommand.as (After, using Spring ActionScript)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 | package com.allenmanning.exampleflexapp.account.commands { import com.adobe.cairngorm.commands.Command; import com.adobe.cairngorm.control.CairngormEvent; import com.allenmanning.exampleflexapp.account.business.DelegateLocator; import com.allenmanning.exampleflexapp.account.business.IAccountDelegate; import com.allenmanning.exampleflexapp.account.event.UpdatePreferencesEvent; import com.allenmanning.exampleflexapp.account.model.IAccountModels; import com.allenmanning.exampleflexapp.account.model.UserPreferences; import com.allenmanning.exampleflexapp.error.Assert; import com.allenmanning.exampleflexapp.model.ModelLocator; import mx.rpc.IResponder; import mx.rpc.events.ResultEvent; /** * Command that updates application UserPreferences for a particular User- collaborates with * the Account Delegate for persistence, and updates the local Account Model * * @author amanning */ public class UpdatePreferencesCommand implements Command, IResponder { //------------------------------------------------------------------------------------------ // Methods //------------------------------------------------------------------------------------------ /** * Execute this command - update the Preferences and wait for response * * @param cairngormEvent the CairngormEvent which was dispatched to invoke this command */ public function execute(cairngormEvent:CairngormEvent):void { Assert.isTrue( ModelLocator.getInstance().accountModels.userIsLoggedIn, "The user must be logged in"); var event:UpdatePreferencesEvent = (cairngormEvent as UpdatePreferencesEvent); Assert.notNull(event.user, "The user must not be null"); Assert.notNull(event.userPreferences, "The preferences must not be null"); var delegate:IAccountDelegate = DelegateLocator.getInstance().accountDelegate; delegate.updatePreferences(event.user.id, event.userPreferences); } /** * Event listener for the successful result from the delegate- update the local model with * the results * * @param data The data passed back from the server. While <code>data</code> is typed as * Object, it is often (but not always) an mx.rpc.events.ResultEvent. * @see mx.rpc.IResponder#result */ public function result(data:Object):void { var updatedPreferences:UserPreferences = ((data as ResultEvent).result as UserPreferences); Assert.notNull(updatedPreferences, "The preferences must not be null"); ModelLocator.getInstance().accountModels.preferences = updatedPreferences; } /** * Event listener for the error result from the delegate- log the error * * @param info The application fault. While <code>info</code> is typed as Object it is * often (but not always) an mx.rpc.events.FaultEvent. * */ public function fault(info:Object):void { // TODO implement this } } } |
There are two major differences in the above refactorings.
_accountDelegate has been removed. We were using an internal variable to inject the delegate into this class before. We would inject a mock for our unit tests after the object has been constructed. We have replaced this with a new DelegateLocator.
In the Prana Store example, the delegates were injected onto the Model Locator. This tight coupling between the business model and the delegates is something we should move away from. Delegates and Models should have now knowledge of each other.
The second major difference that we have added a basic implementation for the onResult function- the results are being set into the model. This is another area that we are using dependency injection. Both the accountDelegate and the accountModel are being injected using Spring ActionScript. The command itself is gaining access to these dependencies through two Singleton Locator Objects.
Injecting The Dependencies
In our application-context.xml we wire up the relationships. In this case, we are using mocks for testing. The DelegateLocator has a mock account delegate injected into it. The same goes for the Account Model in the ModelLocator. In both cases we are injecting Mocks for testing purposes. For a deployment context, the application-context.xml would be different.
application-context.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | <objects xmlns="http://www.pranaframework.org/objects" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.pranaframework.org/objects http://www.pranaframework.org/schema/objects/prana-objects-0.6.xsd"> <!-- =================================================================== --> <!-- This application context defines a single test configuration, it can be expanded to include a deployment configuration as well, please see comments below. 1. Testing: Mock Delegates 2. Deployment: Remote Object Delegates To use a configuration, uncomment it and comment the one you don't want to use. By default, the Mock Delegates configuration is used. --> <!-- =================================================================== --> <!-- =================================================================== --> <!-- 1. Testing: Mock Delegates --> <!-- =================================================================== --> <object id="DelegateLocator" class="com.allenmanning.exampleflexapp.account.business.DelegateLocator" factory-method="getInstance"> <property name="accountDelegate"> <object class="com.allenmanning.exampleflexapp.account.business.MockAccountDelegate"/> </property> </object> <object id="ModelLocator" class="com.allenmanning.exampleflexapp.model.ModelLocator" factory-method="getInstance"> <property name="accountModels"> <object class="com.allenmanning.exampleflexapp.account.model.MockAccountModels"/> </property> </object> <!-- =================================================================== --> <!-- 2. Deployment: Remote Object Delegates --> <!-- =================================================================== --> <!-- <object id="DelegateLocator" class="com.allenmanning.exampleflexapp.account.business.DelegateLocator" factory-method="getInstance"> <property name="accountDelegate"> <object class="TODO create real account delegate"/> </property> </object> <object id="ModelLocator" class="com.allenmanning.exampleflexapp.model.ModelLocator" factory-method="getInstance"> <property name="accountModels"> <object class="com.allenmanning.exampleflexapp.account.model.AccountModels"/> </property> </object> --> </objects> |
Testing with Mocks and Spring ActionScript Our unit test for this command, assumes that Mocks have been injected into our Command and take advantage of these to test the object behavior.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | package com.allenmanning.exampleflexapp.account.commands { import com.allenmanning.exampleflexapp.account.business.DelegateLocator; import com.allenmanning.exampleflexapp.account.business.IAccountDelegate; import com.allenmanning.exampleflexapp.account.business.MockAccountDelegate; import com.allenmanning.exampleflexapp.account.event.UpdatePreferencesEvent; import com.allenmanning.exampleflexapp.account.model.IAccountModels; import com.allenmanning.exampleflexapp.account.model.MockAccountModels; import com.allenmanning.exampleflexapp.account.model.User; import com.allenmanning.exampleflexapp.account.model.UserPreferences; import com.allenmanning.exampleflexapp.error.AssertionError; import com.allenmanning.exampleflexapp.model.ModelLocator; import com.anywebcam.mock.Mock; import flexunit.framework.TestCase; import mx.rpc.events.ResultEvent; /** * Responsible for testing the Command that updates application UserPreferences for a * particular User * * @author amanning */ public class UpdatePreferencesCommandTest extends TestCase { //------------------------------------------------------------------------------------------ // Member Variables //------------------------------------------------------------------------------------------ /** The command that we are testing */ private var _commandToTest:UpdatePreferencesCommand; /** A fake user, used in testing */ private var _fakeUser:User = new User(123456, 'aFakeUser'); /** Fake user preferences, used in testing */ private var _fakePreferences:UserPreferences = new UserPreferences(false); /** A fake event used in testing */ private var _fakeEvent:UpdatePreferencesEvent = new UpdatePreferencesEvent(_fakePreferences, _fakeUser); /** * A mock Account delegate, with configurable expectations - used in testing. We have * injected this mock into the Delegate locator. */ private var _mockAccountDelegate:IAccountDelegate = DelegateLocator.getInstance().accountDelegate; //------------------------------------------------------------------------------------------ // Constructors //------------------------------------------------------------------------------------------ /** * Class Constructor * * @param methodName the test to add to this test suite * @see flexunit.framework.TestCase */ public function UpdatePreferencesCommandTest( methodName:String = null ) { super( methodName ); } //------------------------------------------------------------------------------------------ // Methods //------------------------------------------------------------------------------------------ /** * Called before every test to create testing data, or reset any test-specific properties * * @see flexunit.framework.TestCase#setUp */ override public function setUp():void { _commandToTest = new UpdatePreferencesCommand(); (_mockAccountDelegate as MockAccountDelegate).mock = new Mock(); var accountModel:IAccountModels = ModelLocator.getInstance().accountModels; (accountModel as MockAccountModels).mock = new Mock(); } /** * Called before after test to clean up testing data * * @see flexunit.framework.TestCase#tearDown */ override public function tearDown():void { // if this mock was not interacted with, and we expected it to this should throw an // exception (_mockAccountDelegate as MockAccountDelegate).mock.verify(); (ModelLocator.getInstance().accountModels as MockAccountModels).mock.verify(); } public function testUserNotLoggedIn():void { var accountModel:IAccountModels = ModelLocator.getInstance().accountModels; (accountModel as MockAccountModels).mock.property("userIsLoggedIn").returns(false); try { _commandToTest.execute(_fakeEvent); } catch (error:AssertionError) { // expected return; } fail('An Assertion Failed Error should have been thrown'); } /** * Testing utility function for executing the command with invalid data, execute should * throw an exception * * @param invalidFakeEvent a fake event with invalid data * */ private function invalidDataTester(invalidFakeEvent:UpdatePreferencesEvent):void { try { _commandToTest.execute(invalidFakeEvent); } catch(e:Error) { if(e.message.indexOf("No Expectation set for updatePreferences with args") != -1) { fail("updatePreferences should not be called with invalid data"); } else { // It was expected to fail return; } } fail("An exception was expected, the event was not properly populated"); } public function testExecuteNullDataBoth():void { invalidDataTester(new UpdatePreferencesEvent(null,null)); } public function testExecuteNullDataUser():void { invalidDataTester(new UpdatePreferencesEvent(_fakePreferences,null)); } public function testExecuteNullDataPreferences():void { invalidDataTester(new UpdatePreferencesEvent(null, _fakeUser)); } public function testExecute():void { //Configure a mock delegate and assign expectations. We are expecting the Delegate's //updatePreferences method to be called only once with the correct arguments (_mockAccountDelegate as MockAccountDelegate).mock.method( "updatePreferences").once.withArgs( Number, UserPreferences); var accountModel:IAccountModels = ModelLocator.getInstance().accountModels; (accountModel as MockAccountModels).mock.property("userIsLoggedIn").returns(true); // The mock will throw an exception, if it's "updatePreferences" method isn't called // exactly once with a Number and UserPreferences type arguments. _commandToTest.execute(_fakeEvent); } public function testOnResult():void { var accountModel:IAccountModels = ModelLocator.getInstance().accountModels; (accountModel as MockAccountModels).mock.property( "preferences").withArgs(UserPreferences); var stubbedResultEvent:ResultEvent = new ResultEvent( "result", false, true, _fakePreferences); _commandToTest.result(stubbedResultEvent); } } } |
There is no injection done in the test itself. The test assumes mocks have been injected into Command and we can set up expectations for how the command should interact with these mocks in different contexts.
This Flex Project includes all of the sample code run in a FlexUnit test suite.