28,80
рейтинг
24 февраля 2010 в 13:47

Разное → Слой контроллера веб-приложения на основе архитектуры REST

В этой статье хотелось бы поделиться опытом разработки слоя контроллера в нашем веб-фреймворке. Что мы хотим от этого слоя:
  • абстракция HTTP-запроса и отклика, компенсирование неудобств встроенной реализации;
  • возможность компоновки обработчиков запросов из отдельных модулей (middleware);
  • самое главное: диспетчеризация URL, простая структура набора правил диспетчеризации;
  • REST как наиболее универсальная архитектура.


В основном хотелось бы сосредоточиться на последних двух пунктах, поэтому про остальное — очень поверхностно, тем более, что это очевидные вещи.

Абстракция HTTP-запроса и отклика


В отличие от подавляющего большинства веб-фреймворков в различных языках, в PHP нет нормального интерфейса для работы с объектом запроса (по крайней мере, в среднестатистической конфигурации). Есть множество специальных переменных, содержащих разрозненную и не всегда единообразно структурированную информацию (чего стоит только разница в структуре данных, например, в $_POST и $_FILES).

Поэтому в лучших традициях PHP по-быстрому изобретаем велосипед в виде объекта класса Net_HTTP_Request. Его основные возможности: удобный доступ к параметрам и полям заголовка запроса, uploads в виде файловых объектов и т.д.

1 <?php<br>
2 $id = $request['id'];<br>
3 $auth = $request->header['Authorization']<br>
4 foreach ($request['upload'] as $line) { /* ... */  }<br>
5 $stores = $request['upload']->copy_to($permanent_path);<br>
6 ?><br>


Аналогично, отклик представляется объектом класса Net_HTTP_Response, который обеспечивает удобную работу с заголовками отклика, представление body как строки, итератора или файлового объекта и т.д.

1 <?php<br>
2 $file = IO_FS::File($path);<br>
3 return Net_HTTP::Response()-><br>
4   status(Net_HTTP::OK)-><br>
5   content_type(MIME::type_for_file($file))-><br>
6   body($file);<br>
7 ?><br>


Обработчики запросов, middleware


Обработка запросов выполняется объектами-сервисами, реализующими стандартный интерфейс:

1 <?php<br>
2 interface WS_ServiceInterface {<br>
3   public function run(WS_Environment $env);<br>
4 }<br>
5 ?><br>


Результатом выполнения метода run() является объект класса Net_HTTP_Response. Объект класса WS_Environment предназначен для обмена информацией между объектами сервисов, каждый сервис может создать, прочитать или записать значение параметра окружения. По умолчанию окружение содержит элемент $env->request класса Net_HTTP_Request.

Если сервис в процессе обработки вызывает другой сервис, модифицируя при этом запрос, отклик или окружение — мы получаем так называемый middleware-компонент, название введено в обиход стандартом WSGI. У нас есть набор тривиальных middleware-сервисов, реализующих конфигурирование приложения, подключение к базе данных, кеширование, авторизацию и, самое интересное, диспетчеризацию.

В общем, это была очевидная преамбула, переходим, наконец, к амбуле.

Диспетчеризация на основе REST


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

Диспетчеризация на основе набора регулярных выражений имеет несколько недостатков, прежде всего:
  1. для больших приложений есть риск получить большую сложную систему неоднородных правил, тяжелую в поддержке;
  2. система правил статична, сложно повлиять на результат диспетчеризации непосредственно в ее процессе;
  3. затруднена диспетчеризация для вложенных ресурсов с произвольным уровнем вложенности.

В процессе выбора схемы диспетчеризации для библиотеки мы рассмотрели различные существующие варианты и больше всего нам понравился подход, предложенный в Java-стандарте JAX-RS (JSR-311).

Почему мы выбрали этот стандарт?
  • простой;
  • прозрачно переносит модель REST на уровень кода приложения;
  • позволяет удобно работать со вложенными ресурсами;
  • не накладывает ограничений на классы, реализующие ресурсы.

Мы реализовали упрощенную версию стандарта с учетом PHP-специфики:
  • для описания ресурсов используется собственный DSL, а не аннотации;
  • мы отказались от возможности произвольного порядка определения ресурсов, что существенно упростило алгоритм.

Наш диспетчер запросов реализован в виде объекта-сервиса (
WS_ServiceInteface
). Его метод run() делегирует обработку пользовательcкому объекту ресурса, создаваемому диспетчером на основе описания набора ресурсов приложения.

