技术笔记外传二——用elasticsearch搭建自己的搜索框架(四)
五 esengine的视图表单类
在上一篇博客中,我们实现了高级搜索的后台部分。高级搜索可以使我们组合各种条件来进行搜索,从而得到更精确的结果。在这篇博客中,我将为大家介绍esengine的视图表单类,以及一个自定义django标签highlightresult。通过自定义标签,用户可以在前端对搜索结果进行自定义。
esengine的表单与之前的whoosh版框架表单大同小异,这里主要介绍它的高级搜索表单esadvancesearchForm的实现。
图中就是高级表单。从图中可知,该表单包含两个文本框,用于输入需包含的关键字以及要排除的关键字;一组单选框表示以何种方式包含关键字;两组复选框,用于选择搜索字段;以及两个以选择形式出现的日期框。
因此,我们的表单代码如下:
# esengine/searchForm.py
# ...
class esadvancesearchForm(forms.Form):
def __init__(self,*args,**kwargs):
self.searchfields = {}
includelist = kwargs.pop('includelist',[])
excludelist = kwargs.pop('excludelist',[])
startyear = kwargs.pop('startyear','1900')
endyear = kwargs.pop('endyear','2050')
yearrange = range(int(startyear),int(endyear))
super(esadvancesearchForm, self).__init__(*args, **kwargs)
self.includeChoice = self.__buildSearchrange(includelist)
self.excludeChoice = self.__buildSearchrange(excludelist)
self.fields['includeKeyword'] = forms.CharField(label=u'包含以下关键词,以逗号分割',max_length=40,required=False)
self.fields['includerange'] = forms.MultipleChoiceField(label='', widget=forms.CheckboxSelectMultiple,
choices=self.includeChoice, initial=1,required=False)
self.fields['includemethod'] = forms.ChoiceField(label='',widget=forms.RadioSelect,choices=[['1',u'与'],['2',u'或']],initial='1')
self.fields['excludeKeyword'] = forms.CharField(label=u'排除以下关键词,以逗号分割',max_length=40,required=False)
self.fields['excluderange'] = forms.MultipleChoiceField(label='',widget=forms.CheckboxSelectMultiple,
choices=self.excludeChoice,initial=1,required=False)
self.fields['startdate'] = forms.DateField(label=u'起始时间',widget=forms.SelectDateWidget(years=yearrange))
self.fields['enddate'] = forms.DateField(label=u'终止时间',widget=forms.SelectDateWidget(years=yearrange))
def __buildSearchrange(self,searchlist):
searchrange = []
i = 1
for choice in searchlist:
if type(choice) == str:
subrange = []
subrange.append(str(i))
subrange.append(choice)
i = i + 1
searchrange.append(subrange)
elif type(choice) == dict:
for key in choice:
subrange = []
subrange.append(str(i))
subrange.append(choice[key])
self.searchfields[str(i)] = key
i = i + 1
searchrange.append(subrange)
return searchrange
# ...
这里仍然用表格来介绍一下这个表单的各个字段。
字段名 | 含义 | 类型 |
includeKeyword | 输入包含关键字的文本框 | 文本框 |
excludeKeyword | 输入排除关键字的文本框 | 文本框 |
startdate |
起始日期 |
下拉选择框 |
enddate | 终止日期 | 下拉选择框 |
includerange | 包含关键字的搜索字段 | 复选框 |
includemethod | 用与还是或来搜索包含关键字 | 单选框 |
excluderange | 排除关键字的搜索字段 | 复选框 |
在django中,我们使用widget这一属性来实现不同类型的表单显示形式。通过设置不同的widget,我们可以得到不同形式的表单,如下拉、单选以及多选。常用的widget有以下几种:
Widget | 含义 |
CheckboxSelectMultiple | 多选框 |
RadioSelect | 单选框 |
SelectDateWidget | 选择日期框,格式为月-日-年 |
PasswordInput | 密码输入框 |
在设计好表单后,我们就可以来设计视图类了。 我们的视图类需要达到以下要求:1、在第一次进入页面时,要显示我们的高级搜索表单;2、在用户执行了搜索后,如果有结果,则分页显示搜索结果,且在右上角显示一个普通搜索表单;若用户的搜索没有结果,则继续显示高级搜索表单,且右上角不显示普通搜索表单;3、若有搜索结果,则根据用户在前端的要求对结果进行高亮显示。
下面就让我们来看一下我们的视图类是如何实现的:
# esengine/views.py
# ...
class esAdvanceSearchView(View):
def __init__(self,indexname,doctype,model,templatename,includelist,excludelist,datefield,resultsperpage = 10):
self.indexname = indexname
self.doctype = doctype
self.model = model
self.templatename = templatename
self.keyword = ''
self.includelist = includelist
self.excludelist = excludelist
self.datefield = datefield
self.resultsperpage = resultsperpage
def buildform(self,request):
kwargs = {}
#kwargs['includelist'] = [{'title':u'标题'},{'content':u'正文'}]
#kwargs['excludelist'] = [{'title': u'标题'}, {'content': u'正文'}]
kwargs['includelist'] = self.includelist
kwargs['excludelist'] = self.excludelist
self.form = esadvancesearchForm(request.GET,**kwargs)
self.simpleform = esbasesearchForm(request.GET)
def __call__(self, request):
self.request = request
return self.create_response()
def search(self,request):
if self.form.is_valid():
engine = esengine(self.indexname, self.doctype, self.model)
includekeywords = self.form.cleaned_data['includeKeyword']
excludekeywords = self.form.cleaned_data['excludeKeyword']
startdate = self.form.cleaned_data['startdate']
enddate = self.form.cleaned_data['enddate']
includemethod = self.form.cleaned_data['includemethod']
includefields = []
excludefields = []
for searchrange in self.form.cleaned_data['includerange']:
includefields.append(self.form.searchfields[searchrange])
for searchrange in self.form.cleaned_data['excluderange']:
excludefields.append(self.form.searchfields[searchrange])
totalcount,result = engine.advancesearch(self.indexname,self.doctype,includefields,
includekeywords,excludefields,excludekeywords,
self.datefield,startdate,enddate,includemethod)
#print(result)
return totalcount,result
else:
#print('form is not valid')
return 0,{}
def buildpage(self,request,results):
# 引入分页机制
paginator = Paginator(results, self.resultsperpage)
page = request.GET.get('page')
try:
searchresult = paginator.page(page)
except PageNotAnInteger:
searchresult = paginator.page(1)
except EmptyPage:
searchresult = paginator.page(paginator.num_pages)
return searchresult
def create_response(self):
self.buildform(self.request)
resultcount, searchresults = self.search(self.request)
if resultcount>0:
page_result = self.buildpage(self.request, searchresults)
full_path = self.request.get_full_path()
pageurl = full_path[full_path.index('?'):]
content = {
'simplesearchform':self.simpleform,
'resultcount': resultcount,
'searchResult': page_result,
'pageurl':pageurl
}
else:
page_result = searchresults
content = {
'searchform': self.form,
}
content.update(**self.extradata())
return render(self.request,self.templatename,content)
def extradata(self):
return {}
# ...
在这个视图类中,比较重要的是buildform、search和create_response这三个函数。buildform函数用于根据构造函数传入的参数来构造高级搜索表单,以及生成一个需要显示在结果页面上的普通搜索表单;而search函数用于处理从网页中传过来的值,并将其传入advancesearch函数中,得到最后的搜索结果。而这里需要额外处理的就是includerange和excluderange这两个复选框,这两个复选框将会以选中的index列表传给django,如['1','2'],这表示第1项和第2项都被选中了。我们需要做的就是从我们的searchfields(形式如{'1':'content','2':'title'})中获取字段名,再将其存入includefields和excludefields中;而当表单没有通过验证时,我们直接返回0和空字典作为搜索结果个数以及搜索结果集,表明这是一次无效的搜索;最后的create_response函数则是用来渲染最后的页面。当搜索结果个数大于0时,返回分好页的搜索结果,并显示普通搜索表单。此外,由于我们的搜索比较复杂,因此在前端来拼接下一页或前一页的url行不通了。在这里,django提供了request.get_full_path()方法来获得当前页面的url,这个url会包含我们搜索的所有参数,因此我们只需将其处理一下(拿到?之后的值)再以pageurl的名称丢给前端即可;而当搜索结果为0时(无效表单输入或是真没搜到),继续返回高级搜索表单以便用户继续搜索。
好,看来我们已经实现了我们上面三个需求中的前两个。那如何实现根据用户在前端的要求来高亮显示搜索结果呢?这就需要用到django提供的自定义tag功能了。
自定义tag功能是django模板语言提供的一个强大功能。通过自定义tag,我们可以在前端层面实现各种复杂灵活的显示效果,包括过滤指定字符、统一时间格式、实现特殊格式显示等功能,是个强大的前端工具。在这里我们可以设计一套自己的tag来支持用户用自定义的格式来对显示结果进行高亮,从而实现我们的第三条需求。
在实现tag之前,先让我们设计一下我们的tag。我们的tag支持两种形式的高亮:1、直接使用字体标签(<b>,<i>等)对指定内容进行高亮;2、通过css类对指定内容进行高亮。因此,我们的标签格式有以下两种形式:
{% highlightresult result font "<b>" %}
{% highlightresult result css "classname" %}
其中,highlightresult为tagname,result为要高亮显示的内容;font和css为关键字,分别表示要以字体标签进行高亮和以css形式进行高亮。
在设计好tag语法后,让我们来看一下该如何实现这个tag。
在django中,网页的渲染可以细化为对结点(Node)的渲染,即每个网页都是由很多结点的list组成。因此,自定义tag其实就是对指定tag包围的那些结点进行特殊处理。
和之前建立filter一样,我们需要在esengine目录下新建一个名为templatetags的目录,在其中来编写我们的highlightresult tag。目录名不要修改,这是django默认的自定义tag/filter目录名。
我们在其中新建highlighttemplate.py,开始实现我们的标签:
# esengine/templatetags/highlighttemplate.py
from django import template
register = template.Library()
@register.tag
def highlightresult(parser,token):
# syntax
# {% highlightresult result font "<b>" %}
# {% highlightresult result css "classname" %}
try:
format_string_list = token.split_contents()
tag_name = format_string_list[0]
highlightcontent = format_string_list[1]
format_string = format_string_list[2]+' '+format_string_list[3]
except ValueError:
raise template.TemplateSyntaxError("%r tag requires a single argument" % tag_name)
return SearchResultNode(highlightcontent,format_string)
class SearchResultNode(template.Node):
def __init__(self,highlightcontent,format_string):
self.format_string = format_string
self.highlight_content = template.Variable(highlightcontent)
def render(self, context):
actual_content = self.highlight_content.resolve(context)
actual_content = actual_content.replace('<script>','<script>').replace('</script>','</script>')
style_tag = self.format_string.split(' ')[0]
if style_tag == 'font':
style_tag_element = self.format_string.split(' ')[1][2:-2]
style_tag = '<%s>' % style_tag_element
anti_style_tag = '</%s>' % style_tag_element
elif style_tag == 'css':
style_tag_element = self.format_string.split(' ')[1]
style_tag = '<span class=%s>' % style_tag_element
anti_style_tag = '</span>'
#return '<b>' + actual_content + '</b>'
return style_tag+actual_content+anti_style_tag
开头的register=template.Library()是必须的,通过这句将我们的tag注册在系统中。
这个tag由两个函数组成:highlightresult和SearchResultNode。highlightresult负责解析从前端模板中传递过来的字符串,并返回一个SearchResultNode实体;而SearchResultNode则是用于接收前端的内容,并对字符串进行一番修饰后由django调用render函数返回。
让我们看一下这两个函数。首先是highlightresult函数,该函数有两个参数parser和token,都是django提供的系统参数,在这里我们只需关注token即可。关于token,一个很重要的参数就是token.content,它会返回被{% %}包含的所有内容,包括引号在内。如{% highlightresult test font "<b>" %},若使用token.content,我们就可以得到内容为highlightresult test font "<b>"的字符串;而使用split_content,django会根据空格将刚才的字符串进行split,返回一个list,而在这个过程中,引号依然被保留。此外,token.split_content[0]一定为tagname。在这个函数中,我们会将test以及font "<b>"传递给SearchResultNode作为构造参数,从而在Node中返回拼好的html字符串。
在SearchResultNode的构造函数中,我们使用了template.Variable函数来取得highlightcontent的值,而不是直接使用传进来的值,这是因为我们从网页中得到的highlightcontent是一个变量值,需要通过这种方法拿到前端模板中提供的变量值,随后在render函数中,再使用resolve(content)得到其真正的值。在得到了真正的值后,我们开始拼接html字符串。首先就是对<script>进行一个简单的转义,随后再根据用户使用的是font还是css对内容做一个格式拼接。注意这里有个奇怪的点:在拼接字符串时要使用格式化字符串赋值的形式,而不要直接hardcode,否则在前端显示会不正常。
最后,使用@register.tag的修饰器修饰highlightresult,完成我们的tag编写。
下面来让我们看一下前端页面的编写。在myblog/templates/myblog下新建advancesearch.html页面,写入以下内容:
advancesearch.html
{% extends "parentTemplate.html" %}
{% load blogfilter %}
{% load highlighttemplate %}
{% block othernavitem %}
{% if simplesearchform %}
<form method="get" action="{% url 'simpleSearch' %}">
搜索:{{ simplesearchform.searchKeyword }}
<input type="submit" value="搜索">
<a href="{% url 'advanceSearch' %}">高级搜索</a>
</form>
{% endif %}
{% endblock %}
{% block content %}
{% if searchform %}
<form method="get" action="{% url 'advanceSearch' %}">
<p>包含以下关键字,以逗号分割:{{ searchform.includeKeyword }}</p>
<p>{% for choice in searchform.includemethod %}
<span> {{ choice }} </span>
{% endfor %}</p>
{% for choice in searchform.includerange %}
<span>{{ choice }}</span>
{% endfor %}
<p>排除以下关键字,以逗号分割:{{ searchform.excludeKeyword }}</p>
{% for choice in searchform.excluderange %}
<span>{{ choice }}</span>
{% endfor %}
<p>从{{ searchform.startdate }}</p>
<p>到{{ searchform.enddate }}</p>
<input type="submit" value="搜索">
</form>
{% endif %}
<div class="content-wrap">
共搜索到{{ resultcount }}条记录
{% for blog in searchResult %}
<div>
<h3><a href="{% url 'blogs:content' blog.id %}">
{{ blog.title }}
</a></h3>
{% for highresult in blog.highlight %}
{% highlightresult highresult font "<b>" %}
{% endfor %}
</div>
{% endfor %}
{% if searchResult.has_previous %}
{% if pageurl %}
<a href="{{ pageurl }}&page={{ searchResult.previous_page_number }}">前一页</a>
{% endif %}
{% endif %}
第{{ searchResult.number }}页
{% if searchResult.has_next %}
{% if pageurl %}
<a href="{{ pageurl }}&page={{ searchResult.next_page_number }}">下一页</a>
{% endif %}
{% endif %}
</div>
{% endblock %}
这里要注意的是,要想使用我们的highlightresult tag,我们要在页面中写入{% load highlighttemplate %}。注意,这里load的是自定义tag的py文件名,py文件名,py文件名(重要事情说三遍),而不是我们定义的tag类名。在引入这个py文件后,我们就可以在模板中使用我们的tag了,即
{% for highresult in blog.highlight %}
{% highlightresult highresult font "<b>" %}
{% endfor %}
这里我选用了<b>的形式,因为实在不想设计css类。
最后,我们就得到了有用户自定义的高亮搜索结果:
六 结语
我们的esengine框架到此就全部完成了。与whoosh篇一样,依然包括以下四个部分:建立更新索引、普通搜索、高级搜索以及前端表单设计。与whoosh相比,elasticsearch的搜索性能更强,由于其分布式的特性,更加适合大公司使用;在搜索方面,es提供的json形式的Query DSL虽然比whoosh直接提供的搜索函数复杂的多,然而也灵活的多,可以充分将不同搜索条件嵌套组合;而从占用资源的角度来说,whoosh是普通的一个python库,不存在额外的资源占用,而es则需要启动一个server或多个server,占用系统资源较多,如果再配上完整的工具栈则消耗资源更多。总体来说,这两种搜索工具各有长短:企业级别一般会使用es来进行大量搜索,充分利用其分布式特性;而个人学习的话还是建议从whoosh入手,比较简单易于上手(我猜流量不大的话用whoosh也可以,仅是猜测,因为我并不是做互联网的)。
这个专题就结束了,在今后的博客中,我将继续带来django的相关内容,希望大家继续关注~