Aspects
Logging, timing, auditing and tracing are written once and needed in many places, and the usual cost of adding them is an
edit to every method they touch. Java's answer is AspectJ, which weaves advice into bytecode under a pointcut language and
needs its own build step, or Spring AOP, which wraps beans in run-time proxies chosen by pointcut expressions held in strings.
Python reaches for a decorator at the definition; a web framework reaches for middleware. EK9 attaches the advice at a
different place: the registration line of an application, the same line that Dependency Injection and Applications used to
choose an implementation. The component is not edited and neither is anything that calls it. This chapter is about what that
line can do, in what order it does it, and what it cannot.
Advice at the registration line
An aspect is a class that extends the built-in Aspect. Its two methods are called around every method of the component it is
attached to: beforeAdvice receives a JoinPoint (the component's name and the method's name) and returns a
PreparedMetaData; afterAdvice receives that same PreparedMetaData back. You attach one or more aspects after the
registration, with with aspect of, which requires the as contract in front of it:
#!ek9 defines module book.aspects.order defines class LabelAspect extends Aspect label as String? default private LabelAspect() LabelAspect() -> label as String this.label: label override beforeAdvice() -> joinPoint as JoinPoint <- rtn as PreparedMetaData: PreparedMetaData(joinPoint) Stdout().println(`${label} before ${joinPoint.componentName()}.${joinPoint.methodName()}`) override afterAdvice() -> preparedMetaData as PreparedMetaData Stdout().println(`${label} after ${preparedMetaData.joinPoint().methodName()}`) default operator ? defines component Greeter as abstract greet() as abstract -> name as String PoliteGreeter is Greeter override greet() -> name as String Stdout().println(` hello ${name}`) defines application greetingApp register PoliteGreeter() as Greeter with aspect of LabelAspect("outer"), LabelAspect("inner") defines program Greeting() with application of greetingApp greeter as Greeter! greeter.greet("Steve")
outer before book.aspects.order::PoliteGreeter.greet
inner before book.aspects.order::PoliteGreeter.greet
hello Steve
inner after greet
outer after greet
PoliteGreeter has no aspect code and Greeting does not mention one; only greetingApp does. Two facts are visible in the
output. First, the order: before-advice runs in the order the aspects are written, then the component's own method, then
after-advice in the reverse order, so the first aspect listed is the outermost layer, as with nested middleware. Second,
componentName() names the implementation (PoliteGreeter), not the contract the program holds. An aspect is a class like any
other, so LabelAspect("outer") is an ordinary constructor call with an argument, and the compiler checks the arguments as
it checks any call.
The compiler will tell you what it has woven. ek9 -di -E3 lists each registration with its aspects, in order, and states
the sequence in words:
Wiring Map
──────────
Application: shopApp (./PriceBook.ek9:82)
[1] SimplePriceBook as PriceBook:83
aspects: LoggingAspect, TimerAspect
[ASPECT CHAIN]
Aspect weaving is configured for one or more registrations. Each proxied
method will have before/after advice applied in registration order.
Execution flow per proxied method:
1. JoinPoint created with method name and type
2. Before advice called on each aspect (in order)
3. Delegate method called (wrapped in try/finally)
4. After advice called on each aspect (in reverse order)
5. Return value passed back to caller
That output is from the program in the next section.
Carrying a value from before to after
The PreparedMetaData that beforeAdvice returns is the only channel between the two calls, which is what makes a timer
possible: the start time is prepared before, and read after. Extend PreparedMetaData with the value you need, and return that
subtype:
TimerData extends PreparedMetaData started as Millisecond? default private TimerData() TimerData() -> started as Millisecond joinPoint as JoinPoint super(joinPoint) this.started: started started() <- rtn as Millisecond: started default operator ? default operator $ default operator :=: default operator <=> TimerAspect extends Aspect clock as Clock? default private TimerAspect() TimerAspect() -> clock as Clock this.clock: clock override beforeAdvice() -> joinPoint as JoinPoint <- rtn as TimerData: TimerData(clock.millisecond(), joinPoint) afterAdvice() -> timerData as TimerData elapsed <- clock.millisecond() - timerData.started() Stdout().println(`timer: ${timerData.joinPoint().methodName()} took ${elapsed}`) default operator ?
Two details need explaining. TimerData adds a field below PreparedMetaData, so it must state its own ?, $, :=: and
<=>; a subtype that added a field and inherited those would silently leave the new field out of the text, the copy and the
ordering (E07238).1 And afterAdvice in TimerAspect is not marked override. Aspect declares
afterAdvice as a dispatcher taking a PreparedMetaData; the timer's version takes a TimerData, which is a second
overload, and the dispatcher routes the call to it by the run-time type of what beforeAdvice returned. That is the
mechanism The Dispatcher describes, used here without your writing the word. Mark it override and the compiler explains the
distinction:
EK9 : Error : E05110: 'afterAdvice' on line 52 position 15: 'override public Void <- afterAdvice(timerData as TimerData)' takes other parameter types than the parent's 'public Void <- afterAdvice(preparedMetaData as PreparedMetaData)' in built-in 'Aspect' (see 'ek9 -h Aspect') - an override takes exactly the same types, so this is a new overload; remove 'override' from THIS member, or take the parent's types to override it: does not 'override' any method/operator
See: ek9 -h E05110
Put the two aspects on one component, a returning method and one that returns nothing, and the registration is still one line:
defines application shopApp register SimplePriceBook() as PriceBook with aspect of LoggingAspect(), TimerAspect(StandStillClock()) defines program Shop() with application of shopApp stdout <- Stdout() prices as PriceBook! stdout.println("-- a call that returns a value") stdout.println(`kettle: ${prices.priceOf("kettle")}`) stdout.println("-- a call that returns nothing") prices.reload()
-- a call that returns a value
log: before priceOf
timer: priceOf took 0ms
log: after priceOf
kettle: 12.50#GBP
-- a call that returns nothing
log: before reload
(price book reloaded)
timer: reload took 0ms
log: after reload
Read it against the order rule: log: before (the timer's before-advice prints nothing), the method, then the timer's after-advice
and only then the logger's. Every method of the contract is advised, the one that returns a value and the one that does not.
StandStillClock is a Clock whose millisecond() never advances, which is why the figure is 0ms: it makes the output
reproducible. Register SystemClock() instead and the line reads a different figure on every run.
When the advised method throws
The compiler's own description says the delegate call is "wrapped in try/finally", and a charge that refuses a negative amount shows what that means in practice:
defines application paymentsApp register StrictGateway() as PaymentGateway with aspect of LoggingAspect() defines program Payments() with application of paymentsApp stdout <- Stdout() gateway as PaymentGateway! for amount in [20.00#GBP, -5.00#GBP] try gateway.charge(amount) catch -> ex as Exception stdout.println(`caller caught: ${ex.reason()}`)
log: before charge
charged 20.00#GBP
log: after charge
log: before charge
log: after charge
caller caught: refusing to charge -5.00#GBP
On the second call the gateway throws, the after-advice still runs, and the caller then catches the same exception. An aspect
cannot swallow, change or replace what the component throws. It also cannot tell that the call threw: afterAdvice receives
only the PreparedMetaData, and nothing in it records success or failure. An aspect that logs "completed" in its after-advice
is wrong for the second call above.
Only a component, only an Aspect
An aspect exists only at the registration site, and the compiler holds that line from both ends. A class cannot be registered at all, so there is nothing to attach an aspect to:
defines application shopApp register SimplePriceBook() as PriceBook with aspect of LoggingAspect()
EK9 : Error : E50020: 'PriceBook' on line 27 position 36: resolved as a 'class' which is a 'type' rather than a 'component': incompatible genus
See: ek9 -h E50020
And a class that has the right method but does not extend Aspect is not one:
EK9 : Error : E50030: 'Tracer' on line 27 position 61: 'Tracer' is not assignable to 'Aspect': types are not compatible with each other
See: ek9 -h E50030
What it costs
The honest criticism of aspects is action at a distance: you read prices.priceOf("kettle") and the console shows three lines
you cannot find in priceOf, Shop or the component. EK9 does not remove that; it confines it. Advice can be attached in
exactly one kind of place, the register line, and the compiler will list every such line for you (ek9 -di -E3, above). That
is a smaller surface than annotations scattered over a codebase, and a larger one than a call that does what it says.
The mechanism is also deliberately narrow, and each narrowness is something you cannot do:
- No pointcuts. Every method of the registered contract is advised. To act on only some, the aspect tests
joinPoint.methodName()itself. - No arguments and no result. A
JoinPointholds two names. The advice cannot see what was passed, what came back, or whether the call threw, so it suits logging, timing and counting, and not validation or caching. - No "around". There is no advice that wraps the call and decides whether to make it; before-advice cannot veto the method.
- No per-call-site advice. Two programs that want different advice register differently, in different applications.
An aspect is also shared state. One instance serves every call, so a field it writes is written by every caller. In a single-threaded program that is fine, and a counting aspect works:
CountingAspect extends Aspect calls <- 0 override beforeAdvice() -> joinPoint as JoinPoint <- rtn as PreparedMetaData: PreparedMetaData(joinPoint) calls++ override afterAdvice() -> preparedMetaData as PreparedMetaData Stdout().println(`calls so far: ${calls}`) default operator ?
Two calls to the advised component print:
calls so far: 1
one
calls so far: 2
two
but hand the advised component to concurrent code, here a TCP handler that calls it for each connection, and the same write is refused:
EchoServer() with application of countingApp stdout <- Stdout() worker as Worker! handler <- (worker) is TCPHandler as function output.println(worker.work(input.next())) try -> tcpServer <- TCP(NetworkProperties(port: 0, backlog: 2, maxConcurrent: 2, localOnly: true)) if tcpServer.accept(handler) stdout.println("done")
EK9 : Error : E08251: 'calls' on line 12 position 8: mutation of shared state must happen inside MutexLock.enter(...) — wrap the write in a MutexKey body
See: ek9 -h E08251
The rule belongs to Concurrency, Safely. It is one reason to keep per-call state in the PreparedMetaData, which belongs
to a single call, and not in a field of the aspect.
What the narrowness buys is that an aspect cannot break the component it wraps: it cannot alter a result, suppress an exception or skip a call, and the wiring that attaches it is checked with the rest of the application. A test application can register the same component with the aspect or without it, as the test needs; Testing the Compiler Trusts shows what a test application is for.
-
The full rule is E07238 (
ek9 -h E07238); the model behind it is in Operators and Pragmatic Purity, in Full. ↩