Level: Intermediate Abhijit Belapurkar (abhijit_belapurkar@infosys.com), Senior Technical Architect, Infosys Technologies Limited
09 Mar 2004 If you've ever inherited and then had to maintain a Java-based legacy application, then this article is for you. Author Abhijit Belapurkar shows you how to use aspect-oriented programming (AOP) to gain an unprecedented view into the inner workings of even the most opaque of legacy applications.
A software system typically starts out with a finite set of
well-understood requirements. As most successful systems evolve,
however, they take on more and more requirements, incorporating
innumerable functional and nonfunctional aspects. In an enterprise
environment, you can easily end up adding to this tangle of modules a
number of third-party libraries and frameworks, all of which interact
and play with each other beneath the surface of the system's
day-to-day workings. In effect, given just a few years, the system
that started out with a simple, manageable requirement set has become
a behemoth: an unruly and obtuse tower of code.
Into this tower steps the Java developer, newly tasked with its
daily maintenance and evolution. If you are this developer, then your
first task is to gain an intimate understanding of the structure of
the system. Understanding the structure will be key to your ability
to make enhancements and troubleshoot any problems that, inevitably, will arise. Of course, peering into any unknown system
for the first time is easier said than done. In some cases you will
be able to ask questions of the original developers, in others you
won't. But even with access to the development team some systems are
simply too massive to be accessed and understood without
mechanical help.
While a number of tools are available to aid you in comprehending
complex programs (see Resources) most are expensive, time-consuming to
learn, and limited in scope of functionality (that is, you will have
no recourse if the tool does not meet your needs). In this article I
propose an alternative approach. Aspect-oriented programming is a
full-fledged programming paradigm that can be applied to a wide range
of programming scenarios, including the comprehension and maintenance
of legacy applications.
Please note that this article assumes that you are generally
familiar with AOP under AspectJ, and in particular with AspectJ's
static and dynamic crosscutting techniques. Although I do provide a
brief overview of AOP crosscutting in the next section, please refer to Resources for more detailed information.
Conceptual
overview
Java-based AOP employs a flexible and rich expression language
with which you can slice and dice complex applications in a seemingly
infinite number of ways. The Java-based AOP syntax is similar enough
to the Java language that you should have little trouble grasping it.
Once learned, AOP is a programming technique with many
applications. In addition to comprehending legacy system internals,
you can also use AOP to unobtrusively refactor and enhance such
systems. Although you'll work entirely with AspectJ in this article,
most of the techniques discussed are portable to other popular
Java-based AOP implementations such as AspectWerkz and JBossAOP (see
Resources).
About crosscutting
Any application is composed of multiple functional and systemic
concerns. Functional concerns are relevant to the application's daily
use, whereas systemic ones pertain to the overall health and
maintenance of the system. For example, the functional concerns of a
banking application include account maintenance and allowing
debit/credit operations, and its systemic concerns include security,
transactions, performance, and audit logging. Even if you develop
your applications using the best-of-breed programming methodologies,
you will eventually find that its functional and systemic concerns
become tangled up with each other in a manner that spans across
multiple application modules.
Crosscutting is an AOP technique for ensuring that independent
concerns remain modularized but are also flexible enough to be
applied at different points throughout an application. The two
varieties of crosscutting are static and dynamic. Dynamic
crosscutting entails changing the execution behavior of an
object by weaving in new behavior at specific points of interest.
Static crosscutting lets us alter the very structure of an
object by injecting additional methods and/or attributes into it.
The syntax of static crosscutting is quite different from that of
dynamic crosscutting. The following terminology applies
to dynamic crosscutting:
- A join point is a particular point of execution in a Java
program, such as a method in a class.
- A pointcut is a language-specific construct that denotes
or captures a particular join point.
- An advice is a piece of code (typically, a crosscutting
functionality) to be executed when a specific pointcut is reached.
- An aspect is a construct that defines pointcuts and
advices and the mapping between them. Aspects are used by the AOP
compiler to weave additional functionality into existing objects at
specific execution points.
All of the code demonstrations in this article will utilize
dynamic crosscutting. See Resources to learn
more about static crosscutting.
AOP under AspectJ
To follow the examples in this article you should be
familiar with the following features specific to AOP under
AspectJ:
- AspectJ provides a compiler/bytecode weaver called
ajc that compiles AspectJ and Java language
files. ajc weaves together aspects as
necessary to produce .class files compliant with any Java VM
(1.1 or later).
- AspectJ supports aspects that specify that a particular join
point should never be reached. If the
ajc
process determines otherwise, it signals a compile-time warning or
error (depending on the aspect).
Application
and system analysis
In the following sections you will learn two different mechanisms
of application and system analysis using AOP. The first mechanism,
which I call static analysis, requires you to do the
following:
- Check out from CVS (or whatever code versioning system you use) the
entire application code base into your local area.
- Alter the build file to use the AspectJ compiler (
ajc).
- Include the aspect classes at the appropriate places.
- Run a complete build of the system.
The second mechanism, which I call dynamic analysis,
requires you to not only build the system in your local area but also
run the actual use cases of the system and gather information from
runtime reflection into the running system. In the sections that
follow I'll go over each mechanism in detail, using code examples to
illustrate key concepts.
Static analysis
I'll examine a number of techniques for performing
static analysis of a legacy application, applying them
to three common maintenance scenarios, as follows:
- Evaluating the impact of an interface change
- Identifying dead or unreachable code
- Instituting loose coupling
Now, let's get started!
Evaluating the impact of an interface change
An object-oriented legacy application or system should consist
of a number of modules that expose a well-defined interface to its
clients. Suppose, then, that you have been tasked with incorporating
a new requirement that entails changing an existing interface. Being
new to the code, your first challenge is to figure out the exact
impact that changing this interface will have on the system's
clients. For the sake of this example, the implied impact is more
than that of merely changing each of the clients to make the method
calls per the changed signatures. Therefore, knowing all the clients
of the interface may be instrumental in determining the best approach
to implementing the new requirement, as well as whether the
requirement is worthwhile for the given system. I'll use static
analysis to first determine the clients of the interface, and then
evaluate the impact of interface change on the system.
This technique is based on aspects signaling compile-time warnings
if specific join points are found to be reachable. In this case, you
will write join points to capture calls to all methods of the specific
interface. You must first extract the application code base in a
local area, alter the build file to use the AspectJ compiler and to
include the aspect classes at the appropriate places, and then run a
complete build of the system. If you have captured the join points
correctly you can expect to see compile-time warnings to signal
calls to the methods of interest in the codebase.
The compile-time aspect shown in Listing 1 detects and displays
all classes that call (an implementor of) the interface com.infosys.setl.apps.AppA.InterfaceA, while
excluding the (one or more) implementor classes themselves. So, for
the sample InterfaceA, its implementor
class ClassA, and the caller class ClassB, the aspect will generate a compile-time
warning for the call a.methodA() in ClassB.
Listing 1.
Classes that use InterfaceA
package com.infosys.setl.apps.AppA;
public interface InterfaceA
{
public String methodA();
public int methodB();
public void methodC();
}
package com.infosys.setl.apps.AppA;
public class ClassA implements InterfaceA
{
public String methodA()
{
return "Hello, World!";
}
public int methodB()
{
return 1;
}
public void methodC()
{
System.out.println("Hello, World!");
}
}
package com.infosys.setl.apps.AppB;
import com.infosys.setl.apps.AppA.*;
public class ClassB
{
public static void main(String[] args)
{
try
{
InterfaceA a =
(InterfaceA)Class.forName(
"com.infosys.setl.apps.AppA.ClassA").newInstance();
System.out.println(a.methodA());
}
catch (Exception e)
{
e.printStackTrace();
}
}
}
package com.infosys.setl.aspects;
public aspect InterfaceCallersAspect
{
pointcut callerMethodA(): call
(* com.infosys.setl.apps.AppA.InterfaceA.methodA(..)) &&
!within(com.infosys.setl.apps.AppA.InterfaceA+);
pointcut callerMethodB(): call
(* com.infosys.setl.apps.AppA.InterfaceA.methodB(..)) &&
!within(com.infosys.setl.apps.AppA.InterfaceA+);
pointcut callerMethodC(): call
(* com.infosys.setl.apps.AppA.InterfaceA.methodC(..)) &&
!within(com.infosys.setl.apps.AppA.InterfaceA+);
declare warning: callerMethodA(): "Call to InterfaceA.methodA";
declare warning: callerMethodB(): "Call to InterfaceA.methodB";
declare warning: callerMethodC(): "Call to InterfaceA.methodC";
}
|
The impact of an interface change on the implementor classes can
be determined by an aspect like the one shown in Listing 2. For the
sample classes in the previous listing, this aspect generates a
compile-time warning for ClassA.
Listing 2.
Classes that implement InterfaceA
package com.infosys.setl.aspects;
public aspect InterfaceImplAspect
{
pointcut impls(): staticinitialization (com.infosys.setl.apps.AppA.InterfaceA+) &&
!(within(com.infosys.setl.apps.AppA.InterfaceA));
declare warning: impls(): "InterfaceA Implementer";
}
|
Identifying
dead code
A system grows piecemeal as enhancements requests trickle in.
Whenever you make a change in one part of a system or application it
is important to be aware of its impact in other parts. For example,
refactoring the code in Module A may result in code elsewhere (either
methods in a class or the entire class itself) becoming unreachable
(or "dead") because the control/data flow has been updated to
navigate around it. Leaving dead code unattended can eventually lead
to performance issues as the system becomes weighed down with unused
code.
Fortunately, the same technique used to determine the
impact of code enhancement (as shown in the previous section) can be
applied to the identification of dead or unreachable code. Just as
the compile-time aspect shown in Listing 1 can be used to detect all methods in
the interface that are being called, they can also point us to those
that are not. Any methods in the interface that do not
generate a compile-time warning can be presumed dead, and can thus be
safely removed.
Instituting loose coupling
At some point you may have to maintain a legacy application that
has been developed with hardcoded calls to or against specific
components as opposed to interfaces. Altering such systems -- for
example with the addition of new or alternative components -- can be
quite intrusive, and also very challenging. Fortunately, you can use
AOP to decouple specific components from the system and replace them
with generic, interface-based components. In so doing, you ensure
that the actual implementor can be plugged in on-the-fly.
Consider the sample class shown in Listing 3. The methodA
of ClassA has a hardcoded form of logging
in which calls are directly made to System.out.
Listing 3. Class with hard-coded logging calls
package com.infosys.setl.apps.AppA;
public class ClassA
{
public int methodA(int x)
{
System.out.println("About to double!");
x*=2;
System.out.println("Have doubled!");
return x;
}
public static void main(String args[])
{
ClassA a = new ClassA();
int ret = a.methodA(2);
}
}
|
It clearly would be intrusive to manually replace all of the
existing logging calls with calls to a generic logger interface.
A better option would be to use the aspect shown in Listing 4 to find-and-replace all
such calls. The generic logger interface would then be provided
by LoggerInterface, and a factory class LoggerFactory could be used to obtain specific
logger instances (SETLogger in Listing 4).
Listing 4. A generic interface to replace hardcoded calls
package com.infosys.setl.utils;
public interface LoggerInterface
{
public void log(String logMsg, Object target);
}
package com.infosys.setl.utils;
public class LoggerFactory
{
private static LoggerFactory _instance = null;
private LoggerFactory(){}
public static synchronized LoggerFactory getInstance()
{
if (_instance == null)
_instance = new LoggerFactory();
return _instance;
}
public LoggerInterface getLogger()
{
LoggerInterface l = null;
try
{
l = (LoggerInterface)Class.forName(
"com.infosys.setl.utils.SETLogger").newInstance();
}
catch (Exception e)
{
e.printStackTrace();
}
finally
{
return l;
}
}
}
package com.infosys.setl.utils;
public class SETLogger implements LoggerInterface
{
public void log(String logMsg, Object target)
{
System.out.println(logMsg);
}
}
package com.infosys.setl.aspects;
import com.infosys.setl.utils.*;
public aspect UnplugAspect
{
void around(String logMsg, Object source):
call (void java.io.PrintStream.println(String)) &&
within(com.infosys.setl.apps.AppA.ClassA) &&
!within(com.infosys.setl.aspects..*) && args(logMsg) && target(source)
{
logger.log(logMsg, source);
}
private LoggerInterface logger = LoggerFactory.getInstance().getLogger();
}
|
 |
Dynamic analysis
In this section I'll go over several techniques of dynamic
analysis, once again applying them to familiar maintenance scenarios.
The scenarios you'll resolve with dynamic analysis are as follows:
- Generating a dynamic call graph
- Evaluating the impact of exceptions on a system
- Customizing a system based on specific conditions
Recall that a dynamic AOP analysis requires us to check out the
application code base into a local area, change the build file to use
the AspectJ compiler and include the aspect classes at the
appropriate places, build the entire system, and then run the use
case(s) that the system was designed for. Assuming that you specify the join
points of interest correctly, you will be able to gather a wealth of
information by reflection into the running application.
Generating a dynamic call graph
A single use-case invariably traverses
multiple program modules. It is not uncommon to form a mental
representation of this traversal path by running through the
execution sequence of the code in your mind. Obviously, remembering
all this information becomes harder as the traversal path becomes
longer, as is wont to happen for larger systems. In this section,
you'll learn how to generate a dynamic call graph, which is a nested trace
that depicts the pushing and popping of frames on the execution stack
as a use-case runs from start to finish.
In Listing 5, you can see
how you use a compile-time aspect to generate a call graph
dynamically, following the execution of a use-case.
Listing 5. Control-flow graph generation
package com.infosys.abhi;
public class ClassA
{
public void methodA(int x)
{
x*=2;
com.infosys.abhi.ClassB b = new com.infosys.abhi.ClassB();
b.methodB(x);
}
}
package com.infosys.abhi;
public class ClassB
{
public void methodB(int x)
{
x*=2;
com.infosys.bela.ClassC c = new com.infosys.bela.ClassC();
c.methodC(x);
}
}
package com.infosys.bela;
public class ClassC
{
public void methodC(int x)
{
x*=2;
com.infosys.bela.ClassD d = new com.infosys.bela.ClassD();
d.methodD(x);
}
}
package com.infosys.bela;
public class ClassD
{
public void methodD(int x)
{
x*=2;
}
}
package com.infosys.setl.aspects;
public aspect LogStackAspect
{
private int callDepth=0;
pointcut cgraph(): !within(com.infosys.setl.aspects..*)
&& execution(* com.infosys..*.*(..));
Object around(): cgraph()
{
callDepth++;
logEntry(thisjoin point, callDepth);
Object result = proceed();
callDepth--;
logExit(thisjoin point, callDepth);
return result;
}
void logEntry(join point jp, int callDepth)
{
StringBuffer msgBuff = new StringBuffer();
while (callDepth >0)
{
msgBuff.append(" ");
callDepth--;
}
msgBuff.append("->").append(jp.toString());
log(msgBuff.toString());
}
void logExit(join point jp, int callDepth)
{
StringBuffer msgBuff = new StringBuffer();
while (callDepth >0)
{
msgBuff.append(" ");
callDepth--;
}
msgBuff.append("<-").append(jp.toString());
log(msgBuff.toString());
}
void log(String msg)
{
System.out.println(msg);
}
}
Output:
=======
->execution(void com.infosys.abhi.ClassA.methodA(int))
->execution(void com.infosys.abhi.ClassB.methodB(int))
->execution(void com.infosys.bela.ClassC.methodC(int))
->execution(void com.infosys.bela.ClassD.methodD(int))
<-execution(void com.infosys.bela.ClassD.methodD(int))
<-execution(void com.infosys.bela.ClassC.methodC(int))
<-execution(void com.infosys.abhi.ClassB.methodB(int))
<-execution(void com.infosys.abhi.ClassA.methodA(int))
|
AspectJ also provides a construct called cflowbelow that indicates the join points
(points of execution) below the control flow of each join point
picked out by a certain pointcut. Using this construct, you can ask
for the call graph to be truncated to arbitrary depths. This is
useful in such situations as where the control flow is running
through a large loop, causing the same call graph output to be
repeated over and over again (which in turn leads to an increased
graph size and consequent reduced usefulness).
Evaluating the impact of exceptions
Oftentimes, you will be called upon to debug an exception that
occurred in the production environment. Such exceptions can cause the
undesirable effects of (for example) dumping the stack trace on the
user interface or not releasing a contentious resource such as a
shared lock.
While it's important to evaluate the impact of exceptions on the
overall system, it can be very hard to simulate certain exceptions in
your development environment. This may be due to the nature of the
exception (such as a network exception) or due to the development
environment not being an exact replica of the production environment.
For example, an exception that occurred in the production environment
due to database corruption cannot be simulated without knowing the
exact whereabouts of the corruption. It also may not be possible to
capture a snapshot of the production database to transfer to the
development server.
In addition to simulating the exception to figure out where the
problem lies within the application, you would also like to be able
to test that the code fix to ensure that it handles the problem
correctly. This is problematic unless the exception can be generated
in the patched code and its handling observed. In this section,
you'll see how to use AOP techniques to simulate the throwing of an
exception as a result of calling a method on an object, without
having to recreate the exact runtime conditions that would cause the
exception to be really thrown.
Consider the sample class com.infosys.setl.apps.AppA.ClassA shown in Listing 6. The call to methodA() of this class could lead to a
java.io.IOException under
certain circumstances. You're interested in knowing the
behavior of the caller of methodA() (the
class com.infosys.setl.apps.AppB.ClassB
shown in the same listing) if this exception were to happen. You don't,
however, want to spend the time and resources to set up the
conditions for an actual exception.
To resolve this, you use the pointcut genExceptionA of aspect GenException to trap the execution of methodA() and cause an java.io.IOException at runtime.
You can then test whether the application (represented here by
ClassB) is able to handle the exception as
per specification, as shown in Listing 6. (Of course, you could also
alter the advice to an "after" advice, which could execute following
the execution of methodA(), as represented
by the pointcut genExceptionAfterA.)
Note that in a real life
scenario, ClassA could be a third-party
library such as a JDBC driver.
Listing 6. Generating an exception from running code
package com.infosys.abhi;
import java.io.*;
public class ClassA
{
public void methodA() throws IOException
{
System.out.println("Hello, World!");
}
}
package com.infosys.bela;
public class ClassB
{
public static void main(String[] args)
{
try
{
com.infosys.abhi.ClassA a = new com.infosys.abhi.ClassA();
a.methodA();
com.infosys.abhi.ClassC c = new com.infosys.abhi.ClassC();
System.out.println(c.methodC());
}
catch (java.io.IOException e)
{
e.printStackTrace();
}
catch (Exception e)
{
e.printStackTrace();
}
}
}
package com.infosys.abhi;
public class ClassC
{
public String methodC()
{
System.out.println("Hello, World!");
return "Hi, World!";
}
}
package com.infosys.setl.aspects;
public aspect GenException
{
pointcut genExceptionA():
execution(public void com.infosys.abhi.ClassA.methodA() throws java.io.IOException);
pointcut genExceptionC():
call(void java.io.PrintStream.println(String)) &&
withincode(public String com.infosys.abhi.ClassC.methodC());
pointcut genExceptionAfterA():
call(public void com.infosys.abhi.ClassA.methodA() throws java.io.IOException);
void around() throws java.io.IOException : genExceptionA()
{
java.io.IOException e = new java.io.IOException();
throw e;
}
after() throws java.io.IOException : genExceptionAfterA()
{
java.io.IOException e = new java.io.IOException();
throw e;
}
after() throws java.lang.OutOfMemoryError : genExceptionC()
{
java.lang.OutOfMemoryError e = new java.lang.OutOfMemoryError();
throw e;
}
}
|
In a similar scenario, you might run across an application
throwing an unchecked exception (say, a subclass of java.lang.RuntimeException such as NullPointerException) or an error (say, a
subclass of java.lang.Error such as OutOfMemoryError). Both of these exception types
are difficult to simulate in a development environment. Instead, you
could use the pointcut genExceptionC along
with the corresponding after advice (shown above) to cause the
running code to throw an OutOfMemory error following the
call to System.out.println() within methodC() of ClassC.
Customizing a system based on conditions
Typically, AOP is thought of in the context of concerns that
crosscut multiple system modules. There is, however, scope for
using aspects for concerns that are concentrated to a specific part
of the system only. For example, you might be required to make some very
specific changes to the system, such as to keep a customer happy,
or to reflect a change in local regulatory laws. Some change(s) may even be
temporal (that is, they must be removed from the system after a specific time
period has passed).
In such cases, you may not find it possible (or even desirable) to
branch the code off to make the relevant enhancements directly in the
code, due to the following reasons:
- You may end up having to manage multiple code branches
in the CVS with differing versions. This is a management headache and
increases the possibility of errors. Even assuming that all the
requested enhancements are beneficial features that do figure in
the overall product roadmap, introducing these features via aspects
first will give the development team the opportunity to "test the
water," as it were, without having to commit the change to the
CVS.
- If a temporal feature must be removed from the system, the
regression testing required if the feature had been introduced
directly into the code will be resource consuming and prone to
mistakes. On the other hand, if the feature were to be introduced via
AOP, removal would be simply a matter of recompiling the system
with the aspect excluded from the build.
Fortunately, it's easy to weave in such customization to the system
via aspects. The weaving can be built-in at compile time (with AspectJ) or at load-time (with AspectWerkz). When designing the aspect it's important
to keep in mind that the AOP implementation
may have inherent limitations with respect to context exposure (for
example, AspectJ doesn’t allow local variables of a method to be
exposed to the advice code via the execution context). However,
generally speaking, using aspects to customize the system will lead
to a clean implementation with a clear separation of the isolated
concern from the baseline version of the system code.
Conclusion
In this article you've learned how to use aspect-oriented
programming to both comprehend and unobtrusively maintain large,
complex legacy systems. You've combined two techniques of analysis and
the power of dynamic crosscutting to a number of common maintenance
scenarios, with emphasis on troubleshooting errors and unobtrusively
enhancing existing code. All of the techniques demonstrated here
should prove invaluable if you are responsible for the day-to-day
maintenance of a legacy application.
Resources
About the author  | |  | Abhijit Belapurkar has a bachelor of technology degree in
computer science from the Indian Institute of Technology (IIT),
Delhi, India. He has worked in the areas of architectures and
information security for distributed applications for almost 10 years
and has used the Java platform to build n-tier applications for more
than five years. He presently works as a senior technical architect
in the J2EE space, with Infosys Technologies Limited, Bangalore,
India. |
Rate this page
|