Все больше приложений взаимодействуют с пользователем через браузер, что заставляет предъявлять новые требования к протоколу HTTP. Он был разработан для передачи и кэширования статических документов, поэтому концепции состояния, клиентских сессий, транзакций, авторизации, многоступенчатых взаимодействий в нем отсутствуют или реализованы недостаточно. Это обусловило возникновение множества технологий, в частности методики создания Web-приложений с помощью лексических замыканий и продолжений с серверной стороны.
Всемирная паутина начиналась как виртуальная сеть для распространения академических публикаций, а сегодня динамические Web-приложения приспосабливают ее механизмы для своих нужд. Это стало источником ряда проблем, например таких.
- Гостевая книга. Ее легко реализовать в виде страниц «просмотр записей» и «добавление записи», причем после добавления записи пользователь попадает на страницу просмотра. В запросе на добавление обычно используется CGI, и новая запись передается в качестве параметра страницы «просмотр записей». Однако если пользователь после добавления записи нажмет в браузере кнопку Refresh (обновить), та же запись будет добавлена второй раз.
- Internet-магазины. Типичная ошибка — хранить содержимое «корзины» в скрытых полях форм или в скрытом фрейме. Казалось бы, если после оформления заказа нажать кнопку Back (назад) браузера, то заказ отменится (Back действует как Undo). Однако оба способа «не работают» в ситуации разветвления сессии: если пользователь откроет какую-то часть магазина в новом окне и что-то в нем закажет, то на содержимом «корзины» в старом окне это не отразится. Другая ошибка — хранить содержимое «корзины» на сервере короткое время, например 20 мин с момента последнего обращения. Если пользователь отвлечется, он обнаружит, что все его заказы пропали.
- Сложное Web-приложение. Типичная ошибка — отслеживать состояние авторизации и сессию по IP-адресу или с помощью cookie. Приложение «сломается», если пользователь располагает динамическим IP-адресом или его подсеть экранирована одним IP-адресом, а поддержка cookie у клиента может быть и вовсе отключена. Кроме того, на одном компьютере порой работают несколько пользователей. Другая распространенная ошибка — хранить ключ сессии в скрытых полях форм и забыть его вставить в одну из гиперссылок или форм. Такой ошибки сложно избежать, когда форм много.
Из этих примеров видно — протоколу HTTP требуются расширения, в том числе для обслуживания специализированных систем разработки Web-приложений (скажем, ASP, JSP, PHP, Apache Cocoon, Seaside). Они сталкиваются с еще большим количеством проблем, связанных с масштабируемостью и распределением нагрузки, читаемостью исходного кода, распределением обязанностей, ограниченностью вычислительных ресурсов с серверной стороны, корректной работой с «закладками» и обеспечением безопасности. Кроме того, характерной проблемой Web-инфраструктур является «инфляция ответственности»: выбранная система разработки Web-приложений навязывает свои язык программирования, модель взаимодействия с базами данных, метод описания интерфейсов и т.д.
Модальный подход
Движущая идея модального подхода - организация кода Web-приложения не по отдельным страницам, а по путям взаимодействия с пользователем. К примеру, код, соответствующий гиперссылке в HTML, соседствует с кодом, который будет выполнен на сервере при переходе по этой ссылке. Сценарий, в котором пользователь переходит последовательно от одной страницы к другой (как wizard в MS Windows), будет описан в коде Web-приложения линейно и в одном файле; ветвления в сценарии будут описываться стандартными управляющими конструкциями (типа if или while). Такой эффект достигается благодаря использованию лексических замыканий и продолжений (в тех языках программирования, где они поддерживаются) для хранения состояния клиентской сессии. Название «модальный подход» (modal web development) предложил Ави Брайант (Avi Bryant) по аналогии с «модальными диалоговыми окнами» в оконных интерфейсах (www.cincomsmalltalk.com/userblogs/ avi/blogView?showComments=true&entry=3258414140).
Лексические замыкания
Концепция «лексических замыканий» (lexical closures) довольно стара. Она берет начало в лямбда-исчислении, а впервые была реализована в языке Lisp. Чтобы понять, что такое замыкание, можно рассмотреть следующий кусочек кода на языке Ruby:
x = 0
my_proc = lambda {x = 1}
print x # будет выведен 0
Лексическое замыкание — это фрагмент программного кода (анонимная функция), который может обращаться к лексическому контексту (локальным переменным и this) в точке своего создания, но существует отдельно в виде объекта. В приведенном примере функция lambda превратила операцию присвоения {x = 1} в объект, способный «пережить» контекст, в котором он был создан.
У лексических замыканий много применений, но нас интересует их использование в качестве обработчиков (callbacks). Ключевая идея состоит в том, что программа способна, например, связать с каждой гиперссылкой обработчик события перехода по ней, который будет иметь доступ к контексту выполнения на момент генерации исходной HTML-страницы. Таким образом, Web-приложение становится не набором слабосвязанных скриптов, а полноценной программой. Одна страница может свободно передавать на другую произвольные объекты языка программирования, которые иногда сложно (или небезопасно) размещать в CGI-параметрах. Например, создание ссылки на страницу оплаты заказа может выглядеть так:
create_link(«Purchase») {show_payment_page(shopping_cart) }
При этом гипотетический объект shopping_cart, доступный в лексическом контексте вывода данной страницы, окажется доступным и при выводе следующей страницы (после перехода по ссылке), хотя в явном виде он не сохраняется и не передается от одной страницы к другой. В этом и состоит ценность лексических замыканий - у кода Web-приложения появляется некий общий контекст выполнения, и временные барьеры между отдельными страницами становятся прозрачными.
Продолжения
«Продолжения» (continuations) — также очень старая концепция. Первая широко известная реализация продолжений (call/cc) появилась в языке Scheme. Можно привести следующий пример Ruby-кода:
x = callcc {|cc| x = 2; cc.call(1) }
print x # напечатает 1
В нем системная функция callcc создает объект cc, к которому применим единственный метод call. Когда этот метод вызывается, первое обращение к функции callcc возвращает значение. Таким образом, переменной x будет присвоено значение 1, переданное cc.call. Объект cc можно, к примеру, сохранить, а позже вызвать его метод call. Программа совершит «нелокальный скачок» и продолжит свое выполнение с момента завершения функции callcc. Объект cc, имеющий тип «продолжение» (continuation), инкапсулирует состояние программы в определенный момент (а именно - в момент вызова функции callcc), и впоследствии она способна вернуться к этому состоянию. Можно сказать, что функция callcc позволяет определять не только то, какое именно значение вернуть, но и куда его вернуть.
Ценность продолжений для Web-приложений заключается в том, что они дают возможность строить взаимодействие с пользователем линейно - например, «показать данную форму, потом, в зависимости от введенных значений, показать другую, а затем еще одну». Для каждого этапа работы программы на сервере можно хранить объект-продолжение. При этом не нужно беспокоиться о работе кнопки Back в браузере, поскольку каждая показанная пользователю страница сама «знает», с какого «продолжения продолжать». Более того, одну и ту же цепочку диалогов можно открыть в двух окнах браузера и в каждом из них понемногу продвигаться, причем все будет работать правильно. Лексические замыкания и продолжения позволяют, к примеру, реализовать необычную для Web модель поведения «вызов-возврат»:
create_link(«Delete this file») {if(confirm(«Are you sure?»)) delete(file) }
При нажатии на ссылку Delete this file перед пользователем появится страница, «спрашивающая» его, уверен ли он в своем решении. Она является HTTP-аналогом модального диалога. Когда пользователь выберет ye» или no, функция confirm обеспечит возврат соответствующего значения, и выполнение обработчика продолжится. Реализация этого с помощью CGI была бы на порядок сложнее (например, странице confirm нужно было бы «знать», о каком именно действии спрашивается).
Реализации модального подхода
Впервые идею применения замыканий и продолжений в Web-интерфейсах выдвинул в 1996 году Пол Грэм, основатель фирмы Viaweb. Эта идея была использована им в приложении Yahoo! Stores для создания Internet-магазинов. Сейчас того приложения больше не существует, а его исходный код был закрыт. Известно лишь, что оно было полностью написано на Lisp и неправильно «обрабатывало» кнопку Back.
Следующие крупные продвижения в этой области пошли из языков Scheme и Smalltalk. В комплект PLT Scheme (одной из сред разработки для Scheme, диалекта Lisp) входит программа Web Server (docs.plt-scheme.org/web-server/), которая позволяет писать сервлеты с использованием примитива send/suspend («послать пользователю страницу, дождаться ответа и продолжить выполнение»). Scheme оказался идеальным языком для реализации подобных проектов. В некоторых вариантах удается даже сохранять объекты-продолжения на диске или обмениваться ими по сети, что невозможно ни в одном другом распространенном языке программирования. Кнопка Back и расщепление сессии в PLT Web Server работают (URL каждой страницы является ключом объекта-продолжения на сервере), а закладки - нет, поскольку сервер удаляет старые объекты-продолжения.
Проблема закладок вообще не очень хорошо исследована. В одной из немногих работ на эту тему [1] предлагается конвертировать в строку данные о состоянии и отправлять ее клиенту, избегая таким образом хранения продолжений. Однако такой метод подходит далеко не для всех языков программирования и к тому же не вполне приемлем с точки зрения информационной безопасности — клиент может внести в строку состояния несанкционированные изменения.
Чтобы решить эти проблемы, Ави Брант реализовал проект IOWA на языке Ruby (enigo.com/projects/iowa/index.html), который затем превратился в Seaside на Squeak Smalltalk (seaside.st). Среда разработки Squeak еще более необычна, чем любая Scheme-среда. Например, исходный код в ней не хранится в файлах, поэтому необходимо перевести на Squeak Smalltalk большую часть программного обеспечения. Вместе с тем Seaside2, по-видимому, — самая эффективная на сегодняшний день библиотека для разработки Web-приложений.
Каждое Web-приложение в этой среде представляет собой объектно-ориентированную программу. Состояние сессии — дерево объектов-компонентов. Любой компонент умеет себя рисовать и регистрировать обработчики событий. Компоненты способны на время делегировать обязанности рисования другим компонентам (модель «вызов-возврат»). Каждый компонент может «зарегистрироваться», чтобы по кнопке Back откатывалось его состояние (значения переменных экземпляра). Состояние в Seaside хранится на сервере, а клиенту через URL передаются лишь ключи состояний и продолжений. Благодаря этому кнопка Back и разветвление сессии практически всегда работают правильно, но заставить верно работать закладки — довольно сложно. Эрик Ходел (Eric Hodel) перевел Seaside2 обратно на язык Ruby, и законченная версия получила название Borges (borges.rubyforge.org/). В проекте Apache Cocoon (cocoon.apache.org) диалект JavaScript, поддерживающий продолжения, используется для описания интерфейсов. А в проекте WDialog (wdialog.sourceforge.net) задействуется язык OCaml, не поддерживающий продолжений и применяющий идеи функционального программирования фактически для их эмуляции.
Достоинства и недостатки модального подхода
Модальный подход позволяет группировать логику приложения по наборам обработчиков и реакций на действия пользователя, а не по отдельным страницам. Также он обеспечивает прозрачную поддержку пользовательских сессий (через общий контекст выполнения для каждого потребителя). Модальный подход серьезно облегчает моделирование логики взаимодействия с пользователем, особенно если эта логика сложна. Ави Брант сравнивает модальный подход с хорошо известным переходом от оператора GOTO (гиперссылок) к структурному программированию (замыканиям и продолжениям).
Однако далеко не все языки программирования имеют нужные свойства, идиома «закладок» не всегда работает, интеграция с ведущими HTTP-серверами (Apache и IIS) затруднена или невозможна, и практически каждый продукт навязывает свою компонентную модель, среду разработки, язык программирования и даже язык описания интерфейсов. Кроме того, хранение замыканий и продолжений на сервере может требовать намного больших вычислительных ресурсов, чем при использовании CGI, а динамически распределять нагрузку между несколькими серверами чаще всего невозможно или крайне сложно.
Недостатки модального подхода не ограничиваются сложностями реализации. Например, Филип Эби (Phillip J. Eby) считает: «Продолжения имеют такое же отношение к Web-программированию, как функциональная декомпозиция к графическим интерфейсам. На уровне пользовательского интерфейса это — плохая идея. Она более приемлема для программистов с линейным мышлением» (discuss.fogcreek.com/ joelonsoftware/default.asp?cmd=show&ixPost=94006). Действительно, Web должна давать пользователю все возможности контролировать ситуацию, а не превращать его в «периферийное устройство, с которого считываются данные».
Это мнение разделяют многие последователи REST-подхода [2] к написанию Web-приложений. Основные особенности REST таковы: стремление к осмысленным URL, отсутствие хранимого на сервере состояния сессии, использование кодов ошибок HTTP на прикладном уровне, сохранение семантики HTTP-методов GET и POST (отсутствие побочных эффектов у GET и идемпотентность POST).
Наша реализация
Одна из причин, побудивших нас задуматься о создании собственной реализации модельного подхода, — сложность существующих систем.
В Seaside/Borges (Smalltalk и Ruby) каждое Web-приложение представлено в виде набора компонентов, которые могут быть вложены друг в друга. Страница выводится путем рисования корневого компонента. Используются концепции «фильтрации запросов» и «декорации компонентов». Имеется собственная модель HTML-документов (через деревья лексических замыканий), вывод HTML-кода вручную усложнен. Пользователь должен создавать классы компонентов для каждой задачи.
В WDialog (OCaml) интерфейс страниц описывается в собственном нестандартном XML-формате, отдельно от исходного кода, а вывод HTML вручную невозможен.
В Apache Cocoon (JavaScript и различные диалекты HTML) фактически навязывается определенная модель архитектуры приложения. Последнее рассматривается как цепочка обработки XML-данных независимыми компонентами - «генераторами», «трансформерами» и «сериализаторами». Написать простое приложение на Cocoon, судя по всему, нельзя.
Наша реализация модального подхода - это библиотека Yo. Ее исходный код занимает несколько килобайтов, а основная цель — добавить к CGI новые возможности, не навязывая разработчику дополнительных архитектурных решений.
В отличие от обычного CGI (где каждый запрос запускает новый интерпретатор), в Yo вся работа осуществляется в рамках одного процесса, который может выделять внутри себя отдельные нити (threads), что позволяет быстрее обрабатывать запросы. Та же идея реализуется в модулях для Apache (типа mod_perl) и ISAPI. Это - общее достоинство практически всех Web-библиотек, позиционирующихся как альтернатива CGI.
При разработке, к примеру, гостевой книги для CGI-программиста было бы более естественно сделать отдельные скрипты (файлы) просмотра и добавления записи. Однако если эти скрипты имеют какой-то общий код (например, таблицу стилей) и программист выполнит рефакторинг, то файлов станет уже три, а общий код будет вынесен в отдельный файл. CGI-модель фактически навязывает программисту представление о том, как нужно делить Web-приложение на файлы. Такое представление часто оказывается неверным. В свою очередь, многие из упомянутых высокоуровневых Web-библиотек заставляют программиста делить свой код на компоненты, навязывают собственные модель разделения полномочий, язык описания интерфейса и т.д. Библиотека Yo позволяет факторизовать функциональность так, как это удобно программисту.
Web-приложение в Yo может хранить глобальное состояние в глобальных переменных. Так, при реализации гостевой книги можно хранить ее содержимое в массиве памяти, а периодически сохранять записи на диске несложно. Кроме того, из-за общности контекста можно, например, иметь лишь одно открытое соединение с базой данных на все приложение. Общностью контекста характеризуются многие Web-библиотеки, но мы попытались максимально облегчить работу с контекстом (скажем, не нужно специально соединяться с JavaBean, «объектом сессии»).
Библиотека позволяет при генерации любой HTML-страницы зарегистрировать обработчик (создать лексическое замыкание), получить соответствующий ему динамический URL и создать на странице ссылку на этот URL. При выполнении обработчик получает в качестве аргумента набор пар «ключ-значение», переданных в качестве CGI-переменных. Данная возможность — основное достоинство всех Web-библиотек, основанных на продолжениях.
Стандартное поведение обработчиков действий в Yo — сразу после выполнения мгновенно перенаправить пользователя на какую-либо страницу, URL которой не является автоматически сгенерированным. Таким образом, статические URL в Yo соответствуют данным, а динамически сгенерированные - действиям. Идея мгновенного перенаправления была позаимствована из библиотеки Seaside2.
В принципе, можно было бы писать приложение так же, как во всех прочих библиотеках, основанных на замыканиях и продолжениях: установить одну фиксированную «точку входа» и сделать все внутренние URL временными. Однако с помощью библиотеки Yo можно писать и вполне стандартные CGI-приложения (наборы страниц, ссылающихся друг на друга), все URL в которых по умолчанию остаются активными неограниченное время. Те URL, которые генерируются для обработчиков событий, все равно не будут использоваться в закладках, поскольку при переходе по ним чаще всего мгновенно происходит redirect на один из «обычных» URL. Таким образом, сами сгенерированные URL никогда не фигурируют в адресной строке браузера, и заботиться об их сохранении не нужно.
Деление на «обычные» и «динамически сгенерированные» URL - одна из ключевых возможностей Yo при создании Web-приложений в CGI-стиле, когда каждая ссылка ведет на некоторую страницу с данными. Если ссылки связаны не с данными, а с выполнением неких действий, можно регистрировать обработчики. Закладки же на страницы будут работать всегда.
По умолчанию в Yo после выполнения обработчика действия происходит перенаправление на исходную страницу, если обработчик сам не «захотел» вывести какой-либо текст. В противном случае контекст внутри обработчика действия ничем не отличается от контекста создания новой страницы: в нем можно выводить HTML, регистрировать новые обработчики и т.д. Таким образом, удается «сцепить» несколько страниц в линейную последовательность, внутри которой пользователь не может ставить закладки — все URL автоматически генерируются и через некоторое время пропадают.
Эмуляция продолжений с помощью цепочки лексических замыканий называется «CPS-преобразованием». Этот метод применим в любом языке программирования, в котором есть лексические замыкания. Основная идея состоит в том, что каждый этап должен быть отдельным лексическим замыканием, в которое как аргументы передаются все последующие (каждый этап «знает будущее» и передает его дальше). Такая методика лучше, чем использование продолжений в явном виде. Последнее имеет следующие недостатки:
- оно захватывает весь стек, в том числе контекст выполнения разных функций внизу стека, внутри Web-сервера, а это дорого, да и не нужно;
- продолжения неочевидным образом взаимодействуют со «сборщиком мусора»;
- каждое продолжение можно вызвать лишь из той же нити, в которой оно было создано (а замыкания - откуда угодно, то есть не нужно создавать отдельную нить для каждой клиентской сессии);
- если момент завершения замыкания четко определен, то продолжению чаще всего необходимо явно передавать escape-продолжение, чтобы остановить разматывание стека;
- очень сложна отладка программ, явно использующих продолжения (особенно поиск утечек памяти).
Когда пользователь заканчивает движение по цепочке динамических страниц, он может выйти на одну из обычных страниц (по ссылке или перенаправлению).
Перспективы
Библиотека Yo (yo-lib.rubyforge.org) написана на языке Ruby и пока еще навязывает выбор Web-сервера (WEBRick), не предоставляя доступа ко многим важным объектам (например, HTTP-заголовкам). Это затрудняет ее применение в промышленных приложениях. Вместе с тем исходный код ее реализации весьма компактен и не использует продолжения. А потому перевод Yo на любой язык программирования, поддерживающий в каком-то виде лексические замыкания (Perl, Smalltalk, Lisp, Java с анонимными классами), проблем не вызывает. Вероятно, в целях практического применения библиотеки нужно переписать ее на Perl/FastCGI и создать ISAPI-модуль или Java-сервлет. Однако в данном случае выбор технологии и особенности реализации будут диктоваться коммерческими, а не исследовательскими интересами.
Литература
- Paul Graunke, Robert Findler, Shriram Krishnamurthi, Matthias Felleisen. Automatically Restructuring Programs for the Web. // Automated Software Engineering. 2001.
- Roy T. Fielding. Architectural Styles and the Design of Network-based Software Architectures. // PhD Thesis. University of California, Irvine, 2000.
Владимир Слепнев (slepnev_v@rambler.ru) — младший научный сотрудник НИИ механики МГУ (Москва).
Технические проблемы реализации модального подхода
Сборка мусора
В Web-приложениях, использующих лексические замыкания и продолжения, проблема памяти выходит на первый план. Лексическое замыкание не дает «сборщику мусора» уничтожить объекты, к которым можно обратиться из контекста замыкания. Если несколько замыканий ссылаются друг на друга, образуя цепочку, то Web-приложение с каждым обращением захватывает все больше и больше памяти.
Замыкания редко оказываются причиной таких проблем, поскольку «видят» лишь непосредственный контекст выполнения (лексические переменные верхней функции стека в момент создания). Но когда появляются продолжения, возникают сложности: продолжение захватывает полный контекст выполнения в момент создания (лексические переменные всех функций в стеке вызовов). Если где-то в стеке была возможность обратиться к другому продолжению, то и оно будет исключено из процесса «сборки мусора», а кроме того, будут охвачены еще какие-нибудь контексты, продолжения и т.д. Подобные ошибки крайне трудно обнаружить с помощью автоматических тестов, и еще труднее найти их причину. Наконец, когда в программе используются продолжения, возникают некоторые специфические проблемы, которые естественно решать также с помощью продолжений (к примеру, escape continuations).
Откат
Такие среды, как Seaside2, удобны для создания и отладки Web-приложений. Они поддерживают устойчивую иллюзию, будто показываемая пользователю Web-страница является изображением дерева объектов, существующего на сервере. Эта метафора очень полезна (например, можно прямо в браузере редактировать внешний вид отдельных компонентов страницы и сразу же видеть изменения), но поддерживать ее сложно. Одна из проблем - откат, «перемотка» назад. По ходу работы программа способна создавать экземпляры компонентов, имеющие внутреннее состояние. Позже эти экземпляры могут стать ненужными и подвергнуться процедуре «сборки мусора». Но для того, чтобы «отматывать» назад состояние компонентов при нажатии кнопки Back, система должна знать, какие именно компоненты «отматывать». Для этого необходим некий список, но он содержит ссылки на старые компоненты и, следовательно, мешает правильной «сборке мусора».
Одно из решений — использовать «слабые» ссылки (weak references), не влияющие на «сборку мусора». Правда, они могут неожиданным образом взаимодействовать с лексическими замыканиями и продолжениями. Эта проблема часто возникает у пользователей Seaside и Borges. Кроме того, если для решения проблемы требуется задействовать «слабые» ссылки (и вообще object identity), то ее постановка, скорее всего, неверна.
Закладки
Проблема «перемотки» назад появляется из-за того, что некоторая часть состояния системы принципиально не может (или не должна) выноситься на сторону клиента. К примеру, передавать лексические замыкания через URL очень сложно, а объекты-продолжения, судя по всему, — вообще невозможно. Из-за этого во многих реализациях состояние хранится на сервере, а клиент получает лишь «ключ» состояния. Естественно, при «сборке мусора» серверное состояние может исчезнуть. К тому же замыкания и продолжения - довольно «тяжелые» объекты (по требованиям к памяти и вычислительным ресурсам), и, когда клиентов много, хранить больше десятка старых состояний для каждого из них становится сложно.
В Seaside предложен такой механизм: приложение может отправить произвольную информацию в URL (вместе с ключом состояния), но ответственность за ее последующее извлечение также ложится на приложение. Заставить таким способом Seaside- или Borges-приложение правильно работать с закладками сложно, однако в других Web-системах, основанных на продолжениях, такая возможность и вовсе не рассматривается.
Скрытое состояние
Проблема «скрытого состояния» является типичной для систем, основанных на продолжениях. Пользователи привыкли к тому, что страница полностью отображает текущее состояние диалога, однако в системе, основанной на продолжениях, это часто не так. К примеру, страница подтверждения может «не знать», какое именно действие она подтверждает. Если несколько компонентов расположены на разных закладках, то состояние невидимых компонентов «незримо присутствует», хоть их и не видно на экране.
Хорошо это или плохо — вопрос сложный. Самая большая проблема возникает в ситуации «невидимого стека», когда одна страница вызывает функцию, рисующую другую страницу, которая, в свою очередь, рисует третью и т.д. При этом каждая страница сохраняет продолжения, и каждое продолжение хранит ссылку на предыдущее («стек вызовов»), из-за чего занимаемая память растет с каждым запросом. Проблема состоит в том, что писать подобный код в системах, основанных на продолжениях, очень легко, а научить новичка видеть такие ошибки в коде — очень сложно.