Python: Веб-разработка без фреймворков

Эта серия статей была написана в 2008 году и с тех пор успела несколько устареть. Изначально в планах был еще ряд статей в развитие темы, но потом я передумал.

Часть 1: Основы

Каждый разработчик тщательно выбирает свой инструментарий и чем лучше он им владеет, тем эффективнее его работа и тем более востребованы его услуги. В то же время многие программисты гордятся своей ленью и ищут такие инструменты, которые делали бы за них как можно больше работы. При этом, на мой взгляд, забывают, что бесплатный сыр только в мышеловке и гоняются за иллюзорным «лучшим» фреймворком тщательно сверяя какой из них делает больше работы: Zope, ROR или Django? Мой опыт говорит о том, что время на изучение фреймворков и подстройка под их ограничения почти никогда не окупается, а пользуясь минимальным инструментарием, с которым я хочу вас ознакомить, можно добиться гораздо лучших результатов.

WSGI

Не так давно библиотек и фреймворков для веб-разработки на Python было очень много, и взаимодействовали они между собой не наилучшим образом. Выбрав для разработки одно решение приходилось придерживаться его и в дальнейшем. Разрешением этой проблемы многие считали выбрать один-два главных фреймворка и сконцентрироваться на их разработке, таким образом сделав остальные ненужными. Такая консолидация решила бы проблемы с взаимодействием разных частей, но и минусов у неё порядочно. Ведь так много библиотек возникло не зря, и причина этого именно в том что нет очевидно предпочтительного подхода к веб-разработке.

Решение подоспело в виде стандарта WSGI (PEP-333). Это сверх-компактная спецификация, которая решает как вопросы взаимодействия разных компонент веб-приложений, так и связки между этими приложениями и HTTP-сервером.

Вкратце, WSGI (Web Server Gateway Interface) требует от приложения предоставлять следующий интерфейс:

def app(environ, start_response):
    """
    (dict, callable( status: str,
                     headers: list[(header_name: str, header_value: str)]))
                  -> body: iterable of strings
    """

Т.е. приложение будет вызвано с двумя аргументами. Первый — словарь environ, содержащий различные данные о запросе, второй — функция запускающая процесс ответа на запрос. В основном содержимое environ совпадает с содержимым переменных окружения CGI скрипта при аналогичном запросе, откуда и происходит имя этого аргументa, это же позволяет использовать уже имеющиеся средства для разбора строки запроса итп. Назначение start_response проще показать на примере, а возвращать WSGI приложение должно итератор по строкам, т.е. функция может быть генератором и «скармливать» веб-серверу ответ по частям. Например:

import cgi
def hello_app(environ, start_response):
    start_response('200 OK', [('Content-type', 'text/plain')])
    yield "Hello, "
    form = cgi.FieldStorage(environ=environ)
    name = form.getfirst('name', 'stranger')
    yield name

Удобство использования WSGI заключается, конечно, не в том, что мы можем писать такой код, а в том, что предоставив такой интерфейс, мы получаем доступ к множеству совместимых библиотек. Например, мы элементарно можем запустить сервер с вышеуказанным приложением:

from paste.httpserver import serve
serve(hello_app)

Мы можем также предоставить его через FastCGI, SCGI или еще какой-нибудь диковинный протокол общения между HTTP-сервером и сервером приложений. Существует также mod_wsgi, модуль Apache, позволяющий в считанные минуты запустить ваше приложение через этот сервер, причем с отличной интеграцией с его инфраструктурой.

Ну и, конечно же, мы можем вызывать другие WSGI приложения и обрабатывать их результаты (о чем будет сказано в разговоре о middleware). Этот стандарт к тому же делает возможным библиотеки тестирования веб-приложений, не привязанные ни к каким фреймворкам. В результате библиотек для веб-разработки врядли стало меньше, ведь создавать новые теперь куда проще, но проблема совместимости была решена. Теперь любой фреймворк, вне зависимости от того каким образом он сообщается с вашим кодом, предоставляет WSGI. Различные приложения, не использующие сторонних фреймворков, также доступны через WSGI (например Trac).

PythonPaste

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

Например, в нем есть готовая поддержка самых разных способов аутентификации (Basic, Digest, form, signed cookie, auth_tkt), поддержка корректной и удобной генерации ответов и заголовков (к примеру редиректы, Cache-control, Expires, gzipper и прочие). Различные базовые средства комбинации приложений (URLMap, Cascade, Recursive), статических данных (с учетом Etag, If-Modified итп). Скажем так:

from paste.urlmap import URLMap
from paste.fileapp import FileApp

root_app = URLMap()
root_app['/static'] = FileApp('/var/www/static/')

from paste.auth.digest import digest_password, AuthDigestHandler

def private_app(environ, start_response):
    remote_user = environ['REMOTE_USER']
    app = FileApp('/home/%s/htdocs/' % remote_user)
    return app(environ, start_response)

def authfunc(environ, realm, username):
    return digest_password(realm, username, username) # password == username

private_wrapped = AuthDigestHandler(private_app, "Private area", authfunc)
root_app['/private'] = private_wrapped

Как видите, кода написано минимум, а часть сайта уже защищена паролем и соответствует разным папкам на диске в зависимости от имени пользователя. Представить себе, что нечто подобное можно записать короче и понятней, трудно.

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

PythonPaste для отладки

Особо радуют средства для отладки приложений. Тут и возможность приложения автоматически перезапускаться при изменении части исходных кодов, и профайлер для каждого запроса, и возможность слать отчеты об ошибках на почту (или сохранять их локально). Можно валидировать все [X]HTML ответы сервера, есть те самые средства тестирования WSGI приложений итд итп. Есть даже возможность интерактивной отладки прямо в браузере. На этом стоило бы остановиться особо, но это лучше один раз увидеть, чем сто раз прочитать, просто запустите этот код и откройте в браузере http://localhost:8080/. (Также можно посмотреть скринкаст).

from paste.evalexception.middleware import EvalException
def error_app(environ, start_response):
    raise ValueError

from paste.httpserver import serve
serve(EvalException(error_app))

Часть 2: WebOb

В прошлой части я постарался рассказать о том, что чистый WSGI код писать не так уж сложно и что преимущества такого подхода налицо, но есть ли у этого обратная сторона? Единственным, пожалуй, недостатком я могу назвать некоторые неудобства по работе с данными в запросе. Был ли запрос GET или POST? Какая у запроса кодировка? Неужели значения формы надо разбирать при помощи cgi.FieldStorage? К счастью эти и многие другие задачи здорово помогает решить WebOb.

webob.Request

В библиотеке WebOb есть класс Request, позволяющий работать с данными из environ с куда большим комфортом. Строки с различными данными из HTTP запроса и от обработавшего его сервера превращаются в удобные в использовании, богатые на функциональность объекты. Доступ к cookies (req.cookies) теперь как к словарю объектов из стандартного модуля Cookie. Переменные формы можно посмотреть в виде как обычных (req.str_GET), так и юникод строк (req.GET). Если хотите, различайте между данными из POST (.POST) и данными из строки запроса (.GET), не хотите — используйте ’.params’. Charset и MIME-тип из Content-Type теперь будут разделены по своим атрибутам (charset, content_type). О scheme, method, remote_addr, referer, user_agent, remote_user итп и говорить нечего.

Очень удобный доступ к пути, по которому было найдено данное WSGI приложение (.script_name, .path, .path_qs, .application_url), какой «хвост» адреса нам предстоит обработать (.path_info, .path_info_peek(), .path_info_pop()). Есть средства работы с различными полезными адресами, которые пригодятся при генерации ссылок в ответе (.host_url, .application_url, .path_url, .relative_url). Кроме всего прочего это позволяет избежать ситуации, общей для PHP приложений, когда при установке они первым делом спрашивают «ГДЕ Я?»

Если вы хотите поддерживать HTTP стандарт по максимуму, то вам повезло. Атрибут if_modified_since — экземпляр datetime, а обработка значения из заголовка Accept позволяет без хлопот выбрать предпочтительный для клиента формат ответа (.accept.best_match(['text/html', ...])).

Пример

Приведу небольшой пример использования этого класса. Представим, что перед нами стоит задача написать форум и мы хотим, чтобы у приложения были красивые URLы и не менее красивое разделение функциональности внутри. Нередко для решения такой задачи пытаются применить какие-то средства фреймворка, но, обычно, оказывается, что инструмент вроде и подходит, но по большому счету не годится и надо делать всё равно самому. Бывает, пользуются какой-нибудь специальной библиотекой (как например Routes). Нередко умудряются связаться с регулярными выражениями и файлами конфигурации лежащими в специальной папке. А ведь ничего сложного нет:

from webob import Request
def forum_app(environ, start_response):
    req = Request(environ)
    peek = req.path_info_peek()
    if not peek:
        return list_topics_app(environ, start_response)
    elif peek == 'new_topic':
        return new_topic_app(environ, start_response)
    elif peek.isdigit():
        topic_id = int(req.path_info_pop())
        environ['forum_app.topic_id'] = topic_id
        return view_topic_app(environ, start_response)
    else:
        start_response('404 Not Found', [])
        return '404'

peek, как вы возможно и предположили, это фрагмент пути находящийся после адреса, по которому доступен сам forum_app, и до первого следующего слеша ('/'). Например, если сам форум расположен по адресу website.com/forum, то при обработке запроса website.com/forum/111/page-2 таким фрагментом будет '111'.

Если такого фрагмента нет, то мы выведем список имеющихся на форуме тем (переложив эту задачу на соответствующее приложение), если этот фрагмент 'new_topic', то мы вызываем по цепочке приложение которое займется открытием новой темы. Если же этот фрагмент состоит из цифр, то мы посчитаем что это номер темы, которую надо отобразить. И, если ничто другое не помогло, то выдадим ошибку №404.

Обратите внимание на фрагмент с показом отдельной темы — мы не передаем номер темы как аргумент, а сохраняем его в запросе, таким образом мы не требуем от view_topic_app нарушения WSGI стандарта и сохраняем возможность показывать темы и по другим запросам, нужно только помнить заранее добавлять в запрос данные о идентификаторе темы.

Другой немаловажный момент это использование path_info_pop(), этот метод поправит SCRIPT_NAME и PATH_INFO в запросе таким образом, что вызванное нами в дальнейшем приложение (view_topic_app) сможет корректно оценить по какому адресу оно доступно. В частности, вызывая path_info_peek() оно получит уже следующий фрагмент пути, который, в нашем случае, может обозначать, например номер страницы в теме.

Декоратор

Читатель может справедливо заметить, что код получился не самым чистым из возможных. Это верно, но можно привести всё в порядок при помощи небольшого декоратора. Декоратор впоследствии будет использоваться повсеместно, я бы даже ратовал за включение его в дистрибутив WebOb, но там уже есть нечто похожее, но как по мне куда более неприглядное.

Как мы видим, в forum_app функция start_response непосредственно не вызывается, она только передается далее по цепочке. По большому счету результаты за forum_app генерируют другие приложения (кроме 404, но и на это управа найдется), поэтому давайте согласимся, что принимать нашему приложению положено будет объект Request, а возвращать WSGI приложение. Подчеркиваю, не вызывать а именно возвращать, вызов выполнит уже декоратор. Это можно реализовать вот так:

def webob_wrap(func):
    def wrapped(environ, start_response):
        req = Request(environ)
        app = func(req)
        return app(environ, start_response)
    return wrapped

Код приложения теперь стал хорошо причесанным:

from webob.exc import HTTPNotFound

@webob_wrap
def forum_app(req):
    peek = req.path_info_peek()
    if not peek:
        return list_topics_app
    elif peek == 'new_topic':
        return new_topic_app
    elif peek.isdigit():
        topic_id = int(req.path_info_pop())
        environ['forum_app.topic_id'] = topic_id
        return view_topic_app
    else:
        return HTTPNotFound()

Обратите внимание на HTTPNotFound, такие же приложения есть для всех возможных HTTP ответов: HTTPMovedPermanently(req.host_url + 'new_path') итп. Также теперь удобно поменять способ передачи идентификатора темы отображающему её приложению. Для этого следует, вместо использования функции, реализовать view_topic_app как класс, тогда соответствующие строки превратятся в следующее:

elif peek.isdigit():
    topic_id = int(req.path_info_pop())
    return ViewTopicApp(topic_id)

Возвращать 404 приходится довольно часто, так что стоит поменять в декораторе пару строк:

app = func(req)
if app is None:
    app = HTTPNotFound()

Благодаря этому оборачиваемая функция может сообщить что ничего не было найдено просто вернув None. Так что теперь последние строки forum_app можно отбросить.

webob.exc.HTTPException

Модуль webob.exc содержит реализацию HTTP ответов со всевозможными статус-кодами. Удобной особенностью является то, что все эти реализации наследуют от класса HTTPException и при наличии в стеке HTTPExceptionMiddleware из того же модуля, можно делать raise HTTPBadRequest() итп. Повторю, что это одновременно и исключения и WSGI приложения. Это удобно, т.к. некоторые ошибки обработки запроса могут возникать в функциях, которые не имеют непосредственного влияния на ответ. Однако благодаря HTTPException мы можем избежать проверки результатов их работы, полагаясь на то, что в случае ошибки они выбросят соответствующее исключение, которое будет уловлено уровнем выше, в middleware.

(Кстати, этот модуль практически точная копия модуля paste.httpexceptions, с взаимной совместимостью).

На мой же взгляд разумно добавить поддержку таких исключений прямо в наш декоратор, благо это увеличит его лишь на три строки, судите сами:

try:
    app = func(req)
except HTTPException, app:
    pass

Если хотите попробовать развить этот проект самостоятельно, вот наш полный код на данный момент, включая заглушки для недостающих частей. Рекомендую прочитать еще раз и убедиться как всё компактно и понятно:

from webob import Request
from webob.exc import HTTPException

def webob_wrap(func):
    def wrapped(environ, start_response):
        req = Request(environ)
        try:
            app = func(req)
        except HTTPException, app:
            pass
        if app is None:
            app = HTTPNotFound()
        return app(environ, start_response)
    return wrapped

####

@webob_wrap
def forum_app(req):
    peek = req.path_info_peek()
    if not peek:
        return list_topics_app
    elif peek == 'new_topic':
        return new_topic_app
    elif peek.isdigit():
        topic_id = int(req.path_info_pop())
        return ViewTopicApp(topic_id)

####

from webob import Response
new_topic_app = Response('NEW')
list_topics_app = Response('<br>'.join('<a href="%d">Topic #%d</a>' % (i,i)
                                        for i in range(10)))
def ViewTopicApp(id):
    return Response('VIEW %d' % id)

from paste.httpserver import serve
serve(forum_app)

Часть 3: Ответы

Прочитав предыдущие статьи читатель, надеюсь, убедился, что средств PythonPaste и WebOb более чем достаточно для разбора запроса, композиции приложений и выполнения множества стандартных задач. Далее мы будем рассматривать вопросы генерации ответов, развертывания на сервере и выбора различных вспомогательных библиотек и инструментов.

До сих пор в примерах мы почти не занимались собственно ответами, перекладывая эту задачу на какие-то уже существующие приложения. Как уже было упомянуто в описании стандарта WSGI, ответы можно генерировать по частям, однако, как правило, сначала приложение выполняет все нужные вычисления, затем подставляет результаты в шаблон и возвращает полученный документ. Для такой модели работы с ответами хорошо подходит класс webob.Response. Впрочем, он также имеет поддержку последовательной генерации (см. app_iter), просто о ней мы говорить не будем.

webob.Response

Первое, что нужно понять, это то, что экземпляры этого класса являются WSGI приложениями. Создав и наполнив такой экземпляр данными мы получаем WSGI приложение, которое можно использовать единожды или многократно, по желанию. Давайте разберем его использование на примере (внимание, используется декоратор из прошлой статьи:

from webob import Response

@webob_wrap
def hello_app(req):
    if 'name' in req.params:
        if req.charset is None:
            req.charset = 'UTF-8'
            print req.user_agent, 'sucks'
        return Response(unicode_body=u'Hello, %(name)s!' % req.params)
    else:
        return form_app

form_app = Response('<form method=POST><input name=name><input type=submit>')

Хотелось бы сказать пару теплых слов о поддержке браузерами Unicode в запросах: ни IE, ни столь горячо любимый всеми Firefox не способны указать в какой кодировке были отправлены данные формы (если ваш браузер с этой задачей справляется, напишите, пожалуйста, в комментариях). Поэтому, чтобы получать данные формы как положено, в Unicode, нам пришлось добавить несколько строк, которые устанавливают кодировку запроса по умолчанию в UTF-8. Место этих строк, конечно, в нашем незабвенном декораторе, поэтому давайте туда их и перенесем. В результате тело hello_app укоротилось до четырех строк:

@webob_wrap
def hello_app(req):
    if 'name' in req.params:
        return Response(unicode_body=u'Hello, %(name)s!' % req.params)
    else:
        return form_app

Как видно из примера, ответ можно и даже следует создавать из Unicode строк — HTTP ответ будет сгенерирован корректно. Также видно, что экземпляры Response «многоразовые» (см. выше: form_app).

Постепенное наполнение ответа данными

Response не обязательно наполнять данными при инициализации. Можно делать это постепенно, например вот так:

@webob_wrap
def hello_app_cookie(req):
    r = Response(charset='UTF-8')
    if 'name' in req.params:
        name = req.params['name']
        r.set_cookie('name', name)
    elif 'name' in req.cookies:
        name = req.cookies['name']
        #r.delete_cookie('name')
    else:
        return form_app
    r.unicode_body = u'Hello, %s!' % name
    return r

В целом это понятно из кода, но на всякий случай обращу внимание, что мы теперь используем cookies для запоминания имени пользователя и опять-таки все значения Unicode, как и должно быть.

Совсем несложно контролировать кеширование (.cache_expires) и условные ответы. Делать это самостоятельно весьма трудоемко, и мало какой фреймворк предоставляет для этого средства. Итак, добавив лишь пару строк, мы получаем поддержку Etag / If-None-Match:

r.conditional_response = True
r.md5_etag()
return r

Тут вызов md5_etag() вычисляет Etag как MD5 хеш тела ответа, но при желании etag можно устанавливать самостоятельно. Ничуть не сложнее работать с If-Modified-Since, для этого у ответа устанавливается .last_modified. Таким образом, хоть мы и сгенерировали ответ целиком, он будет передан клиенту только в том случае, если его кеш устарел.

Request.get_response

Как правило, мы получаем экземпляры Request и строим экземпляры Response, но иногда нам понадобится делать всё ровно наоборот, а именно для тестирования и для написания middleware. Создать новый экземпляр Request не имея полного WSGI окружения очень просто:

test_req = Request.blank('/?name=John')

Естественно, можно наполнить запрос данными присваивая значения соответствующим атрибутам. Использовать же такой объект можно вовсе не только с теми функциями и методами которые готовы работать с WebOb. На самом деле мы можем вызвать с его помощью любое WSGI приложение и получить результат в удобном нам виде, а именно Response:

test_response = test_req.get_response(hello_app_cookie)
assert isinstance(test_response, Response)
assert test_response.body == 'Hello, John!'
print test_response

Весьма удобно, что Response.__str__ возвращает строку с соответствующим HTTP ответом:

200 OK
content-type: text/html; charset=UTF-8
Set-Cookie: name="John"; Path=/
Content-Length: 12
ETag: mE3orpck0aUrpsNFPcMzzw

Hello, John!

Как уже упоминалось, такой подход работает для любых WSGI приложений:

>>> from webob import Request
>>> from paste.fileapp import DataApp
>>> dataapp = DataApp('alert("foo")', [('Content-type', 'text/javascript')])
>>> print Request.blank('/').get_response(dataapp)
200 OK
Content-type: text/javascript
Accept-Ranges: bytes
Last-Modified: Sun, 13 Apr 2008 15:40:34 GMT
ETag: 1208101234.52-12
Content-Range: bytes 0-11/12
Content-Length: 12

alert("foo")

Часть 4: Деплоймент

В предыдущих статьях мы разобрались, как можно создавать веб-приложения на Python используя лишь необходимые средства. Следующим этапом будет развертывание приложения на сервере и связанная с этим задача конфигурации его компонент (deployment).

Сама задача WSGI стандарта — установить интерфейс, через который HTTP сервер будет общаться с веб-приложением, так что не приходится беспокоиться поддерживается ли нравящийся нам вариант связки или сервер. Выбор большой:

В зависимости от вашего выбора связка выполняется или просто или очень просто, так что выбирать следует исходя из того в каком окружении будет работать ваше приложение и с каким сервером вы наиболее знакомы. Я перепробовал много вариантов, но когда появился mod_wsgi опробовал и перешел на его использование во всех случаях когда только возможно.

mod_wsgi

Как ясно из названия, это модуль Apache написанный специально для связки непосредственно с WSGI приложениями. Я думаю для многих важно, что в нем есть базовая поддержка хостинга пользовательских скриптов в shared-hosting окружениях. На самом деле одной из ведущих мотиваций при написании этого замечательного модуля было сделать хостинг веб-приложений на Python доступнее, а сами приложения в таких условиях быстрее. mod_wsgi показал себя отлично в реальных условиях, работает стабильно, быстро и безошибочно, потому можно рассчитывать, что он получит распространение среди хостеров. Также он на пути к включению в основные репозитории Debian.

Приблизительная модель работы этого модуля такова:

  • Есть один или более именованных пулов python-процессов обрабатывающих запросы. Обратите внимание, что, среди прочего, благодаря этому снимаются какие-либо вопросы к поддержке параллельных вычислений в Python
  • Конфигурация сервера позволяет определять политику наполнения пулов процессами (минимальное и максимальное их количество, количество потоков в каждом, таймауты и прочее)
  • У каждого пула может быть свой python-path. Таким образом, можно создавать независимые окружения (при помощи virtualenv) не привязанные к версиям библиотек установленных на системе глобально или в других окружениях. Такие окружения также могут принадлежать непривилигерованым пользователям
  • Есть довольно гибкий механизм для определения какой группой процессов будет обработан тот или иной запрос. На это могут влиять довольно высокоуровневые переменные вроде имени пользователя (при условии, что аутентификация была проведена самим Apache)
  • Для крайне нагруженных серверов можно использовать менее гибкий, зато еще более быстрый вариант связки — встраивание интерпретатора и исполнение приложения непосредственно в процессе Apache.

Каким же именно образом в конфигурации сервера указывается WSGI приложения и их конфигурация? Пожалуй самым простым из возможных — указанием Python-скрипта по исполнению которого в его пространстве имен окажется переменная application значение которой и будет использовано как WSGI-приложение. Скрипт может иметь любое имя, но принято давать таким файлам расширение .wsgi. Как правило, такой скрипт будет импортировать все необходимые компоненты и конфигурировать их для данного размещения. Может показаться, что это какой-то недостаточно мощный или непродуманный подход, но опыт показывает, что это оптимальное решение. Для того чтобы стало понятней почему я так считаю, давайте кратко рассмотрим альтернативный подход.

PasteDeploy

PasteDeploy — это, в первую очередь, средство конфигурации и компоновки WSGI приложений, а также выбора и конфигурации связки с веб-сервером. Предназначено оно, среди прочего, для конечного пользователя / администратора не обязательно знакомого с Python, поэтому конфигурация хранится в .ini файлах. Эту конфигурацию можно запускать, мониторить и в целом использовать как UNIX-демон благодаря команде paster из PasteScript. На первый взгляд это хорошее решение, но практическое его использование раскрывает ряд недостатков.

Я не буду вдаваться в подробности его применения, так как моя цель раскритиковать такой подход, а не научить им пользоваться. Вкратце конфигурация содержит отдельные секции для каждого используемого приложения, фильтра (middleware) и сервера. Используемые приложения указываются в виде URI в специальных схемах. Наиболее используемая URI-схема egg: указывает на setuptools entry point, который является заводом (factory) по производству искомых приложений из передаваемой конфигурации. Также есть схема для использования других конфигурационных файлов (config:).

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

  • Для того чтобы сделать свое приложение доступным необходимо:
    • определить функцию или класс производящие приложения из конфигурации. Тут открываются разные детали:
      • предусмотренных переменных конфигурации всегда оказывается мало и со временем factory нужно подправлять;
      • конфигурация это всегда словарь где и ключи и значения — строки, таким образом возникают неопределенности если вам нужны юникод-значения;
      • если в конфигурации неплохо бы применить список или еще какое значение типом посложнее, то придется изобретать кодирование такого значения для ini формата;
    • затем нужно создать entry point в setup.py для вашего пакета,
    • задокументировать это,
    • сослаться на него в конфигурации.
  • Для использования приложения надо знать имя его entry point, как именно его конфигурировать итп. Таким образом возникает необходимость в еще одном наборе документации к приложению. Впрочем, чаще всё равно приходится читать исходники.
  • Также есть сложности с повторным использованием блоков конфигурации, например, если нужно использовать одну и ту же базу данных в различных приложениях. Минимальная поддержка есть, но нельзя скажем параметризовать включаемый конфигурационный файл. Также, например, не смотря на то, что мы можем использовать одну и ту же БД в разных приложениях, они будут использовать раздельные пулы соединений.

Хотя часть этих проблем может быть решена конфигурацией в другом виде, например XML, большая часть проблем неизбежна при использовании отдельного языка для конфигурации приложений. Если же для конфигурации использовать Python-скрипты, то все упомянутые проблемы решаются сами собой — повторное использование параметризованных блоков конфигурации превращается ни во что иное как определение и вызовы функций. Не нужны никакие уловки для построения более сложных структур данных. Использование точек входа становится необязательным. И именно так работают wsgi-скрипты для mod_wsgi. Такое решение, кажущееся на первый взгляд кустарным, оказывается гораздо более мощным и удобным в использовании, чем любая возможная альтернатива. Мы теряем возможности PasteScript по управлению демонами сервера приложений, но это нас не беспокоит, так как теперь они управляются совместно с Apache, для их перезапуска применяется привычное sudo service apache2 force-reload.

Что до конфигурации приложений непрограммистами, то править файл с синтаксисом Python ничуть не сложнее чем аналогичный INI.

Запуск WSGI-скриптов без Apache

Что ж, с развертыванием на сервере более-менее ясно, а как быть с разработкой? Будем поднимать локальный сервер, настраивать его, перезапускать всякий раз когда вносим в код изменения? Я считаю что не стоит, и потому привожу свой модуль для исполнения wsgi-скриптов (это сокращенный, но рабочий вариант):

import sys
import os
from time import sleep
from traceback import print_exc

from paste.httpserver import serve
from paste.evalexception import EvalException
from paste.debug.prints import PrintDebugMiddleware
from paste import reloader

def run_script(script):
    if not os.path.isfile(script):
        print script, "does not exist"
        sys.exit(1)
    reloader.install()
    reloader.watch_file(script)

    script_locals = {}
    execfile(script, {'__file__': script}, script_locals)
    app = script_locals['application']
    app = EvalException(app)
    app = PrintDebugMiddleware(app)
    serve(app)

if __name__ == '__main__':
    try:
        run_script(sys.argv[1])
    except SystemExit, exc:
        raise exc
    except:
        print_exc()
        print '-' * 20, 'Restarting in 5 secs..', '-' * 20
        sleep(5)
        sys.exit(3)

Сохраним этот файл как run_wsgiscript.py там, где он будет доступен импортированию. Лучше оформить его как пакет, но самым простым вариантом будет разместить его в site-packages.

Такой скрипт загружает WSGI приложение образом аналогичным mod_wsgi, но в дополнение он оборачивает его парой отладочных middleware и запускает HTTP-сервер (по умолчанию на 8080-м порту). Также, благодаря paste.reloader, раз в секунду будет проверяться наличие изменений в самом скрипте и загруженных им модулях. Если такие изменения появятся, то скрипт выйдет с кодом ошибки 3; подразумевается, что внешний, вызывающий его скрипт воспринимает это как сигнал к перезапуску, о чем чуть дальше. Если при загрузке скрипта произошла ошибка (обычно это происходит если во внесенных изменениях есть синтаксическая ошибка), то на консоль будет выведен трейсбек и наш модуль выдержит пятисекундную паузу перед перезапуском (чтобы дать спокойно прочитать где была ошибка).

Последняя недостающая часть нашей системы — это внешний скрипт который перезапукает наш модуль. Я разрабатываю под Windows и пользуюсь таким (файл run-wsgi.bat):

@echo off
:repeat
    python -m run_wsgiscript %1
if %errorlevel% == 3 goto repeat

Если всё еще сохранилась неясность почему и как это сделано, просто посмотрите краткую документацию paste.reloader — мы просто применили эту систему к загрузке wsgi скриптов.

SciTE

Тем, кто, как и я, использует в работе SciTE, будет удобно добавить в его конфигурацию следующие строки, которые добавят wsgi -скриптам корректную подсветку и позволят запускать их по F5:

file.patterns.py=*.py;*.pyw;*.wsgi
command.go.*.wsgi=run-wsgi.bat "$(FilePath)"
command.go.subsystem.*.wsgi=2

WSGI-скрипт

На всякий случай, упреждая возможные вопросы, приведу пример скрипта. Поскольку wsgi-скрипт это полноценный питоновский файл, то мы можем определить WSGI приложение прямо в нем. Т.е. любой из примеров приведенных в предыдущих статьях можно использовать таким образом. Только теперь там, где мы вызывали paste.httpserver.serve(APP) нужно писать application = APP. Например вот наш самый первый пример в виде WSGI-скрипта (добавилась последняя строка кода):

import cgi
def hello_app(environ, start_response):
    start_response('200 OK', [('Content-type', 'text/plain')])
    yield "Hello, "
    form = cgi.FieldStorage(environ=environ)
    name = form.getfirst('name', 'stranger')
    yield name

application = hello_app

В Apache тоже ничего мудрить не надо, для начала достаточно такого:

WSGIScriptAlias /path-to-app /home/user/public/hello.wsgi

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

Часть 5: Middleware

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

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

Middleware

Стандарт какого-то интерфейса создает пространство для особого рода компонент — middleware. Это слово часто употребляют в смысле «адаптер» (совместно с которыми мидлварь часто используется), хотя это не совсем верно. Я стараюсь использовать его только в узком смысле — компонента предоставляющая и потребляющая один и тот же интерфейс. Например, load-balancer это middleware, кеширующая прослойка — тоже, а вот преобразователь XML-RPC ↔ SOAP уже под вопросом. В нашем случае middleware будет потреблять и предоставлять, конечно же, WSGI. Таким образом у нас есть возможность придать новые свойства любому существующему WSGI-приложению. Ряд уже готовых mw уже был упомянут в первой статье серии, и их еще огромное множество, но мы рассмотрим, как написать такую компоненту с нуля.

Справедливость ради, стоит упомянуть, что некоторые фреймворки также используют middleware. Например, существует Django middleware, которое, естественно, работает только в своей песочнице и потому для всех остальных бесполезно.

JSMin

Начнем c примера попроще. Всем кто использовал в приложениях JavaScript известно, что, для ускорения загрузки, скрипты можно «минифицировать». Для этого есть разные инструменты, так что само по себе это не проблема. Неудобство в том, что иметь отдельные укороченные скрипты очень неудобно — чтобы их содержимое не устаревало нужно добавить специальный шаг в процесс деплоймента, да и может выйти, что какой-то из скриптов был забыт и прочие подобные неприятности. Для большинства случаев можно обойтись меньшей кровью написав мидлварь которая паковала бы скрипты на лету. На машине разработчика использовать её не стоит т.к. может понадобиться полистать используемые скрипты в браузере, но если вы усвоили идеи из статьи про деплоймент, то ничего сложного в этом не окажется.

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

Решением должна быть такая функция jsminify_middleware чтобы приложение, получающееся в следующем коде, возвращало все JS-скрипты минифицированными. Тут предполагается что ’yui’ это папка с дистрибутивом YUI (как вполне вероятный вариант применения).

from paste.urlparser import StaticURLParser
yui_app = StaticURLParser('yui')
yui_app = jsminify_middleware(yui_app)

Решение

Решение может быть например таким:

js_mimetypes = frozenset(['text/javascript', 'application/x-javascript'])

def jsminify_middleware(app):
    @webob_wrap
    def middleware_app(req):
        r = req.get_response(app)
        if r.content_type in js_mimetypes and r.body:
            r.body = jsmin(r.body)
        return r
    return middleware_app

Тут нет ничего нового. Мы просто создали замыкание (middleware_app), которое является WSGI-приложением, которое, вместо того чтобы генерировать ответ самостоятельно, поручает эту задачу обернутому приложению. Затем полученный от него ответ мы проверяем по типу содержимого и если там JS, то обрабатываем тело ответа jsmin.

Обращу внимание на ряд вещей:

  • Если нижележащее приложение поддерживает If-None-Match или If-Modified-Since эта поддержка сохраняется.
  • Мы проверяем наличие тела ответа, т.к. ответ может иметь Content-Type, но быть пустым, например 304 Not Modified. У таких ответов .body is None.
  • Мы проверяем наличие тела ответа в последнюю очередь, т.к. доступ к атрибуту .body линеаризует ответ, а в случае, когда мы не собираемся дополнительно его обрабатывать, это привело бы к бессмысленной потере эффективности и трате памяти.
  • Мидлварь применима к любому приложению и потому может также минифицировать скрипты из архива или даже сгенерированные динамически. Мы также можем обернуть ей приложение написанное с использованием фреймворков — до внутренностей нам нет никакого дела.

Ошибки в реализации

Но в этом решении есть как минимум две ошибки, ведь мы не учли некоторых возможностей HTTP. Попробуйте самостоятельно понять, в чем они заключаются.

Ошибки такие: мы не учитываем, что ответ может быть закодированным (content-encoding: gzip) или частичным (range).

Content-Encoding

Чтобы исправить первое, достаточно добавить r.decode_content(), впрочем, поскольку скрипты неплохо бы еще и сжать при передаче, давайте добавим и это.

if r.content_type in js_mimetypes and r.body:
    r.decode_content()
    r.body = jsmin(r.body)
    if 'gzip' in req.accept_encoding:
        r.encode_content()

Как вариант последние две строки можно записать так:

r.encode_content(req.accept_encoding.best_match(['gzip', 'identity'], 'identity'))

Эти варианты не идентичны. В том случае если user agent прислал заголовок Accept-Encoding: gzip;q=0.5,identity;q=1 первый вариант запакует ответ, а второй прислушается к пожеланиям агента и запаковывать не станет. Конечно, если мы очень дорожим своим каналом, то у нас тоже может быть предпочтение по-поводу того в каком виде отдавать данные. В таком случае мы можем записать всё ту же строку следующим образом:

r.encode_content(req.accept_encoding.best_match([('gzip', 1), ('identity', 0.2)], 'identity'))

Таким образом мы указываем что отдавать скрипты в сжатом виде для нас предпочтительнее в пять раз. В таком случае на тот же запрос данные всё же будут запакованы (так как. 1*0.5 > 0.2*1). Но мы всё же оставляем клиенту шанс указать что незапакованные данные предпочтительнее если в запросе будет достаточно низкий q для gzip.

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

Range

Исправить ошибку с частичным ответом (Range) можно несколькими путями. Вполне приемлемый вариант оставить всё как есть — вряд ли когда либо случится так, что скрипт будут скачивать по частям.

Можно добавить условие and not r.range чтобы не обрабатывать такие ответы дополнительно. В таком случае разумно при упаковке изменять Etag ответа, чтобы при корректном (с If-Range) запросе на частичное содержимое нижележащее приложение замечало бы разницу с ожидаемым Etag и отдавало цельный ответ. Это позволит избежать маловероятной ситуации когда клиент начал скачивать скрипт в минифицированом виде, но из-за обрыва соединения вынужден был попробовать снова и послал запрос на недостающую часть. В таком случае помимо Range он должен бы указать заголовок If-Range со значением Etag из первого ответа. Если Etag пакованного и оригинального ответа не отличаются то в результате клиент получит нерабочего «мутанта» склеенного из несовместимых частей. Изменение Etag спасает ситуацию, но к сожалению такой подход не поможет если в If-Range будет указан не Etag а Last-Modified оригинального ответа, что тоже допустимо по стандарту.

Поэтому самый надежный способ — удалить Range и If-Range из запроса до того как передавать управление нижележащему приложению (req.range = None). Но из-за этого перестанут работать все запросы на частичное содержимое и потому такую мидлварь нужно будет применять осторожнее, чтобы ненароком не обернуть ей, например, какой-то большой файл для скачки.

Решение наверняка

Для таких случаев также предусмотрен метод Request.remove_conditional_headers по умолчанию удаляющий из запроса не только Range но и Accept-Encoding (исправляя заодно нашу первую ошибку), If-None-Match и If-Modified-Since гарантируя таким образом что ответ будет полным и без Content-Encoding. Для нашей задачи это перебор, но знать о его существовании стоит.

Домашнее задание

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

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

Создайте декоратор для удобства написания простых middleware, предполагаемое использование такое:

@webob_middleware
def jsminify_middleware(req, app):
    r = req.get_response(app)
    #...
    return r

Часть 6: Компонетные решения

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

Постановка задачи

Допустим мы пишем веб-приложение, использующее какую-то JS-библиотеку, в нашем примере YUI. Давайте попробуем инкапсулировать логику её подключения к странице. Для таких задач есть целый ряд «решений» вроде ToscaWidgets, но толку от них ноль, как это и принято среди всего имеющего в названии слово «widget».

Фактически YUI может быть размещена на серверах Yahoo, на нашем сервере где-то рядом с приложением или где-то еще, мы будем поддерживать все эти варианты. Также удобно иметь возможность раздавать библиотеку прямо из дистрибутива (yui_x.x.x.zip) не распаковывая.

Приложение будет запрашивать у компоненты HTML код для подключения интересующих её модулей. У модулей есть debug и min версии, поэтому компонента должна учитывать и это. Мы будем местами срезать углы, например, у некоторых компонент в некоторых версиях есть дополнительный суффикс ’beta’, но мы это будем игнорировать. Учесть это несложно, но в рамках статьи не оправдано. Точно также мы не будем выстраивать правильный порядок включения скриптов, зависимости, подключение CSS файлов их минификацию и прочие детали. В настоящей компоненте это всё следует реализовать — времени это займет минимум, а использовать её станет еще удобней и приятней. И всё же некоторые несущественные детали мы будем учитывать в нашей реализации для того чтобы было видно что это не требует никакой магии — всё отлично решается «в лоб».

Подготовительные шаги

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

class App(object):
    def __init__(self, yui):
        self.yui = yui

    @webob_wrap
    def __call__(self, req):
        names = [name for name in req.path_info.split('/') if name]
        links = self.yui.js_links(names)
        return Response(links, content_type='text/plain')

if __name__ == '__main__':
    from paste.httpserver import serve
    yui = YuiYahooHosted(version='2.5.2', debug=True)
    root = App(yui)
    serve(root)

Конечно, для целей тестирования разумно вызывать непосредственно компоненту, но этот пример заодно показывает как она используется в реальном приложении. Для компоновки не требуется никаких трюков или специальных библиотек. На самом деле интересно, кому впервые пришла идея, что так писать недостаточно хорошо, и сколько человеко-часов было угроблено на хитросплетения под девизом «не повторяй себя»?

Итак, если мы захотим посмотреть в браузере как выглядит блок ссылок на скрипты history, animation и json мы откроем http://localhost:8080/history/animation/json итп.

В результате мы увидим результат подобный следующему:

<script type="text/javascript" src="http://yui.yahooapis.com/2.5.2/build/history/history-min.js"></script>
<script type="text/javascript" src="http://yui.yahooapis.com/2.5.2/build/animation/animation-min.js"></script>
<script type="text/javascript" src="http://yui.yahooapis.com/2.5.2/build/json/json-min.js"></script>

В конечном счете эта строка результат вызова

yui.js_links(['history', 'animation', 'json'])

Вариант для Yahoo CDN

Начнем с реализации YuiYahooHosted:

class YuiYahooHosted(object):
    prefix_template = 'http://yui.yahooapis.com/%s/build/'
    prefix_cn_template = 'http://cn.yui.yahooapis.com/%s/build/'
    js_link_template = '<script type="text/javascript" src="%(prefix)s%(name)s/%(name)s%(suffix)s.js"></script>'

    def __init__(self, version, debug=False, minified=False, china=False):
        if debug and minified:
            raise ValueError("Scripts can't be both minified and debuggable")

        self.version = version
        self.debug = debug

        self.minified = minified
        self.china = china

        if china:
            self.prefix = self.prefix_cn_template % version
        else:
            self.prefix = self.prefix_template % version

        if debug:
            self.suffix = '-debug'
        elif minified:
            self.suffix = '-min'
        else:
            self.suffix = ''

    def js_links(self, names):
        links = []
        subst = {'prefix': self.prefix, 'suffix': self.suffix}
        for name in names:
            subst['name'] = name
            links.append(self.js_link_template % subst)
        return '\n'.join(links)

Трудно придумать что-то более очевидное. Понять что делает компонента очень просто, и это здорово даже если никто кроме вас никогда не будет читать ваш код. Но даже для простого кода документация не помешает.

"""
Create a component that generates links to YUI scripts hosted by Yahoo!.

    Constructor Arguments:

        ``version``     YUI version (as of June 2008 the current version is 2.5.2)
        ``debug``       (False by default) Use debug versions of the scripts
        ``minified``    (False by default) Use minified versions of the scripts
        ``china``       (False by default) if True, generated links will point to China-based CDN

``js_links(names)``

    Takes a list of script names (for ex. ['cookie', 'history']) and returns HTML to include in your page.

"""

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

Рефакторинг и выделение общей функциональности

Легко заметить, что основная часть кода может быть использована для других реализаций, поэтому вынесем её в новый класс:

class YuiLinkGen(object):
    """
    Create a component that generates links to YUI scripts.

        Constructor arguments:

            ``prefix``      Specifies prefix for the generated URIs (location of the YUI files)
            ``debug``       (False by default) Use debug versions of the scripts
            ``minify``      (False by default) Use minified versions of the scripts (debug flag takes precedence)
    """

    js_link_template = '<script type="text/javascript" src="%(prefix)s/%(name)s/%(name)s%(suffix)s.js"></script>'

    def __init__(self, prefix, debug=False, minify=False):
        self.debug = debug
        self.minify = minify
        self.prefix = prefix

        if debug:
            self.suffix = '-debug'
        elif minify:
            self.suffix = '-min'
        else:
            self.suffix = ''

    def js_links(self, names):
        """
        Takes a list of script names (for ex. ['cookie', 'history']) and returns HTML to include in your page.
        """
        links = []
        subst = {'prefix': self.prefix, 'suffix': self.suffix}
        for name in names:
            subst['name'] = name
            links.append(self.js_link_template % subst)
        return '\n'.join(links)

Это не только базовый класс, он также может быть использован непосредственно, но самое интересное конечно будет в подклассах. Для начала посмотрим какой стала реализация для размещения на Yahoo.

class YuiYahoo(YuiLinkGen):
    """
    Create a component that generates links to YUI scripts hosted by Yahoo!.

        Contructor arguments:

            ``version``     YUI version (as of June 2008 the current version is 2.5.2)
            ``debug``       same meaning as in YuiLinkGen
            ``minify``      same, but True by default
            ``china``       (False by default) if True, generated links will point to China-based CDN

    """
    prefix_template = 'http://yui.yahooapis.com/%s/build'
    prefix_cn_template = 'http://cn.yui.yahooapis.com/%s/build'

    def __init__(self, version, debug=False, minify=True, china=False):
        if china:
            prefix = self.prefix_cn_template % version
        else:
            prefix = self.prefix_template % version
        self.version = version
        self.china = china
        super(YuiYahoo, self).__init__(prefix=prefix, debug=debug, minify=minify)

Что ж, пока что всё было просто, как насчет того чтобы совместить генерацию ссылок и собственно хостинг скриптов?

Размещение на собственном сервере

Поскольку задачи предоставления папки или zip-архива уже решены в Paste, то реализация выйдет на удивление короткой. Соотношение кода к документации, как положено, приближается к 1:1.

class YuiSelfHosted(YuiLinkGen):
    """
    This class implements a component that generates links to YUI scripts hosted by ourselves
    and can create the app used to serve the files from directory or distro zipfile.

        Constructors:

            ``from_directory()``

                ``prefix``      same meaning as in YuiLinkGen constructor
                ``dirpath``     filesystem path to 'build' directory from YUI distro
                ``debug``       same as in YuiLinkGen
                ``minify``      asks to minify the served files. No -min suffixes are
                                generated and debug versions can be minified too.
                ``gzip``        (True by default) asks to gzip responses if possible.

            ``from_distro_zipfile()``

                Same as ``from_directory()``, but instead of ``dirpath`` argument it takes
                ``filename`` which would be the .zip file containing the YUI distribution to serve
                (as downloaded from YUI website).

        Attributes:

            ``yuiapp``  WSGI app to be mounted at ``prefix``

    """

    def __init__(self, prefix, yuiapp, **kw):
        super(YuiSelfHosted, self).__init__(prefix, **kw)
        self.yuiapp = yuiapp

    @classmethod
    def from_distro_zipfile(cls, prefix, filename, **kw):
        from paste.fileapp import ArchiveStore
        app = ArchiveStore(filename)
        @webob_wrap
        def restrict_app(req):
            req.path_info = '/yui/build' + req.path_info
            return req.get_response(app)
        return cls.from_app(prefix, restrict_app, **kw)

   @classmethod
   def from_directory(cls, prefix, dirpath, **kw):
       from paste.urlparser import StaticURLParser
       app = StaticURLParser(dirpath)
       return cls.from_app(prefix, app, **kw)

    @classmethod
    def from_app(cls, prefix, yuiapp, debug=False, minify=False, gzip=True):
        return cls(prefix, cls.wrap_app(yuiapp, minify=minify, gzip=gzip), debug=debug)

    @classmethod
    def wrap_app(cls, app, minify, gzip):
        if minify:
            app = jsminify_middleware(app)
        if gzip:
            from paste.gzipper import middleware as gzip_middleware
            app = gzip_middleware(app)
        return app

Опять, не прибегая ни к каким трюкам, получился качественный код. В этой реализации мы используем написанную в предыдущей статье мидлварь и вместо того чтобы для минификации добавлять суффикс —min к имени файла, мы минифицируем его самостоятельно. Это позволяет минифицировать также отладочные версии скриптов (полезно разве что для отладки скриптов удаленно размещенного приложения, что случается не часто), но главное наша минификация отрезает заголовки с копирайтом которые сохранены в файлах из дистрибутива — каждый байт на счету! А если серьезно, то собственная минификация пригодится на следующем этапе.

Мы также оборачиваем приложения в gzipper, но и это можно отключить передав конструктору gzip=False.

Обратите внимание на restrict_app из from_distro_zipfile, таким образом мы ограничиваем доступ папкой build из дистрибутива и облегчаем себе одну предстоящую задачу (о которой позже).

Собственно в интеграции генератора ссылок и самого WSGI приложения со скриптами нет ничего мудреного, у генератора есть атрибут yuiapp с приложением, которое скрипт конфигурации должен сделать доступным по префиксу указанному в конструкторе.

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

Поскольку мы отказались от использования специальных систем конфигурации, получившийся код готов к употреблению. App — приложение использующее значение аргумента своего конструктора как генератор ссылок на скрипты. Оно делает вызовы вроде self.yui_linkgen.js_links([…]) и потому полностью совместимо со всеми нашими реализациями, никакие их внутренние отличия его не касаются.

Уже размещенные скрипты:

yui = YuiLinkGen('http://static.website.com/scripts/yui', minify=True)
application = App(yui)

На серверах Yahoo:

yui = YuiYahoo(version='2.5.2')
application = App(yui)

Самостоятельно:

yui = YuiSelfHosted.from_directory('/_yui', '/home/web/checkouts/yui/build', minify=True)

или

yui = YuiSelfHosted.from_distro_zipfile('/_yui', 'yui_2.5.1.zip')

root = URLMap()
root['/_yui'] = yui.yuiapp
root['/'] = App(yui)

application = root

Или в ходе тестирования:

serve(root)

Если мы знаем домен по которому будет размещен root, то стоит добавить его к первому аргументу.

Обратите внимание, что однажды создав экземпляр YuiLinkGen, мы можем использовать его многократно, если у нас есть несколько приложений способных использовать такую компоненту разумно передавать им одну и ту же копию. Реализовать это не используя в конфигурации Python было бы затруднительно, к тому же не ясно: чего ради?

Склеивание скриптов

Если в прошлой статье мы сумели сделать минификацию более удобной, то возможно нам удастся упростить склейку скриптов? Чем больше запросов браузер шлет к серверу тем обычно больше задержка при загрузке страницы, поэтому при переходе в продакшн толково сделанные (читай «не встречающиеся в природе») сайты склеивают свои скрипты в один файл, все CSS-файлы в другой и используют их в таком виде. Это уменьшает количество запросов к серверу, что в свою очередь уменьшает нагрузку на него, делает проверку на изменения гораздо более быстрой (важно при обновлении страницы пользователем), gzip на склеенных скриптах эффективнее чем на раздельных итд итп. Иногда для разных страниц нужно использовать разное подмножество скриптов и тогда у подхода описанного ниже обнаружатся и недостатки, но это особый случай и решать его также нужно отдельно. Для большинства случаев мы получим заметный выигрыш используя следующую стратегию.

Для начала мы сделаем middleware способную склеивать скрипты на лету. Мы хотим чтобы путь вида /history/history;/json/json.js был командой к тому чтобы вернуть склеенные /history/history.js и /json/json.js. Мы реализуем это как middleware а не новое приложение склеивающее файлы с диска для того чтобы воспользоваться, среди прочего, предоставлением файлов из архива. К этому, безусловно, нужно добавить кеширование, но в этой статье мы это опустим.

@webob_middleware
def jsjoin_middleware(req, app):
    if req.path_info.endswith('.js') and req.method in ['GET', 'HEAD']:# and not req.query_string
        parts = req.path_info[:-3].split(';')
        if len(parts) > 1:
            subresponses = []
            for part in parts:
                subreq = req.copy()
                subreq.remove_conditional_headers()
                subreq.path_info = part + '.js'
                subres = subreq.get_response(app)
                if subres.content_type not in js_mimetypes or subres.status_int != 200:
                    return HTTPNotFound(comment="%s not found" % subreq.url)
                subresponses.append(subres)
            r = Response(content_type='text/javascript', charset='UTF-8')
            bodies = []
            for subr in subresponses:
                if subr.charset:
                    bodies.append(subr.unicode_body or '')
                else:
                    bodies.append(unicode(subr.body or ''))
            r.unicode_body = u'\n\n'.join(bodies)
            r.md5_etag()
            r.last_modified = max([subr.last_modified for subr in subresponses if subr.last_modified] or [None])
            r.conditional_response = True
            return r
    return req.get_response(app)

Для наших нужд можно было бы опустить работу с юникодом и генерацию правильного last-modified, но я привожу эти фрагменты, чтобы не создать ложного впечатления о том насколько всё просто. Всё просто, но всё же нужно быть внимательным к деталям. По уму также можно не генерировать тело ответа целиком, а склеивать его по мере надобности в app_iter. Главным преимуществом этого была бы экономия в случае ответов 304 Not Modified, но, поскольку мы собираемся кешировать ответы, то генерация полного тела ответа — правильный подход.

Генерация ссылок на склеенные скрипты

Теперь нужно научить нашу реализацию генерировать ссылки на такие склеенные скрипты.

class YuiMerge(YuiLinkGen):
    js_link_template = '<script type="text/javascript" src="%s/%s.js"></script>'
    js_part_template = '%(name)s/%(name)s%(suffix)s'

    def js_links(self, names):
        """
        Same as YuiLinkGen.js_links but generates a link that will fetch all scripts as one file
        """
        parts = []
        subst = {'suffix': self.suffix}
        for name in names:
            subst['name'] = name
            parts.append(self.js_part_template % subst)
        return self.js_link_template % (self.prefix, ';/'.join(parts))

Это прямой наследник YuiLinkGen и может использоваться в тех же случаях. Например вместо блока ссылок вначале статьи он вернет

<script type="text/javascript" src="/yui/history/history;/animation/animation;/json/json.js"></script>

Может ли случиться что нам нужно генерировать такие ссылки не создавая соответствующего приложения для склейки? Это возможно, если такое приложение размещено на другом сервере или процессе. Для более частого случая, когда нам нужны обе части, пригодится следующий класс:

class YuiHostedMerge(YuiMerge, YuiSelfHosted):
    @classmethod
    def wrap_app(cls, app, *args, **kw):
        app = jsjoin_middleware(app)
        return super(YuiHostedMerge, cls).wrap_app(app, *args, **kw)

Обратите внимание на граф наследования, вернитесь к коду и прочитайте его весь еще раз. Заметьте, что YuiJoinHostedLinkGen унаследовал конструкторы from_directory и from_distro_zipfile с соответствующей им семантикой, но добавил поддержку склеивания. Я хочу еще раз подчеркнуть, что если вы пишете код в таком стиле, то вы используете или учитесь использовать те же навыки и архитектурные решения что и при разработке в любой другой области. Если вы умеете применять ООП к месту, если вы видите смысл в документировании кода, если имеете навык деления функциональности на компоненты, то это пригодится в любой области программирования. Нельзя применять особые критерии к веб-разработке: хранение запроса в глобальной переменной — в любом случае извращение, много кода ни о чем — плохой знак, если нет ясных стыков, на которых нужно писать документацию — плохи дела и т.д. Сделать хорошо — можно, но для этого нужно иметь свободу делать как угодно, и для этого WSGI бесценен.

webob_middleware

В конце прошлой статьи я предлагал написать реализацию декоратора webob_middleware (мы использовали его в этой статье) который превращал бы функцию с сигнатурой (Request, WSGI) -> WSGI в конструктор соответствующей мидлвари. Сегодня было написано немало приличного кода, поэтому в качестве передышки предлагаю решение в одну строчку (вообще так писать не надо):

webob_middleware = lambda mw: lambda app: webob_wrap(lambda req: mw(req, app))

Также см. ответ на критику (написано после 3й, опубликовано после 4й части).

If my ideas are intriguing to you and you wish to subscribe to my newsletter, you can do that via RSS, Twitter, Google+ or even actual email.