续 微服务(二)

201 阅读35分钟

验证令牌

上次课我们完成了令牌的生成

下面来对生成的令牌进行验证

这个验证的过程就是从Auth服务器内存中获得token对应的用户信息

现在用户信息还是保存在内存中,token是一个uuid格式的字符串

相当于是在Auth服务器内存中用户信息的key

具体的获取方式是:

根据Oauth2提供的标准的控制器方法

http://localhost:8010/oauth/check_token?token=45cf2b3a-1d44-4e74-99fb-d647490b489e

请求能够验证令牌,显示用户的详情信息

image-20211215173653229.png

验证令牌其实就是解析令牌中用户的信息

这个请求是不限制get\post的

JWT令牌

上面我们提到了

现在的令牌仍然保存在auth项目的内存里

这样的效果是没有达到我们之前设计的目标的

也就是需要将生成的令牌转换为包含用户信息的加密的字符串

不在内存中保存它

Jwt就是解决方案

什么是JWT

JWT(Json Web Token)

它是一个json格式的信息, 在网页和服务器的传递过程中加密后保存到客户端

客户端持有当前用户信息的Jwt后,在访问需要表名自己身份的资源服务器时,将JWT连同请求一起发送给资源服务器,这个服务器就会解析令牌获得用户信息了

下面我们就来学习怎么实现登录时返回jwt

配置Auth项目生成Jwt

know-auth项目中

修改TokenConfig类

@Configuration
public class TokenConfig {
    // 向Spring容器中保存一个令牌的生成策略
    //  策略指:1.保存在内存中   2.保存在客户端
    //  生成JWT令牌

    // 定义解析JWT的口令
    private final String SIGNING_KEY="knows_jwt";

    @Bean
    public TokenStore tokenStore(){
        return new JwtTokenStore(accessTokenConverter());
    }
    // JWT转换器(将json格式信息转换为JWT的对象)
    @Bean
    public JwtAccessTokenConverter accessTokenConverter(){
        JwtAccessTokenConverter converter=
                new JwtAccessTokenConverter();
        converter.setSigningKey(SIGNING_KEY);
        return converter;
    }
}

除此之外,核心配置类AuthotizationServer中tokenService方法生成令牌的代码也要修改

@Resource
private JwtAccessTokenConverter accessTokenConverter;
// 配置生成令牌和保存令牌的方法
@Bean
public AuthorizationServerTokenServices tokenService() {
    // 创建生产令牌的对象
    DefaultTokenServices services=new DefaultTokenServices();
    // 设置令牌如何保存
    services.setTokenStore(tokenStore);
    // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓新增代码↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
    // 实例化令牌增强对象
    TokenEnhancerChain chain=new TokenEnhancerChain();
    // 设置JWT转换对象到令牌增强对象中
    chain.setTokenEnhancers(Arrays.asList(accessTokenConverter));
    // 将令牌增强对象,加载到生成令牌的对象中
    services.setTokenEnhancer(chain);
    // ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑新增代码结束↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑

    // 设置令牌有效期(单位是秒  3600既1小时)
    services.setAccessTokenValiditySeconds(3600);
    // 配置这个令牌为哪个客户端生成
    services.setClientDetailsService(clientDetailsService);
    // 别忘了返回services对象
    return services;
}

重启auth模块

同时保证Nacos和sys模块正在运行

再去postman提交信息,观察结果

http://localhost:8010/oauth/token?client_secret=123456&client_id=knows&username=st2&password=888888&grant_type=password

上面的请求应该能够得到保护jwt在内的响应结果

下面的请求将jwt复制到token后面

http://localhost:8010/oauth/check_token?token=[你的令牌粘贴在这]

解析得到包含用户信息的响应

实现微服务的单点登录

上面我们只是完成了单点登录的第一个大步骤

也就是开发完成了授权服务器,并能够生成和验证令牌

下面我们要继续完成后面的步骤

配置auth模块的网关

和sys\faq模块一样

auth模块也要设置网关路由信息

转到gateway模块

yml文件设置添加如下

- id: gateway-auth
  uri: lb://auth-service
  predicates:
    - Path=/oauth/**

启动gateway网关项目

再次测试生成和验证令牌,但是端口号由8010修改为9000

如果还能正常运行表示路由配置完成

开发登录页

转到knows-client项目

修改login.html页面中的代码

我们自己编写Vue绑定和axios请求

根据postman测试得到jwt的请求格式发送一个异步请求

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="renderer" content="webkit">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <title>达内知道登录</title>
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <link rel="stylesheet" href="bower_components/bootstrap/dist/css/bootstrap.css">
  <link rel="stylesheet" href="bower_components/font-awesome/css/font-awesome.css">
  <link rel="stylesheet" href="css/login.css" >
</head>
<body class="bg-light">
                   <!--  ↓↓↓↓↓↓↓  -->
<div class="container-fluid" id="loginApp">
  <div class="row">
    <div class="mx-auto mt-5" style="width: 400px;">
      <h2 class="text-center "><b>达内</b>·知道</h2>
      <div class="bg-white p-4">
        <p class="text-center">用户登录</p>
        <div id="error" class="alert alert-danger d-none">
          <i class="fa fa-exclamation-triangle"></i> 账号或密码错误
        </div>
        <div id="logout" class="alert alert-info d-none">
          <i class="fa fa-exclamation-triangle"></i> 已经登出系统
        </div>
        <div id="register" class="alert alert-info d-none">
          <i class="fa fa-exclamation-triangle"></i> 已经成功注册,请登录。
        </div>
          <!--  ↓↓↓↓↓↓↓  -->
        <form action="/login" method="post"
          @submit.prevent="login">
          <div class="form-group has-icon">
              
              <!--  ↓↓↓↓↓↓↓  -->
            <input type="text" class="form-control d-inline"
                   name="username" placeholder="手机号"
                    v-model="username">
            <span class="fa fa-phone form-control-icon"></span>
          </div>
          <div class="form-group has-icon">
              <!--  ↓↓↓↓↓↓↓  -->
            <input type="password" class="form-control"
                   name="password" placeholder="密码"
                   v-model="password">
            <span class="fa fa-lock form-control-icon"></span>
          </div>
          <button type="submit" class="btn btn-primary btn-block ">登录</button>
        </form>
        <a class="d-block mt-1" href="resetpassword.html" >忘记密码?</a>
        <a class="d-block mt-1" href="register.html"  >新用户注册</a>
      </div>
    </div>
  </div>
</div>
<script src="bower_components/jquery/dist/jquery.js" ></script>
<script src="bower_components/bootstrap/dist/js/bootstrap.js" ></script>
    <!--  ↓↓↓↓↓↓↓  -->
<script src="bower_components/vue/dist/vue.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
  if (location.search == "?error"){
    $("#error").removeClass("d-none");
  }
  if (location.search == "?logout"){
    $("#logout").removeClass("d-none");
  }
  if (location.search == "?register"){
    $("#register").removeClass("d-none");
  }
    //  ↓↓↓↓↓↓↓ 
  let loginApp =new Vue({
    el:"#loginApp",
    data:{
      username:"",
      password:""
    },
    methods:{
      login:function(){
        let form=new FormData();
        form.append("client_id","knows");
        form.append("client_secret","123456");
        form.append("grant_type","password");
        form.append("username",this.username);
        form.append("password",this.password);
        axios({
          url:"http://localhost:9000/oauth/token",
          method:"post",
          data:form
        }).then(function(response){
          alert(response.data.access_token);
        })
      }
    }
  })
</script>
</body>
</html>

启动knows-client项目

访问登录页,输入用户名和密码点击登录受阻

控制台显示跨域错误

如何解决?

Oauth2授权服务器跨域问题

auth模块和之前我们学习的sys和faq模块一样

也在前端项目访问时会出现跨域问题 我们之前编写的SpringMvc配置解决跨域问题

但是auth项目的跨域情况比较特殊,需要使用特殊的跨域解决方案

针对auth授权服务器的跨域问题,业界已经有成熟的解决方案供我们使用

我么可以使用一个专门的过滤器类实现auth模块的跨域

knows-auth模块

创建一个filter包

包中创建CorsFilter类

image-20211216105229142.png

代码如下

import org.springframework.http.HttpHeaders;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class CorsFilter implements Filter {
    public static final String ACCESS_CONTROL_REQUEST_METHOD = "Access-Control-Request-Method";

    public static final String OPTIONS = "OPTIONS";

    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        if (isCorsRequest(httpRequest)) {
            httpResponse.setHeader("Access-Control-Allow-Origin", "*");
            httpResponse.setHeader("Access-Control-Allow-Methods",
                    "POST, GET, PUT, DELETE");
            httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
            // response.setIntHeader("Access-Control-Max-Age", 1728000);
            httpResponse
                    .setHeader(
                            "Access-Control-Allow-Headers",
                            "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Authorization");
            if (isPreFlightRequest(httpRequest)) {
                return;
            }
        }
        chain.doFilter(request, response);
    }

    public void init(FilterConfig filterConfig) {
    }

    public void destroy() {
    }

    public boolean isCorsRequest(HttpServletRequest request) {
        return (request.getHeader(HttpHeaders.ORIGIN) != null);
    }
    public boolean isPreFlightRequest(HttpServletRequest request) {
        return (isCorsRequest(request) && OPTIONS.equals(request.getMethod()) && request
                .getHeader(ACCESS_CONTROL_REQUEST_METHOD) != null);
    }
}

关于过滤器,之前阶段我们使用过

就是请求在到达目标之前,先进行一些前置处理

这个过滤器就是在请求到达授权服务器之前,先进行了跨域的处理,使得授权服务器的响应可以到达目标客户端

但是过滤器要想生效还需要一些配置,这一点就和sys模块和faq模块配置的SpringMvc配置不同了,我们可以在SpringBoot启动类中编写一些代码注册指定过滤器,实现请求的过滤效果

auth模块的SpringBoot启动类添加代码

// 将一个注册过滤器的类保存在Spring容器中
// Spring启动时会启用这里面注册的过滤器
@Bean
public FilterRegistrationBean registrationBean(){
    // 实例化注册过滤器的对象
    FilterRegistrationBean<CorsFilter> bean=
            new FilterRegistrationBean<>();
    // 设置过滤器生效路径(全部生效即可)
    bean.addUrlPatterns("/*");
    // 设置过滤器的优先级(多个过滤器的运行顺序,优先级高先运行)
    // 这里设置它的优先级为最高
    bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
    // 设置过滤器对象
    bean.setFilter(new CorsFilter());
    return  bean;
}

重启auth项目

在刷新页面,再登录,

观察弹出的对话框中是否包含jwt

将Jwt保存在客户端

上面章节我们已经成功在用户登录完毕后获得Jwt

我们可以使用下面两种方式之一保存在客户端

  • Cookie:在浏览器中专门保存信息的区域,之前项目使用过
  • localStorage(本地仓库):也是浏览器来保存信息的功能

本次我们使用localStorage来说实现保存

knows-client项目

在login.html页面尾部axios方法的then后继续编写

.then(function(response){
    //alert(response.data.access_token);
    // 将jwt保存到localStorage中
    // localStorage使用方便,相当于一个Map结构(key,value)保存信息
    window.localStorage.setItem(
            "accessToken",response.data.access_token);
    // 登录成功,跳转到首页
    location.href="/index.html";
}).catch(function(error){
    // auth模块在生成令牌失败时,自动运行catch方法
    // 向浏览器控制台输出登录失败原因
    console.log(error);
})

上面代码中,登录如果成功,能将jwt保存在localStorage中,并跳转到index.html

这个页面中,我们尝试在发起一次异步请求,获得当前登录用户的信息,判断是学生还是讲师的角色,以决定跳转学生首页还是讲师首页

重启knows-client项目,登录成功现在是404错误

我们就创建这个index.html页面代码如下

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
首页加载中......请稍候
</body>
</html>

这里可以再次重启knows-client项目

观察登录成功后跳转到首页效果

首页取出Jwt

index.html修改代码

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="bower_components/jquery/dist/jquery.js" ></script>
    <script src="bower_components/bootstrap/dist/js/bootstrap.js" ></script>
    <!--  vue和axios的引用  -->
    <script src="bower_components/vue/dist/vue.js"></script>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
首页加载中......请稍候
</body>
<script>
    // 利用jQuery提供的页面加载完毕之后立即运行的方法
    $(function(){
        // 获得localStorage保存的用户的jwt
        let token=window.localStorage.getItem("accessToken");
        // 测试输出到浏览器控制台
        console.log("token:"+token);
        
    })

</script>
</html>

为了输出现在保存在localStorage中的令牌,我们在index页面中获得令牌输出到控制台测试

实现不同身份跳转不同页面

我们已经在index.html页面中获得jwt了

下面要发送一个异步请求,到一个能够根据用户角色跳转不同首页的控制器

在过程中,解析jwt获得用户的详情

创建控制器判断用户角色

portal项目中我们使用HomeController类来判断用户角色

这里我们也可以使用这个类但是需要修改一下

knows-sys模块比较适合判断用户的角色

image-20211216142418412.png

将HomeController类复制到sys模块的controller包

修改后代码如下

// ↓↓↓↓↓↓↓↓↓↓
@RestController
// ↓↓↓↓↓↓↓↓↓↓
@RequestMapping("/v1/home")
public class HomeController {
    // 我们要判断登录用户是什么角色,所以要先定义两个角色的常量,用于判断
    public static final GrantedAuthority STUDENT=
            new SimpleGrantedAuthority("ROLE_STUDENT");
    public static final GrantedAuthority TEACHER=
            new SimpleGrantedAuthority("ROLE_TEACHER");
    // ↓↓↓↓↓↓↓↓↓↓
    @GetMapping
    public String index(@AuthenticationPrincipal UserDetails user){
        // 判断UserDetails中是否包含讲师身份
        if(user.getAuthorities().contains(TEACHER)){
            // 如果是讲师,使用返回特定格式字符串实现页面重定向效果
            // ↓↓↓↓↓↓↓↓↓↓
            return "/index_teacher.html";
        }else if(user.getAuthorities().contains(STUDENT)){
            // ↓↓↓↓↓↓↓↓↓↓
            return "/index_student.html";
        }
        // 既不是讲师也不是学生直接返回null(也可以返回登录页)
        return null;
    }

}

判断用户角色的代码编写好了,但是我们还没有编写解析Jwt和保存用户信息到Spring-Security中的代码,方法中@AuthenticationPrincipal UserDetails user参数user一定是null

下面我们学习如何解决这个问题

Spring Mvc 拦截器

什么是拦截器

拦截器是SpringMvc框架提供的一个功能

它可以在控制器方法运行之前或之后(还有其他特殊时机)对请求进行处理或加工的特定接口

常见面试题:过滤器和拦截器的区别

提供者不同:

  • 过滤器是javaEE提供的
  • 拦截器是SpringMvc提供的

作用目标不同:

  • 过滤器作用目标比较广:可以作用在所有请求当前服务器资源的流程中
  • 拦截器作用目标比较单一:只能作用在请求目标是控制器的流程中

image-20211216145257199.png

功能强度不同:

  • 过滤器依靠源生的java操作,功能较弱,不能直接处理支持Spring容器中的内容和对象
  • 拦截器是SpringMvc框架同的,和Spring框架兼容性好,可以直接操作Spring容器中的内容和对象,而且拦截器提供更多运行的时机,程序员可以选择更合适的来调用

总结

如果请求的目标能确定是一个控制器方法,那么优先选择拦截器

如果请求的目标可能是控制器或其他静态资源,那么需要使用过滤器

拦截器工作流程图

image-20211216151546189.png

拦截器的基本使用

使用拦截器的步骤

1.定义拦截器(就是创建一个实现指定拦截器接口的类)

2.配置拦截器路径

因为拦截器是SpringMvc的功能,所以不需要添加额外依赖

knows-sys模块测试拦截器效果

创建一个专门保存拦截器类的包:interceptor

这个包中创建类DemoInterceptor代码如下

// 拦截器也要保存到Spring容器中进行统一管理
@Component
public class DemoInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 控制器运行之前运行
        System.out.println("preHandle运行");
        // 返回值是boolean类型
        // 返回true表示允许当前请求访问控制器,否则不允许访问,终止请求流程
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        // 控制器方法运行之后运行
        System.out.println("postHandle运行");
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 在页面显示结果之前(同步请求使用较多,异步请求使用较少)
        System.out.println("afterCompletion运行");
    }
}

为了证明拦截器运行的顺序,我将AuthController类中的demo方法添加了一个输出

@GetMapping("/demo")
public String demo(){
    System.out.println("demo方法运行");
    return "controller demo";
}

然后要在SpringMvc设置中,设置拦截器生效的路径

WebConfig类中添加配置如下

@Resource
private DemoInterceptor demoInterceptor;
// 配置拦截器的方法
@Override
public void addInterceptors(InterceptorRegistry registry) {
    // 配置拦截器对象
    registry.addInterceptor(demoInterceptor)
            //设置生效路径
            .addPathPatterns("/v1/auth/demo");
}

重启sys模块

访问http://localhost:8001/v1/auth/demo

观察idea控制台中的输出

拦截器解析JWT

回到我们单点登录的需求中

现在是根据不同身份跳转不同页面

index.html要发送一个axios异步请求到HomeController判断用户角色

在这之间,我们需要添加一个拦截器,在运行HomeController方法之前先解析JWT

并将JWT解析的结果保存在Spring-Security中,

这样HomeController中@AuthenticationPrincipal注解才能获得正确的用户信息

拦截器中解析JWT的核心思路还是访问Auth模块的解析令牌的方法

所以需要sys模块向auth模块发送Ribbon请求

现在必须先在sys模块中添加Ribbon的支持

knows-sys模块的SpringBoot启动类添加

@SpringBootApplication
@EnableDiscoveryClient
@MapperScan("cn.tedu.knows.sys.mapper")
public class KnowsSysApplication {

    public static void main(String[] args) {
        SpringApplication.run(KnowsSysApplication.class, args);
    }
    @Bean
    @LoadBalanced
    public RestTemplate restTemplate(){
        return new RestTemplate();
    }
}

下面去编写解析Jwt的拦截器类

还在interceptor包中创建拦截器类AuthInterceptor

代码如下

// @Component必须写!!!
@Component
public class AuthInterceptor implements HandlerInterceptor {

    //解析Jwt需要Ribbon
    @Resource
    private RestTemplate restTemplate;
    // 在运行控制器方法之前运行的拦截器方法:
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 获得前端请求中包含的jwt信息
        String token=request.getParameter("accessToken");
        // 向auth模块发送ribbon请求解析jwt
        String url="http://auth-service/oauth/check_token?token={1}";
        // 我们可以使用Map类型类接收Ribbon请求的结果
        // Ribbon调用时会自动将json对象解析为键值对的格式赋值给Map对象
        Map<String,Object> map=restTemplate
                            .getForObject(url,Map.class,token);
        // 根据auth模块的解析令牌测试的结果可知
        // 我们需要的用户名key为user_name,用户的权限信息为authorities
        String username=map.get("user_name").toString();
        List<String> list=(List<String>)map.get("authorities");
        // 下面我们需要将用户信息保存到Spring-Security
        // 向Spring-Security中保存用户信息的代码是固定的,而且非常严谨,
        // 必须按照框架规定的方式进行编写
        // 首先需要将我们包含所有权限的List<String> 转换为String[]
        String[] auth=list.toArray(new String[0]);
        UserDetails details= User.builder()
                .username(username)
                .password("")
                .authorities(auth)
                .build();
        // 获得了用户详情,下面要按照Spring-Security给定的方式
        // 将用户详情对象保存到Spring-Security环境中,以便控制器中获得
        PreAuthenticatedAuthenticationToken authenticationToken=
                new PreAuthenticatedAuthenticationToken(
                        details,
                        details.getPassword(),
                        AuthorityUtils.createAuthorityList(auth));
        // 和当前请求进行关联后才能在控制层中获取
        authenticationToken.setDetails(
                new WebAuthenticationDetails(request));
        // 将用户详情保存到Spring-Security容器
        SecurityContextHolder.getContext()
                .setAuthentication(authenticationToken);
        // 千万别忘了返回true,否则无法运行控制器方法
        return true;
    }
}

和之前测试拦截器一样

我们也要设置拦截器生效路径

还是需要到SpringMvc配置类中进行配置注册

WebConfig代码如下

@Resource
private AuthInterceptor authInterceptor;
// 配置拦截器的方法
@Override
public void addInterceptors(InterceptorRegistry registry) {
    // 配置拦截器对象
    registry.addInterceptor(demoInterceptor)
            //设置生效路径
            .addPathPatterns("/v1/auth/demo");
    registry.addInterceptor(authInterceptor)
            .addPathPatterns("/v1/home");

}

续 实现不同身份跳转不同页面

前端代码发送异步请求

根据我们开发的流程

就差前端页面发送包含jwt的请求到控制器方法

knows-client项目

编写index.html发送axios请求

代码如下

// 利用jQuery提供的页面加载完毕之后立即运行的方法
$(function(){
    // 获得localStorage保存的用户的jwt
    let token=window.localStorage.getItem("accessToken");
    // 测试输出到浏览器控制台
    console.log("token:"+token);
    axios({
        url:"http://localhost:9000/v1/home",
        method:"get",
        params:{
            accessToken:token
        }
    }).then(function(response){
        // HomeController类中返回的内容就是用户身份对应的首页的路径
        // 也就是说response.data就是要访问的页面,我们使用js代码直接重定向
        location.href=response.data;
    })


})

Nacos\gateway\auth一直在运行状态

重启sys和client

再次访问http://localhost:8080/login.html

进行登录测试

如果登录不同身份的用户能够跳转到对应的页面,表示一切正常

英文

interceptor:拦截器



迁移问答模块

我们实现了微服务的单点登录

后面要开始迁移我们之前单体项目开发实现的所有功能

迁移业务逻辑层

转到knows-faq模块

之前迁移了tag相关内容,但是其他的问答相关功能没有迁移

下面开始迁移

image-20211216173438257.png

迁移过来,导包即可

然后迁移业务逻辑层实现类

image-20211216173758462.png

这个类的代码导包之后还有一些错误

需要Ribbon调用才能解决

在当前类中定义下面的方法

@Resource
private RestTemplate restTemplate;
// 利用Ribbon根据用户名获得用户对象的方法
private User getUser(String username){
    String url="http://sys-service/v1/auth/user?username={1}";
    User user=restTemplate.getForObject(url,User.class,username);
    return user;
}

在需要根据用户名获得用户对象的位置调用

User user=getUser(username);

一共3个位置

还有需要获得所有讲师Map的位置修改为

// 6.新增问题和讲师的关系
// 通过Ribbon获得所有讲师的数组
String url="http://sys-service/v1/users/master";
User[] teachers=restTemplate.getForObject(url,User[].class);
Map<String,User> teacherMap=new HashMap<>();
for(User u:teachers){
    teacherMap.put(u.getNickname(),u);
}

代码太多,没办法全部粘过来,需要看直接到代码中去对照

迁移控制层代码

image-20211217092850790.png

QuestionController导包操作

别忘了修改v2的路径

//  别忘了修改faq模块的路由特征路径为v2开头!!!
@RequestMapping("/v2/questions")
public class QuestionController {
.....
}

配置拦截器

image-20211217093256137.png

从sys模块赋值AuthInterceptor类到faq模块同样的位置

faq模块中也有很多方法需要这个拦截器来解析Jwt并将用户信息保存在Spring-Security中以使用

拦截器要生效还有做路径的配置

WebConfig类中添加配置代码如下

@Resource
private AuthInterceptor authInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(authInterceptor)
            .addPathPatterns(
                    "/v2/questions",   // 学生发布问题
                    "/v2/questions/my",       //学生首页问题列表
                    "/v2/questions/teacher"   //讲师首页任务列表
            );
}

修改页面内容

转到knows-client项目

为了方便今后调用获得jwt

我们可以在utils.js文件中编写获得jwt的代码

这样的话,其他js文件就无需编写从localStorage中获得jwt的代码了

// 所有页面都引用utils.js
// 这个文件最先导入,代码中会先运行
// 获得localStorage中保存的jwt,最后的所有js代码都可以直接使用它
let token=localStorage.getItem("accessToken");

下面就将上面迁移的所有相关功能的前端调用修改一下

需要jwt解析的要传jwt过去

index.js:

axios({
    url: 'http://localhost:9000/v2/questions/my',
    method: "GET",
    params:{
        pageNum:pageNum,
        accessToken:token
    }
})

index_teacher.js修改

axios({
    url: 'http://localhost:9000/v2/questions/teacher',
    method: "GET",
    params:{
        pageNum:pageNum,
        accessToken:token
    }
})

学生提问的js文件createQuestion.js文件的路径修改为

let form =new FormData();
form.append("title",this.title);
form.append("tagNames",this.selectedTags);
form.append("teacherNicknames",this.selectedTeachers);
form.append("content",content);
// 利用form表单对象将token传递给拦截器 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
form.append("accessToken",token);
console.log(form);
axios({
    url:'http://localhost:9000/v2/questions',
    method:'POST',
    data:form,
})

加载所有标签

axios({
    url:'http://localhost:9000/v2/tags',
    method: 'GET'
})

加载所有讲师

axios({
    url:'http://localhost:9000/v1/users/master',
    method: 'GET'
})

重启client项目

尝试新增问题,观察是否成功

迁移用户信息面板

用户信息面板中包含用户信息和问题数\收藏数等

现在是处于无法显示状态

我们需要迁移它的功能,才能恢复显示

用户信息面板本身是sys模块的功能,但是问题数和收藏数等信息又需要faq模块的支持

我们需要先在faq模块中编写Rest接口支持根据用户id查询问题数的功能,再在sys模块中使用Ribbon调用才能实现最终效果

编写Faq的Rest接口

转到knows-faq模块

faq模块中需要一个根据用户id查询问题数的Rest接口

Mapper中包含这个方法,但是业务逻辑层没有实现

所以从业务逻辑层开始写

IQuestionService添加方法

// 根据用户id查询问题数
Integer countQuestionsByUserId(Integer userId);

QuestionServiceImpl实现

@Override
public Integer countQuestionsByUserId(Integer userId) {
    return questionMapper.countQuestionsByUserId(userId);
}

编写控制层方法

QuestionController类中添加方法

// 根据用户id返回问题数的Rest接口(用于Ribbon调用的)
@GetMapping("/count")
public Integer count(Integer userId){
    return questionService.countQuestionsByUserId(userId);
}

上面就完成了faq模块的Rest接口的编写

迁移sys模块功能

faq支持工作完成

转回到knows-sys模块

主要修改UserServiceImpl类中显示\收集用户面板信息的方法getUserVo

// ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
@Resource
private RestTemplate restTemplate;
@Override
public UserVO getUserVO(String username) {
    // 根据用户名查询用户信息
    User user=userMapper.findUserByUsername(username);
    // 根据用户id查询问题数
    // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
    String url=
       "http://faq-service/v2/questions/count?userId={1}";
    Integer count=restTemplate.getForObject(
            url,Integer.class,user.getId());
    // (作业)根据用户id查询收藏数
    // 实例化UserVo 赋值并返回
    UserVO userVO=new UserVO()
            .setId(user.getId())
            .setNickname(user.getNickname())
            .setUsername(user.getUsername())
        // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
            .setQuestions(count);
    // 千万别忘了返回userVO
    return userVO;
}

拦截器要在显示用户信息面板之前运行,以通过jwt获得用户信息

WebConfig类添加路径:

registry.addInterceptor(authInterceptor)
        .addPathPatterns(
                "/v1/home",        // 根据用户角色跳转页面
                "/v1/users/me"     // 显示用户信息面板
        );

前端修改

knows-client项目

user_info.js修改请求路径

axios({
    url:"http://localhost:9000/v1/users/me",
    method:"get",
    params:{
        accessToken:token
    }
})

nacos\gateway\auth在运行状态

faq\sys\client都要重启!

重新登录,显示首页,就能够显示用户信息面板的内容了!

迁移文件上传的功能

微服务版本中,我们上传的功能由knows-resource项目负责

转到knows-resource项目

创建一个controller包

创建ImageController类保存上传代码

复制portal项目的SystemController中,上传的相关代码

结果如下

@RestController
// 当前项目已经设置了一个前置的默认路径/image
// 访问这个控制器的路径/image/file
@RequestMapping("/file")
@Slf4j
public class ImageController {

    // 从 application.properties文件中获取配置的信息
    @Value("${knows.resource.path}")
    private File resourcePath;

    @Value("${knows.resource.host}")
    private String resourceHost;
    // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓
    @PostMapping
    public String upload(MultipartFile imageFile)
            throws IOException {
        //代码未改变
        //  略...
    }
}

代码中使用@Value在获得配置文件中的信息

portal项目中已经配置完毕,我们需要从中复制这个配置到knows-resource的配置文件中

还要进行一点修改

# 上传文件用的配置信息
knows.resource.path=file:F:/upload
#                                    ↓↓↓↓↓↓↓↓↓↓
knows.resource.host=http://localhost:9000/image

ImageController类上添加跨域注解实现跨域

// 跨域注解
@CrossOrigin
public class ImageController {
    //...
}

这个跨域注解适合当前项目只有少量控制器类时

转到knows-client项目

修改question/create.html页面中上传文件的js代码中axios的路径

axios({
  url:"http://localhost:9000/image/file",
  method:"post",
  data:form
})

启动knows-resource项目

重启knows-client

保证之前已启动的项目没有停止

然后测试create.html页面的上传功能

迁移问题详情页

我们已经迁移了达内知道项目的大部分功能

最后就剩下问题详情页的功能了

转到knows-faq模块开始最后内容的迁移工作

数据访问层

迁移主要是围绕answer和comment

image-20211217141724996.png

先复制两个Mapper接口,导包就能解决错误

再复制xml文件到faq模块

代码如下

<mapper namespace="cn.tedu.knows.faq.mapper.AnswerMapper">
                     <!-- ↑↑↑↑↑  -->
    <resultMap id="answerCommentMap" type="cn.tedu.knows.commons.model.Answer">
              <!-- ↑↑↑↑↑  -->
        <id column="id" property="id" />
        <result column="content" property="content" />
        <result column="like_count" property="likeCount" />
        <result column="user_id" property="userId" />
        <result column="user_nick_name" property="userNickName" />
        <result column="quest_id" property="questId" />
        <result column="createtime" property="createtime" />
        <result column="accept_status" property="acceptStatus" />
        
        <collection property="comments"
                    ofType="cn.tedu.knows.commons.model.Comment">
                                     <!-- ↑↑↑↑↑  -->
            <id column="comment_id" property="id"/>
            <result column="comment_user_id" property="userId" />
            <result column="comment_user_nick_name"
                                             property="userNickName" />
            <result column="comment_answer_id" property="answerId" />
            <result column="comment_content" property="content" />
            <result column="comment_createtime" property="createtime" />
        </collection>
    </resultMap>

    
    <select id="findAnswersByQuestionId"
        
    </select>
</mapper>

迁移业务逻辑层

image-20211217142828878.png

复制VO类和业务逻辑层接口直接导包即可

业务逻辑层实现类复制过来后发现不但需要导包,而且还需要Ribbon

两个类中需要多次相同的Ribbon请求的结果

为了减少代码冗余,我们创建RibbonClient类来提取出当前项目业务逻辑层中所有Ribbon请求尤其是多次调用的Ribbon请求的方法

在业务逻辑层实现类中需要时调用即可

RibbonClient类代码如下

//  将当前类对象保存到Spring容器
@Component
public class RibbonClient {

    @Resource
    private RestTemplate restTemplate;
    // 根据用户名返回用户对象
    public User getUser(String username){
        String url="http://sys-service/v1/auth/user?username={1}";
        User user=restTemplate.getForObject(
                                url  ,  User.class  ,  username);
        return  user;
    }
}

在当前多个类中的多次调用编写上

原则是

哪个类需要哪个类添加依赖注入

@Resource
private RibbonClient ribbonClient;

在需要根据用户名获得用户对象的行编写:

User user=ribbonClient.getUser(username);

迁移控制层

image-20211217150816772.png

还是先导包

然后别忘了将控制权路径开头修改为v2

@RequestMapping("/v2/answers")
@RequestMapping("/v2/comments")

控制层代码处理完毕

拦截器添加路径

当前faq模块,拦截器设置的路径需要随业务的新增而增加

WebConfig类中添加需要当前登录用户信息的请求路径的拦截

registry.addInterceptor(authInterceptor)
        .addPathPatterns(
                "/v2/questions",   // 学生发布问题
                "/v2/questions/my",       //学生首页问题列表
                "/v2/questions/teacher",   //讲师首页任务列表
                "/v2/answers",             // 讲师回复
                "/v2/comments",            // 添加评论
                "/v2/comments/*/delete",   // 删除评论
                "/v2/comments/*/update",   // 修改评论
                "/v2/answers/*/solved"     // 采纳回答
        );

修改前端代码

转到knows-client项目

问题详情页的所有请求都写在question_detail.js文件中

修改这个js文件中所有请求的路径

loadQuestion方法

axios({
    url:"http://localhost:9000/v2/questions/"+qid,
    method:"get"
})

postAnswer方法

let form=new FormData();
form.append("questionId",qid)
form.append("content",content);
form.append("accessToken",token);
axios({
    url:"http://localhost:9000/v2/answers",
    method:"post",
    data:form
})

loadAnswers方法

axios({
    url:"http://localhost:9000/v2/answers/question/"+qid,
    method:"get"
})

postComment方法

let form=new FormData();
form.append("answerId",answerId);
form.append("content",content);
form.append("accessToken",token);
axios({
    url:"http://localhost:9000/v2/comments",
    method:"post",
    data:form
})

removeComment方法

axios({
    url:"http://localhost:9000/v2/comments/"+commentId+"/delete",
    method:"get",
    params:{
        accessToken:token
    }
})

updateComment方法

let form=new FormData();
form.append("answerId",answerId);
form.append("content",content);
form.append("accessToken",token);
axios({
    url:"http://localhost:9000/v2/comments/"+commentId+"/update",
    method:"post",
    data:form
})

answerSolved方法

axios({
    url:"http://localhost:9000/v2/answers/"+answerId+"/solved",
    method:"get",
    params:{
        accessToken:token
    }
})

重启faq模块和client项目,测试上述问题详情的功能

到此为止

达内知道单体项目功能就完全迁移到微服务了!!!!

Elasticsearch全文搜索引擎

软件下载

image-20211217115620611.png

什么是Elasticsearch

Elastic(富有弹性的)

search(搜索)

简称ES

它是一款由java开发的软件

它能够实现高效的从大量数据中模糊查询搜索结果

由于是java开发的,启动它相当于启动了一个项目

这个项目提供了很多可以供我们访问的Rest接口

这些Rest接口(控制器方法)的功能就是能够对当前Es中的数据进行增删改查

Elasticsearch是一个基于Lucene开发的搜索服务器(搜索引擎),Lucene是一套提供了搜索引擎核心功能的Api,Lucene相当于计算的Cpu,Elasticsearch相当于一台安装好的电脑

Elasticsearch也有一些功能类似的软件: MongoDB,还有现在变得非常少见的solr

为什么需要Elasticsearch

数据库缺陷

所有关系型数据(mysql/oracle/DB2/sqlserver)都会有一个非常严重的缺陷

执行前模糊查询的查询效率低

一张千万级别的数据库表,进行一次前模糊查询需要20秒以上的时间

正常情况下数据库查询性能是良好的,可以接受的,而且大部分查询都由优化方案

数据库中如果是按主键ID查询效率是非常高的

因为主键是记录数据存储的物理顺序,这样的"索引"是"聚集索引"

如果按照非id列查询,也可以通过创建索引来提高查询速度

按索引查询效率也很高

但是如果查询的条件是前模糊的,那么就无法使用索引来提高查询速度了,只能数据库表的逐行查询也叫全表查询来解决

也就是说:关系型数据库在模糊查询的情况下性能亟待优化

Elasticsearch就是来弥补关系型数据库模糊查询性能缺陷的

同数据量的相同条件的模糊查询,ES的查询速度是关系型数据库的100倍左右

  • Elasticsearch是java开发的,需要java环境变量
  • Elasticsearch虽然是java开发的,但是任何语言都可以使用它
  • Elasticsearch也是支持分布式部署的,满足"高并发,高可用,高性能"

Elasticsearch查询原理

ES查询原理的核心是分词然后建立分词索引

基本流程是

先确定要保存的内容,然后将内容进行分词处理,再按照分词结果保存到ES中

需要查询时按照分词的索引进行查询,提高查询速度

凡是能够进行这样查询的软件都称之为"全文搜索引擎"

ES也是将所有数据保存到硬盘上,也就是复制数据库表中的数据

这样做,能够保证即使是大数据量的模糊查询,查询速度也能保持在毫秒级别

image-20211217115620611.png

续 Elasticsearch搜索引擎

安装启动Elasticsearch

官方下载链接

www.elastic.co/cn/download…

将下载的280兆的压缩包解压

进入解压后目录中的bin目录

image-20211217171730401.png

双击运行elasticsearch.bat文件,可以启动ES

遗憾的是ES没有开机自动启动的功能,需要我们开机后手动运行,而且dos窗口不能关

image-20211217172053760.png

如果要测试它的运行状态

可以打开浏览器输入地址

localhost:9200

image-20211217172246998.png

mac系统启动

tar -xvf elasticsearch-7.6.2-darwin-x86_64.tar.gz 
cd elasticsearch-7.6.2/bin 
./elasticsearch

linux:

tar -xvf elasticsearch-7.6.2-linux-x86_64.tar.gz
cd elasticsearch-7.6.2/bin
./elasticsearch

ES基本使用

我们安装启动好了ES软件

下面就是要调用ES提供的Rest接口,实现Es的各种功能

创建一个knows-search的项目

这个项目承担之后的搜索模块的功能

image-20211217172827990.png

父子相认

<module>knows-search</module>

子项目pom.xml文件

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>cn.tedu</groupId>
        <artifactId>knows</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tedu</groupId>
    <artifactId>knows-search</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>knows-search</name>
    <description>Demo project for Spring Boot</description>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
    </dependencies>

</project>

我们先不急于使用java代码操作ES

先创建一个http client(http客户端)文件,这个文件中可以编写指令向指定的路径发送各种请求

实现对ES的测试

image-20211217173319836.png

在创建好的文件中编写代码如下

image-20211117174302515.png

### 在httpclient文件中,任何指令必须以###开头,否则运行报错
### 三个#既是注释,又是分隔符
GET http://localhost:9200

### ES分词测试
POST http://localhost:9200/_analyze
Content-Type: application/json

{
  "text": "罗技激光游戏鼠标",
  "analyzer": "standard"
}

"analyzer": "standard"是设定分词器

它是默认的,可以省略

默认分词器standard只能识别英文分词

我们需要安装一个能够识别中文的分词器

安装ik插件实现识别中文分词

image-20211117175653955.png

因为修改了配置,所以要重启ES软件

如果启动成功

修改分词器,再次运行分词测试

{
  "text": "罗技激光游戏鼠标",
  "analyzer": "ik_smart"
}

再次运行分词,就能看到中文分词结果了

IK分词插件

上面已经完成了中文分词插件的安装

但是只使用了一个ik_smart的分词器

实际上插件还包含其他的分词器,他们有不同的特征

### 分词功能测试
POST http://localhost:9200/_analyze
Content-Type: application/json

{
  "text": "北京冬季奥林匹克运动会即将在春节举行",
  "analyzer": "ik_max_word"
}
### 分词功能测试
POST http://localhost:9200/_analyze
Content-Type: application/json

{
  "text": "北京冬季奥林匹克运动会即将在春节举行",
  "analyzer": "ik_smart"
}

上面同样的中文文字片段不同的中文分词器分词结果不同

经过分析我们可以知

  • ik_max_word:会详细的将文字片段分词,已经分词过的内容可能继续分词

    ​ 分词详细,查全率高,但是占用空间大,查询速度慢

  • ik_smart:会粗略的将文字片段分词,已经分词过的内容不会再次分词

    ​ 分词粗略,查全率低,但是占用空间小,查询速度快

下面我们要对我们的ES进行基本的增删改查操作测试

操作ES数据

要想操作ES数据,首先要了解ES保存数据的结构

image-20211220094656480.png

  • ES软件可以创建多个index(索引),我们可以将它理解为数据库中的表的概念
  • 一个索引中可以创建保存多个document(文档),每个文档就相当于数据表中的行
  • 每个文档中使用json格式保存一条数据,json格式中的每个属性相当于数据库表中的列

大家发送一个ES文档

这个文档中包含对Es的操作指令,

我们来执行一下

执行过程略......

SpringBoot项目操作Elasticsearch

Spring Data简介

java程序可能需要连接各种第三方数据源来进行数据的传递

其中我们经常使用的mysql\redis\ES等多种软件都是第三方数据源

Spring框架提供了一个SpringData框架集,框架集中包含了连接操作当今大多数流行的第三方数据源的框架

image-20211220104300352.png

官方网站:spring.io/projects/sp…

之前我们使用过它连接过Redis,只是连接操作比较简单,没有系统讲解

现在要使用它来连接ES,简化操作

java需要使用Socket技术向Es发送请求,并解析响应,非常麻烦

SpringData Elasticsearch能够高效简单的执行这些操作

添加依赖

knows-search模块添加

SpringData Elasticsearch的依赖

pom.xml文件添加依赖如下

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

application.properties

server.port=8003

# 日志门槛
logging.level.cn.tedu.knows.search=debug
logging.level.org.elasticsearch.client.RestClient=debug

# 指定Es的位置和端口
spring.elasticsearch.rest.uris=http://localhost:9200

定义商品类用于测试

我们下面就要具体实施使用SpringDataElasticsearch对ES软件进行操作

如果是操作数据库的话一定会定义一个对应数据库表的实体类

这里操作Es的文档,性质是类似的,我们用商品类Item来进行演示

项目中创建一个vo包,包中创建Item类代码如下

@Data
@Accessors(chain = true)
@AllArgsConstructor     //生成全参构造
@NoArgsConstructor      //生成无参构造
// SpringData的注解
// 指定当前类对应的Es索引名称,SpringData操作数据时会自动创建这个索引
@Document(indexName = "items")
public class Item implements Serializable {

    // SpringData标记主键的注解
    @Id
    private Long id;

    @Field(type = FieldType.Text,
            analyzer = "ik_smart",searchAnalyzer = "ik_smart")
    private String title; //商品名称

    @Field(type = FieldType.Keyword)
    private String category; //分类
    @Field(type = FieldType.Keyword)
    private String brand;    //品牌
    @Field(type = FieldType.Double)
    private Double price;    //价格
    // index=false表示不会对这个属性的内容创建索引表
    // 优点是节省空间,缺点是不能按照这个列的属性来查询了
    // 而图片地址本来就不会作为查询条件,编写这个属性节省空间
    @Field(type = FieldType.Keyword,index=false)
    private String images;   //图片地址
    //  upload/abc-xxx.png

}

有了Item类开始编写数据访问层

创建数据访问层接口

Mybatis(plus)框架需要编写Mapper接口编写实现数据访问操作

SpringData也是类似的,但是SpringData框架命名数据访问层使用Repository

所以我们创建一个repository包,包中创建ItemRepository接口

代码如下

// Spring家族框架都将数据访问层称之为Repository
@Repository
public interface ItemRepository extends
                        ElasticsearchRepository<Item,Long> {
    //MybatisPlus框架继承BaseMapper父接口实现自带很多功能
    // 这里继承的ElasticsearchRepository接口效果类似,自带基本增删改查
    // ElasticsearchRepository<[VO类型],[id类型]>

}

测试代码

@Resource
ItemRepository itemRepository;
// 单增
@Test
void addOne() {
    Item item=new Item()
            .setId(1L)
            .setTitle("罗技激光无线游戏鼠标")
            .setCategory("鼠标")
            .setBrand("罗技")
            .setPrice(160.0)
            .setImages("/1.jpg");
    // 执行自带的新增操作
    itemRepository.save(item);
    System.out.println("ok");
}

// 按id查
@Test
void getOne(){
    // Optional是SpringData查询当行结果返回的类型,是一个包装类型
    Optional<Item> optional=itemRepository.findById(1L);
    // 输出的时候可以取出这个对象
    System.out.println(optional.get());
}

// 批量增
@Test
void addList(){
    // 实例化一个List对象
    List<Item> list=new ArrayList<>();
    list.add(new Item(2L,"罗技激光有线办公鼠标","鼠标",
                    "罗技",102.5,"/2.jpg"));
    list.add(new Item(3L,"雷蛇机械无线游戏键盘","键盘",
            "雷蛇",315.0,"/3.jpg"));
    list.add(new Item(4L,"微软有线静音办公鼠标","鼠标",
            "微软",238.0,"/4.jpg"));
    list.add(new Item(5L,"罗技有线机械背光键盘","键盘",
            "罗技",286.0,"/5.jpg"));
    itemRepository.saveAll(list);
    System.out.println("ok");
}
// 全查
@Test
void getAll(){
    // SpringData框架所有返回多行结果的返回值类型都是Iterable
    Iterable<Item> items=itemRepository.findAll();
    for(Item item:items){
        System.out.println(item);
    }
    //items.forEach(item -> System.out.println(item));
}

SpringData自定义查询

单条件查询

如果我们想按照自己的逻辑去查询ES中的内容

例如查询商品名称"title"中包含"游戏"关键字的商品

这就是关系型数据库中要执行的模糊查询了,效率是很低的

我们这次查询是在ES中,他会使用分词索引查询,效果很高

ItemRepository中编写自定义查询方法

// 自定义查询方法
// SpringData框架都支持使用方法名称来表示查询逻辑
//              所以方法名称必须严格按照格式要求,不能有任何错误
//   query:查询 Item是查询索引 Items表示返回多个
//   By:类似于sql的where后面跟条件   Title:条件属性名称 Matches:模糊匹配
Iterable<Item> queryItemsByTitleMatches(String title);

测试类调用

// 单条件查询
@Test
void query1(){
    Iterable<Item> items=itemRepository
            .queryItemsByTitleMatches("游戏");
    for(Item item:items){
        System.out.println(item);
    }
}

上面查询时SpringData底层向Es发送的请求如下

### 单条件搜索
POST http://localhost:9200/items/_search
Content-Type: application/json

{
  "query": {"match": { "title":  "游戏" }}
}

多条件查询

查询条件可能不只是一个

多个条件之间可能包含"与","或"的查询逻辑

在上面查询语句的基础上添加品牌"brand"属性的条件

ItemRepository接口添加方法如下

//  多条件查询
Iterable<Item> queryItemsByTitleMatchesAndBrandMatches(
                                    String title,String brand);

测试代码如下

// 多条件查询
@Test
void query2(){
    Iterable<Item> items= itemRepository
            .queryItemsByTitleMatchesAndBrandMatches(
              "游戏","罗技");
    for(Item item:items){
        System.out.println(item);
    }
}

底层运行的请求

### 多字段搜索
POST http://localhost:9200/items/_search
Content-Type: application/json

{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "游戏"}},
        { "match": { "brand": "罗技"}}
      ]
    }
  }
}

排序查询

SpringData的方法名中还允许添加排序条件

例如按价格降序排序

ItemRepository接口中再添加方法

// 排序查询
Iterable<Item> queryItemsByTitleMatchesOrBrandMatchesOrderByPriceDesc(
                                    String title,String brand);

测试代码

// 排序查询
@Test
void queryOrder(){
    Iterable<Item> items=itemRepository
            .queryItemsByTitleMatchesOrBrandMatchesOrderByPriceDesc(
                    "游戏","罗技");
    for(Item item:items){
        System.out.println(item);
    }
}

底层执行代码

### 多字段搜索
POST http://localhost:9200/items/_search
Content-Type: application/json

{
  "query": {
    "bool": {
      "should": [
        { "match": { "title": "游戏"}},
        { "match": { "brand": "罗技"}}
      ]
    }
  },"sort":[{"price":"desc"}]
}

分页查询

SpringData框架查询ES中的数据,也可以支持分页结果

这里不是使用PageHelper,而使用SpringData自带的分页查询解决方案

和PageHelper有类似的地方,但是也有很多不同

ItemRepository接口添加分页查询方法

// 分页查询
Page<Item> queryItemsByTitleMatchesOrBrandMatchesOrderByPriceDesc(
                 String title, String brand, Pageable pageable);

测试类代码

// 分页测试
@Test
void page(){
    int pageNum=1;
    int pageSize=2;
    Page<Item> page=itemRepository
        .queryItemsByTitleMatchesOrBrandMatchesOrderByPriceDesc(
        "游戏","罗技", PageRequest.of(pageNum-1,pageSize));
    for(Item item:page){
        System.out.println(item);
    }
    // page类型对象中包含的主要分页信息
    System.out.println("总页数:"+page.getTotalPages());
    System.out.println("当前页:"+page.getNumber());
    System.out.println("每页条数:"+page.getSize());
    System.out.println("是不是首页:"+page.isFirst());
    System.out.println("是不是末页:"+page.isLast());
}

实现达内知道搜索功能

搜索功能业务流程

完成大内知道搜索业务需要4个阶段

  1. 同步数据

    也就是将数据库表question中的所有数据复制到Es中

  2. 按用户输入的关键字进行ES查询,获得搜索结果

  3. 前端页面axios调用搜索功能呢获得返回值显示在页面上

  4. 在学生发布问题的功能中,添加将问题保存到Es中的操作

同步数据

search模块的配置

数据同步的过程可以大概分解为

faq模块要提供从数据库查询所有问题的Rest接口

search模块中要通过Ribbon请求faq模块获得所有问题,再将所有问题新增到ES中

但是在这之前,search模块要去完成一些列相关的配置,首先完成配置三板斧

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>cn.tedu</groupId>
        <artifactId>knows</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tedu</groupId>
    <artifactId>knows-search</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>knows-search</name>
    <description>Demo project for Spring Boot</description>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--  注册中心  -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
        <!--  通用模块  -->
        <dependency>
            <groupId>cn.tedu</groupId>
            <artifactId>knows-commons</artifactId>
        </dependency>
        <!--  安全框架(用户获得登录用户信息) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!--  用于将Page类转换为PageInfo  -->
        <dependency>
            <groupId>com.github.pagehelper</groupId>
            <artifactId>pagehelper</artifactId>
            <version>5.2.0</version>
        </dependency>
        <!-- Spring Data Elasticsearch -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
        </dependency>
    </dependencies>

</project>

application.properties

server.port=8003

# 日志门槛
logging.level.cn.tedu.knows.search=debug
logging.level.org.elasticsearch.client.RestClient=debug

# 指定Es的位置和端口
spring.elasticsearch.rest.uris=http://localhost:9200

# 项目名称  ↓↓↓↓↓↓↓↓↓↓↓
spring.application.name=search-service
# Nacos地址   ↓↓↓↓↓↓↓↓↓↓↓
spring.cloud.nacos.discovery.server-addr=http://localhost:8848

SpringBoot启动类

@SpringBootApplication
@EnableDiscoveryClient
public class KnowsSearchApplication {

    public static void main(String[] args) {
        SpringApplication.run(KnowsSearchApplication.class, args);
    }

    @Bean
    @LoadBalanced
    public RestTemplate restTemplate(){
        return new RestTemplate();
    }

}

编写同步数据的业务逻辑层

我们要做的操作是将数据库中question的数据查询出来然后新增到ES中

实际解决方案是faq模块提供查询所有question表中数据的方法

search模块使用Ribbon来调用

但是实际开发中,往往是数据库数据量较大时才会需要ES的支持

所以不能使用全查来解决,必须使用分批分次的查询,也就是分页查询来实现

转到knows-faq模块

我们要提供一个能够查询所有问题数据的分页查询

IQuestionService接口添加方法

// 分页查询Question表所有数据
PageInfo<Question> getQuestions(Integer pageNum,Integer pageSize);

QuestionServiceImpl实现类代码为:

@Override
public PageInfo<Question> getQuestions(Integer pageNum, Integer pageSize) {
    // 分页查询设置
    PageHelper.startPage(pageNum,pageSize);
    List<Question> list=questionMapper.selectList(null);
    // 别忘了编写返回值
    return new PageInfo<>(list);
}

编写控制层代码

// 分页查询全部question数据的方法
@GetMapping("/page")
public List<Question> questions(Integer pageNum,Integer pageSize){
    // 调用业务逻辑层编写好的分页查询的方法即可
    PageInfo<Question> pageInfo=questionService.getQuestions(
            pageNum,pageSize);
    return pageInfo.getList();
}

英文

analyze:分析

plugins:插件

续 开发达内知道的搜索功能

同步数据

编写控制层

knows-faq模块

上次课完成了分页查询所有问题的控制层方法

下面我们要再添加一个方法,返回按指定页面大小,计算总页数的方法

// 根据指定页面大小,计算总页数的方法
    @GetMapping("/page/count")
    public int pageCount(Integer pageSize){
        // 查询总条数
        // MybatisPlus框架提供了直接返回当前表总条数的方法:count()
        int count=questionService.count();
//        return count%pageSize==0    ? count/pageSize
//                                    : count/pageSize+1;
        return (count+pageSize-1)/pageSize;
    }

search模块创建VO类

转到knows-search模块

之前我们使用Item类来对应一个ES的索引

现在要定义一个新的类来对应数据库中question表

我们创建一个QuestionVO类,这个类的属性和Question实体类一致

我们可以直接复制实体类Question到knows-search模块的vo包下命名为QuestionVO

但是要修改注解

@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@Document(indexName = "knows")
public class QuestionVO implements Serializable {

    private static final long serialVersionUID = 1L;

    public static final Integer POSTED=0;   // 已提交\未回复
    public static final Integer SOLVING=1;  // 正在采纳\已回复
    public static final Integer SOLVED=2;   // 已采纳\已解决


    @Id
    private Integer id;

    /**
     * 问题的标题
     */
    @Field(type = FieldType.Text,
           analyzer = "ik_max_word",searchAnalyzer = "ik_max_word")
    private String title;

    /**
     * 提问内容
     */
    @Field(type = FieldType.Text,
            analyzer = "ik_max_word",searchAnalyzer = "ik_max_word")
    private String content;

    /**
     * 提问者用户名
     */
    @Field(type = FieldType.Keyword)
    private String userNickName;

    /**
     * 提问者id
     */

    @Field(type = FieldType.Integer)
    private Integer userId;

    /**
     * 创建时间
     */
    @Field(type = FieldType.Date,
           format = DateFormat.basic_date_time)
    private LocalDateTime createtime;

    /**
     * 状态,0-》未回答,1-》待解决,2-》已解决
     */
    @Field(type = FieldType.Integer)
    private Integer status;

    /**
     * 浏览量
     */
    @Field(type = FieldType.Integer)
    private Integer pageViews;

    /**
     * 该问题是否公开,所有学生都可见,0-》否,1-》是
     */
    @Field(type = FieldType.Integer)
    private Integer publicStatus;

    @Field(type = FieldType.Date,
            format = DateFormat.basic_date_time)
    private LocalDate modifytime;

    @Field(type = FieldType.Integer)
    private Integer deleteStatus;

    @Field(type = FieldType.Keyword)
    private String tagNames;
    /**
     * 当前问题包含的所有标签对象的list
     */
    // 声明当前属性不在ES中保存
    @Transient
    private List<Tag> tags;
}

search模块QuestionVO数据访问层

在repository包中创建QuestionRepository接口

代码如下

// 数据访问层的注解要添加!
@Repository
public interface QuestionRepository extends
                        ElasticsearchRepository<QuestionVO,Integer> {
    
}

search模块同步数据的业务逻辑层

search模块创建业务逻辑层包service

在包中创建IQuestionService接口

代码如下

public interface IQuestionService {
    
    // 声明同步数据库中Question数据到ES的方法
    void syncData();
    
}

然后创建impl包

包中创建实现类QuestionServiceImpl实现IQuestionService接口

代码如下

@Service
@Slf4j
public class QuestionServiceImpl implements IQuestionService {

    @Resource
    private RestTemplate restTemplate;
    @Resource
    private QuestionRepository questionRepository;
    @Override
    public void syncData() {
        // 通过Ribbon请求获得Question表分页查询的总页数
        String url=
          "http://faq-service/v2/questions/page/count?pageSize={1}";
        int pageSize=8;
        Integer total=restTemplate.getForObject(
                                 url,Integer.class,pageSize);
        // 根据总页数进行循环
        for(int i=1;i<=total;i++){
            // 循环中查询当页的所有数据
            url=
            "http://faq-service/v2/questions/page?pageNum={1}&pageSize={2}";
            // 查询第i页的数据
            QuestionVO[] questions=restTemplate.getForObject(
                                    url,QuestionVO[].class,i,pageSize);
            // 新增到ES中
            questionRepository.saveAll(Arrays.asList(questions));
            log.debug("完成了第{}页的新增",i);
        }

    }
}

上面业务逻辑层的方法只要运行就能同步数据了

所以没有必要编写控制层代码

直接在测试代码中运行即可同步所有数据

在测试类中编写代码如下

// 运行同步数据的方法
@Resource
IQuestionService questionService;
@Test
public void addEs(){
    questionService.syncData();
}

运行前保证Nacos\faq模块\Elasticsearch是启动的状态

本次同步数据方法只需要运行一次

执行搜索

分析搜索实现思路

我们实现的搜索功能

是用户输入一个关键字

然后在Es中按分词索引搜索,将相关的所有问题显示在页面上

为了清楚查询逻辑,我们可以先借助我们相对熟悉的sql语句来查询一下

SELECT * FROM question
WHERE
(title LIKE '%java%'
OR
content LIKE '%java%')
AND
(user_id=11
OR
public_status=1)

我们查询结果需要满足下面条件

1.问题的title或者content要包含查询的关键字

2.问题必须是登录用户提问的或是公开状态的

上面1和2条件是与的关系

下面给大家看一个Es查询的示意图

image-20211221103516054.png

match(匹配):相当于数据库中的like关键字,进行模糊查询也就是Es中的匹配分词

term(相等):相当于数据库中的"=",执行判等操作

should(应该):相当于数据库中的或/or/||

must(必须):相当于数据中的与/and/&&

执行上面逻辑的Es指令为:

### 条件搜索,查询用户11 或者 公开的 同时 标题或者内容中包含Java的问题
POST http://localhost:9200/knows/_search
Content-Type: application/json

{
  "query": {
    "bool": {
      "must": [{
        "bool": {
          "should": [
          {"match": {"title": "java"}}, 
          {"match": {"content": "java"}}]
        }
      }, {
        "bool": {
          "should": [
          {"term": {"publicStatus": 1}}, 
          {"term": {"userId": 11}}]
        }
      }]
    }
  }
}

编写执行搜索功能的数据访问层

QuestionRepository接口中添加方法

// 数据访问层的注解要添加!
@Repository
public interface QuestionRepository extends
                        ElasticsearchRepository<QuestionVO,Integer> {
    // 用户搜索功能的数据访问层方法
    // 因为搜索查询逻辑比较复杂,所以不再使用方法名的方式表示,直接编写查询指令
    @Query("{\n" +
            "    "bool": {\n" +
            "      "must": [{\n" +
            "        "bool": {\n" +
            "          "should": [\n" +
            "          {"match": {"title": "?0"}}, \n" +
            "          {"match": {"content": "?1"}}]\n" +
            "        }\n" +
            "      }, {\n" +
            "        "bool": {\n" +
            "          "should": [\n" +
            "          {"term": {"publicStatus": 1}}, \n" +
            "          {"term": {"userId": ?2}}]\n" +
            "        }\n" +
            "      }]\n" +
            "    }\n" +
            "  }")
    Page<QuestionVO> queryAllByParams(String title, String content,
                                      Integer userId, Pageable pageable);
}

上面的方法要测试才能保证运行

在保证Nacos和Es运行的情况下

开始执行测试代码如下

@Resource
QuestionRepository questionRepository;
@Test
void search(){
    Page<QuestionVO> page=questionRepository
            .queryAllByParams("java","java",
                    11, PageRequest.of(0,8));
    for(QuestionVO vo: page){
        System.out.println(vo);
    }
}

创建分页信息转换类

在编写业务逻辑层之前

我们需要先编写一个Page转换为PageInfo类型的功能

因为我们现在编写好的前端代码都是支持PageInfo类型属性的

如果返回值类型是Page需要修改前端页面,导致较多维护工作

所以我们在业务逻辑层返回是直接返回PageInfo类型

创建utils包,包中创建Pages类

类中代码可以直接复制苍老师网站的资源

代码如下

public class Pages {
    /**
     * 将Spring-Data提供的翻页数据,转换为Pagehelper翻页数据对象
     * @param page Spring-Data提供的翻页数据
     * @return PageInfo
     */
    public static <T> PageInfo<T> pageInfo(Page<T> page){
        //当前页号从1开始, Spring-Data从0开始,所以要加1
        int pageNum = page.getNumber()+1;
        //当前页面大小
        int pageSize = page.getSize();
        //总页数 pages
        int pages = page.getTotalPages();
        //当前页面中数据
        List<T> list = new ArrayList<>(page.toList());
        //当前页面实际数据大小,有可能能小于页面大小
        int size = page.getNumberOfElements();
        //当前页的第一行在数据库中的行号, 这里从0开始
        int startRow = page.getNumber()*pageSize;
        //当前页的最后一行在数据库中的行号, 这里从0开始
        int endRow = page.getNumber()*pageSize+size-1;
        //当前查询中的总行数
        long total = page.getTotalElements();

        PageInfo<T> pageInfo = new PageInfo<>(list);
        pageInfo.setPageNum(pageNum);
        pageInfo.setPageSize(pageSize);
        pageInfo.setPages(pages);
        pageInfo.setStartRow(startRow);
        pageInfo.setEndRow(endRow);
        pageInfo.setSize(size);
        pageInfo.setTotal(total);
        pageInfo.calcByNavigatePages(PageInfo.DEFAULT_NAVIGATE_PAGES);

        return pageInfo;
    }
}

编写业务逻辑层代码

IQuestionService接口添加搜索功能业务逻辑层方法的声明

// 按用户输入的关键字进行搜索功能的业务逻辑层方法
PageInfo<QuestionVO> search(String key,String username,
                            Integer pageNum,Integer pageSize);

QuestionServiceImpl实现类代码如下

@Override
public PageInfo<QuestionVO> search(String key, String username, Integer pageNum, Integer pageSize) {
    // 先根据用户名获得用户对象
    String url="http://sys-service/v1/auth/user?username={1}";
    User user=restTemplate.getForObject(url,User.class,username);
    // 调用数据访问层方法执行搜索
    Pageable pageable= PageRequest.of(pageNum-1,pageSize,
            Sort.Direction.DESC,"createtime");
    Page<QuestionVO> page=questionRepository
            .queryAllByParams(key,key,user.getId(),pageable);
    // 将查询结果转换为PageInfo类型返回
    return Pages.pageInfo(page);
}

业务逻辑层方法也要测试

之前需要的服务不关闭的前提下,新启动Sys模块

代码如下

// 测试搜索业务逻辑层
@Test
void testService(){
    PageInfo<QuestionVO> pageInfo=
            questionService.search("java","st2",
                    1,8);
    for (QuestionVO vo:pageInfo.getList()){
        System.out.println(vo);
    }
}

如果代码没问题但是运行失败

可以尝试删除分页条件中的排序参数,再运行试试

如果成功了,就是ES不稳定造成的,需要重启ES甚至重新安装ES才能解决

编写控制层代码

knows-search模块

创建controller包,包中创建QuestionController类

类中编写控制器方法调用业务逻辑层,并返回结果

@RestController
@RequestMapping("/v3/questions")
public class QuestionController {

    @Resource
    private IQuestionService questionService;
    @PostMapping
    public PageInfo<QuestionVO> search(
            String key,
            Integer pageNum,
            @AuthenticationPrincipal UserDetails user){
        if (pageNum==null)
            pageNum=1;
        Integer pageSize=8;
        PageInfo<QuestionVO> pageInfo=questionService
                .search(key,user.getUsername(),pageNum,pageSize);
        // 别忘了返回pageInfo
        return pageInfo;

    }
}

完善微服务相关配置

配置网关

转到gateway模块

application.yml

- id: gateway-search
  uri: lb://search-service
  predicates:
    - Path=/v3/**

跨域和放行配置

转回knows-search模块

可以直接复制faq模块中的security包,还有拦截器的interceptor包,一起粘贴到search模块中

如果复制过程中有编译错误,导包就能解决

只是拦截器配置路径要修改一下

WebConfig类中的拦截器路径修改为

@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(authInterceptor)
            .addPathPatterns(
                    "/v3/questions"    //搜索问题
            );
}

最终search模块的目录结构为

image-20211221144019280.png

前端显示搜索结果

前端调用思路

我们需要先明确前端调用的流程

我们先开发学生首页执行搜索的功能,在index_student.html页面中输入搜索关键字然后点击搜索按钮,我们设计跳转到search.html页面,然后将用户输入的搜索内容保存到浏览器地址栏?之后

跳转到search.html页面之后,这个页面在加载完毕时就获得url?之后的内容

通过axios向search模块发起搜索请求,search模块返回的结果显示在页面上即可

学生首页跳转到搜索页面

转到knows-client项目

先编写点击搜索按钮跳转到search.html页面并在?之后保存关键字的效果

index_student.html页面39行

<div class="form-inline my-2 my-lg-0" id="searchApp">
  <input class="form-control form-control-sm mr-sm-2 rounded-pill" 
         type="search" placeholder="Search" aria-label="Search"
          v-model="key">
    <!--  ↑↑↑↑↑↑↑↑↑↑↑↑   -->
  <button class="btn btn-sm btn-outline-secondary my-2 my-sm-0 rounded-pill" 
          type="button"
          @click="search">
      <!--  ↑↑↑↑↑↑↑↑↑↑↑↑   -->
    <i class="fa fa-search" aria-hidden="true"></i>
  </button>
</div>

在页面末尾添加跳转到search.html的Vue方法

<script>
  let searchApp=new Vue({
    el:"#searchApp",
    data:{
      key:""
    },
    methods:{
      search:function(){
        // 当前方法的主要目标
        // 1.跳转到search.html页面
        // 2.将用户输入的查询关键字key拼接到路径?之后
        // encodeURI是为了防止查询关键字是中文时出现乱码的情况
        location.href="/search.html?"+encodeURI(this.key);
      }
    }
  })
</script>
</html>

启动或重启client项目

在学生首页输入关键字点击搜索按钮,观察是否能够跳转的设计的路径

编写search.html页面

我们可以直接复制讲师首页为search.html

修改"我的任务"为"搜索结果"(也可以修改一下图标)

search.html页面182行附近

<h4 class="border-bottom m-2 p-2 font-weight-light">
  <i class="fa fa-search" aria-hidden="true"></i> 搜索结果</h4>

因为当前页面不再是查询讲师首页信息,所以需要修改页面尾部引用

不再引用index_teacher.js,而修改为search.js

</body>
<script src="js/utils.js"></script>
<script src="js/tags_nav_temp.js"></script>
<script src="js/tags_nav.js"></script>
<script src="js/user_info_temp.js"></script>
<script src="js/user_info.js"></script>
<script src="js/search.js"></script>
<!-- ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑  -->
</html>

其实当前页面内容大部分和讲师显示的逻辑类似

我们可以复制index_teacher.js文件为search.js

然后在search.js文件中修改代码即可

loadQuestions:function (pageNum) {
    if(! pageNum){
        pageNum = 1;
    }
    //  开始有变化的代码  ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
    // 获得地址栏?之后的内容
    let key=location.search;
    if(!key){
        return;
    }
    // decodeURI是解码方法,能够将Unicode格式的中文,转回成可识别的中文文字
    key=decodeURI(key.substring(1));
    let form=new FormData();
    form.append("pageNum",pageNum);
    form.append("accessToken",token);
    form.append("key",key);

    axios({
        url: 'http://localhost:9000/v3/questions',
        method: "post",
        data:form
        //  ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑   修改结束
    }).then(function(r){
        console.log("成功加载数据");
        console.log(r);
        if(r.status == OK){
            questionsApp.questions = r.data.list;
            questionsApp.pageinfo = r.data;
            //为question对象添加持续时间属性
            questionsApp.updateDuration();
            questionsApp.updateTagImage();
        }
    })
}

下面可以进行测试

需要启动Nacos\ES\Redis

gateway\sys\faq\auth\search

重启client

登录学生,在学生首页输入关键字

查询出的搜索结果是可以分页的

但是没有标签和图片

显示问题的标签和配图

上面的查询不能实现标签和图片的原因是因为

QuestionVO类中的tags属性没有赋值

之前portal项目中我们是利用Question的tagNames属性来获得对应标签的集合

在当前search模块中,我们也要利用QuestionVO的tagNames属性来获得对应标签的集合

只是所有标签内容的获取要通过Ribbon来调用faq模块获得

转到knows-search模块

在QuestionServiceImpl类中添加一个方法获得对应的标签集合

并在搜索功能的方法中来调用

具体代码如下

@Override
public PageInfo<QuestionVO> search(String key, String username, Integer pageNum, Integer pageSize) {
    // 先根据用户名获得用户对象
    String url="http://sys-service/v1/auth/user?username={1}";
    User user=restTemplate.getForObject(url,User.class,username);
    // 调用数据访问层方法执行搜索
    Pageable pageable= PageRequest.of(pageNum-1,pageSize,
            Sort.Direction.DESC,"createtime");
    Page<QuestionVO> page=questionRepository
            .queryAllByParams(key,key,user.getId(),pageable);
    // 根据每个问题的tagNames属性为它的Tags属性赋值
    // ↓↓↓↓↓↓↓↓  新增的for循环  ↓↓↓↓↓↓↓↓↓↓
    for(QuestionVO vo:page){
        vo.setTags(tagNamesToTags(vo.getTagNames()));
    }

    // 将查询结果转换为PageInfo类型返回
    return Pages.pageInfo(page);
}
// 根据tagNames属性获得标签集合的方法
private List<Tag> tagNamesToTags(String tagNames){
    // tagNames:"java基础,javaSE,面试题"
    String[] names=tagNames.split(",");
    //Ribbon请求获得所有标签
    String url="http://faq-service/v2/tags";
    Tag[] tagArr=restTemplate.getForObject(url,Tag[].class);
    // 创建一个Map对象,将所有标签赋值到其中
    Map<String,Tag> tagMap=new HashMap<>();
    for(Tag t:tagArr){
        tagMap.put(t.getName(),t);
    }
    // 实例化保存当前问题所有标签对象的集合
    List<Tag> tags=new ArrayList<>();
    // 遍历tagNames转换成的String数组,将对应的标签对象保存到tags集合中
    for(String name:names){
        tags.add(tagMap.get(name));
    }
    return tags;
}

重启search模块

再次从学生首页进行搜索

就能显示所有标签和配图了

消息队列

学生发布问题同步到ES的性能问题

上面章节为止,我们完成了搜索功能中4个阶段中的前3个

最后一个阶段是要完成学生发布问题之后将这个问题也新增到ES的功能

保证mysql和Es中的数据同步

实现的思路变化如下图

image-20211221171303947.png

图片左侧是没有ES时的运行流程

图片右侧是添加ES后的运行流程

右侧的解决方案中有个一个比较严重影响性能的问题

就是faq模块发送Ribbon请求之后要等待search模块完成新增到ES的操作并作出响应后faq模块才能获得响应完成后面的操作

也就是说search模块完成问题新增到ES的操作时faq模块线程是阻塞的,明显降低运行效率

严重的浪费了线程资源和内存资源

解决问题的症结在于要消除faq模块阻塞\等待的情况

image-20211221172437490.png

上图中使用消息队列

faq模块将要新增的信息发送给消息队列(kafka)

faq模块并不需要等待search模块完成新增,就可以直接做出响应,释放线程资源接受后面的请求

这样就消除了等待,提高了程序运行效率

什么是消息队列

消息队列(Message Queue)简称MQ

一般情况下用于代替等待时间较长的Ribbon请求

Ribbon请求是必须等待目标有响应才能继续运行的

而消息队列我们只需要将信息\数据,提交给它就可以去别的事情了

这实际上也是一种"异步"操作:就是faq模块不等待search模块响应,就可以完成响应给客户端了

image-20211221164521572.png

随笔

67 / 8 +1 = 9

64 / 8 = 8

int total=0;

if(count % size==0){

​ total=count/size

}else{

​ total=count/size+1

}

int total=count % size==0 ? count/size : count/size+1

(count+size-1)/size

(67+7)/8 74/8=9

(64+7)/8 71/8=8

英文

Transient:临时的