EK9 in Depth

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:

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.

  1. The full rule is E07238 (ek9 -h E07238); the model behind it is in Operators and Pragmatic Purity, in Full. ↩