понедельник, 28 февраля 2011 г.

Python 3.2

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

Занятно другое.
20 февраля 1991 был выпущен Python 0.9 - первый официальный релиз Питона. Кажется, поставлялся исключительно в виде исходников.
20 февраля 2011 вышел Python 3.2

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

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

Долгих лет моему любимому языку программирования!

воскресенье, 27 февраля 2011 г.

Питон: времена, даты и временные зоны

Сегодня поговорим о дате-времени.

При этом я не хочу отдельно останавливаться на модуле time - слишком он низкоуровневый.

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

Давайте посмотрим на элементарный пример:

>>> from datetime import *
>>> dt = datetime.now()

Что может быть проще?

Правильно ли так писать? Ответ будет довольно неожиданным: когда как...

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

Назовем их относительным и абсолютным временем.

Относительное время

Этот тип времени никогда не пересекает границ программы, не сохраняется в базе данных и не передается по сети.

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

У относительного времени (naive в английской терминологии) нет информации о временной зоне (timezone). Наш простейший пример создавал именно такое время.

Большинство используемых в небрежно составленной программе времен - относительные. И это далеко не всегда корректно.

Абсолютное время

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

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

Временные зоны

Позвольте немного подробней рассказать о временных зонах.

Это наши привычные смещения относительно времени по Гринвичу. Например, в моём Киеве локальное время отличается на +2 часа (летом +3).

Объекты datetime и time могут принимать необязательный парамер по имени tzinfo.

Этот параметр должен быть или None или экземпляром класса, унаследованного от базового абстрактного класса tzinfo.

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

Теория работы с абсолютным временем

Когда пишем время в базу данных или пересылаем с одной машины на другую - во избежание проблем следует пользоваться абсолютным временем, а еще лучше приводить время к Гринвичу (так называемому UTC).

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

Чтобы окончательно всё запутать, существует летнее время. Дата перехода на него отличается от страны к стране. Если в Украине летнее время начинается в последнее воскресенье марта, то в Бразилии, насколько я помню, оно заканчивается в последнее воскресенье февраля (при условии что эта дата не совпадает с их праздником Карнавала).

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

Если же во всех внешних коммуникациях используется UTC (которое не имеет летнего времени, между прочим) - всё получается однозначно.

К сожалению, об абсолютных временах и "правиле UTC" очень часто забывают при разработке.

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

В таких условиях осуществить миграцию на "правильный" способ уже практически нереально.

Особенности реализации относительного и абсолютного времени в Питоне

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

По умолчанию объект datetime.datetime (всё сказанное применимо и к datetime.time) создается как относительное время, то есть без временной зоны.

Более того, существует два метода получить текущее время: datetime.now() и datetime.utcnow(). Полученные значения, конечно, различаются на действующую сейчас временную разницу. При этом вы не можете программно понять, где время в UTC а где - локальное.

Базы данных вносят дополнительные оттенки. Некоторые могут хранить времена в абсолютном формате, другие - нет. Впрочем, это не важно - даже если конкретная база данных поддерживает абсолютное время и библиотека для работы с этой базой умеет понимать временные зоны - используемая вами объектно-реляционная надстройка (ORM) скорее всего это ценное умение игнорирует.

Поэтому единственный надежный способ работы с базами данных - использовать относительное время, явно преобразуя его в UTC.

На базах данных странности не кончаются.

Дело в том, что только кажется: в Питоне есть лишь один datetime.datetime. Хотя технически все объекты класса datetime.datetime используют этот единственный тип, на самом деле концептуально абсолютные времена (с указанной зоной) никак не совместимы с относительными.

Т.е. вы не можете сравнить абсолютное и относительное время, вычесть из одного другое и так далее - на любую подобную попытку получите TypeError.

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

Она содержит абстрактный класс tzinfo - и ни одной его конкретной реализации.

Лишь в Python 3.2 появилась зона datetime.utc. Локальной зоны всё ещё нет.

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

При передаче этого относительного времени на другую машину получаем неопределенность, чреватую неожиданностями. Ну, дальше вы поняли...

Абсолютные времена, pytz и dateutil

Для более или менее комфортной жизни в этом сумасшедшем доме я рекомендую следующее.

Работайте с временами в базе данных только как с относительными (никуда не денешься), но храните их исключительно в UTC.

При обработке сразу же добавляйте временную зону UTC в явном виде. Для ORM процесс можно автоматизировать, унаследовавшись от существующего в вашей ORM описания колонки (поля) DateTime и добавив в преобразователи явное приведение временных зон. То же самое относится и, например, к библиотекам GUI.

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

Вообще-то говоря, к примеру, измерить время выполнения алгоритма можно и применяя относительное время.

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

Операции с абсолютными временами безопасны, даже если они относятся к разным временным зонам - Питон всё учитывает.

Остается вопрос, где эти временные зоны брать.

Существует прекрасная библиотека dateutil.

Она много чего умеет, неплохо расширяя стандартную datetime.

Что касается временных зон:

>>> from dateutil.tz import tzutc, tzlocal
>>> tzutc = tzutc()
>>> tzlocal = tzlocal()

Если вам требуется получить зону по имени - используйте библиотеку pytz.

>>> import pytz
>>> tzkiev = pytz.timezone('Europe/Kiev')
>>> print tzkiev
Europe/Kiev

Классы временных зон для dateutil и pytz немного отличаются списком доступных методов. Вы можете прочесть все детали в документации. Для этого изложения важно то, что они оба поддерживают интерфейс, требуемый для datetime.tzinfo.

Теперь минимальный набор операций.

Получение пресловутого текущего времени:

>>> now = datetime.now(tzlocal)
>>> print now
2011-02-25 11:52:59.887890+02:00

Преобразование в UTC:

>>> utc = now.astimezone(tzutc)
>>> print utc
2011-02-24 09:52:59.887890+00:00

Объект utc можно класть в базу данных или пересылать по сети - он преобразован с учетом смещения.

Отбросить временную зону (если, например, база данных не игнорирует эту информацию, а требует явного относительного времени):

>>> naive_utc = utc.replace(tzinfo=None)
>>> print naive_utc
2011-02-24 09:52:59.887890

Добавить временную зону к объекту, полученному из базы данных

>>> utc2 = naive_utc.replace(tzinfo=tzutc)
>>> print utc2
2011-02-24 09:52:59.887890+00:00

Преобразовать абсолютное UTC время в абсолютное локальное

>>> local_dt = utc.astimezone(tzlocal)
>>> print local_dt
2011-02-25 11:52:59.887890+02:00

Те же операции следует проводить и с GUI, переводя в локальное относительное время и обратно, если GUI библиотека не поддерживает временные зоны.

Итоги

  • Старайтесь работать с абсолютными временами.

  • Для общения с внешним миром предпочитайте UTC.

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

  • Помните: ввести абсолютные времена на разросшийся и уже массово использующийся проект стоит чрезвычайно дорого.

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

    Гораздо дешевле делать "правильно" с самого начала.

Предупрежден - значит вооружен!

Удачного вам плаванья и семи футов под килем!

Ссылки

четверг, 24 февраля 2011 г.

Регрессионные тесты

Продолжаю предыдущую статью.

Всё та же несложная консольная программа, управляющая этим блогом.

И прежняя главная цель: сделать разработку стабильной.

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

Юниттесты - не универсальное решение

К сожалению, моя программка временами всё ешё глючит. Более того, когда я правлю код, относящийся к одной команде - может совершенно неожиданно сломаться другая, никаким образом (казалось бы!) с первой не связанная.

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

Здесь присутствуют несколько проблем:

  • Юниттесты должны исполнятся в виртуальном окружении.

    Т.е. не могут обращатся к сети, например. Работа с файловой системой допускается, но не очень желательна.

    Мое приложение простое: у него нет базы данных (её заменяет yaml файл), системы распределенных сообщений и других занятных образцов инженерной мысли.

  • Юниттест всегда работает в одиночку.

    Это значит, что TestCase должен подготовить ему это самое виртуальное окружение, выполнить тест и убрать за собой. Даже тесты внутри одного TestCase работают независимо, порядок их вызова не определен. Кроме того, не все они обязаны быть вызванными.

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

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

  • Список неудобств можно перечислять очень долго, но о последнем и самом главном скажу особо. Ручное тестирование в моем случае предполагает запуск серии команд в консоли с контролем их выполнения. Программа рассматривается как большой черный ящик.

    У юниттестов совершенно другая идеология. Если хотите, юниттесты скорее предназначены для тестирования API библиотек чем конечных приложений.

    Конечно, даже для моей маленькой программы можно выделить "библиотечную" часть. Собстенно говоря, это происходит естественным образом в ходе правильного проектирования. Скажу больше - эта часть есть, и юниттесты покрывают именно её.

Остаётся довольно много функционала, под юниттесты не попадающего - и расширить тестовое покрытие до полного крайне неудобно по принципиальным причинам.

Маленький совет. Я стараюсь разносить покрывающийся тестами код и не тестирумый по разным модулям. Если для модуля есть хоть один тест - то покрытие для этого модуля должно стремиться к полному. Это сильно облегчает анализ и придает некоторую строгость подходу.

Ручное тестирование

Оно великолепно! Нет, кроме шуток!

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

  • Это - долго. Я печатаю довольно быстро - но не настолько же!
  • Это - утомительно. При повторении одних и тех же действий очень быстро устаю.
  • Самый минимальный прогон - два десятка команд. Более или менее полный - около сотни. И это - очень простая система!
  • Наконец, я в такие моменты ощущаю себя обезьяной. Определение "мартышкин труд" очень хорошо подходит к ручному тестированию.

В результате:

  • Ручное тестирование никогда не выполняется в полном объеме - задачка явно превышает человеческие возможности.
  • В силу предельной однообразности действий внимание притупляется, что тоже отрицательно сказывается на качестве.
  • Даже при скурпулёзном следовании бумажному тестовому плану (кто их пишет?) - процесс занимает непозволительно много времени.

Коллега Kogrom подсказывает:

Регрессионные тесты не ограничиваются только юнит-тестами.Есть ещё и регрессионные функциональные тесты, которые требуют больше ресурсов и времени, но зато более близки к ручному тестированию.

Постановка задачи: требуется регрессионный функциональный тест

  • Нужно запускать команды сценария так, как это делал бы человек.
  • Проверять результаты каждого вызова.
  • Это должна быть линейная последовательность действий. Если она сломалась на каком-то шаге - выполнять оставшуюся часть не нужно, это просто бессмысленно (сравните с работой юниттестов).
  • Последнее, немаловажное требование: запись сценария должна быть лаконичной. Не забывайте про обезьяну за клавиатурой!

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

Решение

Технически задачка выглядит простой. Требуется запускать программу через subprocess.Popen, смотреть на код завершения и сравнивать перехваченные stdout и stderr с ожидаемыми.

Первый же вариант делал всё, что требуется.

Когда я написал код проверки пяти вызовов - устал. Copy-Paste очень сильно помогает в такой работе, но текста получается очень много.

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

Автоматизация - налицо, но назвать удобной эту систему можно, только сильно покривив душой.

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

Начало работы:

def run():
    folder = os.path.dirname(os.path.abspath(sys.argv[0]))
    regr_folder = os.path.join(folder, 'regr-data')
    blog_cmd = 'blog'

    exe = Executor(blog_cmd, regr_folder)
    exe.rmtree()
    exe.mkdir('.')

    project_dir = exe.full_name('sample_blog')

Теперь кусок теста. Пытаемся создать проект, проверяя успешность попытки. В случае любых затруднений - выбрасывается исключение:

    CREATE_PROJECT = Q("INFO Create project {0!q}")
    ALREADY_IN_PROJECT = Q("ERROR Already a project: {0!q}")

    print 'init project'
    out = exe.go('init sample_blog')
    CREATE_PROJECT(project_dir) == out

    print 'init the same project'
    out = exe.go('init sample_blog', retcode=255)
    ALREADY_IN_PROJECT(project_dir) == out

Простые проверки неплохо работают, перегруженный оператор Q.__eq__ выбрасывает исключение, если результат сравнения - ложь.

Добавляем файл статьи и генерируем для него html:

    print "****** SIMPLE HTML *******"

    ADD = Q("INFO Add {name!q} -> {file!q}")

    HTML = Q("INFO Generate html for {name!q}")
    HTML_WARNING_NO_TEMPLATE = HTML + '\n' + Q("""
        WARNING User settings has no template specified.
        Use markdown output as html.
        """)

    TXT = 'Text of sample article'
    INNER_HTML = '<p>' + TXT + '</p>'

    print "add post"
    with exe.cd('sample_blog'):
        exe.write('article.rst', TXT)
        rst_fname = exe.full_name('article.rst')
        out = exe.go('add article.rst')
    ADD(name='article', file='article.rst') == out

    print "generate html without template"
    with exe.cd('sample_blog'):
        out = exe.go('html article.rst')
        Q(INNER_HTML) == exe.read('article.html')
    HTML_WARNING_NO_TEMPLATE(name='article', file=rst_fname) == out

Для простых случаев всё отлично. Но одним сравнением дело обходится не всегда. Нужны регулярные выражения.

Получаем разнообразную информацию о добавленном файле:

    POST_SHOW = Q("""\
        INFO Post {changed}{name!q}
            title: {title}
            link: {link}
            slug: {slug}
            labels: {labels}
            postid: {postid}
            localstamp: (?P<localstamp>.+?)$
    """)

    print 'show post article'
    with exe.cd('sample_blog'):
        out = exe.go('post article')
    ret = POST_SHOW(name='article',
              title='',
              changed='[*]', # screen * regexp spec symbol
              link='',
              slug='article',
              labels='a',
              postid='').match(out)
    localstamp = ret['localstamp']

Вместо проверки на == выполняем поиск по регулярному выражению .match. Найденные регуляркой группы можно запомнить для дальнейшего использования, проверить на соответствие и так далее.

Не хочу построчно всё описывать - долго, утомительно и неблагодарно. Вы, наверное, уже заметили, что вся работа строится вокруг двух классов:

  • Executor (exe) - работает как пользовательская консоль. Создаёт папки и файлы, запускает команды.

  • Q, что значит query - сравнивает свой шаблон с аргументом (__eq__), умеет делать поиск по регулярному выражению (.match), работать с результатом успешного поиска по регулярке (она же regexp).

    Этот класс очень привередлив, что крайне важно. Если сравнение или работа поиск по регулярному выражению будут неудачными - выбросится исключение.

    Именно потому я использую нечто вроде CREATE_PROJECT(project_dir) == out для проверок.

Executor

Тривиальная обертка вокруг нескольких функций os и subprocess.

 class Executor(object):
     def __init__(self, blog_cmd, regr_folder):
         self.blog_cmd = blog_cmd
         self.regr_folder = os.path.abspath(regr_folder)
         self.cwd = [regr_folder]
         self.out = None
         self.retcode = None

     @contextlib.contextmanager
     def cd(self, arg):
         self.cwd.append(os.path.abspath(os.path.join(self.cwd[-1], arg)))
         yield
         self.cwd.pop()

     def get_cwd(self):
         return self.cwd[-1]

     def mkdir(self, arg):
         os.makedirs(os.path.join(self.cwd[-1], arg))

     def rmtree(self):
         folder = self.cwd[-1]
         if os.path.exists(folder):
             shutil.rmtree(folder)

     def write(self, fname, text):
         with open(self.full_name(fname), 'wb') as f:
             f.write(text)

     def read(self, fname):
         with open(self.full_name(fname), 'rb') as f:
             return f.read()

     def full_name(self, fname):
         return os.path.join(self.cwd[-1], fname)

     def go(self, args, retcode=0):
         """run blog with args, return stdout merged with stderr"""
         args_list = shlex.split(args)
         proc = subprocess.Popen([self.blog_cmd] + args_list,
                                 cwd=self.get_cwd(),
                                 stdout=subprocess.PIPE,
                                 stderr=subprocess.STDOUT)
         self.out, err = proc.communicate()
         self.retcode = proc.returncode
         if retcode is not None:
             if retcode != self.retcode:
                 raise RuntimeError("RETCODE %s, EXPECTED %s\n%s" %
                                    (self.retcode,
                                     retcode,
                                     self.out)
                                    )
         return self.out

Откровенно говоря, всё просто как пять копеек.

cd, mkdir, read, write говорят сами за себя.

go запускает тестируемую программу с нужными аргументами, проверяет ее код завершения и возвращает объединенный вывод стандартных потоков.

shlex.split используется для правильного разбиения полученной командной строки на составные части.

Query

Проверяет шаблон на соответствие строке. Проверки бывают двух типов: на равенство и на регулярное выражение. Если проверка не проходит - выбрасывается исключение.

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

Для большего удобства Q наследуется от Template (смотрите "Форматирование строк") и использует NamespaceFormatter, возвращающий None для каждого неизвестного параметра.

 class Q(Template):
     """Query, used for result check"""
     NS = NamespaceFormatter(collections.defaultdict(lambda: None))

     def format(__self, *__args, **__kwargs):
         return Q(__self.NS.vformat(__self, __args, __kwargs))

     def eq(self, other, strip=True):
         if strip:
             other = other.strip()
         if str(self) != str(other):
             raise RuntimeError('Not Equals\n%s\n----------- != ------------\n%s'
                                % (self, other))

     def __eq__(self, other):
         return self.eq(other)

     def __ne__(self, other):
         return not self.eq(other)

     def match(self, test, strip=True):
         if strip:
             test = test.strip()
         match = re.match(self, test, re.M)
         if match is None:
             raise RuntimeError("%s\n doesn't match pattern\n%s" % (test, self))
         return Match(match)

     def ifind(self, test, strip=True):
         if strip:
             test = test.strip()
         return (Match(m) for m in re.finditer(self, test, re.M))

 class Match(object):
     def __init__(self, match):
         self.match = match

     def check(self, **kwargs):
         groups = self.match.groupdict()
         for name, val in kwargs:
             group = groups(name)
             Q(val) == group

     def __getitem__(self, key):
         return self.match.groupdict()[key]

Примеры наглядно демонстрируют применение класса Q, что-то дополнительно объяснять вроде бы и не нужно.

Результат работы

Вот и всё.

У меня есть очень длинный сценарий регрессионного теста:

  • Изделие работает, и делает своё дело очень хорошо.
  • Добавлять новые тесты легко и приятно.
  • Что не менее важно - сценарий легко читать.
  • Запуск регрессионного теста позволяет с большой степенью уверенности говорить о том, что работа программы не нарушена.
  • На создание инфраструктуры я потратил от силы час. Конечно, написание самих тестов (и, главное, устранение выявившихся в процессе ошибок в программе) заняло куда больше времени - но оно того стоило.
  • Теперь я запускаю эти тесты перед каждым commit, вместе с юниттестами, ожидаю завершения - и таки делаю фиксацию с чистым сердцем или, что чаще бывает, продолжаю править баги.

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

Заключение

Возникают несколько вопросов.

Почему бы не отказаться от юнит-тестирования и делать только регрессионные тесты?

Ответ сложный.

  • Регрессионный тест смотрит на программу снаружи, не вникая в детали. Для тестирования внутренней логики лучше подходят именно юниттесты. Они в моем тривиальном случае отрабатывают примерно за секунду. Регрессионный занимает больше минуты.

  • У юниттестов есть своя ниша, в которой они хороши. Помните, что я писал о библиотеках в начале этой статьи?

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

    И это может быть очень медленно.

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

Если бы регрессионные тесты выполнялись мгновенно - большая часть вопросов бы отпала. К сожалению, это не так.

Я рассмотрел пример тестов для консольного приложения. Что делать в других случаях?

Да то же самое!

Для Веба есть Mechanize, twill, Selenium. Можно виртуально ходить по ссылкам, не запуская настоящего браузера. Предоставьте эту честь роботу!

Для GUI тоже есть способ. Проще всего воспользоваться средсвами используемой вами GUI библиотеки. Запустите приложение в том же процессе и в idle цикле эмулируйте действия пользователя. Ввводите текст и посылайте сообщения о том, что нужная кнопка нажата. Заодно библиотека GUI даст возможность узнать содержимое текстовых полей и так далее. Это удобно!

И, самое главное - регрессионные тесты выполняются автоматически. Достаточно запустить скрипт и дождаться его окончания. Всю тяжелую "обезьянью" работу выполняет скурпулёзный и внимательный компьютер, который не подвержен лени.

вторник, 22 февраля 2011 г.

Форматирование строк

Речь идет об обычных строках, не касаясь шаблонных движков и прочих занятностей.

  • Вы используете эту операцию по много раз на дню.
  • Вы даже предпочитаете новый стиль форматирования, использующий .format вместо %.
  • Вы знаете о форматировании всё!

Я тоже так считал, пока один случай не заставил пересмотреть своё мнение.

Думаю, эти несколько рецептов по форматированию будут интересны и полезны.

Пристегнулись? От винта! Поехали!

Конструктор

Приключения начинались довольно невинно. У меня появилось немало многострочных переменных. Для их удобного задания очень хорошо подходит функция textwrap.dedent. Она убирает общие ведущие пробелы из каждой строки текста:

def test():
    # end first line with \ to avoid the empty line!
    s = '''\
    hello
      world
    '''
    print repr(s)          # prints '    hello\n      world\n    '
    print repr(dedent(s))  # prints 'hello\n  world\n'

Проблем две. Я часто забываю ставить обратный слеш в начале. И последний возврат каретки обычно тоже не нужен.

А ещё очень хочется, чтобы строки всегда были юникодом независимо от того, указал я это явно через u'some string' или нет. И временами требуется сдвинуть весь текст на несколько пробелов вправо.

class Template(unicode):
    def __new__(cls, pattern, **kwargs):
        strip = kwargs.get('strip', True)
        dedent = kwargs.get('dedent', strip)
        indent = kwargs.get('indent', 0)
        if dedent:
            pattern = _dedent(pattern)
        if strip:
            pattern = pattern.strip()
        if indent:
            prefix = ' ' * indent
            pattern = '\n'.join(prefix + line for line in pattern.splitlines())
        return super(Template, cls).__new__(cls, pattern)

    def __add__(self, other):
        return self.__class__(unicode(self) + other, strip=False)

    __iadd__ = __add__

Класс Template наследуется от unicode - значит все Template строки тоже становятся юникодными.

Необязательные настройки передаются как keyword only arguments:

  • strip - включить обрезку основного параметра. По умолчанию установлена.
  • dedent - включить удаление ведущих пробелов. По умолчанию равна актуальному значению strip. Т.е. если пользователь обрезку явно выключил, то и ведущие пробелы по умолчанию удалять тоже не нужно.
  • indent - количество пробелов, на которые нужно сдвинуть каждую строку получившегося текста вправо.

Заодно переопределим конкатенацию, чтобы возвращала объекты нашего класса. Зачем? Пригодится! Разумное поведение при конкатенации - не обрезать получившийся результат.

Аппетит приходит во время еды

В новой нотации задания шаблонов используется понятие явного конвертера. Например, "{0!r:>10}" указывает, что для форматируемого значения нужно сначала взять repr, а затем уже к результату применять выравнивание.

Необязательный явный конвертер задается одной буквой. По умолчанию их два: r для repr и s для str. Остается еще так много незадействованных букв!

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

Или другие, форматирующие принятым в программе способом время и дату. И так далее.

Поведение стандартного форматирования можно расширить.

class Formatter(string.Formatter):
    def convert_field(self, value, conversion):
        if 'q' == conversion:
            return qname(value)
        else:
            return super(Formatter, self).convert_field(value, conversion)

_formatter = Formatter()

Создаём свой собственный форматировщик, умеющий обрабатывать явное преобразование q -> qname в дополнение к стандартным.

Изменить стандартный системный форматировщик нельзя. Зато можно переопределить метод .format для нашей строки:

class Template(unicode):
    ...

    def format(__self, *__args, **__kwargs):
        # use __self etc to don't clash with __kwargs keys
        return _formatter.vformat(__self, __args, __kwargs)

    def __format__(__self, *__args, **__kwargs):
        return __self.format(*__args, **__kwargs)

    def __call__(__self, *__args, **__kwargs):
        return __self.format(*__args, **__kwargs)

Присутствует небольшая хитрость: имена параметров начинаются с __. Это сделано для того, чтобы можно было вызывать .format(self='self value') без конфликта имён.

Заодно и переопределим __call__, чтобы вместо Template('{0!q}').format('a b') можно было писать просто Template('{0!q}')('a b'). Результатом обоих вызовов будет '"a b"'.

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

__format__ нужен для того, чтобы заработало выражение вроде: format(Template('{0!q}'), 'a b').

И тут Остапа понесло

Для уточнения мелких деталей я просматривал PEP 3101, описывающий спецификацию на новомодный тип форматирования. И увидел там замечательную идею: форматирование с использованием пространств имён.

Идея очень простая. При определении значения именованного параметра сначала ищем в явно заданных аргументах, а если не нашли - последовательно перебираем все переданные в конструктор форматировщика пространства имен (простые словари).

class NamespaceFormatter(Formatter):
    def __init__(self, *namespaces):
        self.namespaces = namespaces

    def get_value(self, key, args, kwargs):
        if isinstance(key, basestring):
            try:
                return kwargs[key]
            except KeyError:
                for namespace in self.namespaces:
                    try:
                        return namespace[key]
                    except KeyError:
                        pass
        return super(NamespaceFormatter, self).get_value(key, args, kwargs)

Разжевывать не буду, а приведу отрезок из теста:

    class A(object):
        def __init__(self, val):
            self.val = val

    ns1 = {'b': A(2)}
    ns2 = {'c': A(3)}
    fmt = NamespaceFormatter(ns1, ns2)
    ret = fmt.format("{a.val} {b.val} {c.val}", a=A(1))
    assert '1 2 3' == ret

Как видите, b.val берется из ns1 а c.val - из ns2.

Хорошо, а какая польза от этих пространств имен?

Очень простая: в их качестве можно задавать стандартные globals и locals.

global_name = 'global'

def test_ns():
    local_name = 'local'
    fmt = NamespaceFormatter(locals(), globals())
    ret = fmt.format("{local_name} {global_name}")
    self.assertEqual('local global', ret)

Обратите внимание: в отличие от привычного порядка locals идёт первым. Потому что имена в нём должны перекрывать имена в globals.

