×Закрыть

Python: Веб-разработка без фреймворков (часть 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 src="<a href=" target="_blank" type="text/javascript">yui.yahooapis.com/…<wbr>ld/history/history-min.js</a>"></script>
<script src="<a href=" target="_blank" type="text/javascript">yui.yahooapis.com/…<wbr>nimation/animation-min.js</a>"></script>
<script src="<a href=" target="_blank" type="text/javascript"><a href="http://yui.yahooapis.com/…" target="_blank">yui.yahooapis.com/…</a><wbr></wbr>.2/build/json/json-min.js"></script>

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

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

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

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

class YuiYahooHosted(object):    
    prefix_template = '<a href="http://yui.yahooapis.com/%s/build/'" target="_blank">yui.yahooapis.com/%s/build/</a>
    prefix_cn_template = '<a href="http://cn.yui.yahooapis.com/%s/build/'" target="_blank">cn.yui.yahooapis.com/%s/build/</a>
    js_link_template = '<script src="%(prefix)s%(name)s/%(name)s%(suffix)s.js" type="text/javascript"></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 = '<a href="http://yui.yahooapis.com/%s/build'" target="_blank">yui.yahooapis.com/%s/build</a>
    prefix_cn_template = '<a href="http://cn.yui.yahooapis.com/%s/build'" target="_blank">cn.yui.yahooapis.com/%s/build</a>

    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('<a href="http://static.website.com/scripts/yui'" target="_blank">static.website.com/scripts/yui</a>, 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.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 бесценен.

PS

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

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

6 комментариев

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

1) Закоммитил фикс.2) Действительно в статье куда-то потерялось. Патч:

else:          bodies.append(unicode(subr.body or ’’))+      r.unicode_body = u’nn’.join(bodies)      r.md5_etag()      r.last_modified = max([subr.last_modified for subr in subresponses if subr.last_modified] or [None])

jsjoin_middleware (req, app): 1) req.copy () вызывает ошибку (скорее всего бага webob) >>> r = webob.Request.blank ( “/” ) >>> r.copy () Traceback (most recent call last): File “", line 1, in r.copy () File “buildbdist.win32eggwebob< a href= ‘http://init.py’ rel= ‘nofollow’ > init.py”, line 1037, in copy File “buildbdist.win32eggwebob< a href= ‘http://init.py’ rel= ‘nofollow’ > init.py”, line 1106, in copy_bodyTypeError: an integer is required>>> r.body’’>>> r.body = r.body>>> r.copy () “пропатчить” можно на лету вот так: ...req.body = req.bodyfor part in parts: subreq = req.copy () subreq.remove_conditional_...2) bodies никуда не привязан

Ваш вариант проще в реализации, а мой проще в использовании. Я предпочитаю последнее.

а можно и так:

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

конечно не так универслаьно, но зато проще

Надо же, кто-то заметил =) Текущая версия декораторов доступна в WebOb/contrib/decorators.py (svn). Там же доки и тесты.Почему и как это работает недавно писал Бикинг.

декоратор @webob_wrap нужно модифицировать для работы в классе.

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