验证令牌
上次课我们完成了令牌的生成
下面来对生成的令牌进行验证
这个验证的过程就是从Auth服务器内存中获得token对应的用户信息
现在用户信息还是保存在内存中,token是一个uuid格式的字符串
相当于是在Auth服务器内存中用户信息的key
具体的获取方式是:
根据Oauth2提供的标准的控制器方法
http://localhost:8010/oauth/check_token?token=45cf2b3a-1d44-4e74-99fb-d647490b489e
请求能够验证令牌,显示用户的详情信息
验证令牌其实就是解析令牌中用户的信息
这个请求是不限制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提交信息,观察结果
上面的请求应该能够得到保护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类
代码如下
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模块比较适合判断用户的角色
将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提供的
作用目标不同:
- 过滤器作用目标比较广:可以作用在所有请求当前服务器资源的流程中
- 拦截器作用目标比较单一:只能作用在请求目标是控制器的流程中
功能强度不同:
- 过滤器依靠源生的java操作,功能较弱,不能直接处理支持Spring容器中的内容和对象
- 拦截器是SpringMvc框架同的,和Spring框架兼容性好,可以直接操作Spring容器中的内容和对象,而且拦截器提供更多运行的时机,程序员可以选择更合适的来调用
总结
如果请求的目标能确定是一个控制器方法,那么优先选择拦截器
如果请求的目标可能是控制器或其他静态资源,那么需要使用过滤器
拦截器工作流程图
拦截器的基本使用
使用拦截器的步骤
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相关内容,但是其他的问答相关功能没有迁移
下面开始迁移
迁移过来,导包即可
然后迁移业务逻辑层实现类
这个类的代码导包之后还有一些错误
需要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);
}
代码太多,没办法全部粘过来,需要看直接到代码中去对照
迁移控制层代码
QuestionController导包操作
别忘了修改v2的路径
// 别忘了修改faq模块的路由特征路径为v2开头!!!
@RequestMapping("/v2/questions")
public class QuestionController {
.....
}
配置拦截器
从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
先复制两个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>
迁移业务逻辑层
复制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);
迁移控制层
还是先导包
然后别忘了将控制权路径开头修改为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全文搜索引擎
软件下载
什么是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也是将所有数据保存到硬盘上,也就是复制数据库表中的数据
这样做,能够保证即使是大数据量的模糊查询,查询速度也能保持在毫秒级别
续 Elasticsearch搜索引擎
安装启动Elasticsearch
官方下载链接
将下载的280兆的压缩包解压
进入解压后目录中的bin目录
双击运行elasticsearch.bat文件,可以启动ES
遗憾的是ES没有开机自动启动的功能,需要我们开机后手动运行,而且dos窗口不能关
如果要测试它的运行状态
可以打开浏览器输入地址
localhost:9200
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的项目
这个项目承担之后的搜索模块的功能
父子相认
<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的测试
在创建好的文件中编写代码如下
### 在httpclient文件中,任何指令必须以###开头,否则运行报错
### 三个#既是注释,又是分隔符
GET http://localhost:9200
### ES分词测试
POST http://localhost:9200/_analyze
Content-Type: application/json
{
"text": "罗技激光游戏鼠标",
"analyzer": "standard"
}
"analyzer": "standard"是设定分词器
它是默认的,可以省略
默认分词器standard只能识别英文分词
我们需要安装一个能够识别中文的分词器
安装ik插件实现识别中文分词
因为修改了配置,所以要重启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保存数据的结构
- ES软件可以创建多个index(索引),我们可以将它理解为数据库中的表的概念
- 一个索引中可以创建保存多个document(文档),每个文档就相当于数据表中的行
- 每个文档中使用json格式保存一条数据,json格式中的每个属性相当于数据库表中的列
大家发送一个ES文档
这个文档中包含对Es的操作指令,
我们来执行一下
执行过程略......
SpringBoot项目操作Elasticsearch
Spring Data简介
java程序可能需要连接各种第三方数据源来进行数据的传递
其中我们经常使用的mysql\redis\ES等多种软件都是第三方数据源
Spring框架提供了一个SpringData框架集,框架集中包含了连接操作当今大多数流行的第三方数据源的框架
之前我们使用过它连接过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个阶段
-
同步数据
也就是将数据库表question中的所有数据复制到Es中
-
按用户输入的关键字进行ES查询,获得搜索结果
-
前端页面axios调用搜索功能呢获得返回值显示在页面上
-
在学生发布问题的功能中,添加将问题保存到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查询的示意图
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模块的目录结构为
前端显示搜索结果
前端调用思路
我们需要先明确前端调用的流程
我们先开发学生首页执行搜索的功能,在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中的数据同步
实现的思路变化如下图
图片左侧是没有ES时的运行流程
图片右侧是添加ES后的运行流程
右侧的解决方案中有个一个比较严重影响性能的问题
就是faq模块发送Ribbon请求之后要等待search模块完成新增到ES的操作并作出响应后faq模块才能获得响应完成后面的操作
也就是说search模块完成问题新增到ES的操作时faq模块线程是阻塞的,明显降低运行效率
严重的浪费了线程资源和内存资源
解决问题的症结在于要消除faq模块阻塞\等待的情况
上图中使用消息队列
faq模块将要新增的信息发送给消息队列(kafka)
faq模块并不需要等待search模块完成新增,就可以直接做出响应,释放线程资源接受后面的请求
这样就消除了等待,提高了程序运行效率
什么是消息队列
消息队列(Message Queue)简称MQ
一般情况下用于代替等待时间较长的Ribbon请求
Ribbon请求是必须等待目标有响应才能继续运行的
而消息队列我们只需要将信息\数据,提交给它就可以去别的事情了
这实际上也是一种"异步"操作:就是faq模块不等待search模块响应,就可以完成响应给客户端了
随笔
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:临时的