Немного сахара

Почти хорошо. Только приходится довольно много писать. Автоматизируем поиск пространств имён.

def auto_format(spec, **spec_kwargs):
    template = Template(spec, **spec_kwargs)
    frame = sys._getframe(1)
    fmt = NamespaceFormatter(frame.f_locals, frame.f_globals)
    return fmt.format(template)

Берём фрейм вызвавшей функции, извлекаем оттуда заветные globals и locals.

Пример:

def test_auto_format():
    local_name = 'local'
    self.assertEqual('local global',
                     auto_format("{local_name} {global_name}"))

И, наконец, позволим использовать короткие имена:

T = Template
NF = NamespaceFormatter
a = auto_format

Мы строили, строили, и наконец - построили!

Полный код (за исключением импортов и функции qname):

class Formatter(string.Formatter):
    def convert_field(self, value, conversion):
        if 'Q' == conversion:
            if value is None:
                return 'None'
            else:
                return qname(value)

        if 'q' == conversion:
            return qname(value)

        return super(Formatter, self).convert_field(value, conversion)

_formatter = Formatter()

class Template(unicode):
    def __new__(cls, pattern, **kwargs):
        strip = kwargs.get('strip', True)
        dedent = kwargs.get('dedent', strip)
        indent = kwargs.get('indent', 0)
        if dedent:
            pattern = _dedent(pattern)
        if strip:
            pattern = pattern.strip()
        if indent:
            prefix = ' ' * indent
            pattern = '\n'.join(prefix + line for line in pattern.splitlines())
        return super(Template, cls).__new__(cls, pattern)

    def format(__self, *__args, **__kwargs):
        # use __self etc to don't clash with __kwargs keys
        return _formatter.vformat(__self, __args, __kwargs)

    def __format__(__self, *__args, **__kwargs):
        return __self.format(*__args, **__kwargs)

    def __call__(__self, *__args, **__kwargs):
        return __self.format(*__args, **__kwargs)

    def __add__(self, other):
        return self.__class__(unicode(self) + other, strip=False)

    __iadd__ = __add__

class NamespaceFormatter(Formatter):
    def __init__(self, *namespaces):
        self.namespaces = namespaces

    def get_value(self, key, args, kwargs):
        if isinstance(key, basestring):
            try:
                return kwargs[key]
            except KeyError:
                for namespace in self.namespaces:
                    try:
                        return namespace[key]
                    except KeyError:
                        pass
        return super(NamespaceFormatter, self).get_value(key, args, kwargs)

def auto_format(spec, **spec_kwargs):
    template = Template(spec, **spec_kwargs)
    frame = sys._getframe(1)
    fmt = NamespaceFormatter(frame.f_locals, frame.f_globals)
    return fmt.format(template)

T = Template
NF = NamespaceFormatter
a = auto_format

Пример использования:

def info_list(self, long, indent=4):
    changed = '*' if self.changed else ''
    ret = a("{changed}{self.name!q}")
    if long:
        ret += '\n' + a("""
            title: {self.title}
            link: {self.link}
            slug: {self.slug}
            labels: {self.labels_str}
            postid: {self.postid}
            localstamp: {self.local_stamp}
            """, indent=indent)
    return ret

Ещё один пример:

USER_INFO = T("""\
    INFO User info:
        email: {email!Q}
        default blogid: {blogid!Q}
        Template:
            dir: {dir!Q}
            file: {file!Q}
    """)
ret = USER_INFO(email=email, blogid=blogid, dir=dir, file=file)

Итоги

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

Жить стало легче, жить стало веселей!

Всё вышесказанное тестировалось на Python 2.6, на "тройке" работает с одним изменением: следует везде заменить unicode и basestring на str.

среда, 16 февраля 2011 г.

Поговорим о юниттестах

Когда и как вводить тестирование

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

Существует даже известная максима: сначала тесты, а потом код.

С моей точки зрения она абсурдна: никто не утверждает, что ходить всегда нужно только с правой ноги - следует задействовать обе и почаще их менять.

Так и с тестами: код и тесты создаются одновременно.

Собственно говоря, тестирование не ограничивается одними юниттестами - но об этом как-нибудь в другой раз.

И, тем не менее, при переходе к практической стороне дела возникают сложности.

Во первых, всегда ли тесты оправданы?

Для любой более или менее большой поделки - да!

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

Дело в том, что blogspot.com, которым я пользуюсь, имеет довольно убогий редактор постов (веб-интерфейс не всегда хорошее решение). То есть написать короткую заметку еще как-то можно, но готовить к публикации объемную статью крайне неудобно.

Сначала проблема решалась написанием статьи в любимом текстовом редакторе в формате Markdown, затем $ markdown_py article.rst > article.html перегонял ее в html, который легко и просто вставлялся как тело поста.

Удобно? Не очень. При мелких правках нужно делать много ручных операций, активно орудуя мышью и клавиатурой. А посты, даже опубликованные, требуют исправлений - в них всегда обнаруживаются ошибки, неточности, и т.д.

В общем, нужен автоматический инструмент, умеющий:

  • создать проект
  • настроить его
  • добавить пост
  • отредактировать текст
  • опубликовать
  • внести правки
  • увидеть список рассинхронизированных постов
  • перезалить нужные посты на сервер (отредактировать записи на blogspot.com)

В голове сразу же возникают десяток команд вроде:

$ blog add article.rst
$ blog publish article.rst
$ blog ls

и так далее.

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

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

К тому же это был этап первичного изучения предметной области. Изначально было не совсем ясно, что именно должно объединятся под понятием "пост", какой должен быть список команд, как работать с blogger.com (так называется Google сервис для blogspot.com).

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

На этом этапе очень важна скорость: быстрее кодировать, чтобы не потерять основную мысль!

Все второстепенне детали - потом!

Оформление проекта - отложить!

Тесты - только мешают!

Кода немного, и он постоянно плывет.

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

Пришла пора создать нормальный проект, сделать кое-какой рефакторинг.

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

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

Я попросту начал тратить на тестирование непозволительно много времени в ущерб собственно программированию. При этом всегда оставался очень неплохой шанс что-либо упустить.

Пришла пора тестов. Общий объем кода уже перевалил за 2000 строк, что не так уж и мало. Покрыть всё тестами за один присест требует недюжинной силы воли - у меня её, видимо, недостаточно.

И тем не менее решение есть, и оно очень простое.

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

Очень полезно прикрутить coverage - он умеет генерировать очень симпатичный html, показывающий покрытие кода тестами. Для запуска тестов я использую nose, к которому coverage пристёгивается стандартным плагином.

Довольно быстро уровень этого покрытия становится адекватным, а разработка - удобной и приятной.

Несколько полезных трюков

Хорошо, вы прочитали первую часть и прониклись идеей. Вообще-то говоря юниттесты в любом изложении звучат привлекательно. Это способ уйти от постоянной боязни "что-то поломать". Чем больше людей работает над проектом - тем опасения выше.

И как, взяли и написали тесты, добившись стопроцентного покрытия?

Не тут-то было!

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

А тесты должны писаться легко и выполнятся быстро - иначе кому они нужны?

Давайте перейдем к практике.

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

  class Post(object):
      MARKDOWN_EXTS = ('codehighlight', 'meta')

      def __init__(self, config, name, rst_file,
                   title=None, slug=None, labels=None):
          self.config = config
          self.name = name
          self.rst_file = rst_file
          self.title = title
          self.slug = slug
          self.labels = frozenset(labels or ())

      @property
      def full_path(self):
          f = self.rst_file
          return os.path.join(self.config.root, f)

      @property
      def html_path(self):
          return self.full_path[:-4] + '.html'

      @property
      def is_html_fresh(self):
          if not os.path.exists(self.html_path):
              return False
          rst_time = os.path.getmtime(self.full_path)
          html_time = os.path.getmtime(self.html_path)
          if rst_time > html_time:
              return False
          return True

      def refresh_html(self, force=False):
          if not force:
              if self.is_html_fresh:
                  return False

          with codecs.open(self.full_path, 'r', 'utf-8') as rst:
              md = markdown.Markdown(extensions=self.MARKDOWN_EXTS)
              source = rst.read()
              html = md.convert(source)

              if 'title' in md.Meta:
                  title = ' '.join(md.Meta['title'])
                  self.title = title

              with codecs.open(self.html_path, 'w', 'utf-8') as f:
                  f.write(html)

          return True

Конструктор простой как пять копеек, но тест нужен и для него:

class TestPost(unittest.TestCase):
    def test_ctor(self):
        cfg = object()
        post = Post(cfg, 'name', 'file.rst', 'Title', 'slug', ['label1'])
        self.assertEqual(cfg, post.config)
        self.assertEqual('name', post.name)
        self.assertEqual('file.rst', post.file)
        self.assertEqual('Title', post.title)
        self.assertEqual('slug', post.slug)
        self.assertEqual(frozenset(['label1']), post.labels)

