24 дек. 2008 г.

Меняющаяся карта

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

Мерзость ее заключалась в том, что появлялась она время от времени. Да, я замечал, что изредка breadcrumbs -- так называется маршрут к текущей странице ('вы здесь...') на веб-портале не появлялся, но винил в этом браузер, memcached, скомпилированные Django-шаблоны -- все, что угодно, только не свой модуль, который отвечает за построение breadcrumbs.

Модуль работает следующим образом:
  • Из YAML-файла читается карта сайта. В результате получается дерево. Это происходит один раз, при старте Django-приложения
  • При выводе очередной страницы вызывается функция 'search' с текущим адресом в качестве аргумента. Она ищет в дереве узел, соответствующий заданному адресу.
  • Если узел найден, то в шаблон возвращается маршрут к нему. Каждый пункт маршрута содержит адрес и заголовок. В результате получается что-то вроде: "Вы здесь: Главная/Сообщения/Непрочитанные".
В любом веб-приложении имеется множество динамических адресов. В Django особенно любят дурачить пользователей и краулеры красивыми псевдо-статическими адресами. Если для доступа к событию в каком-нибудь календарике используется URL /calendar/2008/12/24/, это отнюдь не означает, что выдаваемый документ хранится на сервере в соответствующей структуре каталогов. Фреймворк знает из своей конфигурации, что последние три сегмента являются параметрами функции, отвечающей за обработку запросов по адресу /calendar.

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

YAML получился примерно такой:
--- # Карта сайта
url : /
title : Главная
nodes :
- url : accounts/
title : Пользователи
nodes :
- url : accounts/{USER_ID}/
- url : accounts/{USER_NAME}/
- url : blog/
title : Блог
nodes :
- url : blog/{ENTRY_ID}/
- url : blog/add/
title : Новая запись
...

Наряду со статическими адресами (например: "/", "accounts/"), здесь присутствуют и динамические. Когда функция, отвечающая за обход дерева, встречает узел accounts/{USER_ID}/ и проверяет, соответствует ли этому узлу текущий адрес account/1111/, она говорит: "Ага, это то что мне надо!"

Чтобы найти маршрут для breadcrumbs, достаточно пройти от найденного узла к вершине дерева. Получится:
  • / - "Главная"
  • /accounts - "Пользователи "
  • /accounts/1111 - ...
...Стоп. Нельзя писать в breadcrumbs id пользователя, это некрасиво и не информативно. Надо заменить его именем. Значит, на последнем этапе, прежде чем возвращать результат, следует пройтись по цепочке и где надо, преобразовать id в названия.

Вот как выглядела основная функция search до того, как я исправил ту самую ошибку, с которой начал эту заметку:

def translate(node, goal):
"""
Преобразует узел node карты в структуру, пригодную для использования.
Если в адресе имеются шаблоны, они заменяется динамическими данными
(например: {USER_ID} --> 1111
Если существует правило замены заголовка, то node получает атрибут
'title' с новым значением.

node -- узел карты сайта
goal -- текущий URL
"""
...

def search(url):
"""
Поиск адреса в карте сайта.
Аргументы:
url -- искомый адрес (строка)
Возвращает:
True, маршрут -- если адрес найден
False, пустой список -- если адрес не найден.

Маршрут -- это список узлов, каждый из которых представлен словарем
c теми же ключами, что заданы в конфигурационном файле.
"""
path = [] # маршрут
url = normalize_url(url) # приводит искомый URL к правильному виду

# рекурсивный обход дерева
if visit(SITE_MAP, url, path):
# узел найден
return True, map(
lambda node: translate(node, url), path)
else:
# неудача
return False, []

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


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

import unittest

class TestCase(unittest.TestCase):
def setUp(self):
self.urls = (
# адреса, про которые заранее известно,
# что они прописаны в карте сайта
...
)

def test_search_results(self):
"""
Прогоняем через search self.urls;
Если хотя бы один не найден, тест провален.
"""
print 'Searching %d entries' % len(self.urls)
search_all = ((url, search(url)[0])
for url in self.urls)
negative = [ url for url, result in search_all if not result ]
msg = 'Some urls were not found: %s' % ', '.join(negative)
self.assertEqual(len(negative), 0, msg)

Один узел почему-то не находился. Это был динамический маршрут (из серии .../{ШАБЛОН}). Как ни странно, с другим узлом, попадающим в тот же шаблон, все было в порядке. Сколько я ни вчитывался, никаких опечаток, пробелов -- ничего этого не было.

Я добавил в тест функцию, которая искала этот единственный проблематичный узел:


def test_idpattern(self):
print 'Id pattern'
url = u'/accounts/2227/'
result, path = search(url)
self.assertEqual(result, True, "URL '%s' not found" % url)

Тест прекрасно отрабатывал.

Наконец, я понял, в чем дело. Я попался на типичное питоновское 'gotcha'.

На вход функции "visit" подается исходное дерево, полученное YAML-парсером:
visit(SITE_MAP, url, path)
Время жизни этого дерева совпадает со временем жизни приложения -- а Django-приложения, в отличие от CGI, живут долго. В ходе очередного поиска используется одно и то же дерево.

Когда 'visit ' возвращает маршрут, оно берет его из исходного SITE_MAP. Причем возвращается не копия, а ссылка. Perl, между прочим, вернул бы копию, там операция присвоения работает иначе:

my %a = (a => 'foo', b => 'bar');
my %b = %a;
$b{'a'} = 'buz';

printf "Original: %s\n", $a{'a'};
printf "Copy: %s\n", $b{'a'};
Original: foo
Copy: buz

На вход функции 'translate' подавался все тот же узел (точнее, ссылка на него) и преобразовывала она его же. Из-за этого преобразования и возникала ошибка. После благополучного нахождения узла /accounts/1/, шаблон /accounts/{USER_ID} переставал существовать, "{USER_ID}" заменялось на "1". Не удивительно, что другой адрес такого же типа: accounts/2227 уже не имел шансов. Это все равно, что пытаться ориентироваться по карте, которая по мере твоего продвижения сама меняется.

Чтобы поправить ошибку, достаточно было отредактировать одну-единственную строчку в функции 'search':
from copy import copy

# рекурсивный обход дерева
if visit(SITE_MAP, url, path):
# узел найден
return True, map(
lambda node: translate(copy(node), url), path)
...
Не знаю, через сколько времени была бы обнаружена причина изредка появляющихся ошибок в веб-приложении, если бы не тест.

Комментариев нет: