Aspektusorientált programozás, AspectJ

Paller Gábor

1. Aspektusok

Ahogy a számítógépes programok nőttek, úgy vált egyre súlyosabb problémává ezen programok áttekinthetősége, karbantarthatósága és újrafelhasználhatósága. A kezdeti magas szintű nyelvek (pl. FORTRAN) az assembly nyelvek szerkezetét másolták. A goto utasításokkal teletűzdelt "spagetti kód" érthetetlensége hozta a strukturális nyelveket, amelyeknek legfontosabb élő tagja a C. Ezen nyelvek használata nyílt, jól definiált interfészeket és jól körülhatárolt modulokat eredményezhet, amelyek az olvashatóságot és az újrahasználhatóságot nagyban megnövelik. Problémájuk, hogy az implementáció viszonylag nyílt a programozó számára (nincs adat- és kódrejtés) valamint a bővítéshez hozzá kell nyúlni a modulhoz.

Az objektumorientált nyelvek mindkét problémára választ adnak. Ezen nyelvek adat- és kódrejtési szolgáltatásaival pontosan meghatározhatjuk, mit teszünk láthatóvá más modulok számára, az öröklődési szolgáltatásokkal pedig úgy bővíthetjük az eredeti modulokat, hogy azok implementációjához nem kell hozzányúljunk.

Az  objektumorientált programozás széleskörű elterjedése mégse hozta meg a modularizáció kívánt fokát. Ennek oka több kutató szerint az átmetsző követelmények (crosscutting concerns) jelenléte. Átmetsző követelmény az, ami a szoftver választott modularizációjában nem fogható meg egy modul által. Minthogy a modularizáció általában a funkció függvénye, ezek tipikusan nem funkcionális követelmények vagy pedig olyan funkcionális követelmények, amelyek az eredeti funkcionális dekompozíciónál nem voltak ismertek és a jelenlegi modulszerkezetben nem helyezhetők el egy modulként. Hipermetszeres ismereteinket felhasználva azt is mondhatjuk, hogy az átmetsző követelmény hipermetszete a domináns dekompozíció hipermetszetére vetítve nem modularizálható.

Tipikus átmetsző követelmény pl. a naplózás (ahol a naplót a program sok pontján kell írni), a biztonság (ahol a biztonsági követelményeket sok helyen kell ellenőrizni) vagy felhasználói felületek általános viselkedése (ha rábökök az ablak sarkára, az mindig bezárul, függetlenül attól, milyen ablak).

A domináns dekompozíciót megváltoztatva az átmetsző követelmény esetleg egyszerűen modularizálhatóvá válik. Kiczales, az aspektusorientált programozás atyja azt állítja, hogy mindig található olyan követelmény, ami az adott dekompozícióban átmetszővé válik. A hiperteres megközelítésben ez triviálisnak látszik, mert egy hipermetszet vetítése a domináns dekompozíció metszetére nem feltétlenül eredményez jól körülhatárolt programmodult. Az állítás formális bizonyítása mindenesetre még várat magára.

Az aspektus egy átmetsző követelmény reprezentációja. Az aspektus hasonlít a komponenshez abban, hogy a követelményt egy "csomagban" írja le, de az aspektus hatása a rendszerben elszórva jelentkezik. A pontos definíció a következő:

Az aspektusorientált programozási nyelvek az aspektusok modularizálását tűzik ki célul. A funkcionális dekompozíció (ez a dekompozíció domináns) céljára egy alapnyelvet választanak, ezt a nyelvet komponensnyelvnek is hívják. Az első aspektusorientált nyelvek kivétel nélkül objektumorientált nyelvet használtak komponensnyelvnek, ígéretes kísérletek folynak a C nyelvvel. Az aspektusokat egy másik nyelven, az aspektusnyelven írják le. Mint látni fogjuk, az aspektusnyelv lehet éppenséggel ugyanaz az objektumorientált nyelv is. Indulásként adott egy program a komponensnyelven megírva, ez általában a funkcionális dekompozíció eredménye. Erre alkalmazzuk az aspektusokat és az aspektusok által leírt átmetsző követelmények megváltoztatják a program működését. Ez a folyamat a szövés (weaving). A szövés során az aspektus programhelyeket azonosít és ezeken a helyeken megváltoztatja a kódot (tipikusan betold programrészt). Ezek a helyek a programpontok (join point).

Az aspektus tehát önmagában nem létezhet, csakis a módosított programrészletek kontextusában. Ezt úgy is mondják, hogy az objektum valami, az aspektus viszont valaminek a valamije. A hiperteres megközelítéstől eltérően az aspektusorientált modellben kötelezően van domináns dekompozíció (a komponensnyelvben véghezvitt funkcionális dekompozíció) és az aspektusok nem fejleszthetők magukban, csakis a domináns dekompozíció kontextusában.

Jelenleg a legnépszerűbb és legérettebb aspektusorientált implementáció az AspectJ. Ezzel kezdjük az ismerkedést.

2. Az AspectJ aspektusnyelv

Az aspektusnyelv programpontokat azonosít és ide kóddarabokat szúr. Egy aspektust tehát két dolog ír le:
Ezen két elemet egy szintaktikai egységbe fogják össze. Az AspectJ az aspektusokat Jáva osztályokra képezi le, habár ez a nyelv szintjén nem derül ki (észrevehetjük viszont a .class fájlokat a lefordított programban). Az ismerkedést kezdjük egy egyszerű példával! Lássuk a következő egyszerű programot!

Hello.java:
public class Hello {

  public void saySomething() {
    System.out.println( "Something" );
  }

  public int saySomethingElse( String msg ) {
    System.out.println( "Something else: "+msg );
    return 0;
  } 
 
  public static void main( String args[] ) {
    Hello h = new Hello();
    h.saySomething();
    h.saySomethingElse( "hello" );
  }
}


Ezek után nézzünk egy aspektust, ami a This szöveget írja ki minden say-jel kezdődő nevű metódus végrehajtása előtt, a That szöveget pedig utána,

TT1.java:
public aspect TT1 {
  pointcut p1() : execution( * *.say*( .. ) );
 
  before(): p1() {
    System.out.println( "This" );
  }

  after(): p1() {
    System.out.println( "That" );
  }

}


Nézzük ezt részekben! Először is definiáltunk egy pontszűrőt, aminek a p1 nevet adtuk. Maga a kiválasztókifejezés ez: execution( * *.say*( .. ) ). Ez azt mondja, hogy keressük az összes metódust akármilyen visszatérési értékkel (első *) bármilyen osztályban (*.) aminek neve say-jel kezdődik (say*) és akárhány ill. akármilyen típusú paraméterei vannak. ((..)). Ha ilyet találunk, akkor a metódus végrehajtása maga a kiválasztott programpont. A metódus végrehajtása előtt (before() : p1() ) egy programdarabot hajtunk végre, ami most kiírja a This szöveget, utána (after() : p1()) pedig egy másikat, ami a That szöveget írja ki. Ha a programot lefordítjuk (ajc Hello.java TT1.java) majd végrehajtjuk (java Hello) a kimenet nem meglepő:

This
Something
That
This
Something else: hello
That


Ha a pontszűrőt csak egy tanácsnál akarjuk használni, a pontszűrőt nem kell elnevezni, egyszerűsített szintakszis is használható. A következő példában nincs after() tanács, tehát egyszerűsíthetjük a szintaxist.

TT1short.java:
public aspect TT1short {
  before() : execution( * *.say*( .. ) ) {
    System.out.println( "This" );
  }
}


Érdemes az execution() programpont-kiválasztóra visszatérni. Van egy hasonló programpont-kiválasztó, a call(). Az execution magának a kijelölt metódusnak a végrehajtását kapja el, tehát a kijelölt metódus törzsébe szúrja a tanácsot. A call() a metódus hívását kapja el, tehát a tanácsot a metódus hívása elé és utána szúrja be. Normálisan ennek nincs jelentősége, de speciális esetekben fontos lehet. A Jáva alkalmazás main() metódusát pl. nem lehet call() kiválasztóval elkapni, mert ez a hívás nincs a Jáva programban. Ugyancsak fontos lehet a különbség konstruktorok hívásánál.

