微服务[学成在线] day12:基于 Nuxt.js 构建搜索前端工程

时间:2022-07-22
本文章向大家介绍微服务[学成在线] day12:基于 Nuxt.js 构建搜索前端工程,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

? 知识点概览

为了方便后续回顾该项目时能够清晰的知道本章节讲了哪些内容,并且能够从该章节的笔记中得到一些帮助,所以在完成本章节的学习后在此对本章节所涉及到的知识点进行总结概述。

本章节为【学成在线】项目的 day12 的内容

  •  Nuxt.js 的基本使用
  •  基于 Nuxt.js 开发搜索门户前端

目录

知识点结合实战应用会更有意义,所以这里我就不再对单个知识点进行拆分成单个笔记,内容会比较多,这里我们可以根据目录进行按需阅读。一、搜索前端技术需求

采用 vue.js 开发搜索界面则 SEO 不友好,需要解决 SEO 的问题。

0x01 什么是SEO?

我们先开一下百度百科是如何描述的

总结:seo 是网站为了提高自已的网站排名,获得更多的流量,对网站的结构及内容进行调整优化,以便搜索引擎(百度,google等)更好抓取到更优质的网站的内容。

下图是搜索引擎爬取网站页面的大概流程图:

搜索引擎的工作流程很复杂,下图只是简单概括

从上图可以看到 SEO 是网站自己为了方便 spider (爬虫) 抓取网页而作出的网页内容优化,常见的 SEO 方法比如:

  • url 链接的规范化,多用 restful 风格的 url,多用静态资源 url
  • 注意 titlekeywords 的设置。
  • 由于 spiderjavascript 支持不好,对于网页跳转用 href 标签。

0x02 服务端渲染和客户端渲染

采用什么技术有利于 SEO?要解答这个问题需要理解服务端渲染和客户端渲染。

那么什么是服务端渲染?

我们用传统的 servlet 开发来举例:浏览器请求 servletservlet 在服务端生成 html 响应给浏览器,浏览器展示html 的内容,这个过程就是服务端渲染,如下图:

服务端渲染的特点:

1)在服务端生成 html 网页的 dom 元素。

2)客户端(浏览器)只负责显示 dom 元素内容。

当初随着 web2.0 的到来,AJAX 技术兴起,出现了客户端渲染:客户端(浏览器) 使用 AJAX 向服务端发起http 请求,获取到了想要的数据,客户端拿着数据开始渲染 html 网页,生成 Dom 元素,并最终将网页内容展示给用户

客户端渲染如下图:

客户端渲染的特点:

1)在服务端只是给客户端响应的了数据,而不是 html 网页

2)客户端(浏览器)负责获取服务端的数据生成 Dom 元素。

两种方式各有什么优缺点?

客户端渲染:

  1. 缺点

不利于网站进行 SEO,因为网站大量使用 javascript 技术,不利于 spider 抓取网页。

  1. 优点

客户端负责渲染,用户体验性好,服务端只提供数据不用关心用户界面的内容,有利于提高服务端的开发效率。

3)适用场景

对SEO没有要求的系统,比如后台管理类的系统,如电商后台管理,用户管理等。

服务端渲染:

  1. 优点

有利于SEO,网站通过 hrefurlspider直接引到服务端,服务端提供优质的网页内容给 spider

  1. 缺点

服务端完成一部分客户端的工作,通常完成一个需求需要修改客户端和服务端的代码,开发效率低,不利于系统的 稳定性。

3)适用场景

SEO 有要求的系统,比如:门户首页、商品详情页面等。

二、Nuxt.js 介绍

0x01 简单的了解一下

移动互联网的兴起促进了 web 前后端分离开发模式的发展,服务端只专注业务,前端只专注用户体验,前端大量运用的前端渲染技术,比如流行的 vue.jsreact 框架都实现了功能强大的前端渲染。

但是,对于有 SEO 需求的网页如果使用前端渲染技术去开发就不利于 SEO 了,有没有一种即使用 vue.jsreact 的前端技术也实现服务端渲染的技术呢?其实,对于服务端渲染的需求,vue.jsreact 这样流行的前端框架提供了服务端渲染的解决方案。

从上图可以看到:

react 框架提供 next.js 实现服务端渲染。

vue.js 框架提供 Nuxt.js 实现服务端渲染。

基本原理

0x02 工作原理

下图展示了从客户端请求到 Nuxt.js 进行服务端渲染的整体的工作流程:

1、用户打开浏览器,输入网址请求到 Node.js

2、部署在 Node.js 的应用 Nuxt.js 接收浏览器请求,并请求服务端获取数据

3、Nuxt.js 获取到数据后进行服务端渲染

4、Nuxt.jshtml 网页响应给浏览器

Nuxt.js 使用了哪些技术?

Nuxt.js 使用 Vue.js + webpack + Babel 三大技术框架/组件,如下图:

Babel 是一个 js 的转码器,负责将 ES6 的代码转成浏览器识别的 ES5 代码。

Webpack 是一个前端工程打包工具。

Vue.js 是一个优秀的前端框架。

那么 Nuxt.js 的特性有哪些?

  • 基于 Vue.js
  • 自动代码分层
  • 服务端渲染
  • 强大的路由功能,支持异步数据
  • 静态文件服务
  • ES6/ES7 语法支持
  • 打包和压缩 JSCSS
  • HTML 头部标签管理
  • 本地开发支持热加载
  • 集成 ESLint
  • 支持各种样式预处理器: SASSLESSStylus 等等

三、Nuxt.js 基本使用

0x01 创建 Nuxt 工程

nuxt.js 有标准的目录结构,官方提供了模板工程,可以模板工程快速创建 nuxt 项目。

模板工程地址:https://github.com/nuxt-community/starter-template/archive/master.zip

本项目提供基于 Nuxt.js 的封装工程,基于此封装工程开发搜索前端,见 资料/xc-ui-pc-portal.zip,解压 xc-ui-pc-portal.zip 到本项目前端工程目录下。

本前端工程属于门户的一部分,将承载一部分考虑 SEO 的非静态化页面。

本工程基于 Nuxt.js 模板工程构建,Nuxt.js 使用 1.3 版本,并加入了今后开发中所使用的依赖包,直接解压本工程即可使用。

0x02 目录结构

目录结构如下

名称

描述信息

assets

资源目录 assets 用于组织未编译的静态资源如 LESS、SASS 或 JavaScript

components

组件目录 components 用于组织应用的 Vue.js 组件。Nuxt.js 不会扩展增强该目录下 Vue.js 组件,即这些组件不会像页面组件那样有 asyncData 方法的特性。

layouts

布局目录 layouts 用于组织应用的布局组件。该目录名为 Nuxt.js 保留的,不可更改。

middleware

middleware 目录用于存放应用的中间件

pages

页面目录 pages 用于组织应用的路由及视图。Nuxt.js 框架读取该目录下所有的 .vue 文件并自动生成对应的路由配置。该目录名为 Nuxt.js 保留的,不可更改。

plugins

插件目录 plugins 用于组织那些需要在 根vue.js应用 实例化之前需要运行的 Javascript 插件

static

静态文件目录 static 用于存放应用的静态文件,此类文件不会被 Nuxt.js 调用 Webpack 进行构建编译处理。 服务器启动的时候,该目录下的文件会映射至应用的根路径 / 下。例如: /static/logo.png 映射至 /logo.png ,该目录名为Nuxt.js保留的,不可更改。

Store

store 目录用于组织应用的 Vuex 状态树 文件。 Nuxt.js 框架集成了 Vuex 状态树 的相关功能配置,在 store 目录下创建一个 index.js 文件可激活这些配置。

nuxt.config.js

nuxt.config.js 文件用于组织 Nuxt.js 应用的个性化配置,以便覆盖默认配置。该文件名为Nuxt.js 保留的,不可更改。

package.json

文件用于描述应用的依赖关系和对外暴露的脚本接口。该文件名为 Nuxt.js 保留的,不可更改。

nuxt.js 提供了目录的别名,方便在程序中引用:

官方文档: https://zh.nuxtjs.org/guide/installation

0x03 页面布局

页面布局就是页面内容的整体结构,通过在 layouts 目录下添加布局文件来实现。在 layouts 根目录下的所有文件都属于个性化布局文件,可以在页面组件中利用 layout 属性来引用。

一个例子:

1、定义:layouts/test.vue 布局文件,如下:

注意:布局文件中一定要加 <nuxt/> 组件用于显示页面内容。

<template>
<div>
    <div>这里是头</div>
    <nuxt/>
    <div>这里是尾</div>
</div>
</template>
<script>
export default {
    
}
</script>
<style>
</style>

2、在 pages 目录创建 user 目录,并创建 index.vue 页面

pages/user/index.vue 页面里, 可以指定页面组件使用 test 布局,代码如下:

<template>
    <div>
    测试页面
    </div>
</template>
<script>
export default{
	layout:'test'
}
</script>
<style>
</style>

3、测试

请求:http://localhost:10000/user,效果如下:

0x04 路由

1、基础路由

Nuxt.js 依据 pages 目录结构自动生成 vue-router 模块的路由配置。

Nuxt.js 根据 pages 的目录结构及页面名称定义规范来生成路由,下边是一个基础路由的例子

假设 pages 的目录结构如下:

pages/
--| user/
-----| index.vue
-----| one.vue

那么,Nuxt.js 自动生成的路由配置如下:

router: {
	routes: [
        {
            name: 'user',
            path: '/user',
            component: 'pages/user/index.vue'
        },
        {
            name: 'user-one',
            path: '/user/one',
            component: 'pages/user/one.vue'
        }
    ]
}

index.vue 代码如下:

<template>
    <div>
    用户管理首页
    </div>
</template>
<script>
export default{
	layout:"test"
} 
</script>
<style>
</style>

one.vue 代码如下:

<template>
    <div>
        one页面
    </div>
</template>
<script>
    export default{
        layout:"test"
    } 
</script>
<style>
</style>

分别访问如下链接进行测试:

http://localhost:10000/user

http://localhost:10000/user/one

2、嵌套路由

你可以通过 vue-router 的子路由创建 Nuxt.js 应用的嵌套路由。

创建内嵌子路由,你需要添加一个 Vue 文件,同时添加一个与该文件同名的目录用来存放子视图组件。

别忘了在父级 Vue 文件内增加 <nuxt-child/> 用于显示子视图内容。

假设文件结构如:

pages/
--| user/
-----| _id.vue
-----| index.vue
--| user.vue

Nuxt.js 自动生成的路由配置如下:

router: {
    routes: [
        {
            path: '/user',
            component: 'pages/user.vue',
            children: [
                {
                    path: '',
                    component: 'pages/user/index.vue',
                    name: 'user'
                },
                {
                    path: ':id',
                    component: 'pages/user/_id.vue',
                    name: 'user-id'
                }
            ]
        }
    ]
}

user.vue 文件创建到与 user 目录的父目录下,即和 user 目录保持平级 。

<template>
    <div>
        用户管理导航
        <nuxt-link :to="'/user/101'">点击修改ID</nuxt-link>
        <nuxt-child/>
    </div>
</template>
<script>
export default{
    layout:"test"
}
</script>
<style>

</style>

_id.vue 页面实现了向页面传入 id 参数,页面内容如下:

<template>
    <div>
        修改用户信息{{id}}
    </div>
</template>
<script>
export default {
    layout:"test",
    data(){
        return{
            id:""
        }
    },
    mounted(){
        this.id = this.$route.params.id;
        console.log(this.id)
    }
}
</script>
<style>
</style>

测试:http://localhost:10000/user

点击修改

0x05 获取数据

1、asyncData

Nuxt.js 扩展了 Vue.js,增加了一个叫 asyncData 的方法, asyncData 方法会在组件(限于页面组件)每次加载之前被调用。

它可以在服务端或路由更新之前被调用。 在这个方法被调用的时候,第一个参数被设定为当前页面的上下文对象,你可以利用 asyncData 方法来获取数据,Nuxt.js 会将 asyncData 返回的数据融合组件 data 方法返回的数据一并返回给当前组件,从而实现服务端渲染页面的效果。

注意:由于 asyncData 方法是在组件 初始化 前被调用的,所以在方法内是没有办法通过 this 来引用组件的实例对象。

例子:在上边例子中的 user/_id.vue 中添加,页面代码如下:

<template>
<div>
    修改用户信息{{id}},名称:{{name}},课程名称:{{course}}
</div>
</template>
<script>
export default{
    layout:'test',
    //根据id查询用户信息
    asyncData(){
        console.log("async方法")
        return {
            name:'黑马程序员'
        }
    },
    data(){
        return {
            id:'',
            course: ""
        }
    },
   	methods:{
      getCourse:function(){
          this.course = "spring实战666"
      }  
    },
    mounted(){
        this.id = this.$route.params.id;
        this.getCourse();
    }
}
</script>
<style>
</style>

此方法在服务端被执行,观察服务端控制台打印输出 "async方法"

此方法返回 data 模型数据,在服务端被渲染,最后响应给前端,刷新此页面查看页面源代码可以看到 name模型数据已在页面源代码中显示,而 course 变量是在 mounted 钩子函数中调用了 getCourse 方法对 course 进行赋值,属于客户端使用 JS进行渲染,所以在页面源代码中没有看到 course 变量的值,如下图所示

2、async/await 方法

使用 asyncawait 配合 promise 也可以实现同步调用,nuxt.js 中使用 async/await 实现同步调用效果。

1、先测试异步调用,增加a、b两个方法,并在 mounted 中调用

methods:{
    a(){
        return new Promise(function(resolve,reject){
            setTimeout(function () {
                resolve(1)
            },2000)
        })
    },
        b(){
            return new Promise(function(resolve,reject){
                setTimeout(function () {
                    resolve(2)
                },1000)
            })
        }
},
    mounted(){
        this.a().then(res=>{
            alert(res)
            console.log(res)
        })
        this.b().then(res=>{
            alert(res)
            console.log(res)
        })
    }

从上述代码中,a 方法使用 setTimeout 延迟了2秒执行,b 方法延迟了1秒,如果按同步顺序进行执行,应该还是先输出 a 方法的内容再输出 b 方法。

观察客户端,并没有按照方法执行的顺序输出,使用 Promise 实现了异步调用,执行结果如下图

2、使用 async/await 完成同步调用

async asyncData({ store, route }) {
    console.log("async方法")
    var a = await new Promise(function (resolve, reject) {
        setTimeout(function () {
            console.log("1")
            resolve(1)
        },2000)
    });
    var a = await new Promise(function (resolve, reject) {
        setTimeout(function () {
            console.log("2")
            resolve(2)
        },1000)
    });
    return {
        name:'黑马程序员'
    }
},

这里我们在 asyncData 方法前面增加了 async 关键字,在调用 Promise 前也增加了 await , 观察服务端控制台发现是按照 a、b 方法的调用顺序输出 1、2,实现了使用 async/await 完成同步调用。

四、搜索服务前端开发

0x01 搜索页面

1、需求分析

上图是课程搜索前端的界面,用户通过前端向服务端发起搜索请求,搜索功能包括:

1、界面默认查询所有课程,并分页显示

2、通过一级分类和二分类搜索课程,选择一级分类后将显示下属的二级分类

3、通过关键字搜索课程

4、通过课程等级搜索课程

2、页面布局

nuxt.js/layout/default.vue 作为所有页面的默认布局,通常布局包括:页头、内容区、页尾

default.vue 内容如下:

<template>
    <div>
        <Header />
        <nuxt/>
        <Footer />
    </div>
</template>
<script>
    import Footer from '../components/Footer.vue'
    import Header from '../components/Header.vue'
    export default {
        components: {
            Header,
            Footer
        }
    }
</script>
<style>
</style>

3、Nginx代理配置

搜索页面中以 /static 开头的静态资源通过 nginx 解析,如下:

/static/plugins:指向门户目录下的 plugins 目录。

/static/css:指向门户目录下的的 css 目录

修改 Nginxwww.xuecheng.com 虚拟主机的配置:

在之前的章节当中如果已经配置了静态资源虚拟主机,可以忽略这个步骤

#静态资源,包括系统所需要的图片,js、css等静态资源
location /static/img/ {
    alias F:/develop/xc_portal_static/img/;
    #静态资源,包括系统所需要的图片,js、css等静态资源
    location /static/img/ {
    alias F:/develop/xc_portal_static/img/;
} 
location /static/css/ {
    alias F:/develop/xc_portal_static/css/;
} 
location /static/js/ {
    alias F:/develop/xc_portal_static/js/;
} 
location /static/plugins/ {
    alias F:/develop/xc_portal_static/plugins/;
    add_header Access-Control-Allow-Origin http://ucenter.xuecheng.com;
    add_header Access-Control-Allow-Credentials true;
    add_header Access-Control-Allow-Methods GET;
}

配置搜索 Url,下图是 Nginx 搜索转发流程图:

用户请求 /course/searchNginx 将请求转发到 nuxt.js 服务,nginx 在转发时根据每台 nuxt 服务的负载情况进行转发,实现负载均衡。

本教程开发环境 Nuxt.js 服务和 www.xuecheng.com 虚拟机主在同一台计算机,使用同一个 nginx,配置如下:

#前端门户课程搜索
location ^~ /course/search {
    proxy_pass http://dynamic_portal_server_pool;
} 
#后端搜索服务
location /openapi/search/ {
    proxy_pass http://search_server_pool/search/;
}

dynamic_portal_server_pool 配置如下 :

#前端动态门户
upstream dynamic_portal_server_pool{
    server 127.0.0.1:10000 weight=10;
}
#后台搜索服务(公开api)
upstream search_server_pool{
    server 127.0.0.1:40100 weight=10;
}

其它配置:

nuxt.js 会自动请求这些一些内置的api,如果不配置的话前端会报错,所以还是给它整上,暂时不需要去追究这些接口是何作用

#开发环境webpack定时加载此文件
location ^~ /__webpack_hmr {
	proxy_pass http://dynamic_portal_server_pool/__webpack_hmr;
}
#开发环境 nuxt 访问 _nuxt
location ^~ /_nuxt/ {
	proxy_pass http://dynamic_portal_server_pool/_nuxt/;
}  

在静态虚拟主机中添加:

#分类信息
location /static/category/ {
    alias E:/Project/XueChengOnline/xcEduUI01/xuecheng/static/category/;
} 

4、搜索页面

创建搜索页面如下:

页面文件参考:资料/search/index_1.vue,重要代码如下: nuxt.js 支持定义 header,本页面我们在 header 中引入 css 样式并定义头部信息。

//配置文件
let config = require('~/config/sysConfig')
import querystring from 'querystring'
import * as courseApi from '~/api/course'
export default {
    head() {
        return {
            title: '传智播客-一样的教育,不一样的品质',
            meta: [
                {charset: 'utf-8'},
                {name: 'description', content: '传智播客专注IT培训,Java培训,Android培训,安卓培训,PHP培训,C++培训,网页设计培训,平面设计培训,UI设计培训,移动开发培训,网络营销培训,web前端培训,云计算大数据培训,全栈工程师培训,产品经理培训。'},
                {name: 'keywords', content: this.keywords}
            ],
            link: [
                {rel: 'stylesheet', href: '/static/plugins/normalize-css/normalize.css'},
                {rel: 'stylesheet', href: '/static/plugins/bootstrap/dist/css/bootstrap.css'},
                {rel: 'stylesheet', href: '/static/css/page-learing-list.css'}
            ]
        }
    },

其它数据模型及方法:

<script>
    //配置文件
    let config = require('~/config/sysConfig')
    import querystring from 'querystring'
    import * as courseApi from '~/api/course'
    export default {
        head() {
            return {
                title: '传智播客-一样的教育,不一样的品质',
                meta: [
                    {charset: 'utf-8'},
                    {name: 'description', content: '传智播客专注IT培训,Java培训,Android培训,安卓培训,PHP培
                     训,C++培训,网页设计培训,平面设计培训,UI设计培训,移动开发培训,网络营销培训,web前端培训,云计算大数据培训,
                     全栈工程师培训,产品经理培训。'},
                     {name: 'keywords', content: this.keywords}
                ],
                link: [
                    {rel: 'stylesheet', href: '/static/plugins/normalize-css/normalize.css'},
                    {rel: 'stylesheet', href: '/static/plugins/bootstrap/dist/css/bootstrap.css'},
                    {rel: 'stylesheet', href: '/static/css/page-learing-list.css'}
                ]
            }
        },
        async asyncData({ store, route }) {
            return {
                courselist: {},
                first_category:{},
                second_category:{},
                mt:'',
                st:'',
                grade:'',
                keyword:'',
                total:0,
                imgUrl:config.imgUrl
            }
        },
        data() {
            return {
                courselist: {},
                first_category:{},
                second_category:{},
                mt:'',
                st:'',
                grade:'',
                keyword:'',
                imgUrl:config.imgUrl,
                total:0,//总记录数
                page:1,//页码
                page_size:12//每页显示个数
            }
        },
        watch:{//路由发生变化立即搜索search表示search方法
            '$route':'search'
        },
        methods: {
            //分页触发
            handleCurrentChange(page) {
            },
            //搜索方法
            search(){
                //刷新当前页面
                window.location.reload();
            }
        }
    }
</script>

5、测试

重启Nginx,请求:http://www.xuecheng.com/course/search,页面效果如下:

0x02 查看全部

1、需求分析

初次进入页面,没有输入任何查询条件,默认查询全部课程,分页显示

2、api方法

在api目录创建本工程所用的api方法类,api方法类使用了public.js等一些抽取类:

/api/public.js-------------抽取axios 的基础方法

/api/util.js-----------------工具类

/config/sysConfig.js----系统配置类,配置了系统参数变量

创建 course.js,作为课程相关业务模块的 api 方法类。

import http from './public'
import qs from 'qs'
let config = require('~/config/sysConfig')
let apiURL = config.apiURL
let staticURL = config.staticURL
if (typeof window === 'undefined') {
    apiURL = config.backApiURL
    staticURL = config.backStaticURL
} 
/*搜索*/
export const search_course = (page,size,params) => {
    let querys = qs.stringify(params);
    return 	http.requestQuickGet(apiURL+"/search/course/list/"+page+"/"+size+"?"+querys);
}

3、搜索方法

实现思路如下:

1、用户请求本页面到达 node.js

2、在 asyncData 方法中向服务端请求查询课程

3、asyncData 方法执行完成开始服务端渲染在 asyncData 中执行搜索,代码如下:

async asyncData({ store, route }) {//服务端调用方法
    //搜索课程
    let page = route.query.page;
    if(!page){
        page = 1;
    }else{
        page = Number.parseInt(page)
    } 
    console.log(page);
    //请求搜索服务,搜索服务
    let course_data = await courseApi.search_course(page,2,route.query);
    console.log(course_data)
    
    //拿到数据
    if (course_data && course_data.queryResult ) {
        let keywords = ''
        let mt=''
        let st=''
        let grade=''
        let keyword=''
        let total = course_data.queryResult.total
        if( route.query.mt){
            mt = route.query.mt
        } 
        if( route.query.st){
            st = route.query.st
        } 
        if( route.query.grade){
            grade = route.query.grade
        } 
        if( route.query.keyword){
            keyword = route.query.keyword
        } 
        return {
            courselist: course_data.queryResult.list,//课程列表
            keywords:keywords,
            mt:mt,
            st:st,
            grade:grade,
            keyword:keyword,
            page:page,
            total:total,
            imgUrl:config.imgUrl
        }
    }else{
        //未拿到数据,返回空值对象到前端
        return {
            courselist: {},
            first_category:{},
            second_category:{},
            mt:'',
            st:'',
            grade:'',
            keyword:'',
            page:page,
            total:0,
            imgUrl:config.imgUrl
        }
    }
}

4、页面

在页面中展示课程列表。

<!-- 渲染课程信息 -->
<div class="recom-item" v-for="(course, index) in courselist">
    <nuxt-link :to="'/course/detail/'+course.id+'.html'" target="_blank">
        <div v-if="course.pic">
            <p>
                <img :src="imgUrl+'/'+course.pic" width="100%" alt />
            </p>
        </div>
        <div v-else>
            <p>
                <img src="/img/widget-demo1.png" width="100%" alt />
            </p>
        </div>
        <ul>
            <li class="course_title">
                <span v-html="course.name"></span>
            </li>
            <li style="float: left">
                <span v-if="course.charge == '203001'">免费</span>
                <span v-if="course.charge == '203002'">¥{{course.price | money}}</span>
                <!-- <em> · </em>-->
                &nbsp;&nbsp;
                <!--<em>1125人在学习</em>-->
            </li>
        </ul>
    </nuxt-link>
</div>

添加在 index.vue 页面的 content-list 节点下,具体代码参考 资料/index_2.vue 文件

访问搜索页面,nuxt.js 会在页面渲染之前请求查询接口拿到数据,并在 node.js 上完成页面的渲染

效果预览

0x03 分页查询

1、服务端代码

服务端实现代码已在 day11 的内容中完成,搜索服务核心代码如下

...
//分页
//当前页码
if(page<=0){
page = 1;
} /
/起始记录下标
int from = (page -1) * size;
searchSourceBuilder.from(from);
searchSourceBuilder.size(size);
...

2、前端代码

使用 Element uiel-pagination 分页插件:

<div style="text-align: center">
    <el-pagination
                   background
                   layout="prev, pager, next"
                   @current-change="handleCurrentChange"
                   :total="total"
                   :page-size="page_size"
                   :current-page="page"
                   prev-text="上一页"
                   next-text="下一页">
        </el-pagination>
</div>

定义分页触发方法:

methods:{
    //分页触发
    handleCurrentChange(page) {
        this.page = page
        this.$route.query.page = page
        let querys = querystring.stringify(this.$route.query)
        window.location = '/course/search?'+querys;
    } .
        ..

0x04 按分类搜索

1、需求分析

1)通过一级分类搜索

2)选择一级分类后将显示下属的二级分类

3)选择二分类进行搜索

4)选择一级分类的全部则表示没有按照分类搜索

5)选择一级分类的全部时二级分类不显示

2、api 方法

课程分类将通过页面静态化的方式写入静态资源下,通过 /category/category.json 可访问,

通过 www.xuecheng.com/static/category/category.json 即可访问。

category.json 的内容如下

我们需要定义 api 方法获取所有的分类

/api/course.js 中添加:

/*获取分类*/
export const sysres_category = () => {
return http.requestQuickGet(staticURL+"/static/category/category.json");
}

3、在 asncData 中查询分类

进入搜索页面将默认显示所有一级分类,当前如果已选择一级分类则要显示所有一级分类及该一级分类下属的二级 分类。在 asyncData 方法中实现上边的需求,代码如下:

async asyncData({ store, route }) {
    //服务端调用方法
    //搜索课程
    let page = route.query.page;
    if (!page) {
        page = 1;
    } else {
        page = Number.parseInt(page);
    }
    console.log(page);
    //请求搜索服务,搜索服务
    let course_data = await courseApi.search_course(page, 2, route.query);
    console.log(course_data);
    let category_data = await courseApi.sysres_category();
    console.log(category_data)
    if (course_data && course_data.queryResult) {
        let keywords = "";
        let mt = "";
        let st = "";
        let grade = "";
        let keyword = "";
        let total = course_data.queryResult.total;
        if (route.query.mt) {
            mt = route.query.mt;
        }
        if (route.query.st) {
            st = route.query.st;
        }
        if (route.query.grade) {
            grade = route.query.grade;
        }
        if (route.query.keyword) {
            keyword = route.query.keyword;
        }

        //全部分类
        let category = category_data.category; //分部分类
        let first_category = category[0].children; //一级分类
        let second_category = []; //二级分类

        //遍历一级分类
        for (var i in first_category) {
            keywords += first_category[i].name + " ";
            if (mt != "" && mt == first_category[i].id) {
                //取出二级分类
                second_category = first_category[i].children;
                // console.log(second_category)
                break;
            }
        }
        return {
            courselist: course_data.queryResult.list, //课程列表
            keywords: keywords,
            first_category: first_category,
            second_category: second_category,
            mt: mt,
            st: st,
            grade: grade,
            keyword: keyword,
            page: page,
            total: total,
            imgUrl: config.imgUrl
        };
    } else {
        return {
            courselist: {},
            first_category: {},
            second_category: {},
            mt: "",
            st: "",
            grade: "",
            keyword: "",
            page: page,
            total: 0,
            imgUrl: config.imgUrl
        };
    }
},

4、页面

在页面显示一级分类及二级分类,需要根据当前是否选择一级分类、是否选择二分类显示页面内容。

<ul>
    <li>一级分类:</li>
    <li v-if="mt!=''"><nuxt-link class="title-link" :to="'/course/search?
        keyword='+keyword+'&grade='+grade">全部</nuxt-link></li>
    <li class="all" v-else>全部</li>
    <ol>
        <li v-for="category_v in first_category">
            <nuxt-link class="title-link all" :to="'/course/search?keyword='+keyword+'&mt=' +
                                                   category_v.id" v-if="category_v.id == mt">{{category_v.name}}</nuxt-link>
            <nuxt-link class="title-link" :to="'/course/search?keyword='+keyword+'&mt=' +
                                               category_v.id" v-else>{{category_v.name}}</nuxt-link>
        </li>
    </ol>
    <!--<ol>
    <li>数据分析</li>
    <li>机器学习工程</li>
    <li>前端开发工程</li>
    </ol>-->
</ul>
<ul>
    <li>二级分类:</li>
    <li v-if="st!=''"><nuxt-link class="title-link" :to="'/course/search?
        keyword='+keyword+'&mt='+mt+'&grade='+grade">全部</nuxt-link></li>
    <li class="all" v-else>全部</li>
    <ol v-if="second_category.length>0">
        <li v-for="category_v in second_category">
            <nuxt-link class="title-link all" :to="'/course/search?keyword='+keyword+'&mt='+mt+'&st='
                                                   + category_v.id" v-if="category_v.id == st">{{category_v.name}}</nuxt-link>
            <nuxt-link class="title-link" :to="'/course/search?keyword='+keyword+'&mt='+mt+'&st=' +
                                               category_v.id" v-else>{{category_v.name}}</nuxt-link>
        </li>
        <!-- <li>大数据</li>
        <li>云计算</li>-->
    </ol>
    <!--<a href="#" class="more">更多 ∨</a>-->
</ul>

5、立即搜索

当用户点击分类时立即执行搜索,实现思路如下:

  • 点击分类立即更改路由。
  • 通过监听路由,路由更改则刷新页面。

1)创建搜索方法

search(){
//刷新当前页面
	window.location.reload();
}

2)定义watch

通过 vue.jswatch 可以实现监视某个变量,当变量值出现变化时执行某个方法。

实现思路是:

1、点击分类页面路由更改

2、通过 watch 监视路由,路由更改触发 search 方法与 methods 并行定义 watch

watch: {
    //路由发生变化立即搜索search表示search方法
    $route: "search"
},

0x05 按难度等级搜索

1、需求分析

用户选择不同的课程难度等级去搜索课程。

2、api方法

使用 search_course 方法完成搜索。

3、页面

按难度等级搜索思路如下:

1)点击难度等级立即更改路由。

2)通过监听路由,路由更改则立即执行 search 搜索方法

按难度等级搜索页面代码如下:

<ul>
    <li>难度等级:</li>
    <li v-if="grade!=''">
        <nuxt-link class="title-link" :to="'/course/search?keyword='+keyword+'&mt=' +
                                           mt+'&st='+st+'&grade='">全部
            </nuxt-link>
    </li>
    <li class="all" v-else>全部</li>
    <ol>
        <li v-if="grade=='200001'" class="all">初级</li>
        <li v-else><nuxt-link class="title-link" :to="'/course/search?keyword='+keyword+'&mt=' +
            mt+'&st='+st+'&grade=200001'">初级</nuxt-link></li>
        <li v-if="grade=='200002'" class="all">中级</li>
        <li v-else><nuxt-link class="title-link" :to="'/course/search?keyword='+keyword+'&mt=' +
            mt+'&st='+st+'&grade=200002'">中级</nuxt-link></li>
        <li v-if="grade=='200003'" class="all">高级</li>
        <li v-else><nuxt-link class="title-link" :to="'/course/search?keyword='+keyword+'&mt=' +
            mt+'&st='+st+'&grade=200003'">高级</nuxt-link></li>
    </ol>
</ul>

0x06 高亮显示

1、服务端代码

高亮的核心代码

	...
    //定义高亮
    HighlightBuilder highlightBuilder = new HighlightBuilder();
    highlightBuilder.preTags("<font class='eslight'>");
    highlightBuilder.postTags("</font>");
    highlightBuilder.fields().add(new HighlightBuilder.Field("name"));
    searchSourceBuilder.highlighter(highlightBuilder);
	...
    //添加数据
    for(SearchHit hit:searchHits){
        CoursePub coursePub = new CoursePub();
        //源文档
        Map<String, Object> sourceAsMap = hit.getSourceAsMap();
        //课程id
        String id = (String) sourceAsMap.get("id");
        coursePub.setId(id);
        //取出name
        String name = (String) sourceAsMap.get("name");
        
        //取出高亮字段
        Map<String, HighlightField> highlightFields = hit.getHighlightFields();
        if(highlightFields.get("name")!=null){
            HighlightField highlightField = highlightFields.get("name");
            Text[] fragments = highlightField.fragments();
            StringBuffer stringBuffer = new StringBuffer();
            for(Text text:fragments){
                stringBuffer.append(text);
            } 
            name = stringBuffer.toString();
        } 
        coursePub.setName(name);
        ....
    }

核心的代码主要是设置 HighlightBuilder 对象的高亮属性,然后在遍历添加数据的循环中,在map中取出name 属性后,再取出高亮字段,并且设置到 name 属性中。

以下是搜索服务的全部代码

package com.xuecheng.search.service;

import com.xuecheng.framework.domain.course.CoursePub;
import com.xuecheng.framework.domain.search.CourseSearchParam;
import com.xuecheng.framework.model.response.CommonCode;
import com.xuecheng.framework.model.response.QueryResponseResult;
import com.xuecheng.framework.model.response.QueryResult;
import org.apache.commons.lang3.StringUtils;
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.common.text.Text;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.MultiMatchQueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.SearchHits;
import org.elasticsearch.search.builder.SearchSourceBuilder;
import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder;
import org.elasticsearch.search.fetch.subphase.highlight.HighlightField;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import javax.naming.directory.SearchResult;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Map;

@Service
public class EsCourseService {
    private static final Logger LOGGER = LoggerFactory.getLogger(EsCourseService.class);

    @Value("${xuecheng.elasticsearch.course.index}")
    private String es_index;
    @Value("${xuecheng.elasticsearch.course.type}")
    private String es_type;
    @Value("${xuecheng.elasticsearch.course.source_field}")
    private String source_field;

    @Autowired
    RestHighLevelClient restHighLevelClient;

    /**
     * 课程列表搜索
     * @param page 页码
     * @param size 每页数量
     * @param courseSearchParam 搜索参数
     * @return
     * @throws IOException
     */
    public QueryResponseResult<CoursePub> findList(int page, int size, CourseSearchParam courseSearchParam) throws IOException{
        //设置索引
        SearchRequest searchRequest = new SearchRequest(es_index);
        //设置类型
        searchRequest.types(es_type);
        //创建搜索源对象
        SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
        //创建布尔查询对象
        BoolQueryBuilder boolQueryBuilder = new BoolQueryBuilder();

        //源字段过滤
        String[] fieldArr = source_field.split(",");
        searchSourceBuilder.fetchSource(fieldArr,new String[]{});

        //根据关键字进行查询
        if(StringUtils.isNotEmpty(courseSearchParam.getKeyword())){
            //匹配关键词
            MultiMatchQueryBuilder multiMatchQueryBuilder = QueryBuilders.multiMatchQuery(courseSearchParam.getKeyword(), "name", "teachplan", "description");
            //设置匹配占比
            multiMatchQueryBuilder.minimumShouldMatch("70%");
            //提升字段的权重值
            multiMatchQueryBuilder.field("name",10);
            boolQueryBuilder.must(multiMatchQueryBuilder);
        }

        //根据难度进行过滤
        if(StringUtils.isNotEmpty(courseSearchParam.getMt())){
            boolQueryBuilder.filter(QueryBuilders.termQuery("mt",courseSearchParam.getMt()));
        }
        if(StringUtils.isNotEmpty(courseSearchParam.getSt())){
            boolQueryBuilder.filter(QueryBuilders.termQuery("st",courseSearchParam.getSt()));
        }
        //根据等级进行过滤
        if(StringUtils.isNotEmpty(courseSearchParam.getGrade())){
            boolQueryBuilder.filter(QueryBuilders.termQuery("grade",courseSearchParam.getGrade()));
        }

        //设置分页参数
        if(page<=0){
            page = 1;
        }
        if(size<=0){
            size = 20;
        }
        //计算搜索起始位置
        int start = (page-1) * size;
        searchSourceBuilder.from(start);
        searchSourceBuilder.size(size);

        //将布尔查询对象添加到搜索源内
        searchSourceBuilder.query(boolQueryBuilder);

        //配置高亮信息
        HighlightBuilder highlightBuilder = new HighlightBuilder();
        highlightBuilder.preTags("<font class='eslight'>");
        highlightBuilder.postTags("</font>");
        //设置高亮字段
        highlightBuilder.fields().add(new HighlightBuilder.Field("name"));
        searchSourceBuilder.highlighter(highlightBuilder);

        //请求搜索
        searchRequest.source(searchSourceBuilder);
        SearchResponse searchResponse = null;
        try{
            searchResponse = restHighLevelClient.search(searchRequest);
        }catch (Exception e){
            //搜索异常
            e.printStackTrace();
            LOGGER.error("search error ...{}",e.getMessage());
            return new QueryResponseResult<>(CommonCode.FAIL,null);
        }

        //结果收集处理
        SearchHits hits = searchResponse.getHits();
        //获取匹配度高的结果
        SearchHit[] searchHits = hits.getHits();
        //总记录数
        long totalHits = hits.getTotalHits();
        //数据列表
        ArrayList<CoursePub> list = new ArrayList<>();

        //添加数据
        for (SearchHit hit: searchHits){
            CoursePub coursePub = new CoursePub();

            Map<String, Object> sourceAsMap = hit.getSourceAsMap();


            //取出id
            String id = (String) sourceAsMap.get("id");
            coursePub.setId(id);
            //取出名称
            String name = (String) sourceAsMap.get("name");

            //取出高亮字段
            Map<String, HighlightField> highlightFields = hit.getHighlightFields();
            if(highlightFields.get("name")!=null){
                HighlightField highlightField = highlightFields.get("name");
                Text[] fragments = highlightField.fragments();
                StringBuffer stringBuffer = new StringBuffer();
                for(Text text:fragments){
                    stringBuffer.append(text);
                }
                name = stringBuffer.toString();
            }
            coursePub.setName(name);
            //图片
            String pic = (String) sourceAsMap.get("pic");
            coursePub.setPic(pic);
            //优惠后的价格
            Float price = null;
            try {
                if(sourceAsMap.get("price") !=null){
                    price = Float.parseFloat(String.format("%.3f",sourceAsMap.get("price")));
                }
            }catch (Exception e){
                e.printStackTrace();
            }
            coursePub.setPrice(price);
            //优惠前的价格
            Float priceOld = null;
            try {
                if(sourceAsMap.get("price_old") !=null){
                    priceOld = Float.parseFloat(String.format("%.3f",sourceAsMap.get("price_old")));
                }
            }catch (Exception e){
                e.printStackTrace();
            }
            coursePub.setPrice_old(priceOld);
            list.add(coursePub);


        }

        //返回响应结果
        QueryResult<CoursePub> queryResult = new QueryResult<>();
        queryResult.setList(list);
        queryResult.setTotal(totalHits);
        return new QueryResponseResult<>(CommonCode.SUCCESS,queryResult);
    }
}

2、前端代码

在后端的代码中,我们在添加高亮标签时候引用了 eslight 的样式,代码如下

highlightBuilder.preTags("<font class='eslight'>");

所以我们在 search/index.vue 中定义 eslight 样式,实现多高亮字段的样式控制。

<style>
.eslight{
	color: red;
} 
...

我们来测试一下,在搜索门户前端下搜索关键词 spring cloud

从测试的结果中可以看到,我们搜索的关键词成功的被高亮。

五、集成测试

1、需求分析

本次集成测试的目的如下:

1、测试课程发布与CMS接口是否正常。

2、测试课程发布与ES接口是否正常。

3、测试课程从创建到发布的整个过程。

2、准备环境

1、启动 MySQL、MongoDB

2、启动 ElasticSearch、RabbitMQ

3、启动 Eureka Server

4、启动 CMS、课程管理服务、搜索服务。

5、启动 Nginx、系统管理前端、教学管理前端、Nuxt.js。

3、开始测试

这里我们发布一个大数据的测试课程

这里要注意的是,发布课程前需要添加课程营销和课程计划,否则将发布失败。

点击课程发布

发布成功,我们到 coursePub 表上看一下我们发布的课程信息

从上图中我们可以看到,我们发布的信息已经成功添加到了 coursePub 表内,这个时候等待 LogStash 自动采集我们课程发布的信息,并添加到 Elastic Search 的索引内。

等待一段时间后,我们从 LogStash 的控制台信息内可以看到,已经采集了我们发布的课程信息,并成功索引到了 Elastic Search

这个时候我们到搜索的前端门户上搜索我们最新发布的课程

成功的搜索到了刚才发布的大数据课程,并且将 "大数据" 这个关键词进行高亮处理。