Тест на full_path не намного сложнее:

    def test_full_path(self):
        class Config(object):
            root = 'config-root'
        cfg = Config()
        post = Post(cfg, 'name', 'file.rst')

        self.assertEqual('config-root/file.rst', post.full_path)

От объекта config для этого теста требуется только root, вот и подставим его. Создавать настоящий объект Config не всегда удобно. Если можете легко его сделать и передать в конструктор Post - так и стоит поступать. На самом деле мой настоящий тест так и делает. Для статьи сойдет и облегченный вариант.

html_path пропустим ввиду его тривиальности. Т.е. тест для него прийдется тоже создать, но в этой статье ему не место.

Переходим к следующему свойству, is_html_fresh. Неприятности начались. Вызываются функции os.path.exists и os.path.getmtime, которые требуют настоящего файла, даже двух - rst и html.

1. Временные файлы

Конечно, можно их создать где-нибудь во временной папке, дать тесту отработать - а затем уничтожить. Звучит как-то грустновато, много работы для такого простого теста. Если и вторая неприятность: проверяются даты настоящих файлов. Значит, нужно использовать os.utime.

И, наконец, работа с файловой системой немного замедлит скорость выполнения тестов.

2. Monkey patching

Перед выполнением теста сделать monkey patching модуля os.path, а потом вернуть всё назад. Помимо того, что monkey patching - это нехорошо, каждый раз писать установку и восстановление довольно утомительно.

3. Необязательные параметры

Передать необязательные параметры, использующиеся только в тесте. Так как is_html_fresh - property, не допускающее параметров - придется переделать в метод:

def is_html_fresh(self, is_html_exists=None, rst_time=None, html_time=None):
    if is_html_exists is None:
        is_html_exists = os.path.exists(self.path_path)
    if not is_html_exists:
        return False
    if rst_time is None:
        rst_time = os.path.getmtime(self.full_path)
    if html_time is None:
        html_time = os.path.getmtime(self.html_path)
    if rst_time > html_time:
        return False
    return True

Что ж, способ работает. Но он - кривой. Код стал сложнее читаться, его объем вырос. В то время как ясные и понятные исходники значат очень много в жизни как программ, так и программистов. Хуже другое: без всяких на то причин изменилась сигнатура функции, в угоду одному единственному тесту.

Если бы такое изменение диктовалось внутренней логикой развития - я бы всё понял. А так имеем часть функционала, которая на самом деле не востребована.

Каждый раз глаз цепляется за этот длинный список необязательных параметров - а мозг пытается вспомнить, где же они применяются. Только в тестах? Не факт... Вы не сможете ответить на этот вопрос, не просканировав все имеющиеся исходники.

Если у меня во время работы над проектом появится потребность добавить еще один файл в список сравниваемых - что делать? Добавлять еще один необязательный параметр, или он таки не нужен?

Резюмируя, могу сказать что это - очень плохое решение. К тому же таким образом не добиться 100% тестового покрытия этого метода - надеюсь, понятно почему.

4. Использование атрибутов класса

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

Вот как это выглядит:

class Post(object):
    exists = os.path.exists
    getmtime = os.path.getmtime

    @property
    def is_html_fresh(self):
        if not self.exists(self.html_path):
            return False
        rst_time = self.getmtime(self.full_path)
        html_time = self.getmtime(self.html_path)
        if rst_time > html_time:
            return False
        return True

class TestPost(unittest.TestCase):
    def test_is_html_fresh_not_found(self):
        mocker = mocker.Mocker()

        exists = mocker.mock()
        config = mocker.mock()

        exists('root/file.html')
        mocker.result(False)
        config.root
        mocker.result('root')
        mocker.count(1, None)

        with mocker:
            post = Post(config, 'file.rst')
            post.exists = exists

            self.assertEqual(False, post.is_html_fresh)

    def test_is_html_fresh_yes(self):
        mocker = mocker.Mocker()

        exists = mocker.mock()
        config = mocker.mock()
        getmtime = mocker.mock()

        exists('root/file.html')
        mocker.result(True)
        config.root
        mocker.result('root')
        mocker.count(1, None)
        getmtime('root/file.rst')
        mocker.result(5)
        getmtime('root/file.html')
        mocker.result(10)

        with mocker:
            post = Post(config, 'file.rst')
            post.exists = exists
            post.getmtime = getmtime

            self.assertEqual(True, post.is_html_fresh)

Я использовал библиотеку mocker для создания обманок. Вкратце она работает так: записывается сценарий работы, а with mocker: его проигрывает, выполняя собственно тест. Детальное описание (к слову, исчерпывающее и довольно небольшое - за что и люблю) расположенно на странице, указанной в ссылке.

Поскольку реализация is_html_fresh использовала доступ к .exists и .getmtime через self, то вызовутся перекрытые мокером реализации, и всё будет хорошо.

Восстанавливать после теста ничего не нужно, он изменял только локально созданный объект post не трогая класс Post.

Это - хороший способ? Когда как... Если таких заглушек немного - он вполне оправдан и достаточно элегантен. Но для нашего случая есть вариант и получше.

5. Последний вариант

Обратите внимание: os.path.exists и os.path.getmtime относятся к одной и той же предметной области, как и применяющийся ниже codecs.open.

Поэтому выделяем их в отдельный класс FileSystem. А Config.file_system - отличное место для хранения этого класса.

У такого подхода есть и дополнительная выгода: FileSystem - удобный контейнер для разных полезных функций, которых нет в стандартной библиотеке (вычисление относительного пути, например).

При тестировании можно эмулировать мокером этот config.file_system, подменяя все вызовы на наши реализации.

Остается последний момент: Post использует мокер для тестов файловой системы, в то время как реальный код будет обращаться к настоящему FileSystem.

Реализация FileSystem может изменится, тесты ничего не заметят - а "живой" запуск сломается.

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

class FileSystem(object):
    class Impl(object):
        exists = staticmethod(os.path.exists)
        getmtime = staticmethod(os.path.getmtime)
        open = staticmethod(codecs.open)

    def __init__(self, config):
        self._config = config
        self._impl = Impl()

    def open(fname, mode):
        return self._impl.open(fname, mode, 'utf-8')

    def exists(self, fname):
        return self._impl.exists(fname)

    def getmtime(self, fname):
        return self._impl.getmtime(fname)

    def full_name(self, rel_name):
        return os.path.join(self._config.root, rel_name)

    def replace_ext(self, fname, new_ext):
        root, old_ext = os.path.splitext(fname)
        return root + new_ext

class Post(object):
    MARKDOWN_EXTS = ('codehighlight', 'meta')

    def __init__(self, config, name, rst_file,
                 title=None, slug=None, labels=None,
                 blogid=None
                 postid=None):
        self.config = config
        self.name = name
        self.rst_file = rst_file
        self.title = title
        self.slug = slug
        self.blogid = blogid
        self.postid = postid
        self.labels = frozenset(labels or ())

    @property
    def full_path(self):
        f = self.rst_file
        return os.path.join(self.config.root, f)

    @property
    def html_path(self):
        return self.config.file_system.replace_ext(self.full_path, '.html')

    @property
    def is_html_fresh(self):
        fs = self.config.file_system
        if not fs.exists(self.html_path):
            return False
        rst_time = fs.getmtime(self.full_path)
        html_time = fs.getmtime(self.html_path)
        if rst_time > html_time:
            return False
        return True

    def refresh_html(self, force=False):
        fs = self.config.file_system
        if not force:
            if self.is_html_fresh:
                return False

        with fs.open(self.full_path, 'r') as rst:
            md = markdown.Markdown(extensions=self.MARKDOWN_EXTS)
            source = rst.read()
            html = md.convert(source)

            if 'title' in md.Meta:
                title = ' '.join(md.Meta['title'])
                self.title = title

            with fs.open(self.html_path, 'w') as f:
                f.write(html)

        return True

    class TestPost(unittest.TestCase):
        def setUp(self):
            self.mocker = mocker
            self.config = Config('root')
            self.fs = self.mocker.mock()
            self.config.file_system._impl = self.fs
            self.post = Post(self.config, 'name', 'file.rst')

        def test_is_html_fresh_yes(self):
            self.fs.exists('root/file.rst')
            self.mocker.result(True)
            self.fs.getmtime('root/file.rst')
            self.mocker.result(5)
            self.fs.getmtime('root/file.html')
            self.mocker.result(10)

            with self.mocker:
                self.assertEqual(True, self.post.is_html_fresh)

Длинно? Я гораздо дольше писал эту статью, чем создавал тесты для "настоящего" модуля. Обратите внимение на последний вариант теста: использование setUp заметно сократило объем тестового кода.

Покрыть тестами метод refresh_html теперь не составляет никакого труда, но я не стану приводить код - поберегу ваше время, статья и так получилась не очень короткой.

Заключение

Я рассказал о том, как появляются тесты на новом проекте, почему и как это происходит.