Térjünk vissza a metódus kiválasztására, mert ez az AspectJ-nek különös erőssége. A metódus nevén kívül annak akármelyik tulajdonságára kereshetünk. Például az int *.say*( String ) olyan metódusokat keres, amelyek visszatérési értéke int, a neve say-jel kezdődik és egy String paramétere van.  A módosítókra is kereshetünk, a static * *(..) pl. kiválasztja az összes statikus metódust visszatérési értéktől, deklaráló osztálytól, névtől és paraméterektől függetlenül. A konstruktor hívása is elkapható, pl. execution( Hello.new() ); elkapja a Hello osztály paraméter nélküli konstruktorának végrehajtását. Az osztálykijelölésnél az osztály neve lehet interface név is, pl. MyInterface.new() elkapja az összes MyInterface interfészt implementáló osztály konstruktorának végrehajtását.  Sőt, a leszármazást is kezelni lehet, pl. * Hello+.method() elkapja a Hello osztály és leszármazottjai method() metódusainak hívásait (akármilyen visszatérési értékük is legyen).

Ezen felül még további feltételeket adhatunk meg a pontszűrőben. Íme néhány közülük:
Feltételek gazdagon kombinálhatók a !,||,&& operátorokkal. Pl. handler( ArrayOutOfBoundsException ) && within( MyClass ) csak a MyClass metódusaiban levő ArrayOutOfBounds kivételkezelőket választja ki. Ennél még ravaszabbat is tehetünk: pl. az execution( public !static * *( .. ) ) minden publikus, nem statikus metódus végrehajtását kiválasztja.

A pontszűrőnek paraméterei lehetnek, ez lehetővé teszi a tanács kódjának, hogy hozzáférhessen a programpont információihoz. A következő példa a saySomethingElse paraméterét írja ki.

TT4.java:
public aspect TT4 {
  pointcut p1( String parms ) : execution( int saySomethingElse( String ) ) && args( parms );
 
  before( String parms ): p1( parms ) {
    System.out.println( "saySomethingElse called with parameter "+parms );
  }
}

Az args() szűrő elkap minden hívást, ahol az argumentumok típusa megfelel az args paraméterlistájának. Pl.:
pointcut intArg(int i): args(i);

Ez elkapja az összes hívást, ahol csak egy int paraméter van. Az args-nak azonban mellékhatása is van: ha a pontszűrőnek paramétere is van, akkor az args() segítségével kiszűrt paraméterek a tanácsba juttathatók.

Ugyanez a trükk a target() feltétellel felhasználható a célobjektum referenciájának az aspektusba juttatására. A célobjektum egy metódushívás vagy változómanipuláció célja lehet.

TT5.java:
public aspect TT5 {
  pointcut p1( Hello h ) : execution( int saySomethingElse( String ) ) && target( h );
 
  before( Hello h ): p1( h ) {
    System.out.println( "calling saySomething before saySomethingElse ..." );
    h.saySomething();
  }
}


A this( azonosító ) szűrő hasonlóképpen felhasználható a this() típusa szerinti szűrésre és illeszkedés esetén a this referencia tanácsba juttatására.

A set() az args()-sal kombinálva lehetővé teszi az értékadó művelet jobb oldalának vizsgálatát az értékadás előtt. A set() csak osztály és egyedváltozókra működik, lokális változókra nem, így Hello programunkat egy kicsit módosítani kell.

  static Hello h = null;
...
  h = new Hello();


Ekkor az aspektus:

TT10.java:
public aspect TT10 {
  pointcut p1( Hello h ) : set( Hello h ) && args( h );
 
  before( Hello h ) : p1( h ) {
    System.out.println( "Hello object is set: "+h );
  }
}


És a kiírás:

Hello object is set: null
Hello object is set: Hello@1a16869
Something
Something else: hello


Tehát a h mindkét értékadását elkapta.

Eddig megismertük a before() és after() tanácsot, ami a kiválasztott programpont előtt és mögött tud kódot végrehajtani. Az after() tanács változatai meg tudják különböztetni, ha a metódus rendben tért vissza, vagy ha kivételt dobott. A példa kedvéért módosítsuk a Hello.java-t:

Hello2.java:
public class Hello2 {

  public int saySomethingElse( String msg ) {
    if( msg.equals( "hello" ) )
        throw new IllegalArgumentException();
    System.out.println( "Something else: "+msg );
    return 0;
  }

  public static void main( String args[] ) {
    Hello2 h = new Hello2();
    h.saySomethingElse( "hallo" );
    h.saySomethingElse( "hello" );
  }
}


Az új saySomethingElse kivételt dob, ha "hello" paraméterrel hívják meg. Kezeljük le a két különböző visszatérési lehetőséget!

TT1_2.java:
public aspect TT1_2 {
  pointcut p1() : execution( int saySomethingElse( String ) );

  after() returning: p1() {
    System.out.println( "saySomethingElse returned" );
  }

  after() throwing: p1() {
    System.out.println( "saySomethingElse threw exception" );
  }
}

A kiírási kép megmutatja, hogy az after() throwing előbb lefut, majd a kivétel rendben továbbterjesztődik a hívónak:

Something else: hallo
saySomethingElse returned
saySomethingElse threw exception
Exception in thread "main" java.lang.IllegalArgumentException
        at Hello2.saySomethingElse(Hello2.java:5)
        at Hello2.main(Hello2.java:13)


A kiválasztott pontot teljesen kihelyettesíthatjük az around() tanáccsal. A következő példa a saySomethingElse metódust egy új implementációval helyettesíti (újra visszatérünk a Hello.java-ra).

TT6.java:
public aspect TT6 {
  pointcut p1( String s ) : execution( int saySomethingElse( String ) ) && args( s );
 
  int around( String s ) : p1( s ) {
    System.out.println( "New implementation printing "+s );
    return 1;
  }
}


Az eredeti kód a proceed speciális metódushívással hívható, tehát a példánkban proceed( s ) meghívta volan az eredeti saySomethingElse() metódust.

Az AspectJ lehetővé teszi az aspektusnak, hogy megváltoztassa az osztályok struktúráját. Az aspektus új leszármazási hierarchiát hozhat létre, új interfészek implementálását írhatja elő, új változókat és metódusokat szúrhat az osztályokba. Lássunk egy példát, ami kedvenc Hello osztályunkba egy új statikus változót szúr be és ezt a változót felhasználja a say-jel kezdődő nevű metódusok végrehajtásainak számlálására.

TT7.java:
public aspect TT7 {
  public static int Hello.callCtr = 0;
 
  pointcut p1() : execution( * *.say*( .. ) );

  before() : p1() {
    ++Hello.callCtr;
    System.out.println( "say* method were executed "+Hello.callCtr+" times" );
  }
}

Az aspektus megváltoztathatja az osztály leszármazási hierarchiáját is. Az alábbi példában Hello alkalmazásunkból appletet csinálunk olymódon, hogy a java.applet.Applet-et tesszük az ősosztályává és implementálunk egy init() metódust.

TT8.java:
public aspect TT8 {
  declare parents: Hello extends java.applet.Applet;

  public void Hello.init() {

    saySomething();
    saySomethingElse( "hello" );
  }
}


A lehetőségek érzékeltetésére az AspectJ kézikönyvből kimásoltam egy példát, amely a Point, Line és Square nevű osztályokkal implementáltatja a HasName nevű interfészt, amit ugyancsak az aspektus definiál és a metódusainak implementációit is megadja.

A.java:
public aspect A {
  private interface HasName {}
  declare parents: (Point || Line || Square) implements HasName;

  private String HasName.name;
  public  String HasName.getName()  { return name; }
}


Az AspectJ a legkiforrottabb aspektusorientált nyelv, fejlődése során jelentősen megváltozott, a kezdeti változatok biztonsági és elvi problémáit kiküszöbölték. Nem lehet azonban látni azt, hogy az AspectJ eszközkészletét elég hasznosnak találják-e a programozók valódi feladatok megoldására vagy hogy az aspektusorientált megközelítés kiállja-e az idő próbáját.

Ajánlott irodalom: