Kategorie
coding

Tworzenie własnego DSL

Właściwie po co nam DSL?

Czasami stajemy przed sytuacjami, gdy chcemy zbliżyć się do języka biznesowego, a odejść nieco od języka czysto technicznego. Powodów może być wiele, np:

  • język domenowy jest powszechnie używany i akceptowany w danym biznesie,
  • zamodelowane procesy biznesowe będą lepsze,
  • konfiguracja systemu stanie się łatwiejsza dla osób nietechnicznych,
  • dodawane funkcje do systemu będą bardziej precyzyjne.

W takich właśnie momentach przydaje się DSL, czyli domain specific language. Mógłbym podejmować próby dokładniejszego wytłumaczenia, ale na nasze potrzeby wystarczy rozumieć to jako warstwę abstrakcji jaką nakładamy na pewien fragment rozwiązywanego problemu w naszym systemie. Z dokładniejszym wyjaśnieniem przychodzi Martin Fowler w tym opisie.

Nakreślenie problemu

Ostatnio stanąłem przed podobnym problemem, gdzie miałem stworzyć reguły wybierania obiektów jakie system będzie dopuszczał w głąb siebie. Gdyby ów mechanizm zawierał reguły wybierania w liczbie równej jeden, można by było nie zaprzątać sobie tym głowy i wpisać logikę na sztywno. Niemniej jeśli reguł ma przybywać i większość z nich ma być tworzona przez ludzi mniej zorientowanych technicznie, a bardziej zorientowanych biznesowo, wymyślny mechanizm może jednak przynieść pewne korzyści. Szybko pojawiło się zatem założenie, aby reguły były podobne składniowo do lubianego przez wszystkich SQL (takowe lubowanie występuje bardzo lokalnie dla niektórych osób z mojego otoczenia 😉 ).

Poniżej przykład prostej reguły wyboru, która miałaby określić czy dany obiekt jest akceptowany:

entity.name equal 'Mario' and entity.age above 42 or entity.bestFriend in ('Marco', 'Peach')

Powyższa reguła z mniejszym lub większym powodzeniem ukrywa niepotrzebny techniczny szum i próbuje też nadać nieco przyjemniejszego odbioru dla osoby, która będzie musiała nad tym pracować dzień w dzień.

W tym miejscu od razu pierwszą myślą, jaka może się pojawić jest proste podejście bazujące na dzieleniu tekstu używając np. spacji. O ile użycie java.util.Scanner jest wygodne w prostych przypadkach, to tutaj pojawi się bardzo szybko parę przypadków brzegowych, w tym inne puste znaki niż spacja czy też zagnieżdżone warunki logiczne. W powyższym przykładzie tego do końca nie widać, ale prawdopodobieństwo jest wysokie, że drodze ewolucji takowe pojawią się prędzej, niż później.

((( entity.name equal 'Mario' or entity.name equal 'Luigi' ) and entity.age above 42 ) or entity.bestFriend in ('Marco', 'Peach'))
Bardziej wymyślny sposób

Drugi przykład pokazuje jak szybko poziom skomplikowania może wzrosnąć i często proste podejście może okazać się ślepym zaułkiem. Wtedy lepszym wyjściem może okazać się zastosowanie narzędzi ze świata kompilatorów oraz interpreterów, a w szczególności myślę tutaj o lexerze oraz parserze. Aby tak zrobić, musimy spojrzeć na problem w nieco inny sposób, a mianowicie, jak na zestaw uogólnień, które opisują reguły w coraz bardziej uniwersalny sposób (poniżej przykład jak taka przemiana może wyglądać):

((( entity.name equal 'Mario' or entity.name equal 'Luigi' ) and entity.age above 42 ) or entity.bestFriend in ('Marco', 'Peach'))

Powyższą regułę można nieco uprościć, gdy przyjmiemy, że każde porównanie (equal, above, …) to operator binarny pomiędzy zmienną var i wartością val. Po drobnej modyfikacji przybiera to postać:

➥ ((( <var> <operator> <val> or <var> <operator> <val> ) and <var> <operator> <val> ) or <var> <operator> ( <val>, <val> ))

Idąc dalej, każdy zbiór var / operator / val może być określony jako formuła expression (z drobną wariacją dla operatora in) otrzymujemy:

➥ ((( <expression> or <expression> ) and <expression> ) or <expression> ))

A gdy przyjmiemy, że formuła expression oprócz powyżej podanego pierwszego przykładu, może być także pomiędzy dwoma wyrażeniami expression z operatorem logicznym (and, or, …) to dochodzimy do kolejnych uproszczeń:

➥ (( <expression> and <expression> ) or <expression> ))

➥ ( <expression> or <expression> )

➥ <expression>

Posiadając powyższy zarys, takie podejście zapewni dużo większą elastyczność długofalowo niż chałupniczo wykonane rozwiązanie, i o ile na potrzeby domowego kodowania raczej nie bierzemy pod uwagę tego aspektu, to tworząc rozwiązania dla szeroko pojętego biznesu, jest to bardzo ważny czynnik.

Lexer jest to narzędzie dokonujące analizy leksykalnej wczytanych uprzednio danych w formie np. tekstu, dzieląc je następnie na tokeny, by na wyjściu zwrócić strumień tokenów (często spotykany pod właśnie nazwą token stream). Parser natomiast (działając w parze z lexerem), dokonuje analizy składniowej wcześniej wytworzonego strumienia tokenów. Dopasowując frazy zdefiniowane w gramatyce parsera tworzy drzewo składniowe (AST). AST natomiast, możemy już w bardziej zorganizowany sposób trawersować i wykorzystać jako faktyczną strukturę wejściową do naszego programu. Choć te kilka zdań w żaden sposób nie wyjaśnia skomplikowania tego zagadnienia, zawiera słowa-klucze, które pozwolą na dalsze zgłębienie tematu.

Teoria brzmi naturalnie intrygująco, ale praktyczny aspekt jest w tym względzie dużo istotniejszy. Nikt nie będzie (przynajmniej na początku) porywał się na tworzenie swojego lexera oraz parsera od zera, bo w 95% przypadków narzędzia i generatory już dostępne są wystarczające, a ponadto jeśli wybierzemy narzędzie szeroko używane przez społeczność programistów, nie będzie ono zawierać błędów, które my prawdopodobnie byśmy jednak popełnili. Jeśli należysz do 5% osób, które zajmują się powyższymi zagadnieniami na co dzień i tworzą wyjątkowo zoptymalizowane parsery, być może również przychylisz się do tej uwagi 😉

Jednym z takich narzędzi jest ANTLR, generator parserów, który pozwala na zdefiniowanie zarówno formuł leksykalnych jak i składniowych, a dodatkowo wygeneruje bazowe komponenty, które możesz wykorzystać do przejścia po wygenerowanym drzewie AST.

Tworzenie gramatyki dla ANTLR

Aby rozwiązać zadanie postawione powyżej najpierw musi powstać gramatyka języka, który opisuje owe reguły wyboru. Ta powstaje u podstawy z małych elementów, które później formują coraz większe, aż do momentu gdy każda reguła jest możliwa do opisania za pomocą tej gramatyki (wedle standardu wymaganego przez ANTLR).

Ponieważ reguły w dużej mierze opisywane są w alfabecie łacińskim, gramatyka definiuje poszczególne litery bez rozróżnienia ich wielkości. Jeśli byłoby to istotne dla składni, mogłaby, ale w naszym przykładzie tak nie jest. Definiuje także ogólne zbiory, które pozwolą na bardziej uniwersalne reguły.

fragment SAFE_SPECIAL_CHARACTER : ( '.' | '_' | '-' | '|' | ',' ) ;
fragment LETTER: [A-Za-z] ;
fragment DIGIT: [0-9] ;

fragment A: [aA] ;
fragment B: [bB] ;
fragment C: [cC] ;
...

Ponieważ znaki białe nie mają zbyt dużego znaczenia dla reguł, nie powinny mieć wpływu na gramatykę. ANTLR pozwala ignorować znaki, których nie potrzebujemy.

WHITESPACE: ( ' ' | '\t' | '\r' | '\n' )+ -> skip ;
...

I od mniejszych elementów, przechodzimy do coraz bardziej złożonych elementów leksykalnych, np. aby gramatyka definiowała operator binarny, który wyodrębniliśmy wcześniej ale w taki sposób aby reprezentował zarówno operator equal jak i after itd.

BINARY_OPERATOR
    : EQUAL_OPERATOR
    | DIFFERENT_OPERATOR
    | AFTER_OPERATOR
    | BEFORE_OPERATOR
    ;
EQUAL_OPERATOR: E Q U A L ;
DIFFERENT_OPERATOR: D I F F E R E N T ;
AFTER_OPERATOR: A F T E R ;
BEFORE_OPERATOR: B E F O R E ;
...

Podobnie definiujemy operatory logiczne:

NOT_OPERATOR: N O T ;
AND_OPERATOR: A N D ;
OR_OPERATOR: O R ;
IN_OPERATOR: I N ;
...

Oraz reprezentacje, z jakich znaków dane reprezentacje val oraz var mogą korzystać. Warto zaznaczyć, że znak + w poniższej gramatyce podobnie jak w wyrażeniach regularnych oznacza 1..N wystąpień, * natomiast, 0..N wystąpień.

VALUE
    : NULL_VALUE
    | DIGIT+
    | '\'' ALLOWED_VALUE_CHARACTER* '\''
    ;
VARIABLE
    : LETTER ALLOWED_VARIABLE_CHARACTER*
    ;
NULL_VALUE: N U L L ;
ALLOWED_VARIABLE_CHARACTER : LETTER | DIGIT | SAFE_SPECIAL_CHARACTER ;
ALLOWED_VALUE_CHARACTER : LETTER | DIGIT | SAFE_SPECIAL_CHARACTER ;
...

W tym miejscu stworzyliśmy formuły leksykalne dla gramatyki (zaczynające się z dużą literą). W podobny sposób tworzy się formuły składniowe dla parsera (zaczynające się z małą literą), z czego na uwagę zasługuje zdefiniowana wyżej formuła expression, pozwalająca na zagnieżdżenia w nawiasach oraz użycie w połączeniu z operatorem logicznym:

expression : NOT_OPERATOR expression
    | expression AND_OPERATOR expression
    | expression OR_OPERATOR expression
    | '(' expression ')'
    | single_condition
    ;
...

Cała gramatyka, z nieco szerszymi możliwościami, na podstawie której ANTLR wygeneruje zarówno nasz lexer i parser wygląda tak: https://bitbucket.org/PawelRebisz/dsl-with-antlr/src/master/src/main/resources/DslGrammar.g4

ANTLR w akcji

Mając gotową gramatykę opisującą reguły wyboru, możemy wygenerować klasy pomocnicze pochodzące z ANTLR, w tym interfejs DslGrammarBaseListener do trawersowania drzewa AST. W prezentowanym rozwiązaniu jest to zrobione poprzez TokenToConditionListener. Listener posiada hooki, pozwalające na obsługę danego fragmentu drzewa po wejściu do niego lub przed jego opuszczeniem. Większość obsługi w pokazanym rozwiązaniu bazuje na drugiej opcji.

Poniżej, przykład jak obsługiwany jest element single_condition opisany następująco w gramatyce:

variable ( BINARY_OPERATOR VALUE | IN_OPERATOR '(' values_in_brackets* ')' )
@Override
public void exitSingle_condition(DslGrammarParser.Single_conditionContext context) {
    super.exitSingle_condition(context);

    process(context);
}
...
private void process(DslGrammarParser.Single_conditionContext context) {
    recordErrorsWithContext(context.getText(), () -> {
        String path = context.children.get(0).getText();
        String operator = context.children.get(1).getText().toLowerCase().trim();

        if (Objects.equals(operator, "in")) {
            Condition<T> condition = conditionFactory
                    .aggregate(operator, createEqualConditionFromStoredValues(path));
            storedConditions.push(condition);
        } else {
            String value = context.children.get(2).getText();
            Condition<T> condition = conditionFactory.typedCondition(outputType, path, operator, value);
            storedConditions.push(condition);
        }
    });
}

Aby móc otrzymać dostęp do składowych elementów zdefiniowanych w gramatyce, korzystamy z przekazywanego kontekstu, a dokładniej context.children. Na pierwszej pozycji tej kolekcji znajduje się element variable, na drugim jeden z dwóch możliwych operatorów, na trzecim pojedyńcza wartość lub wartości w nawiasach. Mając te informacje, jesteśmy w stanie poprawnie obsłużyć wsparcie dla przewidzianych operatorów.

Istotnym aspektem jest również zagnieżdżenie reguł. Wychodząc z danego fragmentu drzewa AST, może się okazać, że jest to formuła expression, która zakłada (prawie dla wszystkich przypadków) użycie warunku logicznego pomiędzy regułami znajdującymi się wewnątrz niej. Aby osiągnąć założony efekt, reguły wewnętrzne muszą zostać stworzone i zapisane do czasu ich skomponowania. Można to zaobserwować przy operacji storedConditions.push(condition). Zapamiętane w ten sposób reguły mogą byc następnie użyte do zagregowania przy obsłudze jednego z typów elementu expression.