Код не всегда удобен для тестирования - изменяйте его по мере необходимости. Здесь нужно искать баланс: все детали дизайна должны быть понятны и объяснимы без отсылок к тестам, и вместе с тем дизайн должен строится с учетом простоты тестового покрытия.

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

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

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

  1. Баг больше не повторится.
  2. Вы починили именно его, а не то о чём подумали. Нередко в попытках воспроизвести проблему на тестовом окружении с удивлением обнаруживал: код, который я собирался править - работал, а ошибка находилась совсем в другом месте.

Новый тест может выполнятся в крайне тяжело воспроизводимой вручную ситуации, и проверять очень редко исполняемые части кода - это просто замечательно!

Вот и всё. Удачного тестирования!

В продолжение - регрессионное тестирование.

Литература

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

среда, 9 февраля 2011 г.

Мультипоточность в Питоне. Часть 3 - высокоуровневые объекты

В частях 1 и 2 был описан низкоуровневый базис мультипоточных программ.

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

Поэтому поговорим об объектах мультипоточности.

С точки зрения объектно ориентированного программирования в применении к многопоточности изменяемые объекты делятся на:

  • Неизменяемые. Блокировать ничего не нужно, объект не может изменить свое состояние во время работы.

  • Не имеющие представления по потоках. Доступ к ним должен обеспечиваться явными блокировками, выполнение которых - ответственность программиста. Иначе при одновременном обращении из разных потоков возможно повреждение объекта и прочие неприятности.

  • Мониторы (monitors). Содержат внутри блокировку, которая берется при обращении к публичным атрибутам. Полностью потокобезопасные - одновременно с монитором работает только один поток.

  • Активные объекты (active objects). Содержат внутри как минимум один поток. Вызовы публичных методов активного объекта возвращают отложенное значение (future), которое будет заполнено после того, как вложенный поток произведет нужные операции.

Мониторы

Назовем объектом-монитором такой объект, доступ к публичной части которого защищен блокировкой.

import threading

class A(object):
    def __init__(self, val):
        self._lock = threaing.RLock()
        self._val = val

    @property
    def val(self):
        with self._lock:
            return self._val

    @val.setter
    def val(self, val):
        with self._lock:
            self._val = val

    def do(self):
        with self._lock:
            pass # do stuff

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

Дело вот в чём: монитор - это концепт. Если часть публичного интерфейса защищена блокировками, а часть нет - концепт оказывается порванным.

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

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

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

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

Явное выделение "чистых" мониторов способствует ясному, чистому и надежному дизайну.

Пулы потоков (thread pools)

Прежде чем перейти к обсуждению активных объектов давайте поговорим о пулах потоков.

Дело в следующем: создание потока (объекта ядра вашей операционной системы, между прочим) - довольно дорогая операция. Если код потока выполняется быстро, время создания потока может быть сопоставимо со временем исполнения этого кода.

Есть и еще одна проблема - операционные системы не очень любят когда потоков слишком много. У процесса заканчивается виртуальная память и количество доступных объектов ядра. Детали зависят от конкретной операционной системы, да на самом деле они и не нужны.

Важно запомнить: тысяча потоков - это практически всегда плохо, сотня - тоже скорее всего излишне.

Теория считает, что оптимальное количество активных (т.е. не спящих в ожидании того или иного события) потоков равно числу ядер процессора.

Для Питона это не совсем верно (всё тот же GIL), но примерный порядок чисел ясен.

Обе проблемы эффективно решаются созданием поточного пула.

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

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

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

Я видел пару десятков различных реализаций этого механизма. К счастью, анархии пришел конец и в Питоне 3.2 появляется concurent.futures .

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

from concurent.futures import ThreadPoolExecutor, as_completed

def f(a):
    # do something
    return a

with ThreadPoolExecutor(max_workers=5) as pool:
    results = [pool.submit(f, i) for i in range(100)]

    for future in as_completed(results):
        print(future.result())

Основные моменты перечислю здесь, а подробности можно посмотреть в документации.

  • Создается объект pool - пул потоков. Сколько этих потоков запущено - не важно. Потоки могут автоматически создаваться по мере необходимости. Создание и завершение потоков - часть внутренней реализации, которая может изменяться.

    max_workers - ограничение на максимально возможное для пула количество потоков.

    Пул работает в with блоке, чтобы вызвать .shutdown при выходе.

    Метод shutdown дождется завершения всех запущенных потоков.

  • Самый важный метод - submit. Он ставит в очередь запрос на обработку вместе со всеми требуемыми параметрами и возвращает future - отложенное значение.

  • У отложенного значения можно узнать, завершено ли асинхронное вычисление или еще нет, получить результат вычисления (или поймать исключение) и т.д.

  • as_completed - очень удобная утилита. Принимает список отложенных вычислений и возвращает генератор, выдающий завершенные вычисления по мере готовности.

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

Активные объекты

Активным объектом будем называть объект, выполняющим свои методы в отдельных потоках.

Имея пул потоков создать активный объект очень легко.

Давайте сделаем простенький пример, проверяющий - жив ли еще интернет?

#!/home/andrew/projects/py3k/python

import threading
from concurrent import futures
from collections import defaultdict, namedtuple
from urllib.request import urlopen, URLError

State = namedtuple('State', 'addr ok fail')

class Pinger:
    def __init__(self, pool):
        self._pool = pool
        self._lock = threading.RLock()
        self._results = defaultdict(lambda: {'ok': 0, 'fail': 0})
        self._pendings = set()

    def result(self, addr=None):
        def _make_state(addr, res):
            return State(addr=addr, ok=res['ok'], fail=res['fail'])
        with self._lock:
            if addr is not None:
                return _make_state(addr, self._results[addr])
            else:
                return {_make_state(addr, val)
                        for addr, val in self._results.items()}

    @property
    def pendings(self):
        with self._lock:
            return set(self._pendings)

    def ping(self, addr):
        with self._lock:
            future = self._pool.submit(self._ping, addr)
            self._pendings.add(future)
            future.add_done_callback(self._discard_pending)
            return future

    def _discard_pending(self, future):
        with self._lock:
            self._pendings.discard(future)

    def _ping(self, addr):
        try:
            ret = urlopen(addr)
            ret.read()
        except URLError:
            result = False
        else:
            result = True

        with self._lock:
            if result:
                self._results[addr]['ok'] += 1
            else:
                self._results[addr]['fail'] += 1

        return result

if __name__ == '__main__':
    from pprint import pprint

    with futures.ThreadPoolExecutor(max_workers=3) as pool:
        pinger = Pinger(pool)

        pinger.ping('http://google.com')
        pinger.ping('http://ya.ru')

        print("State for 'ya.ru'", pinger.result('http://ya.ru')) # 1

        future = pinger.ping('http://python.su/forum/index.php')
        print("Result for 'python.su'", future.result()) # 2

        pinger.ping('http://asvetlov.blogspot.com')

        futures.wait(pinger.pendings) # 3

        print("Total table")
        pprint(pinger.result()) # 4

Вышло не так уж и коротко - но оно того стоит.

Давайте разберем код по порядку, обращая внимание на существенные места.

  • Имеем класс Pinger, который является активным объектом. Конструктор класса принимает параметром ThreadingPoolExecutor, а не создает его внутри.

    Зачем? Потому что пингователей может быть много. Каждый будет пытаться создавать новые потоки, а операционная система это не любит.

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

    Или создать несколько пулов для разных целей - но оставить себе контроль за их применением.

  • Внутри класса есть блокировка self._lock (куда без нее), таблица результатов и множество ожидающих вызовов - тех future, которые были запланированы на выполнение но еще не завершились.

  • Два публичных атрибута для опроса текущего состояния.

    • метод result возвращает всю таблицу или только ее строку для указанного адреса.

    • свойство pendings вернет список еще не выполненных запросов.

    Обратите внимание: эти действия берут блокировку _lock и возвращают копию изменяемых данных. Оба пункта очень важны.

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

    Если возвращать ссылку на внутреннюю структуру вместо копии - повреждение данных произойдет позже, при обработке результата вызова во внешнем коде.

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

  • Метод ping ставит обработчик (непубличный метод _ping, делающий всю работу) в очередь пула потоков.

    Блокировка нужна и здесь, так как изменяется _pendings.

    К полученному отложенному вызову добавляем callback, чтобы удалить наше вычисление из _pendings после того, как оно будет выполнено.

    Удаление (_discard_pending) тоже происходит с блокировкой.

  • Рабочий код _ping пытается получить html страницу через urlopen.

    Блокировка не берется - доступ к интернету медленный, а мы как раз хотели получить возможность пинговать несколько сайтов одновременно. Столько, сколько окажется свободных потоков у _pool.

    Тем не менее для обновления внутренних структур класса блокировка нужна.

    Наконец, возвращаем результат, который попадет в future и заодно вызовет callback _discard_pending.

  • _discard_pending будет вызван в контексте рабочего потока из пула, запомните этот факт. Не в потоке, вызвавшем 'ping' - а в рабочем, внутри пула.

Теперь проверка. У меня довольно быстрый интернет, и тем не менее результат запуска такой:

andrew@ocean ~/p/a/threading> ./pinger.py 
State for 'ya.ru' State(addr='ya.ru', ok=0, fail=0)
Result for 'python.su' True
Total table
{State(addr='http://asvetlov.blogspot.com', ok=1, fail=0),
 State(addr='http://google.com', ok=1, fail=0),
 State(addr='http://python.su/forum/index.php', ok=1, fail=0),
 State(addr='http://ya.ru', ok=1, fail=0),
  • На момент постановки 'ya.ru' в очередь пинг только выполняется, результата нет.

  • Явный вызов future.result() для 'python.su' дожидается завершения, True демонстрирует вечноживучесть форума.

  • futures.wait(pinger.pendings) ждет завершения всех отложенных задач. Если future на тот момент уже готова - ждать не потребуется.

Другие высокоуровневые мультипоточные объекты

Откровенно говоря, их нет.

Всё, что вы придумаете будет является комбинацией мониторов и активных объектов.

Иногда эти поделки удобны.

Временами, - в случае нарушения правил работы с потоками, - больно бьют по рукам.

Общее правило остается в силе - объект потокобезопасен только в двух случаях:

  1. он константный

  2. все публичные операции с объектом защищены тем или иным методом синхронизации и передаваемые/возвращаемые параметры тоже потокобезопасны.

Естественно, в реальной жизни всё куда сложнее. Сплошь и рядом приходится нарушать "правила безопасности жизнедеятельности", идя на компромисс в целях улучшения производительности или по каким-нибудь другим причинам.

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

вторник, 8 февраля 2011 г.

Графический интерфейс, часть третья

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

Настала пора переходить к решениям.

Сразу скажу: полного и хорошего ответа я не знаю.

Простое решение номер один: Model-View-Presenter

Мартин Фаулер обратил внимание на проблемы парадигмы Model-View-Controller довольно давно.

Советую почитать его статью GUI Architectures, в которой подведен итог размышлений о различных подходах к созданию пользовательского интерфейса. Помимо всего прочего статья содержит много ссылок на используемые её автором шаблоны проектирования, ознакомится с которыми полезно.

За несколько лет предложенный Фаулером шаблон Model-View-Presenter усложнился и эволюционировал, далеко уйдя от первой (весьма наивной) реализации.

И всё же имеет смысл начинать именно с неё.

  1. Есть модель предметной области (Domain Model). В ней сконцентрированы бизнес-логика, взаимодействие с базами данных и прочие полезные вещи.

  2. Форма GUI или вид (View) отображает эту модель (или её часть) в виде своих виджетов.

  3. Представление (Presenter) осуществляет связь между моделью и видом, реагируя на события пользователя и обновляя вид в ответ на изменения модели.

Модель содержит все данные, необходимые для работы вида.

Например, если форма имеет поле ввода, которое может быть запрещено для редактирования и должно изменять свой цвет в зависимости от состояния, то модель должна иметь атрибуты:

  • value_text, read-write

  • value_enabled, readonly

  • value_color, readonly

Представление подписывается на события вида. В ответ на изменение пользователем текста в поле ввода (или при нажатии кнопки "применить", поведение зависит от используемого сценария работы) представление берет текст, содержащийся в поле ввода и записывает его в модель. Если был выбран сценарий с кнопкой "применить" - вызывается соответствующий метод модели.

Затем представление обновляет вид, приводя поле ввода в соответствии с value_text, value_enabled и value_color.

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

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

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

Комплексные модели

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

Для работы с такими моделями представления обязаны быть наблюдателями, одновременно выступая адаптерами для виджетов.

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

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

Следование сложным сценариям работы может потребовать создания множества различных адаптеров для каждой новой формы.

Тестирование

Разнесение вида и модели, проведение взаимодействия между ними только через представление очень помогает при создании автоматических тестов.

Во первых, модели могут и должны работать независимо от представлений (и, тем более, видов). Значит и тестировать их можно независимо.

По вторых, можно создавать тестовые виды, содержащие все необходимые для представления атрибуты - но не являющиеся виджетами. Т.е. тестирование представлений может быть проведено без создания форм GUI.

Недостатки

Куда ж без них?

Во первых, приходится писать довольно много рутинного и однообразного кода в представлениях. Правильно выбранная архитектура, декомпозиция представлений на агрегирируемые вспомогательные классы (синхронизатор поля ввода с изменяемым цветом, например - и т.д.) - неплохо помогают, но проблема всё же временами дает о себе знать.

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

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

Это тоже требует дополнительного кодирования.

Есть и более серьёзные проблемы.

Изменение модели может порождать изменение вида, которое в свою очередь опять меняет вид - и так до бесконечности. Выявление и предотвращение таких циклов не всегда легкое и очевидное дело.

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

Заключение

Предложенная схема организации модель-вид-представление обладает очевидными достоинствами.

Это, в первую очередь, четкое разделение модели предметной области и графического интерфейса с прописыванием их синхронизации в едином месте - представлении.

При помощи рассмотренного подхода можно создавать интерфейсы любой степени сложности.

Значительно облегчается автоматическое тестирование.

Вместе с тем я перечислил и недостатки. Они не фатальны, но требуют внимательности и дисциплины при проектировании приложения.

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

Небольшое отступление.

Я очень люблю книгу Мартина Фаулера "Refactoring". Купите - не пожалеете.

Помимо очень хороших рекомендаций по улучшению кода этот справочник имеет еще одну крайне полезную функцию.

Если вы чуете пятой точкой, что нужно сделать так а не иначе - откройте Refactoring и найдите нужное вам изменение. Покажите страницу начальнику или коллегам. Вероятно, авторитет Мартина позволит вам протолкнуть собственную точку зрения.

Соблюдайте осторожность! Если коллеги внимательно читали принесенную вами в качестве последнего довода книгу - они с легкостью найдут в ней противоположный рецепт.

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

понедельник, 7 февраля 2011 г.

Графический интерфейс, часть вторая

Продолжение. Начало смотрите в первой части.

Отлично!

  • Model-View-Controller -- фикция.
  • Приложения должны использовать Model-Widget.
  • Всё хорошо?

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

Виджет:

  • Зачастую модели не имеет. Какая модель у поля ввода? А у метки (label), содержащей лишь статический текст? Такие виджеты, как правило, не утруждают себя созданием примитивной модели. Вместо этого у виджета имеются методы вроде set_text и get_text, их достаточно.

  • Модель появляется у сложных виджетов. Например, QTreeView, - дерево с несколькими колонками, - имеет модель типа QAbstractItemModel.

  • Эти модели не представляют предметную область, а являются адаптерами, отображающими объекты этой области (дерево вложенных элементов для tree view) в форме, пригодной для использования виджетом.

  • Модели виджетов появились в ответ на появление в библиотеках GUI комплексных виджетов. Это просто следствие декомпозиции.

    Если экранный элемент имеет нетривиальное логическое представление и сложную схему взаимодействия с пользователем - естественным образом возникает желание разделить его на несколько классов.

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

    Таких классов может быть и больше - довольно популярным является вынесение валидаторов и декораторов, отвечающих за внешний вид элементов сложного виджета. Тот же QTreeView имеет itemDelegate, itemDelegateForColumn и itemDelegateForRow, позволяющие управлять внешним видом и встроенными редакторами ячеек.

    А также selectionModel, контролирующую правила выделения отдельных вложенных элементов.

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

Пользовательское приложение (application):

  • Модель для приложения выполняет совершенно другую роль. Это - представление предметной области.

  • Модель приложения отвечает за операции с пользовательскими данными и бизнес-логику.

  • Как правило, имеет смысл по меньшей мере на уровне формы - виджеты отображают лишь часть модели, в то время как сама модель представляет собой цельную и законченную абстракцию из предметной области.

Разница между адаптером для виджета и моделью для приложения очень велика, это по сути совершенно разные вещи.

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

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

Проблема сообщений графического интерфейса.

Любая библиотека GUI имеет встроенную систему сообщений.

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

Естественным образом добавились другие события: изменение размеров окна-формы, запрос на перерисовку, закрытие окна и т.д.

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

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

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

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

Снова перейдем к прикладному программированию.

Имеет ли смысл пользоваться сообщениями во время проектирования бизнес-логики?

Очевидно, да: с точки зрения объектно ориентированного подхода даже вызов метода является посылкой сообщения. Существуют и более сложные способы: асинхронные сообщения, обратные вызовы (callbacks).

Стоит ли использовать сообщения библиотеки GUI?

Ответ неоднозначный.

Естественно, взаимодействие с пользователем обязано базироваться за сообщениях библиотеки GUI - просто потому, что она так работает.

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

Это примерно как мультипоточность: можно использовать низкоуровневые средства синхронизации потоков операционной системы и выдавать в результате хорошо работающий код.

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

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

Другими словами, существуют ли общие концепции, подходы и шаблоны проектирования моделей - не завязанные на механизм передачи сообщений лежащего ниже слоя GUI?

Заключение

"Модель" есть представление предметной области. Используемые библиотеками графического интерфейса модели на самом деле к предметной области отношения не имеют. Будем называть их адаптерами к "правильным" моделям, позволяющим виджетам взаимодействовать с объектами предметной области.

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

Резюмируя:

  • Есть предметная область и ее бизнес-правила.

  • Предметную область удобно описывать в терминах "моделей". Это очень естественное определение.

  • "Модели" библиотек графического интерфейса (GUI) означают нечто совсем отличное от того, то подразумевают модели предметной области. Назовем модели GUI адаптерами, чтобы уходя от неоднозначности терминов.

  • Модели и GUI взаимодействут через механизм сообщений GUI. При этом внутренняя логика модели не обязана использовать эти сообщения - требуется иная, более подходящая для работы с предметной областью схема:

    1. GUI передает модели информацию о ее изменении, вызванной действиями пользователя.

    2. Модель проводит измение своего состояния, реагируя на ввод.

      Как она это делает - забота одной лишь модели.

      Главное - все действия должны быть выполнены на этом шаге. Нарушение правила ведет к потенциальной бесконечной рекурсии.

      Длительные по времени изменения разделяются на конечные этапы, демонстрирующие progress bar или другую сигнализацию активности.

    3. GUI получает извещение о новом состоянии модели, отображая их в пользовательском интерфейсе.

  • Распространенные библиотеки GUI потребовали огромное количество человеко-часов на их создание. Они широко используются и обладают массой очевидных достоинcтв. Средства автоматического генерирования интерфейса пользователя - далеко не последний момент, особенно для неопытных программистов.

  • Хороший подход должен при минимальных изменениях обеспечивать склейку любого UI с моделью.

Проблемы (далеко не все) - показаны. Пора переходить к решениям.

Продолжение - в третьей части.

суббота, 5 февраля 2011 г.

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

Продолжение - во второй части.

Хотел порассуждать о разработке интерфейсов пользователя.

Сначала - исключительно применительно к Питону.

Довольно быстро сообразил, что проблемы не зависят от применяемого языка программирования.

Хорошо, поговорим о создании Graphic User Interface (GUI) "вообще", постаравшись проиллюстрировать это примерами из популярных кроссплатформенных библиотек.

И тут не всё гладко: задачи, стоящие перед разработчиком standalone application на Qt довольно похожи на создание полноценного интерактивного интерфейса с помощью ajax, long polling, web sockets и прочих умных слов.

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

В web разработке более или менее успешно решаются другие серьезные проблемы: масштабирование, высоконагруженные системы. Но в плане сложного интерфейса - увы и ах - запросы, как правило, невелики.

При этом принципы дизайна и стоящие вызовы, повторюсь, очень похожи.

Итак, будем говорить о графическом интерфейсе, не делая принципиального различия между web и standalone приложениями.

Model-View-Controller

Первое, что приходит в голову при мысли о хорошей архитектуре GUI - MVC. За детальным описанием обратитесь, например, к википедии.

Модель содержит данные и бизнес-логику работы с ними - всё чётко и ясно.

А вот с видом и контроллером ситуация гораздо хуже.

Возможно, в Smalltalk (от которого всё пошло) и было четкое разделение.

В нынешних системах оно утеряно. Примеры:

  1. Django. Есть вид, но контроллер отсутствует. За него иногда на полном серьезе выдают правила роутинга (urls.py) - что просто смешно.

  2. Pylons. Появился контроллер (при том что роутинг тоже никуда не делся), вид отсутствует. Иногда в качестве вида пытаются указывать шаблон страницы.

  3. Pyramid, новая поделка. В попытках уйти от неоднозначности ввели новый термин ресурс, оставив вид. К чести создателей Пирамиды, ребята постарались явно описать причины отказа от MVC и предложили иную схему: Model-Resource-View-Renderer.

Может, MVC не применима к веб разработке? А на её "родном" поле всё хорошо?

Как бы не так: Qt, wxWidgets, WinForms, Swing - все эти библиотеки используют понятие widget, объединяя в нём вид и контроллер. Для сложных виджетов модель выделяется в отдельный класс, в то время как тривиальные моделью не обладают, предпочитая держать всё вместе.

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

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

MVC ни разу не является шаблоном проектирования (design pattern), где все четко расписано: какие классы входят в шаблон, за что отвечают и каким образом взаимодействуют.

Наверное, поэтому схема Model-View-Controller не вошла в знаменитую книгу "Design Patterns" (GoF) - это просто набор благих пожеланий и никак не строгая структура.

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

View

В мире библиотек для GUI наблюдается интересная стандартизация.

Веб давно и прочно сидит на HTML+CSS.

Это позволило выделить отдельную категорию разработчиков - верстальщики.

В последние годы тенденция распространяется и на средства разработки standalone aplication.

Проблема в следующем: программисты, как правило - плохие дизайнеры. Слишком разный склад ума.

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

Что получается, если типичному программисту заказывают дизайн "в розовых тонах с симпатичными рюшечками?".

Правильно! Изделие напоминает выпуск военно-промышленного комплекса, покрашенное в розовый цвет и снабженное криво сидящим унылым бантиком.

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

Или таки нарисую (если сильно прижмут) - но мне будет стыдно, а вам неловко.

Итак, естественным образом возникла идея: раз уж дизайнеры (технические художники) присутствуют в массовых количествах и они волей-неволей освоили вёрстку веба - нужно дать им похожий инструмент для создания пользовательского интерфейса традиционных программ.

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

Появилась идея HTML Applications. Весь интерфейс в html, вместо удаленного сайта используется локально запущенный сервер, и всё оформляется в едином флаконе - запусти приложение и увидишь красивую картинку.

Всеми уважаемая фирма Microsoft некоторое время усиленно продвигала этот подход.

Получилось не то чтобы успешно. Причина очевидна: html в первую очередь ориентирован на создание сайтов. Его выразительная мощь проигрывает традиционным библиотекам GUI. HTML Applications были красивыми внешне, но отличались крайне низкой usability.

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

И тем не менее это - не mainstream.

Сейчас присутствуют и более многообещающие альтернативы.

  1. В Qt 4.7 появились Declarative Views и язык разметки QML.
  2. Microsoft выпустила Windows Presentation Framework (WPF) с разметкой в XAML.
  3. Меня попросили добавить XUL - язык разметки пользовательского интерфейса для Mozilla project. Я с ним никогда не имел дела - но пусть будет.

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

Как мне кажется, основная цель этих поделок:

  1. Дать возможность программистам сосредоточиться на функциональной стороне продукта.
  2. Предоставить дизайнерам язык разметки, очень похожий на привычный им html/css. При минимальном времени на привыкание дизайнеры могут создавать внешний вид программы используя родные для библиотеки виджеты, обладающие требуемой программистам функциональностью.

Этот подход в корне отличается от использования средств графического проектирования.

Если помните, для веба тоже были (и есть до сих пор) похожие инструменты.

Например, Adobe Dreamweaver. О "замусоренности" выдаваемой вёрстки говорить не буду: кто сталкивался - понимает.

При всех достоинствах практика показала: хороший верстальщик с твердым знанием html и css гораздо лучше изумительного художника, выдающего результат исключительно в Dreamweaver.

В очень тяжелом случае художник может попытаться объяснить дизайнеру, как делать "нарезку" - избавив этим программиста от непрофильной работы.

У нашего бедняги и без того задач хватает. Будет заниматься версткой - не успеет сделать функционал.

Такой же подход должен прийти на рынок "не веба".

Для "традиционных" GUI библиотек ситуация с версткой еще драматичней.

Есть средства графического проектирования интерфейса. Они - глюкавые. Это - факт.

Гораздо легче создать Domain Specific Language (DSL) для описания узкой области размещения красиво оформленных элементов на форме, чем сделать удобный редактор, работающий без ошибок.

Для набора этого текста я использую rst формат, затем markdown перегонит его в html. Набор в emacs. Это гораздо удобней Microsoft Office Word или Open Office Writer, даёт лучший контроль.

Но у автоматических средств GUI есть и более существенный недостаток.

Сейчас ими могут пользоваться только программисты. Просто потому, эта автоматика оперирует свойствами объектов, доступными только в "программерской" документации.

В то время как для веба уже давно есть отдельные доки по html и css, написанные для верстальщиков и не затрагивающие даже java script.

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

Заключение

Погружение в предметную область вышло неожиданно длинным.

Рассуждения об изменеиях в подходе визуалиации форм для "традиционных" приложений заняли очень много времени.

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

Есть опасение, что второй статьи такого же размера всё равно не хватит.

Продолжение - во второй части.