Ресурс — просто объект произвольного класса. Его не обязательно наследовать от какого-либо стандартного родительского класса, ему не нужно реализовывать какой-либо стандартный интерфейс. При этом в пользовательском приложении, скорее всего, имеет смысл ввести иерархию наследования для того, чтобы избежать дублирования кода, однако фреймворк этого не требует.

Класс ресурса реализует набор методов, которые можно разделить на три вида:
  • вспомогательные методы, предназначенные для использования внутри класса;
  • HTTP-методы, обрабатывающие различные виды запросов и возвращающие объекты отклика;
  • сублокаторы, порождающие экземпляры классов вложенных ресурсов.

Ресурсы приложения


Набор ресурсов формирует приложение. Приложение содержит описание своих ресурсов, для построения описания используется внутренний DSL.

Для принятия решения о вызове метода ресурса необходимо иметь полное описание доступных ресурсов и их методов. Реально выполняется код одного или нескольких ресурсов, поэтому нет необходимости загружать все классы. Таким образом, описание ресурсов, составляющих приложение, должно быть отделено от собственно их реализации.

Пример схемы приложения:

 1 <?php<br>
 2 $companies = WS_REST_DSL::Application()-><br>
 3   begin_resource('company', 'App.WS.Company', 'company/{name:[a-zA-Z][a-zA-Z-]+}')-><br>
 4     sublocator('blog')->       // вложенный ресурс  - блог<br>
 5     sublocator('vacancies')->  // вложенный ресурс  - список вакансий<br>
 6     for_format('html')-><br>
 7       index()->                // /company/Techart/ - профиль компании<br>
 8     end->     <br>
 9   end-><br>
10   begin_resource('blog', 'App.WS.Blog', null)-><br>
11     sublocator('entry', '{\d+:id}')-><br>
12     for_format('html')-><br>
13       get_for('{page_no:\d+}', 'index')-> // /company/Techart/blog/5.html - страница блога<br>
14       post()->                            // создание новой записи - по умолчанию метод create()<br>
15       index()->                           // /company/Techart/blog/ - страница блога по умолчанию<br>
16     end-><br>
17     for_format('rss')-><br>
18       get('index_rss')->  // /company/Techart/blog/index.rss - RSS-лента блога<br>
19     end-><br>
20   end-><br>
21   begin_resource('entry', 'App.WS.Entry', null)-><br>
22     for_format('html')-><br>
23       index()->                           // /company/Techart/blog/82715/ - страница записи в блоге<br>
24       get_for('print', 'print_version')-> // /company/Techart/blog/82715/print.html - версия для печати <br>
25       put()->     // изменение записи, по умолчанию - метод update()<br>
26       delete()->  // удаление новой записи, по умолчанию - метод delete()<br>
27     end-><br>
28   end-><br>
29   begin_resource('vacancies', 'App.WS.Job', null)-><br>
30     for_format('html')->    <br>
31       index()->  // /company/Techart/vacancies/ - список вакансий компании<br>
32     end-><br>
33   end-><br>
34 end;<br>
35 ?><br>


В этой схеме ресурс описывается тремя параметрами:
  • name — имя ресурса, вспомогательный параметр, можно пока не обращать внимания;
  • classname — имя класса, реализующего ресурс;
  • path — шаблон URL, соответствующий ресурсу.

Шаблон URL представляет собой регулярное выражение (куда ж без них!) с именованными параметрами.

HTTP-методы


HTTP-методы выполняют обработку запросов различного вида и формирует отклик. Параметры описания метода:
  • name — имя метода;
  • http_mask — маска, задающая сочетание http-методов, которые обрабатывает метод ресурса;
  • path — шаблон URL, соответствующий методу;
  • formats — список форматов представлений, которые формирует метод.

Конструктор класса ресурса и методы ресурса могут иметь произвольный набор аргументов. Если шаблон URL содержит параметры, имена которых совпадают с аргументами методов, значения параметров автоматически подставляются при вызове конструктора или методов ресурса. Кроме того, есть несколько предопределенных стандартных параметров, например $env, $request и $format. Если имя аргумента не входит в набор параметров шаблона и не является предопределенным параметром, подставляется null.

Для каждого метода можно указать список форматов представлений. Определение запрашиваемого формата производится по заголовкам HTTP-запроса или по расширению запрашиваемого документа. Для каждого формата можно предусмотреть отдельный метод, или выполнить обработку в одном методе, используя параметр $format. Форматы можно указывать как для отдельных методов, так и для целых ресурсов.

