28,79
рейтинг
3 февраля 2010 в 10:31

Разное → Элементы DSL на PHP: как сделать библиотечные API удобнее в использовании

При разработке нашего внутреннего фреймворка (к сожалению, PHP вообще очень способствует постоянному переизобретению велосипеда), мы старались таким образом проектировать интерфейсы библиотечных модулей, чтобы клиентский код, использующий эти интерфейсы, получался простым, лаконичным и читаемым.

В идеале специализированный модуль, предназначенный для решения той или иной задачи, должен формировать некий упрощенный язык, позволяющий разработчику описывать решение или результат решения задачи максимально близко к терминам предметной области. В случае, если при этом мы не выходим за рамки используемого языка программирования, речь идет о реализации так называемого internal DSL.



Про реализацию DSL на различных языках написано очень много, например, на сайте Фаулера доступен каталог паттернов на эту тему. Особенности любого паттерна проектирования в значительной степени определяются языком реализации, для DSL-паттернов это верно вдвойне. К сожалению, диапазон возможностей, которые может предоставить PHP, крайне ограничен. Тем не менее, используя два типовых шаблона — Method Chaining и Expression Builder, можно добиться более удобного и читаемого API.

Правильное именование классов и методов — половина дела при разработке API в стиле DSL. Важно, чтобы методы именовались максимально близко к предметной области, а не к программной реализации. Звучит банально, но можно найти массу примеров, когда именование обусловлено, например, реализацией того или иного классического паттерна проектирования из GoF.

Использование цепочек вызовов (method chaining) делает код более лаконичным и в ряде случаев позволяет добиться эффекта специализированного DSL. При разработке библиотечных модулей мы стараемся следовать правилу: если метод не возвращает функционально необходимый результат, пусть вернет $this. Также обычно мы обеспечиваем набор методов для установки внутренних свойств объекта, что позволяет осуществлять настройку параметров объекта внутри выражения и также делает код более лаконичным.

Паттерн Builder позволяет более удобно строить системы вложенных объектов, когда родительский объект содержит ссылки на дочерние, те, в свою очередь, на свои дочерние и так далее. Отметим, что в PHP желательно избегать двунаправленных ссылок (родительский объект ссылается на дочерний, а дочерний на родительский), так как сборщик мусора не работает с циклическими ссылками.

Для создания таких систем создадим очень простой базовый класс:

  1. <?php
  2. class DSL_Builder {
  3.  
  4.   protected $parent;
  5.   protected $object;
  6.  
  7.   public function __construct($parent, $object) {
  8.     $this->parent = $parent;
  9.     $this->object = $object;
  10.   }
  11.  
  12.   public function __get($property) {
  13.     switch ($property) {
  14.       case 'end':
  15.         return $this->parent ? $this->parent : $this->object;
  16.       case 'object':
  17.         return $this->$property;
  18.       default:
  19.         throw new Core_MissingPropertyException($property);
  20.     }
  21.   }
  22.  
  23.   public function __set($property, $value) { throw new Core_ReadOnlyObjectException($this); }
  24.  
  25.   public function __isset($property) {
  26.     switch ($property) {
  27.       case 'object':
  28.         return isset($this->$property);
  29.       default:
  30.         return false;
  31.     }
  32.   }
  33.  
  34.   public function __unset($property) { throw new Core_ReadOnlyObjectException($this); }
  35.  
  36.   public function __call($method, $args) {
  37.     method_exists($this->object, $method) ?
  38.       call_user_func_array(array($this->object, $method), $args) :
  39.       $this->object->$method = $args[ 0];
  40.     return $this;
  41.   }
  42. }
  43. ?>


Объекты этого класса выполняют настройку целевого объекта, ссылка на который хранится в поле $object, делегируя ему вызов методов и установку свойств. Разумеется, объект-builder может определять и набор собственных методов для более сложной настройки целевого объекта. При этом псевдосвойство end позволяет вернуться к построителю родительского объекта и так далее.

Напишем на основе этого класса простейший DSL для описания конфигурации приложения.

  1. <?php
  2. class Config_DSL_Builder extends DSL_Builder {
  3.  
  4.   public function __construct(Config_DSL_Builder $parent = null, stdClass $object = null) {
  5.     parent::__construct($parent, Core::if_null($object, new stdClass()));
  6.   }
  7.  
  8.   public function load($file) {
  9.     ob_start();
  10.     include($file);
  11.     ob_end_clean();
  12.     return $this;
  13.   }
  14.  
  15.   public function begin($name) {
  16.     return new Config_DSL_Builder($this, $this->object->$name = new stdClass());
  17.   }
  18.  
  19.   public function __get($property) {
  20.     return (strpos($property, 'begin_') ===  0) ?
  21.       $this->begin(substr($property, 6)) :
  22.       parent::__get($property);
  23.   }
  24.  
  25.   public function __call($method, $args) {
  26.     $this->object->$method = $args[ 0];
  27.     return $this;
  28.   }
  29. }
  30. ?>


Теперь мы можем создать файл config.php в котором описать конфигурацию нашего приложения в таком виде:

  1. <?php
  2. $this->
  3.   begin_db->
  4.     dsn('mysql://user:password@localhost/db')->
  5.   end->
  6.   begin_cache->
  7.     dsn('dummy://')->
  8.     default_timeout(300)->
  9.     timeouts(array(
  10.       'front/index' => 300,
  11.       'news/most_popular' => 300,
  12.       'news/category' => 300))->
  13.   end->
  14.   begin_site->
  15.     begin_from->
  16.       top_limit(7)->
  17.     end->
  18.     begin_news->
  19.       most_popular_limit(5)->
  20.     end->
  21.  end;
  22. ?>


Загрузить конфигурацию можно с помощью вызова:

  1. <?php
  2. $config = Config_DSL::Builder()->load('config.php');
  3. ?>


Разумеется, дело не ограничивается только конфигами. Например, мы описываем структуру REST-приложения вот таким образом:

  1. <?php
  2. WS_REST_DSL::Application()->
  3.       media_type('html', 'text/html', true)->
  4.       media_type('rss', 'application/xhtml+xml')->
  5.       begin_resource('gallery', 'App.Photo.Gallery', 'galleries/{id:\d+}')->
  6.         for_format('html')->
  7.           get_for('{page_no:\d+}', 'index')->
  8.           post_for('vote', 'vote')->
  9.           index()->
  10.         end->
  11.       end->
  12.       begin_resource('index', 'App.Photo.Index')->
  13.         for_format('rss')->
  14.          get('index_rss')->
  15.          get_for('top', 'top_rss')->
  16.         end->
  17.         for_format('html')->
  18.           get_for('{page_no:\d+}', 'index')->
  19.           index()->
  20.         end->
  21.       end->
  22.  
  23.   end;
  24. ?>


Использование быстрых API в стиле DSL позволяет получить короткий и хорошо читаемый код, например, в методах контроллера приложений:

  1. <?php
  2. public function index($page_no = 1) {
  3.     $pager = Data_Pagination::pager($this->db->photo->galleries->count(), $page_no, self::PAGE_LIMIT);
  4.  
  5.     return $this->html('index')->
  6.       with(array(
  7.         'top' => $this->db->photo->galleries->most_important()->select(),
  8.         'pager' => $pager,
  9.         'galleries' => $this->db->photo->galleries->
  10.                                published()->
  11.                                paginate_with($pager)->
  12.                                select()));
  13.   }
  14. ?>


В некоторых, относительно редких, случаях можно пойти еще дальше. Немного расширив класс DSL_Builder, можно описывать не только статическую структуру, но и набор действий, то есть некоторый сценарий. Например, с Google AdWords API можно работать вот так:

  1. <?php
  2. Service_Google_AdWords_DSL::Script()->
  3.   for_campaign($campaign_id)->
  4.     for_ad_group($group_id)->
  5.       for_each('text', 'keyword1', 'keyword2', 'keyword3')->
  6.         add_keyword_criteria()->
  7.           bind('text')->
  8.         end->
  9.       end->
  10.       add_ad()->
  11.         with('headline', 'headline',
  12.              'displayUrl', 'www.techart.ru',
  13.              'destinationUrl', 'http://www.techart.ru/',
  14.              'description1', 'desc1',
  15.              'description2', 'desc2')->
  16.         format("Ad Created")->
  17.       end->
  18.     end->
  19.   end->
  20.   for_each_campaign()->
  21.     format("Campaign: %d, %s\n", 'campaign.id', 'campaign.name')->
  22.     dump('campaign')->
  23.     for_each_ad_group()->
  24.       format("Ad group: %d, %s\n", 'ad_group.id', 'ad_group.name')->
  25.       for_each_criteria()->
  26.         format("Criteria: %d, %s\n", 'criteria.id', 'criteria.text')->
  27.       end->
  28.     end->
  29.   end->
  30. end->
  31.   run_for(Service_Google_AdWords::Client()->
  32.             useragent('user agent')->
  33.             email('email@domain.com'));
  34. ?>


Конечно, использовать такой подход нужно в разумных пределах, но иногда он дает очень хороший результат.
Автор: @mooncube
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Комментарии (33)

  • 0
    > если метод не возвращает функционально необходимый результат, пусть вернет $this.
    Да, это стало популярно, особенно после JQuery =) Аналогично и я делаю.

    А конфиги храню в обычных массивах, т.к. для каждого такого массива потом можно будет сгенерировать форуму для изменения через браузер. И сохранять в массив через var_export() можно.

    Подгружаю так:

    public function configure($id)
    {
    if(isset($this->_config['components'][$id]))
    {
    foreach ($this->_config['components'][$id] as $k => $v)
    {
    $this->_components[$id]->$k = $v;
    }
    unset($this->_config['components'][$id]);
    }

    }
    • +1
      С объектом удобнее работать, как правило, плюс в переопределенные методы можно навешать дополнительную логику, да и вообще выглядит эстетичнее, вроде как.
      • 0
        Да, в этом плане удобнее, но я предпочитаю никакой логики в конфиг не вводить =) А то какой же это конфиг тогда =) Но на вкус и цвет все фломастеры разные =)
    • 0
      Тем кто ставит минусы: я не храню все настройки в массивах =) В них только базовые вещи: логин/пароль к БД, дефольный контроллер, пути.
  • +1
    Мда, DSL на пхп выглядит весьма удручающе.
    • +1
      Как говорится, а что на нем выглядит иначе?
    • 0
      Щас Вам накидают говнеца, знайте.

      А вааще real hard guru поиксу на чём у него DSL. DSL — это концепция, которая как раз позволяет ездить по дорогам языков на своём удобном велосипеде с удобнейшим сиденьем — у кого-то седушка, а у кого-то и массажёр простаты.

      Я тут на досуге осознал, что и BAT-ники под DOS`ом можно писать с IF`ами и с типа переменными и даже циклами, но это к делу не относится :)

      Эта же концепция близка в Literate Programming by тов. Кнут, но по мнению тов. МакКонелла она не всех вставляет.
      • +3
        Real Programmer can write Fortran programs in any language, ага.
  • +3
    Прочитал название вашей группы, как «ТерАкт» )
  • 0
    наверное имелось в виду

    $config = new Config_DSL_Builder()->load('config.php');

    ?
    • 0
      или у нас есть

      class Config_DSL
      {
        public static function Builder()
        {
          return new Config_DSL_Builder();
        }
      
      }
      
      • +1
        Да, так и есть (там еще на самом деле есть список параметров со значениями по умолчанию), у каждого модуля определен соответствующий класс, в нем, как правило, фабричные методы и, иногда, упрощенный процедурный фасад (как в perl).
      • +1
        Статические фабричные методы, кстати говоря, тем еще хороши, что позволяют создать объект и вызвать его метод в одном выражении (по крайней мере в 5.2 «new Config_DSL_Builder()->load('config.php');» не сработает, а «Config_DSL::Builder()->load()» — сработает).

        В этом смысле по соотношению объем кода/полезность лидирует функция:

        function with($x) { return $x; }
        


        Тогда можно:

        with(new Config_DSL_Builder())->load(...);
        
        • 0
          а что делает for? Вроде цикл, но непонятно какой…
          • 0
            В каком примере?
            • 0
              for_campaign($campaign_id)->
            • 0
              это, видимо не цикл, а элемент ассоциативного массива…
        • 0
          было бы полезно добавить удобные методы в статью, если не лень конечно.
        • +2
          Кстати посмотрите мой перевод описания Phemto — библиотеки для Dependency Injection, тоже удобный подход для API:

          часть1: habrahabr.ru/blogs/php/64061/
          часть2: habrahabr.ru/blogs/php/64078/
  • 0
    Это не DSL, это «мы все запихнули в chaining, и автоматически получили новый язык. Он правда не короче РНР, но зато chaining».

    Особенно «end()->end()->end()» доставляют.
    • 0
      DSL не должен подменять базовый язык, он должен его расширять.
    • 0
      Ну, исходный язык такой, что особо не размахнешься, увы. В случае Google AdWords получается, тем не менее, реально короче, там для многих типовых задач альтернатива — написание длинных унылых скриптов с большим количеством промежуточных объектов. Применять, разумеется, следует в очень разумных пределах.

      Для расширения языка есть, к сожалению, крайне мало возможностей, в 5.3 получше за счет переопределения статических методов, но все равно это не радикально лучше. Игрушечный язык — игрушечные DSL, как то так :(
      • –1
        Посмотрите Phemto, на который вам дали ссылку. Это DSL. Они не пытаются все запихнуть в одну цепочку. Они, о боже, используют присваивания. :)
        • 0
          Ничего не имею против присваиваний. Я же не призываю _все_ приложение так писать.

          Теперь по поводу DI. Это немного из другой оперы, как мне кажется. В случае DI у нас есть некий интерфейс и различные его реализации, контейнер позволяет управлять тем, какая конкретная реализация используется в том или ином случае. Собственно, поэтому это так востребовано в Java: стандартная спецификация определяет интерфейсы и все им следуют, в т.ч. различные вендоры. В случае PHP, кстати, никаких общих интерфейсов, кроме SPL, как то особенно и не сложилось.

          Мы же просто строим систему связанных экземпляров объектов, реализация которых однозначна, во многих случаях это несложные value objects с минимальной логикой. Мы же не будем описывать схему роутинга адресов страниц, например, с помощью Dependency Injection.
          • 0
            Я не про DI, я про Phemto, как пример хорошего DSL.
  • +1
    Имхо, вы перестарались. И с конфигом (конфиг можно сделать массивом, .ini-, YAML- или если вы фанат, XML-файлом), и со вторым примером — появляется ненужное усложнение, к тому же, если не знаешь внутреннего устройства объектов, то вообще ничего не понять. Короче, одни недостатки.

    Переписывать foreach () через методы объекта  — нафига??? В php уже есть foreach(). К тому же в вашем foreach() можно вызывать только методы своего же объекта, в отличие от нормального (сделали бы тогда уж через коллбеки/замыкания). Хотите сделать foreach() — используйте итераторы или массивы, не изобретайте велосипед.

    Выглядит мерзко (не обижайтесь), к тому же напрягает иcпользование __get()/__Set()/__call() (которые, я считаю, не стоит использовать кроме особо крайних случаев).

    И еще неоправданное использование ob_*() (что естественно, тоже зло) заметил я в коде.
    • 0
      1. ini, XML и YAML — полностью статичны, иногда выгодно иметь возможность что-то в конфиге вычислить. Кстати, встроенному парсингу ini-файлов в PHP вообще ничего нельзя доверить :);

      2. Второй пример — там полное практически делегирование вызовов конструируемым объектам, этот набор можно создать и традиционным образом, API допускает использование и без билдера, вообще использование билдеров — всегда опциональная надстройка над традиционным API;

      3. ob_* — перестраховка от лишнего текста в конфиге, можно и не использовать, вообще говоря;

      4. foreach — это не переписывание штатного foreach, это выполнение дочерних задач в контексте родительской. Альтернатива — длинные скрипты с большим количеством промежуточных присваиваний и циклов foreach, выглядит довольно уныло.

      5. Про __get(), __set() и __call() — почему вы так считаете?
      • 0
        1. Ну если строго подходить, на то он и конфиг, чтобы ничего в нем не вычислять.

        2. При чем тут делегирование, выглядит-то страшно и запутанно, ошибку и не заметишь. Парсер предупредит о незакрытой скобке, а о пропушенном end()?

        3. Что значит перестраховка от лишнего текста? Откуда он там возьмется?

        > Альтернатива — длинные скрипты с большим количеством промежуточных присваиваний и циклов foreach, выглядит довольно уныло.

        Значит ваши объекты кривые, раз их нельзя нормально использовать без извратов. Итераторы зачем придуманы? Замыкания?

        > Про __get(), __set() и __call() — почему вы так считаете?

        Потому что мощный инструмент, стоит использовать с осторожностью, а не всюду без разбора. Потому что усложняет понимание, когда у объекта нет метода или свойства, а к нему обращаются. И кстати, если отказаться от вашего DSL, делегирование, и эти методы окажутся ненужными :)
        • 0
          По поводу __get() etc — я думал вы про производительность скажете.

          Что касается отсутствия свойства у объекта — у нас это решается наличием стандартных интерфейсов для объектов, которые поддерживают динамические свойства. Называется Core_PropertyAccessInterface, его поддержка гарантирует одновременную реализацию __get, __set, __isset и __unset, реализация интерфейса также подразумевает следование определенной модели обработки этих свойств, в частности, генерацию исключений, если это нужно.

          На и по поводу последнего тезиса — если это мощный инструмент, чего ж его не использовать? В случае PHP он закрывает массу вопросов, как минимум:

          1. честную инкапсуляцию свойств свойств объекта без необходимости реализации массы тупых методов доступа типа get{Property}/set{Property};
          2. поддержку read-only свойств опять же без get{Property};
          3. вычисляемые свойства.

          Можно, в принципе, писать на PHP как на классической Java — с access-методами, бинами и т.д., но тогда лучше сразу на Java, где все стандартно, строго типизировано и не надо ничего изобретать. Посмотрите исходники Shindig, например, такой стиль выбран намеренно (портировали с Java) — на это без слез не взглянешь.
    • 0
      Вы знаете более удачные методы реализации рефлексии в PHP? Быть может, поведаете?

      А по поводу foreach меня захватил сильнейший приступ смеха. Скажите об этом людям, которые изучают функциональное программирование, и они покажут вам традиционный жест с пальцем у виска. Это map. А замыкания (я надеюсь) автор не использовал в целях обратной совместимости. Хотя можно было бы и перегрузку сделать.
      • 0
        > Скажите об этом людям, которые изучают функциональное программирование, и они покажут вам традиционный жест с пальцем у виска.

        Php это язык функционального программирования? Это изврат, имитировать таким образом отсутсвующий функционал, не дай бог кому такой код разбирать придется, что он скажет, а? А ошибку или пропущенный метод в огромном chain слабо найти?

        Я вижу только запутанный код, который сложно поддерживать, в котором велика вероятность ошибок, а вы?
        • 0
          За исключением последнего примера найти ошибку в выражении не сложнее, чем найти ее же в ассоциативном массиве с количеством уровней вложенности > 1, что в общем то вполне себе часто встречаемый паттерн. Причем в случае массива можно еще, например, в имени ключа опечататься (а в случае объектов — в имени свойства) и это никак не контролируется. В случае вероятность заметить ошибку может быть выше.

          В PHP в этом смысле вообще весело, от опечаток может гарантированно защитить только 100% покрытие юнит-тестами :)
  • 0
    Хорошая, годная статья. До этого ещё надо дорасти, поэтому не держите зла на людей, которые здесь подымают флейм. Когда-нибудь они тоже поймут.

    Кстати, я какбы намекну, что jQuery, наверное самый известный в интернетах chaining pattern, появился не случайно.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое Разное