技术博客

越是喧闹,越是孤独。越是寂寞,越是丰富
The more noisy, the more lonely. The more lonely, the more rich

越是喧闹,越是孤独。越是寂寞,越是丰富
The more noisy, the more lonely. The more lonely, the more rich

技术博客


站内搜索功能的开发详解

2022-10-04 Mendel
后端综合

 本文带你一步一步开发自己的站内搜索功能



本站于近期上线了站内搜索功能,点击页面的右上角搜索按钮即可进入,可根据关键字进行全文搜索,目前可以检索出与之相匹配的文章和程序列表。如下图:

(搜索页初始状态)


(搜索结果)


该功能前前后后涉及了对大量技术的综合运用,这里我将整个开发过程整理出来,供同行学习参考。如有不足之处也欢迎批评指正。



00

整体架构



开发本功能时用到的全部技术点:

  • Apifox 等接口调试工具

  • Node.js fetch API 发起HTTP请求

  • MySQL+Node.js sequelize 操作数据库

  • Redis 缓存token数据

  • CSS 补充缺失样式

  • referrer 外链问题

  • ElasticSearch 全文搜索引擎+Iplugin中文分词器

  • Kibana 可视化工具

  • Bootstrap 样式设计

  • Lodash.debounce 实现搜索去抖

  • ......


整体结构图大致如下:






01


发布文章


我们要搜索东西,首先要有内容。很多站点会自带富文本编辑器进行内容的发布,由于本站有同名的公众号,而公众号后台的文章发布功能已经很强大了,所以我目前采用的方式是在公众号发布文章,然后同步到网站。




02


调试接口


为了实现文章数据同步,需要先获取到已发布的文章内容。从微信公众号的官方API文档中,可以找到获取已发布的文章列表的接口:

(https://developers.weixin.qq.com/doc/offiaccount/Publish/Get_publication_records.html)


为了调用这个接口,需要先准备好access_token,而这个token的生成需要app_id和app_secret,如文档所示:

(https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html)


这两个值在“公众号管理后台->设置与开发->基本配置菜单中可以查看。

为了安全,AppSecret微信并不在网页中公开,需要我们查看后自行妥善保存。至于如何妥善保管,可以参考我之前的文章《为ElasticSearch与Kibana设置密码》,其中最后部分提到了解决方案。这里我依然采用这个方式,就是把它存到MySQL中,防止在代码中硬编码密码等信息而导致泄露的风险。


注意,获取access_token时,需要设置访问来源的IP白名单,否则将无法调用。这里通常需要设置两个IP,一个是开发时本机的IP地址,一个是服务器的公网IP地址。本机的IP地址可以通过百度搜索“IP地址”即可查询到当前本机的公网IP地址,由于正常上网普遍都是动态IP地址,所以每次调试接口前注意关注下当前的IP地址是否发生了变化。


准备工作做好后,我们可以先用ApiFox(或者Postman等任何接口测试工具)测试一下这两个公众号接口,看看请求和响应的格式要求,是否能够正常返回结果,然后再编写代码集成。



03


同步文章


接口测试OK后,就可以用代码来实现了。这里用Node.js来开发文章同步的功能。首先在数据库中创建一张文章表用来存储数据(过程略),之后可以按照下面的步骤进行编码:


  • 1、通过fetch API 结合Redis缓存来获取access_token

在Node.js中发起HTTP请求有多种方式,这里采用的是fetch API,参考文档 https://www.npmjs.com/package/node-fetch ,安装node-fetch包。用Node.js Fetch的好处就是它和Web版的Fetch API有相同的用法,迁移起来比较方便。


由于access_token的有效期为2小时,所以我们可以将它缓存到redis中方便下次使用,在redis中设置的过期时间可以比2小时略短一些。这里需要注意的,如果线上和本地在相近的时间内同时请求了access_token,则同一时间只能有一个生效,所以某个redis中缓存的值可能已经失效了,所以需要在接口返回access_token异常时重新获取并重新缓存。


  • 2、使用access_token获取已发布的文章列表

有了access_token,则直接调用接口就可以获得文章数据了。微信提供了两个有关获取文章内容的接口,一个是获取多个文章列表,可以一并返回文章内容,也可以不返回;另一个是根据article_id获取单篇文章信息。大家按需采用即可,本站两个接口都采用了,一个用于展示列表简要信息,一个是用于同步时获取单篇文章内容。


  • 3、将文章内容同步到数据库中

由于文章的文字内容较多,且content字段还是带格式是html形式,所以对于这类的长字符串,在MySQL中需要用Text类型来存储。本站在这一步并没有直接入库,而是采用了向导的方式完成单篇文章的同步。目前越来越多的内容平台上的文章链接在URL上都采用了英文单词连接符,而不是传统的ID或者随机字符串的方式,使用语义化的方式来提升可读性。所以本站借鉴了这个方式,在同步文章时,需要设置一个文章的语义化的id,然后再保存入库。如下图操作界面所示:

有了短标题后,就可以在URL中展示并定位到对应的文章页面了。链接分享给他人时,在不点开时也能大概了解网页里面大概要描述的内容了。例如本站的几篇文章链接长这个样子:

  • https://mengchen.cc/article/build-vue-go-top-component

  • https://mengchen.cc/article/set-password-for-elastic-kibana

  • https://mengchen.cc/article/build-gpu-env-for-deep-learning


关于Node.js操作MySQL,有多种方式,本站目前采用的是Sequelize,是一个基于 Promise 的 Node.js ORM库,支持包括MySQL在内的多种数据库,具体细节可以查看官方文档:https://www.sequelize.cn/



04


样式检查


文章内容同步过来后,猜想你可能会迫不及待地看看在自己的页面上展示的效果如何。结果糟了,由于文章内容字段只有html代码,没有css代码,所以某些地方的排版(主要是文章中的代码段落区域)基本上无格式的。怎么办呢,由于微信公众号的文章本身是可以通过网页访问的,所以我们需要打开文章的原始链接,通过开发者工具,一点一点找出缺失的css样式资源,然后拷贝到网站中。虽然有点麻烦,但是跟文章相关的css样式就那么多,一劳永逸。


下面是我目前从公众号网页同步过来的一些css样式代码的部分截图:


样式处理好了,又发现了问题:文章中的图片都没有了。经过查看html代码发现,<img>标签是存在的,但是没有设置src属性,只有data-src属性,所以这里需要将每个img的data-src赋值给src属性让图像显现。修改后又发现了问题,每个图片都变成了这个样子:

这是因为微信不允许外部站点访问微信的图片资源,它的逻辑是判断请求源(Referrer)是不是微信域名,但是如果源为空是不禁止的,即直接在浏览器中打开图片链接是没问题的。所以我们需要做的就是将<img>的来源设置为空,通过设置referrerpolicy为no-referrer即可。从caniuse.com上查看下该功能的支持情况,主流浏览器都支持。


完整的代码参考如下:

let imgs = this.$refs.content.querySelectorAll('img')Array.from(imgs).forEach(img => {    // 增加 width:100%    img.classList.add('img-fluid')    // 对img禁用referrer,解决微信公众号图片外链不显示的问题,caniuse.com中,此特性支持ios14+    img.setAttribute('referrerpolicy', "no-referrer")    // 先设置referrer再设置src,解决safari的问题,同时增加时间戳防止缓存    img.setAttribute('src', img.getAttribute('data-src') + '&_t=' + Date.now())})




05


构建索引


文章已经保存到MySQL了,并且能正常展示了,用于在网站上显示已经没有问题了。但如果文章越来越多了,就需要进行检索了,而MySQL数据库的特性决定了对于全文检索是无能为力的。所以我们需要引入Elasticsearch来作为我们的全文搜索引擎,以及Kibana用于ES的可视化管理。


在用户搜索文章时,使用ES的倒排索引(或反向索引),根据关键词搜索出文档ID及标题等简要信息展示给用户。之后用户再点击某一篇具体文章的时候,使用MySQL的正排索引,根据文章的语义化id查询文章的内容展示出来。


关于ES和Kibana的安装,这里就不详细介绍了,大家可参考相关文档。这里主要说一下如何用Node.js将文章数据push到ES中构建索引。


从ES官方文档中找到javascript-api的介绍:

https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/7.16/api-reference.html

这里主要完成两步操作:

1、创建文章的索引(index)

2、向索引中添加文档(doc)


创建索引,即定义一下文章的结构,对哪些字段进行索引。使用下面这个API即可进行索引的创建(代码省略

这里我们将文章的title、content构建索引,将来会根据这两个字段进行模糊查询,其他字段如semantic_id不用于检索,但随着查询结果一并返回,用于后续的文章内容查看。


向索引中添加文档,使用 client.index 这个API,将文章的相应字段提交给ES即可,ES将自动为数据构建索引。


这里需要注意的是,content字段是html代码,而我们实际需要提交给ES的应该是文本内容,不应该包括这些html标记的,所以需要先提前将content字段转成纯文本内容形式再入索引。那么如何将html转成text呢,如果在Web端,可以直接使用jQuery的text()方法实现,但是在Node端就不奏效了,因为没有浏览器环境,但是可以通过安装一个jsdom库来实现这个功能,代码如下:

const { JSDOM } = require('jsdom')let contentDom = new JSDOM(`<article>${content}</article>`)let contentText = contentDom.window.document.querySelector('article').textContent

即我们给content内容包裹在一个<article>父标签内,然后通过DOM获取到该节点,并返回它的textContent。这样就去除了html标记,保留了纯文本。


这两个步骤完成之后,想看一下ES里面是不是已经存好了数据,那么可以通过Kibana来查看。进入Kibana主页后,从菜单中选择“Management->Stack Management->Index Management”即可查看目前所有的索引,以及每个索引中的文档数量等信息。至此数据的索引工作就完成了。





06


分词器插件


ElasticSearch默认的关键词检索时,分词策略仅局限于英文,对于中文并没有较好的支持,也就是它会把每个汉字都当成独立的关键词去检索,导致我们搜索出来的结果并不精准。所以我们需要安装一个中文分词器来实现中文文档的精准检索。


我们可以安装IK插件来实现此功能。下载地址为:

https://github.com/medcl/elasticsearch-analysis-ik/releases

选择和ElasticSearch版本一致的IK版本(本站使用的是7.9.2版本),然后按照文档进行安装配置即可。安装成功后,需要重新创建我们之前的文章索引,对title和content字段指定中分分词器,然后再重新添加文档,才能实现中文的检索。例如,我们给content字段指定分词器的代码如下:

"content": {    "type": "text",    "analyzer": "ik_max_word",    "fields": {        "keyword": {            "type": "keyword",            "ignore_above": 256        }    }},

IK分词器插件有两种模式:ik_smart和ik_max_word。

  • ik_max_word: 会将文本做最细粒度的拆分,比如会将“中华人民共和国国歌”拆分为“中华人民共和国,中华人民,中华,华人,人民共和国,人民,人,民,共和国,共和,和,国国,国歌”,会穷尽各种可能的组合,适合 Term Query;

  • ik_smart: 会做最粗粒度的拆分,比如会将“中华人民共和国国歌”拆分为“中华人民共和国,国歌”,适合 Phrase 查询。


本站目前使用的是ik_max_word模式,后面根据实际的查询效果再按需进行调优。大家可以根据自己的需求选择合适的模式。




07


提交给搜索引擎


我们已经完成提交给站内的搜索引擎ES了,但是更需要提交给站外的搜索引擎以利于网页的曝光量。本站由于采用的是SPA(Single Page Application,单页面应用)架构,对国内的百度等并不友好,另外百度自身的SEO策略也导致对个人站点收录效果不佳。相比之下,微软的Bing收录效果就非常好,提交URL后基本上很快就被建立索引了,也很快就可以搜索到了。所以目前我的方式就是,每同步一篇文章,就提交给Bing引擎。


Bing Webmaster提供了Submission APIs来进行URL的提交,从账号中申请API Key之后就可以使用fetch API 将网站URL提交给Bing即可。文档链接为:

https://www.bing.com/webmasters/url-submission-api#APIs


最终,整个文章同步过程的UI界面部分如下图。通过设置文章的短标题,就可以依次执行同步MySQL、同步ElasticSearch和同步Bing的操作,还算方便。后续如果优化,可以做成后台任务,定时监控有没有新文章然后自动执行这些步骤。当然由于目前发表文章频率并不高,暂时先用手动的方式就够了。





08


前端搜索


我们好不容易把ES的索引创建好了,就差一个前端页面来实现检索功能了。目前各大搜索站点,基本上在搜索结果中会返回每个条目命中关键词的上下文信息。同样ES也提供了这个功能,在查询时指定highlight选项即可将命中的上下文一并返回。这里我们主要用了highlight的三个配置项:

  • pre_tags:为命中关键词设置前导字符串

  • post_tags:为命中关键词设置后置字符串

  • fragment_size:返回命中上下文的最大字符数


通过pre_tags和post_tags,我们可以将命中关键词包裹在一个html标签内,并设置一定的样式用于前端的展示。主要代码如下:

body: {    query: {        multi_match: {            query: query,            fields: ['title', 'content']        }    },    highlight: {        fields: {            content: {                pre_tags: '<span class="text-success">',                post_tags: '</span>',                fragment_size: 30,            },            title: {                pre_tags: '<span class="text-success">',                post_tags: '</span>',            }        }    }}

我们将在title和content字段进行检索,并将命中关键词包裹在样式为text-success的<span>中,在前端展示为绿色。fragment_size配置在content中,只返回上下文30个字符,而title不做控制,完整显示。当然要注意,既然包裹了html标记,那么在前端vue绑定时,就需要用 v-html 指令而不是普通的文本指令了。


配合前端页面的设计,最终我们实现的效果就如本文开头的截图一样。例如,输入“vue”后无需回车即可快速查询出结果:

前端这里采用的是Ajax+debounce实现的异步搜索。监听搜索框的input事件,通过lodash的debounce进行去抖控制,在输入停止后500ms后触发ajax异步搜索,既提升体验也节省服务端压力。当然目前ES中的数据量少,还远远达不到性能瓶颈。


前端主要代码如下:

<input type="text" class="form-control" placeholder="全站搜索..."  maxlength="32" @input="onInput" :value="query">
onInput: lodash.debounce(function(e) {    let query = e.target.value    this.query = query    if (query) {        this.search(query)    // 发起搜索请求    }}, 500),


至此,本文告一段落。其实这里面每一个步骤中都可以深挖很多细节,鉴于篇幅太长,所以先产出一篇总览的文章,后面会根据实际情况针对某些个别技术点再进行深入的挖掘和探索。



点击“查看原文”可立即体验本站的搜索功能:https://mengchen.cc/search