Сублокаторы


Создание экземпляра класса, соответствующего вложенному ресурсу, может быть выполнено динамически в процессе обработки. Для этого используются так называемые сублокаторы (sub-resource locators). Метод является сублокатором, если он присутствует в описании ресурса, но в маске HTTP не указано ни одного метода. Сублокатор не обрабатывает запрос, вместо этого он создает экземпляр класса вложенного ресурса. Таким образом, решение о создании того или иного ресурса может быть выполнено в момент обработки запроса в зависимости от определенных внешних условий.

Классы ресурсов


Для приведенного выше примера скелет нескольких классов ресурсов может выглядеть следующим образом:

 1 <?php<br>
 2 // Базовый класс ресурса - не обязателен для фреймворка<br>
 3 class App_WS_Resource {<br>
 4   protected $env;<br>
 5   protected $db;<br>
 6 <br>
 7   public function __construct(WS_Environment $env) {<br>
 8     $this->env = $env;<br>
 9     $this->db  = $env->db;<br>
10   }<br>
11 }<br>
12 <br>
13 // Ресурс blog<br>
14 class App_WS_Blog extends App_WS_Resource {<br>
15 <br>
16   public function index($page_no = 1) { /* $page_no подставляется из шаблона URL  */ }<br>
17 <br>
18   public function index_rss() {  /* RSS-лента */ }<br>
19 <br>
20   public function entry($id) {<br>
21     // Сублокатор - подгружает запись блога из базы и создает контроллер для<br>
22     // вложенного ресурса<br>
23     if ($entry = $this->db->blog->entries[$id]) <br>
24       return new App_WS_Entry($this->env, $entry); <br>
25   }<br>
26 <br>
27   public function create() { /* создание новой записи */ }<br>
28 }<br>
29 <br>
30 // Ресурс entry<br>
31 class App_WS_Entry extends App_WS_Resource {<br>
32   protected $entry;<br>
33 <br>
34   public function __construct(WS_Environment $env, App_DB_Entry $entry) {<br>
35     parent::__construct($env);<br>
36     $this->entry = $entry;<br>
37   }<br>
38 <br>
39   public function index()         {  /* показ записи */ }<br>
40   public function print_version() { /* версия для печати */ }<br>
41   public function update()        { /* изменение записи */ }<br>
42   public function delete()        { /* удаление записи */ }<br>
43 }<br>


Алгоритм диспетчеризации


Алгоритм диспетчеризации, являющийся упрощенной версией алгоритма, изложенного в стандарте JAX-RS, выглядит так:
  1. Определяем требуемый формат представления (по заголовкам запроса или расширению документа);
  2. Просматриваем описания ресурсов и сопоставляем путь каждого с началом URL;
  3. Если не нашли ресурса, для которого URL соответствует шаблону и формат входит в список поддерживаемых — 404;
  4. Ресурс нашли, ищем метод. Убираем из URL совпавшую с путем ресурса часть;
  5. Просматриваем все описания методов ресурса, сопоставляя шаблон URL метода с началом остатка URL;
  6. Если нет ни метода, ни сублокатора, подходящих по шаблону URL и формату представления — 404;
  7. Если найден метод с соответствующим шаблоном URL, форматом и маской HTTP — создаем объект класса ресурса и вызываем метод, выполняя подстановку параметров. Результат выполнения — объект, представляющий отклик, работа завершена;
  8. Если найден сублокатор с соответствующим шаблоном URL — создаем объект класса ресурса и вызываем метод, выполняя подстановку параметров.
  9. Результат выполнения сублокатора — новый ресурс. Ищем в списке ресурсов описание ресурса соответствующего класса.
  10. Если класс ресурса в описании отсутствует — 404;
  11. Удаляем совпавшую строку из начала URL и возвращаемся в пункт 4, и так до тех пор, пока не найдется http-метод.

Для упрощения алгоритма мы считаем, что адреса /resource/ и /resource/index.html (для формата html) — эквивалентны, кроме того, мы принудительно ограничиваем максимальное количество итераций алгоритма.

Для повышения производительности можно, во-первых, кешировать описания ресурсов, а во-вторых, разбивать большие приложения на отдельные более мелкие, привязывая их к подкаталогам URL или поддоменам основного домена приложения.

Результат


Что мы выигрываем, реализовав предложенную схему?
  • мы можем полностью использовать все преимущества REST-модели вне зависимости от вида приложения (традиционное веб-приложение, веб-сервис, AJAX-приложение);
  • код приложения максимально отделен от механизма реализации, это значительно упрощает тестирование и предоставляет разработчику больше свободы в проектировании объектой модели приложения;
  • правила диспетчеризации и, как следствие, классы ресурсов, имеют достаточно однородную структуру, что облегчает проектирование и дальнейшую поддержку. При желании (мы все собираемся, никак не соберемся) можно разработать графическую нотацию для описания структуры приложения и визуализировать ее, скажем, с помощью graphviz.
  • работа с вложенными ресурсами произвольной глубины вложенности тривиальна.

В целом, мы очень довольны предложенной схемой, на наш взгляд, она практически не накладывает ограничений на разработчика и очень проста в реализации. Интересным и неожиданным результатом явилась очень простая интеграция с нашим примитивным ORM-слоем. Реализовав всего два очень простых класса ресурсов, мы получили прозрачный маппинг HTTP-запросов в инструкции ORM-слою, например

GET /api/news/stories/most_popular
$db->news->stories->most_popular->select()

POST /api/news/stories/
$db->news->stories->insert($story)

PUT /api/news/stories/15
$db->news->stories->update($db->news->stories[15]);