...
Condition<T> childOneCondition = storedConditions.pop();
String operator = filteredChildren.get(1)
    .getText()
    .toLowerCase().trim();
Condition<T> childTwoCondition = storedConditions.pop();
Condition<T> aggregatedCondition = conditionFactory
    .aggregate(operator, Arrays.asList(childOneCondition, childTwoCondition));
storedConditions.push(aggregatedCondition);
...

Powyższe fragmenty kodu pokazują podstawową funkcjonalność aplikacji, regułę wyboru, zamodelowaną jako Condition.

public interface Condition {
    boolean evaluate(T object);
} 

Implementacje tego interfejsu stanowią reprezentację zarówno formuły single_condition jak i expression. Pełny zbiór klas użytych w rozwiązaniu wyglada następująco i nie jest on przesadnie skomplikowany.

Przykładowo warunek or zawiera w sobie listę warunków i oczekuje, że chociaż jeden z nich będzie spełniony.

public class OrCondition<T> implements Condition<T> {

    private final List<Condition<T>> conditions;

    @Override
    public boolean evaluate(T object) {
        return conditions
                .stream()
                .anyMatch(condition -> condition.evaluate(object));
    }
}

Klasa PathDependentValueRetriever pozwala na wyciągnięcie poszczególnych pól z obiektów, gdzie mając np. entity.bestFriend oczekujemy wartości, jaka kryje się pod polem bestFriend. Sercem rozwiązania natomiast jest implementacja interfejsu Compiler, czyli klasa ConditionFactory, odpowiadająca za inicjalizację wszystkich zależności.

...
DslGrammarLexer lexer = new DslGrammarLexer(CharStreams.fromString(expressionToCompile));
CommonTokenStream tokenStream = new CommonTokenStream(lexer);
DslGrammarParser parser = new DslGrammarParser(tokenStream);

ParseTreeWalker walker = new ParseTreeWalker();
TokenToConditionListener<T> listener = new TokenToConditionListener<>(outputType, this);
walker.walk(listener, parser.root());
...

Jest również wstrzykiwana do wspomnianego wcześniej listenera przechodzącego po drzewie AST i to dzięki niej otrzymujemy poszczególne implementacje reguł. Tak jest w przypadku operatorów logicznych:

public <T> Condition<T> aggregate(String operator, List<Condition<T>> conditions) {
    switch (operator) {
        case "and":
            return new AndCondition<>(conditions);
        case "or":
            return new OrCondition<>(conditions);
        ...
    }
}

czy też operatorów binarnych:

private static <T> SingleConditionFactory<T> conditionFactoryFor(String operator) {
    switch (operator) {
        case "equal":
            return (accessor, referenceValue, comparableValue) ->
                    new ComparingCondition<>(EnumSet.of(EQUAL), accessor, comparableValue);
        case "different":
            return (accessor, referenceValue, comparableValue) ->
                    new ComparingCondition<>(EnumSet.of(LOWER, HIGHER), accessor, comparableValue);
        ...
    }
}

W ten sposób otrzymujemy rozwiązanie, które spełnia początkowe założenia stworzenia narzędzia do wyboru obiektów o pewnych właściwościach, ale jest również elastyczne, bo wraz ze wzrostem skomplikowania reguł wyboru, fundamenty rozwiązania się nie zmieniają. Rozszerzana jest zaledwie gramatyka.

Słowo na koniec

Powyższy przykład pokazuje skrawek potężnych możliwości oferowanych przez bibliotekę ANTLR i absolutnie nie wyczerpuje jej zastosowań. Zdaje sobie też sprawę iż tworzenie nieskomplikowanego narzędzia do przeglądania wartości pól nie jest najlepszym przykładem DSLa. Nie można jednak zaprzeczyć, że proces tworzenia funkcji systemu opartej o język domenowy nie nastrzęcza dużego problemu, a przy użyciu odpowiednich narzędzi, składnia może przybrać formę dokładnie taką jaką założy twórca.

Natomiast, aby podsunąć też inne zastosowanie, jeśli korzystasz z bibliotek do testowania opartych o Gherkina i nie byłeś oszołomiony możliwościami ze względu na brak elastyczności albo wsparcia dla fraz jakie chciałeś stosować, to masz pewną alternatywę, która być może pozwoli osiągnąć Twoje założenia ( przy odpowiedniej motywacji 😉 ).

Cały projekt, który posłużył jako baza do tego wpisu możesz znaleźć tutaj: https://bitbucket.org/PawelRebisz/dsl-with-antlr/src/master.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *