Czerwiec
07
2010

Aspektowe warunkowanie algorytmu

Słowa kluczowe: , , , , , | Kategorie: Java
No Gravatar

W programach, z których korzysta wielu różnych użytkowników, moduł ustawień potrafi być bardziej skomplikowany niż sama aplikacja. Większość opcji przyjmuje postać wartości logicznych. W naszym zespole powstał nawet termin Single Checkbox Requirement (albo Single Checkbox Apocalypse), oznaczający funkcję programu, która może być włączona lub wyłączona i wpływa na wiele różnych elementów systemu (np. powiadomienia email).

Wprowadzenie jednego SCR do projektu generuje kilka(naście/dziesiąt) dodatkowych instrukcji warunkowych. Większa ilość takich wymagań powoduje, że kod źródłowy staje się całkowicie nieczytelny (niezależnie od zastosowanych zabiegów poprawiających jego jakość) – zwłaszcza, gdy pojawiają się one po pół roku (sic!) pisania aplikacji.

Programowanie aspektowe pozwoliło mi rozwiązać opisany problem szybko i w elegancki sposób.

W przykładzie wykorzystam plik pom.xml opracowany w jednym z wcześniejszych wpisów. Struktura projektu będzie wyglądać następująco:

Adnotacja @Conditional

Aby w prosty sposób włączać i wyłączać wykonywanie określonych metod, oznaczę je adnotacją @Conditional:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface Conditional {
    String value();
}

Wartością atrybutu value będzie nazwa parametru warunkującego wykonanie danej metody.

Interfejs Calculation

Aby ułatwić sobie zadanie, do testów wykorzystam dwie implementacje prostego algorytmu:

interface Calculation {
    double calculate();
}

Metoda calculate będzie wykonywać pewne operacje arytmetyczne, po czym zwróci ich wynik.

Pełna implementacja

Najpierw utworzę klasę FullCalculation, której wszystkie metody będą zawsze wykonywane:

class FullCalculation implements Calculation {

    public double calculate() {
        initValue();
        multiply();
        divide();
        add();
        subtract();
        return value;
    }

    private void initValue() {
        value = 1.0;
    }

    private void multiply() {
        value *= 2.0;
    }

    private void divide() {
        value /= 2.0;
    }

    private void add() {
        value += 1.0;
    }

    private void subtract() {
        value -= 1.0;
    }

    private double value;
}

Algorytm składa się z kilku atomowych operacji:

  • ustawienie wartości na 1,
  • pomnożenie wartości przez 2,
  • podzielenie wartości przez 2,
  • dodanie 1 do wartości,
  • odjęcie 1 od wartości,
  • zwrócenie wartości.

Jeżeli żaden etap obliczeń nie zostanie pominięty, wynik powinien wynosić 1.

Implementacja warunkowana

Klasa ConditionalCalculation jest niemalże dokładną kopią pełnej implementacji. Tym razem metody multiply, divide, addsubtract oznaczone są adnotacją @Conditional z odpowiednimi parametrami:

class ConditionalCalculation implements Calculation {

    public double calculate() {
        initValue();
        multiply();
        divide();
        add();
        subtract();
        return value;
    }

    private void initValue() {
        value = 1.0;
    }

    @Conditional("MULTIPLY")
    private void multiply() {
        value *= 2.0;
    }

    @Conditional("DIVIDE")
    private void divide() {
        value /= 2.0;
    }

    @Conditional("ADD")
    private void add() {
        value += 1.0;
    }

    @Conditional("SUBTRACT")
    private void subtract() {
        value -= 1.0;
    }

    private double value;
}

Parametry warunkujące

Działanie przykładowego aspektu oprę na ustawieniach pobieranych z pliku parameters.properties. W typowej aplikacji będą to raczej wielkości pobierane z bazy danych.

Spośród obliczeń warunkowych włączę tylko mnożenie i dodawanie. Teraz rezultat powinien wynieść 3:

# ConditionalCalculation properties:
MULTIPLY=true
DIVIDE=false
ADD=true
SUBTRACT=false

Testy

Metody testowe dla obu algorytmów są raczej oczywiste:

public class CalculationTests {

    @Test
    public void testCalculateFull() {
        Calculation calculation = new FullCalculation();

        assertEquals(calculation.calculate(), 1.0,
                "Full calculation value");
    }

    @Test
    public void testCalculateParameterized() {
        Calculation calculation = new ConditionalCalculation();

        assertEquals(calculation.calculate(), 3.0,
                "Conditional calculation value");
    }
}

Konfiguracja testów

Aby testy się wykonywały, muszę jeszcze utworzyć plik testng.xml, wskazujący właściwy pakiet:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd">
<suite name="AspectJ" verbose="2">
  <test name="Conditional">
    <packages>
      <package name="pl.info.czerwinski.conditional"/>
    </packages>
  </test>
</suite>

W tej chwili wywołanie mvn test powinno zakończyć się jedną porażką przy dwóch wykonanych testach. Nieprawidłowy rezultat wystąpi w metodzie testCalculateParameterized, gdzie zamiast oczekiwanej wartości 3.0 pojawi się 1.0.

Aspekt warunkujący

Pora przejść do najważniejszego elementu całego przykładu, czyli do aspektu. Pojawia się tu kilka nowości:

@Aspect
public class ConditioningAspect {

    @Around("call(@Conditional * *.*(..)) && @annotation(conditional)")
    public Object condition(
            ProceedingJoinPoint joinPoint,
            Conditional conditional) throws Throwable {
        String parameterName = conditional.value();
        if (Boolean.parseBoolean(parameters.getString(parameterName))) {
            return joinPoint.proceed();
        }
        return null;
    }

    private ResourceBundle parameters =
        ResourceBundle.getBundle("parameters");
}

Poprzednio omówiłem adnotacje @Before oraz @After, wskazujące rady przedpo. Adnotacja @Around służy to utworzenia rady „otaczającej” punkt przecięcia, czyli takiej, która kontroluje dalsze wykonanie programu. Standardowo metoda taka pobiera parametr klasy ProceedingJoinPoint i zwraca dowolny obiekt. Aby rada pozostała bez wpływu na wykonanie programu, powinna składać się z jednej instrukcji:

return joinPoint.proceed();

Innymi słowy, brak tej instrukcji oznacza, że metoda, której rada dotyczy, nie zostanie w ogóle wykonana.

Definicja punktu przecięcia w przykładzie jest dość złożona:

call(@Conditional * *.*(..)) && @annotation(conditional)

Powyższy zapis oznacza wywołanie dowolnej metody oznaczonej adnotacją @Conditional. Rada przyjmuje tę adnotację jako parametr conditional, dzięki czemu można uzależnić dalsze działanie programu od atrybutu value.

Po dodaniu aspektu, wszystkie testy powinny zakończyć się sukcesem.

Podsumowanie

Opisane rozwiązanie daje możliwość szybkiego i łatwego warunkowania kolejnych metod, z zachowaniem czytelności kodu.

Przypuszczam, że do zaproponowanego przeze mnie aspektu można wprowadzić dalsze poprawki (czekam na konstruktywne komentarze). Mam tylko nadzieję, że duża ilość kodu nie zmniejszy czytelności niniejszego wpisu.

  1. 1 Trackback(s)

  2. Pedro Newsletter 07.06.2010 « Pragmatic Programmer Issues – pietrowski.info

Napisz Komentarz

*