Using the Spring AOP Framework with EJB Components
by Eugene Kuleshov
12/19/2005
Advising Components Managed by Spring
In the previous section, you refactored the service entry point to use a Spring-managed bean. Now I'll show you how that could help you improve your component and implement new features.
First of all, imagine that users would like to see the prices for symbols that are not managed by your
TradeManager
component. In other words, you would need to hook up to an external service for retrieving the current market price for requested symbols that you don't currently handle. As an example, you can use a free HTTP-based service from Yahoo portal, though a real application would hook up to the data feed from vendors that serve real-time data, such as Reuters, Thomson, Bloomberg, NAQ, and a number of others.
Begin by creating a new
YahooFeed
component that implements the same
TradeManager
interface, and that pulls the price information from the Yahoo finance portal. A naive implementation can send an HTTP request using
HttpURLConnection
and then parse the response using regular expressions. For example:
public class YahooFeed implements TradeManager {
private static final String SERVICE_URL = "http://finance.yahoo.com/d/quotes.csv?f=k1&s=";
private Pattern pattern = Pattern.compile("\"(.*) -
(.*)\"");
public BigDecimal getPrice(String symbol) {
HttpURLConnection conn;
String responseMessage;
int responseCode;
try {
URL serviceUrl = new URL(SERVICE_URL+symbol);
conn = (HttpURLConnection) serviceUrl.openConnection();
responseCode = conn.getResponseCode();
responseMessage = conn.getResponseMessage();
} catch(Exception ex) {
throw new RuntimeException("Connection error", ex);
}
if(responseCode!=HttpURLConnection.HTTP_OK) {
throw new RuntimeException("Connection error "+responseCode+" "+responseMessage);
}
String response = readResponse(conn);
Matcher matcher = pattern.matcher(response);
if(!matcher.find()) {
throw new RuntimeException("Unable to parse response ["+response+"] for symbol "+symbol);
}
String time = matcher.group(1);
if("N/A".equals(time)) {
return null; // unknown symbol
}
String price = matcher.group(2);
return new BigDecimal(price);
}
public void setPrice(String symbol, BigDecimal price) {
throw new UnsupportedOperationException("Can't set price of 3rd party trade");
}
private String readResponse(HttpURLConnection conn) {
// ...
return response;
}
}
After this implementation is finished and tested (outside of container!), you can integrate it with other components. Traditionally, one would add some code to the
TradeManager2Impl
to check the value returned from the
getPrice()
method. This would at least double the number of tests and will require you to set additional preconditions for each test case. However, using the Spring AOP framework, you can do this in a nice way. You can implement an advice that will use the
YahooFeed
component to retrieve the price if the original
TradeManager
returns no value for the requested symbol (in this case it is just
null
, but you may as well catch a
UnknownSymbol
exception).
To apply advice to a concrete method you'll need to declare an
Advisor
in Spring's bean configuration. A convenient
NameMatchMethodPointcutAdvisor
class allows you to select methods by their names, and in this case, you need a
getPrice
:
<bean id="yahooFeed" class="org.javatx.spring.aop.YahooFeed"/>
<bean id="foreignTradeAdvisor"
class="
org.springframework.aop.support.NameMatchMethodPointcutAdvisor">
<property name="mappedName" value="
getPrice"/>
<property name="advice">
<bean class="org.javatx.spring.aop.ForeignTradeAdvice">
<constructor-arg index="0" ref="yahooFeed"/>
</bean>
</property>
</bean>
As you can see, the above advisor assigns a
ForeignTradeAdvice
to the
getPrice()
method. The Spring AOP framework uses the
AOP Alliance API for advice classes, which means that your
ForeignTradeAdvice
around advice should implement the
MethodInterceptor
interface. For example:
public class ForeignTradeAdvice implements
MethodInterceptor {
private TradeManager tradeManager;
public ForeignTradeAdvice(TradeManager manager) {
this.tradeManager = manager;
}
public Object invoke(MethodInvocation invocation) throws Throwable {
Object res = invocation.proceed();
if(res!=null) return res;
Object[] args = invocation.getArguments();
String symbol = (String) args[0];
return tradeManager.getPrice(symbol);
}
}
The above code invokes an original component using
invocation.proceed()
, and if it returns
null
, it will call another
tradeManager
injected as a constructor parameter on advice creation. See the declaration of the
foreignTradeAdvisor
bean above.
Now you can rename the
tradeManager
bean defined in Spring's bean configuration to the
baseTradeManager
and declare
tradeManager
as a proxy using
ProxyFactoryBean
. The new
baseTradeManager
will be a
target
you will advise with the
foreignTradeAdvisor
defined above:
<bean id="
baseTradeManager" class="org.javatx.spring.aop.TradeDao">
... same as tradeManager definition in the above example
</bean>
<bean id="tradeManager" class="org.springframework.aop.framework.ProxyFactoryBean">
<property name="proxyInterfaces" value="org.javatx.spring.aop.TradeManager"/>
<property name="target" ref="
baseTradeManager"/>
<property name="interceptorNames">
<list>
<idref local="
foreignTradeAdvisor"/>
</list>
</property>
</bean>
Basically, this is it. You implemented additional functionality without changing the original component, using only the Spring application context to reconfigure dependencies. To implement similar change in a classic EJB component without the Spring AOP framework, you would have to either add additional logic to the EJB (making it more difficult to test) or use a decorator pattern (effectively increasing the number of EJBs, which will also increase complexity of the tests, as well as increase deployment time). You can see in the above example that with Spring it became very easy to attach additional logic to the existing components without changing them. Instead of tightly coupled beans you now have several lightweight components that can be tested in isolation and assembled using the Spring Framework. Note that with this approach your
ForeignTradeAdvice
is a self-contained component, which implements its own piece of functionality and which can be tested as a standalone unit outside of an application server as I'll show you in the next section.
Testing the Advice Code
As you may notice, your code has no dependency on
TradeDao
or on
YahooFeed
. That would let you test this component in complete isolation using mock objects testing. A mock objects testing approach allows you to declare expectations before the component execution, and then verify that these expectations are met during a component call. See the
Resources section for more information about mock testing. Here you are going to use the
jMock framework that provides a flexible and expressive API for declaring expectations.
It is a good idea to use the same Spring bean configuration for both test and real applications, but for testing a specific component, you can't use real dependencies because this would break component isolation. However, Spring allows you to specify a
BeanPostProcessor
when creating Spring's application context in order to replace selected beans and dependencies. In this case you can use a
Map
of mock objects that will be created in the test code and used instead of beans defined in a Spring configuration:
public class StubPostProcessor implements BeanPostProcessor {
private final Map stubs;
public StubPostProcessor( Map stubs) {
this.stubs = stubs;
}
public Object postProcessBeforeInitialization(Object bean, String beanName) {
if(stubs.containsKey(beanName)) return stubs.get(beanName);
return bean;
}
public Object postProcessAfterInitialization(Object bean, String beanName) {
return bean;
}
}
In the
setUp()
method of your test case class, you will initialize the
StubPostProcessor
with mock objects for the
baseTradeManager
and
yahooFeed
components created with the jMock API. Then we can create the
ClassPathXmlApplicationContext
(configured to use the
BeanPostProcessor
) to instantiate a
tradeManager
component. The resulting
tradeManager
component will use the mocked dependencies.
Such an approach not only allows you to isolate components for testing, but also ensures that advices are defined correctly in the Spring bean configuration. It is practically impossible to use anything like this to test business logic implemented in EJB components without simulating a lot of the container infrastructure:
public class ForeignTradeAdviceTest extends TestCase {
TradeManager tradeManager;
private Mock baseTradeManagerMock;
private Mock yahooFeedMock;
protected void setUp() throws Exception {
super.setUp();
baseTradeManagerMock = new Mock(TradeManager.class, "baseTradeManager");
TradeManager baseTradeManager = (TradeManager) baseTradeManagerMock.proxy();
yahooFeedMock = new Mock(TradeManager.class, "yahooFeed");
TradeManager yahooFeed = (TradeManager) yahooFeedMock.proxy();
Map stubs = new HashMap();
stubs.put("yahooFeed", yahooFeed);
stubs.put("baseTradeManager", baseTradeManager);
ConfigurableApplicationContext ctx = new ClassPathXmlApplicationContext(CTX_NAME);
ctx.getBeanFactory().addBeanPostProcessor(new StubPostProcessor(stubs));
tradeManager = (TradeManager) proxyFactory.getProxy();
}
...
In the actual
testAdvice()
method, you can specify expectations for the mock objects and verify, for example, that if
getPrice()
on
baseTradeManager
returns
null
, then
getPrice()
on
yahooFeed
also will be called:
public void testAdvice() throws Throwable {
String symbol = "testSymbol";
BigDecimal expectedPrice = new BigDecimal("0.222");
baseTradeManagerMock.expects(new InvokeOnceMatcher()).method("getPrice")
.with(new IsEqual(symbol)).will(
new ReturnStub(null));
yahooFeedMock.expects(new InvokeOnceMatcher()).method("getPrice")
.with(new IsEqual(symbol)).will(
new ReturnStub(expectedPrice));
BigDecimal price = tradeManager.getPrice(symbol);
assertEquals("Invalid price", expectedPrice, price);
baseTradeManagerMock.verify();
yahooFeedMock.verify();
}
This code uses jMock constraints to specify that the
baseTradeManagerMock
expects the method
getPrice()
to be invoked only once with a parameter equal to
symbol
, and, that it will return
null
from that call. Similarly,
yahooFeedMock
also expects a single invocation of the same method, but will return
expectedPrice
. This allows you to run the
tradeManager
component you created in the
setUp()
method and assert the returned result. Mocked dependencies allow you to verify that all calls to the dependent components meet your expectations.
This test case can be easily parameterized to cover all the possible cases. Notice that you can easily declare expectations when exceptions are thrown by the components:
Test | baseTradeManager | yahooFeed | Expected | |||||
---|---|---|---|---|---|---|---|---|
call | return | throw | call | return | throw | result | exception | |
1 | true | 0.22 | - | false | - | - | 0.22 | - |
2 | true | - | e1 | false | - | - | - | e1 |
3 | true | null | - | true | 0.33 | - | 0.33 | - |
4 | true | null | - | true | null | - | null | - |
5 | true | null | - | true | - | e2 | - | e2 |
Using this table you can update the test class to use a parametrized suite that will cover all possible scenarios:
...
public static TestSuite suite() {
BigDecimal v1 = new BigDecimal("0.22");
BigDecimal v2 = new BigDecimal("0.33");
RuntimeException e1 = new RuntimeException("e1");
RuntimeException e2 = new RuntimeException("e2");
TestSuite suite = new TestSuite(ForeignTradeAdviceTest.class.getName());
suite.addTest(new ForeignTradeAdviceTest(true, v1, null, false, null, null, v1, null));
suite.addTest(new ForeignTradeAdviceTest(true, null, e1, false, null, null, null, e1));
suite.addTest(new ForeignTradeAdviceTest(true, null, null, true, v2, null, v2, null));
suite.addTest(new ForeignTradeAdviceTest(true, null, null, true, null, null, null, null));
suite.addTest(new ForeignTradeAdviceTest(true, null, null, true, null, e2, null, e2));
return suite;
}
public ForeignTradeAdviceTest(
boolean baseCall, BigDecimal baseValue, Throwable baseException,
boolean yahooCall, BigDecimal yahooValue, Throwable yahooException,
BigDecimal expectedValue, Throwable expectedException) {
super("test");
this.baseCall = baseCall;
this.baseWill = baseException==null ?
(Stub) new ReturnStub(baseValue) : new ThrowStub(baseException);
this.yahooCall = yahooCall;
this.yahooWill = yahooException==null ?
(Stub) new ReturnStub(yahooValue) : new ThrowStub(yahooException);
this.expectedValue = expectedValue;
this.expectedException = expectedException;
}
public void test() throws Throwable {
String symbol = "testSymbol";
if(baseCall) {
baseTradeManagerMock.expects(new InvokeOnceMatcher())
.method("getPrice").with(new IsEqual(symbol)).will(baseWill);
}
if(yahooCall) {
yahooFeedMock.expects(new InvokeOnceMatcher())
.method("getPrice").with(new IsEqual(symbol)).will(yahooWill);
}
try {
BigDecimal price = tradeManager.getPrice(symbol);
assertEquals("Invalid price", expectedValue, price);
} catch(Exception e) {
if(expectedException==null) {
throw e;
}
}
baseTradeManagerMock.verify();
yahooFeedMock.verify();
}
public String getName() {
return super.getName()+" "+
baseCalled+" "+baseValue+" "+baseException+" "+
yahooCalled+" "+yahooValue+" "+yahooException+" "+
expectedValue+" "+expectedException;
}
...
In more sophisticated cases, the above testing approach can be easily scaled to the much larger set of the input parameters, and it will still run practically in no time and will be easy to manage. Moreover, it would make sense to move all the parameters into an external config or even into the Excel spreadsheet that can be managed by the QA team or directly generated from the requirements.