и так далее.

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

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

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

  • НЛО прилетело и опубликовало эту надпись здесь
    • 0
      Как наиболее универсальная архитектура построения веб-приложения, типа минимальный базис, из которого все выводится.
      • НЛО прилетело и опубликовало эту надпись здесь
        • –2
          Ну, тут элемент словоблудия есть конечно, согласен, имелось в виду, что диспетчеризацию запросов в приложении можно строить по разному, использование REST-принципов, а именно, анализ вида запроса и content negotiation в дополнение к разбору адреса наиболее универсально по сравнению с традиционной системой routes а-ля Rails первых версий и URL patterns в Django, например. Ну то есть под универсальностью понимается минимум ограничений в системе роутинга, как-то так.
  • –1
    а как вам идея диспетчеризации на основе компонентов url?
    Вот пример, может и не особо удачный, но идея я думаю понятна — осуществлять роутинг лишь по адресной строке.
    • +1
      Прошу прощения, Ваш текст бегло глянул, может отвечу не в тему. Если диспетчеризация основывается только на основе компонент url, главный недостаток в том, что это в некотором смысле противоречит сути HTTP, в котором action — это вид запроса, а не часть URL, то есть URL определяет представление ресурса, а не действие с ним.

      Соответственно, получаем более логичную, простую, а главное, стандартную схему адресов, соответствующих структуре представляемой информации, а не действиям над ней. С практической точки зрения — все компоненты приложения единообразны, не нужно придумывать сложных шаблонов адресов, структуру приложения легко формализовать, проще интегрировать приложения с внешними инструментами, например, различными JS-фреймворками.
      • 0
        возможно,… — то есть большинство фреймворков противоречит сути HTTP?
        • +1
          Просто длительное время все считали HTTP чисто транспортным протоколом, в то время как его создатели мыслили гораздо шире. А потом до всех вдруг ДОПЕРЛО :) Собственно, культа из этого делать не надо, пусть цветут все цветы, скажем REST часто противопоставляется XML-RPC и SOAP для веб-сервисов, но это специализированные решения для собственно RPC, а REST — универсален.

          Что до противоречия сути HTTP — так до сих пор и с PUT и DELETE не все умеют работать по человечески, приходится имитировать через POST, со временем оно, конечно, устаканится.
      • НЛО прилетело и опубликовало эту надпись здесь
        • +1
          Для DELETE дополнительный параметр будет в самый раз, поскольку это по сути одна операция, просто с дополнительным сайд-эффектом :) Дополнительные действия могут быть представлены как вложенный ресурс, поддерживающий операцию POST, при этом не может иметь подресурсов.

          То есть, скажем, есть запись блога /entries/1231/, поддерживающая GET, PUT, DELETE. Предположим, я хочу, чтобы за запись голосовали. Создается вложенный ресурс /entries/1231/vote/, поддерживающий только метод POST, в котором и реализуется операция голосования. При этом логика сохраняется — вы действительно создаете новый голос, просто его никто снаружи не видит :) Для DELETE пример в голову не приходит — DELETE — он и в Африке DELETE :)

          На эту тему можно найти много информации, например, по запросу REST design patterns есть масса всего полезного. На меня кстати в свое время произвела большое впечатление презентация автора RoR: Discovering a World Of Resources, очень хорошая, хотя система REST-диспетчеризации в RoR мне не очень нравится.

          То что стандартным линком можно только GET — это правильно, стандартные линки ссылаются на различные представления. Если надо что-то другое — то это уже не линк, а форма, стилизованная под линк. К тому же если действия выполняются по GET, есть шанс, что случайно зашедший поисковик порушит всю цивилизацию :)
          • 0
            что-то я не понял, а можно выполнить несколько разнородных операций за одно обращение?
            поправьте меня, если я ошибаюсь, вы предлагаете убрать традиционную абстракцию, наложенную поверх HTTP?
            • 0
              Можно ли вызвать одновременно две функции за один вызов? Можно, если объединить их вызовы в новую функцию. Вроде как применимо не только к REST.

              По поводу абстракции — я не предлагаю, я пользуюсь тем, что предложили более умные люди, а именно, рассматривать HTTP не как транспортный протокол, а как протокол приложения, он получается вполне достаточен для этого. Собственно меня даже в первую очередь привлекает удобная с точки зрения написания и суппорта архитектура приложения, и уже потом все остальное (stateless, которой иногда даже, о ужас, можно пожертвовать, например).
  • 0
    А как реализовать GET запросы из JS, отправляющие (а не только получающие) данные удаленному веб приложению (кросс доменный запрос) через JSONP? К примеру виджет, который получает информацию от пользователей сайтов использующих этот виджет? А использование POST при работе с некоторыми формами(вставляющими информацию) не всегда лучшее решение. Так не стоит делать, например, когда необходимо сохранить возможность навигации средствами браузера.
    • 0
      Ну, в одном ресурсе может быть сколько угодно методов с различными именами и шаблонами URL, обрабатывающих GET. GET-параметры тоже никто не отменял, так что вроде как заводим соответствующие методы и вперед, если я правильно понял ваш вопрос. Cледование концепции не самоцель, собственно, в этом же синтаксисе можно описать обычный любезный всем URL-matching, просто вписав в маску HTTP сочетание GET|POST.
  • 0
    В презентации к семинару есть упоминание behaviour ресурса, но описания что это и как работает так и не нашел. Хотелось бы узнать об этом по подробнее.
    • 0
      Ну вот, например, в предыдущем ответе пример с голосованием.
  • –1
    И все таки по моему вы изобрели велосипед. Сравните с системой Route во фреймворке Kohana 3 — kerkness.ca/wiki/doku.php?id=routing:routing_basics
    • +2
      В PHP велосипедостроение — стиль жизни, увы. Что касается Kohana — оно же вроде HTTP-методы не различает, то есть там чистый матчинг адресов, или я не прав? Если прав — то это немного другой велосипед :)

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

      Так вот, многие фреймворки используют описание схемы не только для прямого матчинга URL → метод, но и для обратного — для генерации адресов. В нашем случае наличие сублокаторов создает проблему, так как вообще говоря, поведение сублокаторов зависит от факторов, не учитываемых в описании схемы. Есть и еще одно соображение — ссылок надо генерить много, поэтому процесс генерации должен быть максимально простым. Мы решаем эту проблему не особенно красиво — для приложения пишется примитивный класс-генератор адресов, предельно простой и не связанный со схемой приложения. Соответственно, при изменении схемы может понадобиться скорректировать и этот класс, не очень удобно, зато быстро и просто.
      • 0
        Да, там идет regexp относительно url.
        Но основная проблема REST — HTML не позволяет отправить иной запрос к серверу, кроме GET и POST. Поддержка других методов будет в HTML5 и XHTML2.
        Хотя не спорю, за REST будущее, но пока основная область его использования — межсерверные запросы ( хотя могу и ошибаться )
        • 0
          PUT и DELETE имитируются через POST одинаково практически во всех фреймворках (через _method). С точки зрения построения приложения это в любом случае не хуже, чем кодировать delete где-нибудь в URL или в POST-параметре, что, собственно, эквивалентно.
        • 0
          Из скриптовых серверных фреймворков по крайней мере RoR (см. world of resources) и web.py практически чистый REST. Не похоже, чтобы на них писали только веб-службы. Из клиентских фреймворков REST из коробки в ExtJs и Dojo как минимум, это тоже не межсерверные запросы.
  • –1
    ну, в Symfony например есть объект запроса sfRequest
  • –2
    не пиши на пхп
    • 0
      «Чтоб я — авиацию бросил?»
  • 0
    Где можно найти исходники вашего фреймворка? На DevConf вы сказали, что он OpenSource.

    Интересно, как вы реализовали регулярные выражения с именованными параметрами.

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

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