Pull to refresh

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

Reading time 11 min
Views 8.8K
В этой статье хотелось бы поделиться опытом разработки слоя контроллера в нашем веб-фреймворке. Что мы хотим от этого слоя:
  • абстракция 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, вдруг кого заинтересует.
Tags:
Hubs:
+11
Comments 28
Comments Comments 28

Articles

Information

Website
www.techart.ru
Registered
Founded
Employees
101–200 employees
Location
Россия