DSL najłatwiejszy do parsowania

Tak jak zapowiadałem, jako że “środowisko uruchomieniowe” DSLExecutora (wykonywanie wyrażeń) jest już zaimplementowane, od dziś skupiam się na funkcjonalności będącej bliżej użytkownika końcowego. Mowa o parsowaniu kodu DSL podanego przez użytkownika do postaci gotowej do wykonania. Na początek zdefiniuję najprostszy DSL i stworzę jego parser. Do dzieła!

User Story

Zanim jednak zacznę pracę nad parserem, ustalmy sobie prosty przykład użycia DSLExecutora, który w niniejszym poście (i kilku kolejnych) będę chciał doprowadzić do działania. Załóżmy, że tworzymy program, który pozwoli użytkownikowi:

  • wykonywać operacje matematyczne podawane w formie tekstowej,
  • logować komunikaty.
Zestaw funkcji

Logowanie komunikatów realizować będzie funkcja log:

class LogFunction : IFunction<Void>
{
  string Text { get; set; }
}

, a dostępnymi operacjami matematycznymi będą dodawanie (add) i odejmowanie (sub):

class AddFunction : IFunction<int>
{
  int A { get; set; }
  int B { get; set; }
}

class SubFunction : IFunction<int>
{
  int A { get; set; }
  int B { get; set; }
}
Konkretny przypadek użycia

Idziemy dalej. Załóżmy, że użytownik chce wykonać następujące operacje:

  1. Zalogować komunikat Calculating....
  2. Obliczyć wartość wyrażenia 1 + 2 - 3.

W pseudokodzie (a być może w przyszłym DSLu) zapis tych operacji mógłby wyglądać tak:

log("Calculating...")
sub(add(1, 2), 3)

Zauważmy, że – posługując się pojęciami należącymi do dziedziny DSLExecutora – zapis ten reprezentuje następujące wyrażenie:

DSLExecutor_use_case

Jak widać, zadane operacje da się w intuicyjny sposób przedstawić jako obiekt. W tym przypadku jest to obiekt BatchExpression, a w przypadku ogólnym – IExpression. Zatem sygnatura metody parsującej będzie wyglądać tak:

IExpression Parse(string dsl)

Ok, skoro mamy dobrze określony docelowy przypadek użycia, mogę przejść do zdefiniowania DSLa. A skoro parsujemy tekst na pojedynczy obiekt, to jako DSL najprościej będzie wybrać…

JSON jako wykonywalny DSL

Tak jest, zróbmy z notacji JSON wykonywalny, imperatywny język programowania! Miło by było, gdyby zadane wyrażenie udało się zapisać mniej więcej w taki sposób:

{ // Batch
  "SideExpressions": [
    { // Function
      "FunctionType": "LogFunction",
      "ArgumentExpressions": {
        "Text": { "Value": "Calculating..." } // Constant
      }
    }
  ],
  "ResultExpression": { // Function
    "FunctionType": "SubFunction",
    "ArgumentExpressions": {
      "A": { // Function
        "FunctionType": "AddFunction",
        "ArgumentExpressions": {
          "A": { "Value": 1 }, // Constant
          "B": { "Value": 2 } // Constant
        }
      },
      "B": { "Value": 3 } // Constant
    }
  }
}

W celu implementacji takiego parsera wyrażeń postanowiłem użyć najpopularniejszego .NET-owego deserializatora JSON, czyli Json.NET. Od razu jednak pojawił się oczywisty problem: skoro decelowy typ to IExpression, to skąd deserializator ma wiedzieć, do jakiego typu konkretnego ma deserializować? Problem dotyczy nie tylko obiektu będącego korzeniem zapisu, ale i wszystkich wyrażeń w nim zawartych – właściwości SideExpressions i ArgumentExpressions to przecież kolekcje IExpression.

Json.NET pozwala obejść ten problem poprzez podanie opcji TypeNameHandling z wartością All. Deserializator zainicjalizowany tą opcją zakłada, że każdy obiekt w zapisie ma podany swój typ w polu $type. Metoda parsująca DSL wygląda więc tak:

IExpression Parse(string dsl)
{
  var settings = new JsonSerializerSettings
    {
      TypeNameHandling = TypeNameHandling.All
    };
  
  return JsonConvert
           .DeserializeObject<IExpression>(dsl,
                                           settings);
}

Deserializacja działa, ale… wymóg podawania pola $type w obiektach sprawia, że wejściowy JSON jest nieczytelny i obleśny. Przykład dla wyrażenia reprezentującego wartość stałą 3:

{
  $type: "Manisero.DSLExecutor.Domain.ExpressionsDomain.ConstantExpression`1[[System.Int32, mscorlib]], Manisero.DSLExecutor",
  Value: 3
}

Zapisu dla wyrażenia podanego w przypadku użycia nie ośmielę się tu wkleić – można go obejrzeć tutaj.

Cóż, zapowiadało się obiecująco, a wyszło jak zawsze. Pożądany zwięzły zapis mógłbym osiągnąć, ale nie obeszłoby się bez implementacji wstępnego przetwarzania JSONa lub poszukiwania deserializatora, który jest w stanie zgadnąć typ obiektu na podstawie wartości jego właściwości. Ostatecznie mógłbym spróbować wykorzystać XML zamiast JSONa – tam typ obiektu (chciaż nie typ generyczny, jak ConstantExpression<int>) podaje się przecież od zawsze. Ale to wszystko byłoby niezgodne z pierwotnym założeniem – implementacja miała być jak najprostsza i jak najszybsza. A taka właśnie jest implementacja parsowania obleśnego JSONa.

Czy osiągnięte rozwiązanie leci zatem do kosza? Cóż, na pewno nie jest to DSL, który użytkownik chciałby wpisywać ręcznie. Ale gdyby mógł użyć graficznego edytora wyrażeń, którego wynikiem byłby ten obleśny JSON (automatycznie przekazywany DSLExecutorowi, a nie pokazywany użytkownikowi) – czemu nie? (Pod warunkiem, że sam rozmiar zapisu nie stanowiłby problemu.)

Ale dość o JSONie i dość prób wciskania Czytelnikowi czegoś, co nie jest pełnoprawnym wygodnym językiem. Obiecuję, że w kolejnych postach zajmę się już implementacją parsera z prawdziwego zdarzenia. Zapraszam!