Biggest DevOps Conference in Ukraine! Kubernetes, TensorFlow, KubFlow, Cloud solutions, Ballerina and much more. Register until August, 22!

Python: Веб-разработка без фреймворков (часть 5)

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

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

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). Но из-за этого перестанут работать все запросы на частичное содержимое и потому такую мидлварь нужно будет применять осторожнее, чтобы ненароком не обернуть ей, например, какой-то большой файл для скачки.

Nuke the entire site from orbit. It’s the only way to be sure.

Для таких случаев также предусмотрен метод 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</code><code>

Ответы можно давать в виде ссылок на ваш вариант на pastebin.

LinkedIn

4 комментария

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

А, ну с вторым всё ясно, а третьему вроде оды поют, было интересно.

мы разрабатываем веб приложения для интранета.получается так, что мне не нужен монстроидальный зоп, сложность и ограничения которые он накладывает при разработке.мы пишем свой небольшой корпоративный фреймворк на основе wsgi где будет собрано все самое нужное.может в Zope 3 будет все по-новому, но я пока знаком только с двойкой

На здоровье.В принципе первая задача в самом простом виде решается буквально за пару минут, а вторую можно даже быстрее.Можно пару слов о том почему уходите с Zope? =)

интересная серия статей, спасибо! сейчас перехожу с Zope на Apache + WSGI. если времени хватит с удовольствием займусь вашими задачами

Подписаться на комментарии