Django: Древовидные комментарии.

Django 2008-03-24 Dovbush Pavel

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

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

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

  • Отсутствие регистрации (зачем мучить пользователя? будут проблемы со спамом - будем думать).
  • Запоминание данных комментатора в cookie.
  • Маркер авторитетного комментария и комментария автора. (При большом количестве комментариев лично я всегда выискиваю комментарии автора и других авторитетных людей.)
  • Favicon сайта комментатора в качестве его аватарки.
  • Выборка всех комментариев к статье одним запросом. Добавление не должно перелопачивать все комментарии.
  • Валидация, просмотр и добавление без перезагрузки страницы. Корректная работа без JavaScript
  • PS. Не забыть где-нибудь использовать Django :)

Для начала, реализуем все на Django, потом прикрутим JavaScript.

Модель

Модель комментария:

class Comment(models.Model):
	article=models.ForeignKey(Article)
	parent=models.ForeignKey('self', blank=True, null=True, related_name='child_set')
	author_name=models.CharField(max_length=32)
	author_email=models.EmailField()
	author_url=models.URLField(blank=True)
	text=models.TextField()
	pub_date=models.DateTimeField('date published', default=datetime.now)
	admin_comment=models.BooleanField(default=False)

Модель использует стандартные поля Django. Древовидность реализуется ссылкой на родителя.

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

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

Однако валидация нужна, чтобы не получались ссылки вида http://dpp.su/blog/ivan.ivanovich.googlepages.com. По этому пишем свой URLField:

class SimpleURLField(CharField):
	def pre_save(self, model_instance, add):
		url=getattr(model_instance, self.attname, '')
		if add and url and not url.startswith('http://'):
			url='http://%s'%url
			setattr(model_instance, self.attname, url)
			return url
		return CharField.pre_save(self, model_instance, add)

Теперь пришло время задуматься выборкой комментариев из базы. Рекурсивные запросы делать бы очень не хотелось. Хотя, как говорят тут, если убрать рекурсию в хранимую процедуру, то скорость MySQL будет приемлемой. Может быть и так, но мне такой подход не нравится.

Перед нами классическая задача: хранение древовидных структур в БД. Но, все-таки, вооружившись здравым (или не очень) смыслом, начинаем изобретать велосипед.

На баше как раз сегодня цитата очень в тему: гениальную фразу сейчас услышал на семинаре по работе с zend framework - "Можно не изобретать велосипед, а ПОГНУТЬ уже существующий"

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

Если использовать varchar(255) и описывать каждый узел дерева тремя цифрами, то мы получим вложенность 85 и тысячу комментариев на каждом уровне. Если четырьмя - 64 и 10000. Для не очень популярного ресурса вполне хватит.

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

Вернемся к предложенному решению. Если идея вдруг не понятна, приведу пример хранения коментариев в табице. Коментарии:
пример комментариев
А вот как они будут храниться в базе:
пример хранения комментариев

Отсортировав таблицу по полю path мы получаем список комментариев в нужном нам порядке. Теперь нужно написать функцию генерирующую значение поля path. Значение генерируется очень просто - берется значение родителя и дописывается id добавляемого объекта.

	path=('%s-%03d'%(parent.path, self.id, ))[:255]

Однако, при добавлении объекта мы не можем узнать его id, что приведет к дополнительному UPDATE добавленного объекта после его сохранения. Также необходимо задуматься о следующем: если использовать id для генерации path, то ограниечение на количество кометариев при этом методе получается общим для всего сайта. 10000 коментариев к статье - это много, а ко всему сайту - не очень. Поэтому использовать id при генерации пути плохо. Придется создать дополнительную колонку в таблице для хранения относительного id.

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

class TreeOrderField(models.CharField):
	def pre_save(self, model_instance, add):
		if add:
			parent=(model_instance.parent or model_instance.article);
			if parent.seq<1000:
				parent.seq+=1;
				parent.save();
			else:
				# update all comments to use 4digit masks
				pass
			value='%s%03d'%(getattr(parent, self.attname, ''), parent.seq, )[:255]
			setattr(model_instance, self.attname, value)
			return value
		return models.CharField.pre_save(self, model_instance, add)

Тут можно реализовать по-разному, но избавиться от дополнительного UPDATE не получается ни в одном из случаев. В частном случае PostgreSQL наверно можно использовать sequence и тогда лищних udate`ов не будет.

Для определения отступа комментария в дереве можно использовать принцип построения path:

@property
def level(self):
	return max(0,len(self.path)/3-1)

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

Про отображение и контроллер комментариев на Django, а также про использование Ajax`а я расскажу в следующий раз (если кто-нибудь попросит).

Ссылки:

  • django-threadedcomments - реализация коментариев, использующая рекурсию при выводе.
  • django-mptt реализация множественной модели хранения деревьев в БД.