четверг, 14 июля 2011 г.

DSL для конфигурации приложения

Пару лет назад мне довелось писать приложение для учета посещений в танцевальном клубе. Бизнес-логики в нем практически никакой не было, но зато была необходимость прилично настраивать программу. В клубе -- множество групп, они разные по стоимости; оплату можно произвести за фиксированное число занятий, но этих чисел несколько (1, 4, 8, 12); разные типы скидок должны были относиться к разным по стоимости группам и т.д. Ничего сложного, просто нужно вносить много данных. Тогда возможность настройки приложения я сделал целиком через оконный интерфейс. Теперь я бы предпочел конфигурационный файл как наименее затратный вариант.
Поскольку этот конфиг очень сильно заточен на одну задачу (описание танцевального клуба), то он представляет собой классический язык предметной области (DSL). Каким образом проще всего его реализовать?
Вкратце, для тех, кто не до конца представляет собой концепцию языков предметной области, опишу подробнее. В [1] указана главная характеристика языка предметной области: его ограниченность. В отличие от языков общего назначения, DSL, как правило, содержит очень мало синтаксиса. С его помощью нельзя написать любую программу, зато можно очень легко и кратко решить задачу, для которой DSL предназначен. Так, например, в DSL может не быть даже таких простых операторов, как if, for или объявления процедур.
Там же, (в [1]), указано, что суть языка предметной области -- это его семантическая модель, т.е. набор функционала, который DSL реализует. В паттерне MVC семантическая модель занимала бы нишу модели и контроллера, а синтаксис -- представления.
Синтаксис DSL может быть реализован несколькими способами, начиная от оконного интерфейса и заканчивая прямым вызовом функционала семантической модели. Ниже я покажу внешний DSL, реализованный при помощи XML-файла, и внутренний DSL, реализованный при помощи Clojure.
Семантическая модель очень проста. Она содержит в себе классы CardType, Payment, Group и Discount. Тип карты (абонемент) представляет собой отдельную ценовую категорию, в пределах которой пользователь может посещать занятия в разных группах. Владелец более дешевой карты не может посещать занятия в группах более дорогой ценовой категории.
CardType содержит несколько видов оплаты (Payment), каждая оплата -- это объект с двумя свойствами: стоимость и количество оплаченных занятий.
В пределах этой же ценовой категории должны быть описаны группы (Group) и скидки (Discount).
Вот фрагмент класса-парсера DSL-а, реализованного в XML:

        public void startElement(String uri, String localName, String qName, Attributes atts) 
        throws SAXException {
            if (localName.equals("card-type")) {
                CardType cardType = new CardType();
                cardTypeTitle = atts.getValue(0);
                cardType.setTitle(cardTypeTitle);
            }
            
            if (localName.equals("payment")) {
                Payment payment = new Payment();
                try { 
                    payment.setCardTypeTitle(cardTypeTitle);
                } catch (Exception ex) { System.out.println(ex); }
                
                for (int i = 0; i < atts.getLength(); i++) {
                    if (atts.getQName(i).contains("cost")) {
                        int cost = Integer.parseInt(atts.getValue(i));
                        payment.setCost(cost);
                    }
                    if (atts.getQName(i).contains("number")) {
                        int number = Integer.parseInt(atts.getValue(i));
                        payment.setNumber(number);
                    }
                }
            }
            
            if (localName.equals("discount-for")) {
                Discount discount = new Discount();
                
                for (int i = 0; i < atts.getLength(); i++) {
                    if (atts.getQName(i).contains("cost")) {
                        int cost = Integer.parseInt(atts.getValue(i));
                        discount.setCost(cost);
                    }
                    if (atts.getQName(i).contains("percent")) {
                        int percent = Integer.parseInt(atts.getValue(i));
                        discount.setPercent(percent);
                    }
                    if (atts.getQName(i).contains("condition")) {
                        discount.setCondition(atts.getValue(i));
                    }
                    if (atts.getQName(i).contains("cardTypes")) {
                        String[] cardTypes = atts.getValue(i).split(" ");
                        
                        for (String ct: cardTypes) {
                            if (ct.isEmpty()) continue;
                            try {
                                discount.addToCardType(ct);
                            } catch (Exception ex) { System.out.println(ex);}                    
                        }
                    }
                    }
                }
            
            if (localName.equals("group")) {
                Group group = new Group();
                
                for (int i = 0; i < atts.getLength(); i++) {
                    if (atts.getQName(i).contains("title")) {
                        group.setTitle(atts.getValue(i));
                    }
                    if (atts.getQName(i).contains("instructor")) {
                        group.setInstructor(atts.getValue(i));
                    }
                    if (atts.getQName(i).contains("style")) {
                        group.setStyle(atts.getValue(i));
                    }
                    if (atts.getQName(i).contains("cardType")) {
                        try {
                            group.setCardTypeTitle(atts.getValue(i));
                        } catch (Exception ex) { System.out.println(ex);}                    
                    }
                }
            }
        }

Пример описания на этом DSL выглядит так:

<root>
  <card-type title="Regular">
    <payment
        cost="120000"
        number="8" />
    <payment
        cost="150000"
        number="12" />
  </card-type>

  <card-type title="Early">
    <payment
        cost="100000"
        number="8" />
  </card-type>

  <card-type title="Kid">
    <payment
        cost="80000"
        number="8" />
  </card-type>

  <group title="K9"
         cardType="Regular"
         instructor="V.Pupkin"
         style="Hip-hop"/>

  <group title="H7"
         cardType="Early" 
         instructor="I.Sidorov"
         style="Hip-hop R-n-B" />
  
  <discount-for cardTypes="Regular Early"
                percent="15"
                condition="Comes with two friends."/>

  <discount-for cardTypes="Regular Early"
                cost="20000"
                condition="Pays for three months."/>

</root>

При реализации языка программирования последовательно проходят стадии (приблизительно и неформально): парсер, построитель синтаксического дерева, семантическая модель. В данном случае, парсером был парсер XML Apache Xerses. Но обход дерева пришлось реализовывать уже на java. В данном случае, максимальный уровень вложенности структур равен двум, поэтому обход дерева здесь очень простой и негибкий. Если бы нужно было реализовать чуть более мощное средство, то абстрактное синтаксческое дерево построить было бы значительно сложнее.
Самый существенный минус внешних предметно-ориентированных языков в том, что они не могут обращаться к функциональности других языков. Даже организация элементарного цикла, или вызова процедуры с рекурсией потребует значительных затрат сил и времени. Поэтому, если представляется возможность, предпочтительнее пользоваться внутренними DSL-ами, синтаксис которых базируется на хостовом языке.
В [2] даны примеры реализации внутренних DSL-ов на разных языках (Ruby, Scals, Groovy, Clojure). Наибольшие преимущества мы можем извлечь, реализуя DSL на Clojure. Связано это с тем, что на любом лиспе исключается шаг построения синтаксического дерева. Исходник на лиспе и есть дерево разбора выражений, достаточно просто им воспользоваться. В моем DSL нет сложных конструкций, из которых были бы видны все преимущества лиспа. Но если хотите их ощутить -- просто попробуйте расширить приведенный здесь DSL хотя бы операторами if и for(;;), а также определения и вызова процедур при неограниченной языком вложенности конструкций.
Реализация DSL на Clojure выглядит так:

(ns CMDSL.core
  (:import [entities CardType Payment Discount Group])
  (:use [clojure.contrib.repl-utils :only (show)]))

;;; Utility functions ;;;

(defn capitalize [word]
  (let [big-letter (.toUpperCase (.toString (get word 0)))
        rest-word (.substring word 1)]
    (str big-letter rest-word)))

(defn make-setter [property]
  (str ".set" (capitalize property)))

(defn make-getter [property]
  (str ".get" (capitalize property)))

(defmacro props->setters [props object]
  `(do ~@(map
          (fn [k] `(~(symbol (str (make-setter (.substring (str k) 1))))
                   ~object
                   ~(k props)))
          (keys props))))

;;; DSL ;;;

(defmacro card-type [title & payments]
  `(let [cardType# (CardType.)]
     (.setTitle cardType# ~(str title))
     ~@(map (fn [payment] `(.setCardTypeTitle ~payment ~(str title))) payments)
     cardType#))

(defmacro dsl-entity [class args]
  (let [props (apply hash-map args)
         instance (gensym "instance")]
    `(let [~instance (new ~class)]
       (props->setters ~props ~instance)
       ~instance)))

(defmacro payment [& args]
  `(dsl-entity ~Payment ~args))

(defmacro discount [args]
  `(dsl-entity ~Discount ~args))

(defmacro discount-for [card-types & args]
  (let [d (gensym "discount")]
  `(let [~d (discount ~args)]
     ~@(map (fn [ct] `(.addToCardType ~d ~(str ct))) card-types)
     ~d)))

(defmacro group-m [args]
    `(dsl-entity ~Group ~args))

(defmacro group [title card-type & args]
  (if (not= (str (first card-type)) "card-type")
    nil
    (let [t (str title)
          ct (str (second card-type))
          g (gensym "group")]
      `(let [~g (group-m ~args)]
         (.setTitle ~g ~t)
         (.setCardTypeTitle ~g ~ct)
         ~g))))

Использование DSL:

(use 'CMDSL.core)

(card-type Regular
  (payment
    :cost 120000
    :number 8)
  (payment
    :cost 150000
    :number 12))

(card-type Early
  (payment
    :cost 100000
    :number 8))

(card-type Kid
  (payment
    :cost 80000
    :number 8))

(group K9 (card-type Regular)
    :instructor "V.Pupkin"
    :style "Hip-hop")

(group H7 (card-type Early)
    :instructor "I.Sidorov"
    :style "Hip-hop, R&B"
    :level "Medium")

(discount-for [Regular, Early]
    :percent 15
    :condition "Comes with two friends.")

(discount-for [Regular, Early]
    :cost 20000
    :condition "Pays for three months.")

Выводы.
DSL, реализованный на Clojure, позволяет легко конфигурировать Java-приложение. Язык предметной области реализуется очень просто, при этом позволяет пропустить шаги разбора и построения абстрактного синтаксического дерева. Полученный язык обладает преимуществами внутренних DSL, т.к. может обращаться к функционалу хостового языка. Благодаря свойствам хостового языка (Clojure), описываемый DSL легко расширять как другими доменными сущностями, так и более сложными конструкциями.

Литература:
1. Domain-Specific Languages, Martin Fowler, Addison-Wesley, 2010.
2. DSLs in Action, Debasish Ghosh, Manning, 2011.

14 комментариев:

  1. Да це ж не DSL, а просто data-driven. Хотя даже ещё проще. В каком-нибудь JavaScript или даже Python это можно было бы записать просто как непосредственные данные, без необходимости рассуждать о "абстрактных синтаксических деревьях". Просто Java и разные лиспы недостаточно выразительны для записи данных.

    ОтветитьУдалить
  2. В [1], гл.2: Domain-specific language (noun): a computer programming language of limited expressiveness focused on a particular domain.
    Ключевое слово -- "ограниченность"; DSL не должен иметь много абстрактных языковых конструкций, иначе это будет уже не DSL, а язык общего назначения. Пример реализации DSL с таким же подходом, что и мой -- в [2], стр. 149, язык реализации -- Ruby.
    Если нет возможности найти книжки, смотри фреймворк Ruby on Rails: язык определения маршрутов и язык определения миграций. Это все очень простые DSL-ы, сильно ограниченные в своих возможностях и заточенные на решение только одной задачи.

    ОтветитьУдалить
  3. Да я читал и RoR смотрел )) Только ведь есть разница между "форматом данных" и DSL, или нет? В данном случае я не вижу в данном формате никакой управляющей логики. Это типичный пример data-driven.

    ОтветитьУдалить
  4. DSL не обязательно должен содержать управляющую логику, явную или неявную. Этот пример, думаю, тебе тоже знаком? http://lispm.dyndns.org/mov/dsl-in-lisp.mov
    Концепция data-driven design и в самом деле здорово пересекается с тем, что я здесь показал. И все же это -- DSL, т.к. удовлетворяет основным критериям, выведенным М.Фаулером. Второй вариант, реализованный на Clojure, и вовсе настоящий язык программирования, т.к. в нем можно использовать языковые конструкции Clojure, в том числе и с управляющей логикой.

    ОтветитьУдалить
  5. @archimag
    Просто грань между форматом данных и DSL размыта. Нельзя провести чёткую границу. Вот например Glade (GtkBuilder). С одной стороны это просто формат описания GUI, с другой его можно рассматривать как язык предназначенный для построения GUI.

    Эта идея хорошо прослеживается в cl-gtk2.
    Там есть макрос let-ui позволяющий декларативно описывать GUI. Но с той разницей, что описание не интерпретируется, а транслируется в код. И это позволяет примешивать в описание GUI код на CL и наоборот.

    http://www.faqs.org/docs/artu/ch08s01.html

    ОтветитьУдалить
  6. @andy128k
    Хорошая, кстати, ссылка, там рисунок как раз соответствует моим представлениям. Вот XSLT или PostScript (имел с ними большой опыт работы) я понимаю DSL. А о пограничных случаях нужно говорить очень осторожно. Вот там указан regexps - да, пограничный случай, а Glade ещё более пограничный.

    Получается рассказ о преимуществах Лисп для создания DSL, которые на самом деле весьма спорные (DSL) и относятся к пограничным случаям.

    Это же просто мошенничество )) Ведь под DSL большинство понимают тот же XSLT, make, но для создания подобных DSL Лисп по своей природе никаких особых преимуществ не даёт. А большинство упоминаний DSL в контексте лисп куда более близки к понятию "формат данных" При том, что в лиспе очень неудобно записать сложные структуры данных в лоб и изобретение этих недо-DSL решает не проблему предметной области, а проблему ограниченности выразительных средств самого лиспа.

    ОтветитьУдалить
  7. Примеры, которые ты привел, относятся к external DSL, для их реализации необходимо их парсить. Внутренний DSL, реализованный на Python или JS, для сложных вложенных структур данных также потребует построения дерева и процедур его обхода. В лиспе это не нужно, т.к. деревом является сам исходник.
    Более того, если вспомнить тот же SICP (первую главу про уровни абстракции), мы можем DSL-ом назвать даже просто удачно спроектированный API безо всяких дополнительных синтаксисов. Условием для этого является возможность решения задач предметной области с применением только лишь этого API без использования конструкций из нижних уровней, например, оператора условного перехода. Например, мой API может принимать управляющие решения на основе текущей даты даже при остутствии оператора if.

    ОтветитьУдалить
  8. @archimag
    Сила подхода DSL в том что он позволяет перетянуть пограничные языки/форматы "вправо" за счёт примешивания хост-языка.

    DSL -- это **в том числе** и языки описания данных.

    Упомянутый тобой XSLT это же просто xml! :) Запишу я make-файл в синтаксисе какого-нибудь YAML али JSON и ты в нём не признаешь DSL :)

    Видишь к чему мы приходим? Грань между языком и данными размывается. А где этой грани нет вовсе? В лиспе же!

    ОтветитьУдалить
  9. > мы можем DSL-ом назвать даже просто удачно спроектированный API безо всяких дополнительных синтаксисов.

    Во-о-от! И я так считаю. Более того DSL-ем вполне можно назвать удачно написанные flet/labels/macrolet. Особенно если они позволяют придать коду декларативный вид.

    ОтветитьУдалить
  10. > мы можем DSL-ом назвать даже просто удачно
    > спроектированный API

    Вот это я считаю маразмом, полностью компрометирующим понятие DSL.

    ОтветитьУдалить
  11. Твое личное мнение -- не самый весомый аргумент. Посмотри, например, сюда: http://mitpress.mit.edu/sicp/full-text/book/book-Z-H-16.html#%_sec_2.3.2 . Здесь разрабатывается математический язык для символоного дифференцирования. Это самый настоящий DSL, предназначенный только для одной задачи. О нем в главе и говорят, как о расширении языка для дифференцирования выражений. С другой стороны, это просто набор функций -- чистый API.
    То, что подразумеваешь под DSL-ом ты -- это одно из его подмножеств, external DSL. На лиспе же, наоборот, как правило пишут internal DSL-ы, для этого он и предназначен. Многие вообще предлагают использовать лисп как метаязык, фабрику DSL-ов. Ну а любой внутренний DSL на любом языке -- это просто сфокусированный на задаче API. Пруфлинк: [1]. Там же есть и каталог паттернов, помогающих реализовать внутренний ДСЛ на мейнстримных языках как простой API.

    ОтветитьУдалить
  12. > Твое личное мнение -- не самый весомый аргумент.

    Это (моё личное мнение) вообще не аргумент, но мне казалось, что функция комментариев предназначена именно для того, что бы его высказывать.

    ОтветитьУдалить
  13. Ну и ок :-)
    Кстати, мне очень нравится то, что ты делаешь для лиспа. Нас, лисперов, вообще мало, так что закончим этот спор и пойдем двигать технологию :-)

    ОтветитьУдалить