通过扩展Django数据库API支持全文搜索
本文为翻译内容,原文请查看http://www.mercurytide.com/

介绍Django
Django是一个开源的Web应用程序框架,引用创作者的话来说就是:“鼓励快速开发和干净、实效的设计”。它由Python编写,并提供各种组件以创 建高质量的Web应用程序,包括一个ORM(object-relational mapper)框架、一个支持全部特性的模板系统、URL分发器、会话管理、安全认证和其他更多的东西。

一个对框架有价值的衡量标准是:对符合开发者需求的扩展是否简单。这里我们将介绍一下Django的数据库API,并通过扩展让Django支持MySQL的全文搜索功能来演示它的灵活性。

这篇文章包含的源代码已经发布在公共区域了。

Django的数据库API
Django的ORM 提供了一个丰富的API用 来创建数据库查询。这个API可以让应用程序开发人员从SQL语句的细节之中隔离开来,但是依然支持高效率方式的复杂选择标准的查询。这个方法的另一个值 得注意的好处就是它完全避免了“SQL注入攻击”的可能性,同时应用程序开发人员不再需要直接向SQL语句中插入数据值。

对象从数据库中获取之后形成一个 “QuerySet” 查询集合, 它是一个SQL选择语句的提取结果。QuerySet提供了一些减少结果集合到对象集合的匹配/转换细节的方法,其中一些方法就像一个SQL语句中的 WHERE条件子句--实际上,在幕后,一个QuerySet创造了一个SQL陈述作为它的方法被调用了。QuerySet实例是从一个模型类的管理器 (model class's Manager)实例中获得的,通常情况下这些实例被叫做 objects 。这里有一些使用QuerySet的例子:

# 获取包含所有文章的查询集合
articles = Article.objects.all()

# 只包含今年(2007年)之前写的文章
articles = articles.filter(posted_date__lt='2007-01-01')

# 但是我写的文章除外
articles = articles.exclude(author__exact='Richard')

# 最后,把他们按照点击率和发表日期进行升序排序
articles = articles.order_by('rating', 'posted_date')


QuerySets 可以非常方便的被过滤、排序, 同时他们被延迟求值:这些动作操作QuerySets内部的SQL语句,且这些语句不会被执行,除非你通过迭代器或者分片尝试访问QuerySet的记录。


# 获取前五篇文章,只有这个时候才访问数据库
a = articles[:5]

为了扩展这个接口,我们将开发一个管理器子类和一个查询集合子类,但在这之前我们暂时描述一下MySQL的全文搜索。

MySQL和全文搜索
MySQL拥有一个内建的全文搜索引擎,但是没有专门的库(例如 Lucene Xapian)那么强大,它整合了数据库引擎和查询语法,所以在数据库驱动的应用程序中很容易使用。这里我们只看看它对“自然语言”查询的支持,其他细节特性请查看 MySql 文档

通过在数据表的一个字段或者一些列字段上创建全文索引就可以激活 MySQL 的全文搜索功能了。不管行数据被写入、更新,或者删除,这些索引都将自动更新,所以搜索出来的结果都是最新的,永远不会过期。创建索引的语句可以这样使用:

CREATE FULLTEXT INDEX 索引名 ON 需要索引的表 (表中需要索引的列名)

搜索用 MATCH...AGAINST 表达式指定搜索的列名和搜索的文本来操作:

MATCH(要搜索的列名) AGAINST (要搜索的文本)

对于自然语言的查询,使用这个表达式的方法可能向下面这样:

SELECT title, MATCH(title, text) AGAINST ('Django framework')
AS `relevance`
FROM fulltext_article
WHERE MATCH(title, text) AGAINST ('Django framework')

这将返回匹配文本”Django and framework“的所有文章的标题(title)和适当分数(relevance score),默认情况下他们按照 relevance 降序排序。别担心这个语句中重复的 MATCH...AGAINST 表达式 -- MySQL将会发现它们是一样的,且只会执行这个搜索一次。需要特别注意的是,被传到 MATCH 中的列一定要和数据库中创建索引的列保持一致。

扩展数据库API支持搜索
Django的设计原理之一就是”一致性、连贯性”,这个对于扩展是很重要的。为了让全文搜索接口和Django的其他部分保持一致, 这个扩展应该使用 Manager 与 QuerySets, 那样的话它就可以以同样的方式使用了。实际上,也就是说程序员可以像下面这样编写语句:

Article.objects.search('Django Jazz Guitar')
Article.objects.filter(posted_date__gt='2006-07-01').search('Django Python')


这些语句将返回被过滤之后的 QuerySet 自身。为了达到这一个目的,我们将开发一个叫做 SearchQuerySet 的 QuerySet 子类来提供 search() 方法,并创建 MATCH...AGAINST 表达式填充到 SQL 语句中;创建一个叫做 SearchManager 的 Manager 子类返回 SearchQuerySet 子类。因为这个管理类为了便于使用也提供了许多 QuerySet 的方法,所以,为了保持一致,SearchQuerySet 也应该提供一个 search() 方法。这里是代码:

from django.db import models, backend

class SearchQuerySet(models.query.QuerySet):
def __inti__(self, model=None, fields=None):
super(SearchQuerySet, self).__init__(model)
self._search_fields = fields

def search(self, query):
meta = self.model._meta

# 从模型中获取数据表名称和列名称
# 用 'table_name'.'column_name' 的风格
columns = [meta.get_field(name, many_to_many=False).column
for name in self._search_fields]
full_names = ["%s.%s" %
(backend.quote_name(meta.db_table), backend.quote_name(column))
for column in columns]

# 创造 MATCH...AGAINST 表达式
fulltext_columns = ", ".join(full_names)
match_expr = ("MATCH(%s) AGAINST (%%s)" % fulltext_columns)

# 添加额外的 SELECT 和 WHERE 选项
return self.extra(select={'relevance': match_expr},
where=[match_expr],
params=[query, query])

class SearchManager(models.Manager):
def __init__(self, fields):
super(SearchManager, self).__init__()
self.search_fields = fields
def get_query_set(self):
return SearchQuerySet(self.model, self._search_fields)
def search(self, query):
return self.get_query_set().search(query)


这里,SearchQuerySet.search() 向 Django 要了表和列的名字,创建了一个 MATCH..AGAINST 表达式, 然后在一行中添加了查询的 SELECT 和 WHERE 子句。

很方便,所有的实际工作都让 Django 自己做了。模型类中的 _meta 对象存储了关于模型和它的字段的所有“元信息”, 但是这里我们仅仅需要表名和字段名(当SearchQuerySet实例被初始化的时候,它被告知哪些将被搜索)。

QuerySet.extra() 方法提供了一个简单的方式去添加扩展列、WHERE子句表达式,表到 QuerySet的内部 SQL 语句的引用:select 选择参数把列名映射到一个表达式(想想 “SELECT expression AS alias”),where 参数是 WHERE 子句组成的表达式列表,而 params 参数是SQL 语句中内嵌字符串 '%s' 的替换值列表。

SearchManager 又怎么样呢?像上面所说的,它必须返回一个 SearchQuerySet 实例来替换普通的 QuerySets。幸运地是,Manager 类已经编写了这个方法,它有一个 get_query_set() 方法用来返回一个适当的 QuerySet 子类的实例, 且无论合适一个管理类需要创建一个新的 QuerySet 实例的时候,它都会被调用,那么重写这个 get_query_set() 方法让它返回一个 SearchQuerySet 就显得微不足道了。当创建一个 SearchQuerySet 实例的时候,它传入待搜索的字段,这些已经在它自己的构造器中提供好了。我们也想让 SearchManager 实现一个 search() 便利的方法,但是我们其实这是负责代理 SearchQuerySet.search() 就行了。

使用搜索组件
我们将演示这些子类的使用方法。这里有一个表现发表在一个Web站点上的文章的简单模型;所以它可以被搜索,我们创建一个 SearchManager 实例并把它指派到 objects

from django.db import models
from fulltext.search import SearchManager

class Article(models.Model):
posted_date = models.DateField(db_index=True)
title = models.CharField(maxlength=100)
text = models.TextField()

# 用一个 SearchManager 获取对象,并告诉他哪些字段需要被搜索
objects = SearchManager(('title', 'text'))

class Admin:
pass

def __str__(self):
return "%s (%s) " % (self.title, self.posted_date)


文章都有一个标题,一个主要部分正文,和一个它们被发表的日期。我们将在数据库中为标题(title)和正文(text)列定义一个 FULLTEXT INDEX,并传递一个相应字段名称的元组给 SearchManager 实例。这里是创建索引的 SQL 语句。

CREATE FULLTEXT INDEX fulltext_article_title_text
ON fulltext_article (title, text);


假设一个 Django 工程,一个应用程序包含了文章模型,一个填充了合适的文章数据的数据库,那么全文搜索就可以在 Python 的交互式解释器终轻松演示了:

# 这里有多少篇文章?
len(Article.objects.all())

# 找到关于 frameworks 的文章
Article.objects.search('framework')

# 显示这些文章的适当分数
[(a, a.relevance)
for a in Article.objects.search('framework')]

# 把这些文章的搜索结果限制在发表日期为六月份以前
Article.objects.search('framework').filter(posted_date__lt='2006-06-01')

# 注意,filter() 也返回一个 SearchQuerySet:
Article.objects.filter(posted_date__lt='2006-06-01').search('framework')



最终的注意点
现在,我想让你知道一个秘密:从2006年6月份起,Django 已经支持一个搜索操作符(search operator)用来查询 MySQL 的全文搜索了

# 这里使用布尔搜索模式,不是自然语言查询
Article.objects.filter(title__search='+Django -Rails')



尽管如此,这篇文章所演示的技术特性依然可以用于创建数据库API扩展上用来支持任何SQL特性,不论是支持全文搜索,分组、聚合查询还是任何其他SQL特性或者特定的数据库扩展。