目录
一、异常处理
1. 问题分析
现在有一张数据库表emp,表示员工表。其中的电话号码phone字段添加了唯一约束。
当我们在修改数据时,如果输入了一个在数据库表中已经存在的手机号,点击保存按钮之后,前端提示了错误信息,但是返回的结果并不是统一的响应结果,而是框架默认返回的错误结果 (系统接口访问异常)。
规定服务端给前端返回的数据结果包装为一个Result类:
/**
* 后端统一返回结果
*/
@Data
public class Result {
private Integer code; //编码:1成功,0为失败
private String msg; //错误信息
private Object data; //数据
public static Result success() {
Result result = new Result();
result.code = 1;
result.msg = "success";
return result;
}
public static Result success(Object object) {
Result result = new Result();
result.data = object;
result.code = 1;
result.msg = "success";
return result;
}
public static Result error(String msg) {
Result result = new Result();
result.msg = msg;
result.code = 0;
return result;
}
}
状态码为500,表示服务器端异常。但是服务器端向前端返回的数据并不符合我们定义的Result。由于返回的数据不符合开发规范,所以前段并不能解析出响应的JSON数据。
服务器端表示emp表中的phone这个字段重复了。
出现异常之后,项目并没有做任何的异常处理。
当我们没有做任何的异常处理时,我们三层架构处理异常的方案:
Mapper接口在操作数据库的时候出错了,此时异常会往上抛(谁调用Mapper就抛给谁),会抛给service。
service 中也存在异常了,会抛给controller。
而在controller当中,我们也没有做任何的异常处理,所以最终异常会再往上抛。最终抛给框架之后,框架就会返回一个JSON格式的数据,里面封装的就是错误的信息,但是框架返回的JSON格式的数据并不符合我们的开发规范。
2. 全局异常处理器
我们该怎么样定义全局异常处理器?
定义全局异常处理器非常简单,就是定义一个类,在类上加上一个注解@RestControllerAdvice,加上这个注解就代表我们定义了一个全局异常处理器。
在全局异常处理器当中,需要定义一个方法来捕获异常,在这个方法上需要加上注解@ExceptionHandler。通过@ExceptionHandler注解当中的value属性来指定我们要捕获的是哪一类型的异常。如果value属性未指定具体值,则会根据方法中的形参来捕获异常。下面方法中形参类型为Exception,因此会捕获所有类型的异常。
只要项目中代码运行出现相关类型的异常,全局异常处理器就会捕获相关的异常,然后向前端响应方法中定义的相关的信息。
@RestControllerAdvice
public class GlobalExceptionHandler {
//处理异常
@ExceptionHandler
public Result ex(Exception e){//方法形参中指定能够处理的异常类型
e.printStackTrace();//打印堆栈中的异常信息
//捕获到异常之后,响应一个标准的Result
return Result.error("对不起,操作失败,请联系管理员");
}
}
重新启动SpringBoot服务,打开浏览器,再来测试一下 修改员工 这个操作,我们依然设置已存在的手机号:
此时,我们可以看到,出现异常之后,异常已经被全局异常处理器捕获了。然后返回的错误信息,被前端程序正常解析,然后提示出了对应的错误提示信息。
以上就是全局异常处理器的使用,主要涉及到两个注解:
@RestControllerAdvice //表示当前类为全局异常处理器
@ExceptionHandler //指定可以捕获哪种类型的异常进行处理
二、登录校验技术
所谓登录校验,指的是我们在服务器端接收到浏览器发送过来的请求之后,首先我们要对请求进行校验。先要校验一下用户登录了没有,如果用户已经登录了,就直接执行对应的业务操作就可以了;如果用户没有登录,此时就不允许他执行相关的业务操作,直接给前端响应一个错误的结果,最终跳转到登录页面,要求他登录成功之后,再来访问对应的数据。
HTTP协议是一种无状态的协议,所谓无状态,指的就是每一次请求都是独立的,下一次请求不会携带上一次请求的数据。而浏览器与服务器之间进行交互,基于HTTP协议也就意味着现在我们通过浏览器来访问了登陆这个接口,实现了登陆的操作,接下来我们在执行其他业务操作时,服务器也并不知道这个员工到底登陆了没有。因为HTTP协议是无状态的,两次请求之间是独立的,所以是无法判断这个员工到底登陆了没有。
那应该怎么来实现登录校验的操作呢?具体的实现思路可以分为两部分:
在员工登录成功后,需要将用户登录成功的信息存起来,记录用户已经登录成功的标记。
在浏览器发起请求时,需要在服务端进行统一拦截,拦截后进行登录校验。
当用户第一次进行登录请求时,服务器端会生成一个登录标记,然后将登录标记一起响应给浏览器。当再进行其他请求时,前端会携带着登录标记一起发送给服务器端,如果服务器端有这个登录标记,就给前端相应相应的请求。
1.会话技术
在web开发中,会话指的就是浏览器和服务器之间的一次连接,就称为一次会话。在用户打开浏览器第一次访问服务器的时候,这个会话就建立了,直到有任何一方断开连接,此时会话就结束了。在一次会话当中,是可以包含多次请求和响应的。
需要注意的是:会话是和浏览器关联的,当有三个浏览器客户端和服务器建立了连接时,就会有三个会话。同一个浏览器在未关闭之前请求了多次服务器,这多次请求是属于同一个会话。比如:1、2、3这三个请求都是属于同一个会话。当我们关闭浏览器之后,这次会话就结束了。而如果我们是直接把web服务器关了,那么所有的会话就都结束了。
会话跟踪:服务器需要识别多次请求是否来自同一个浏览器,以便在同一次会话的多个请求间共享数据。
会话跟踪方案有两种:
Cookie(客户端会话跟踪技术):数据存储在客户端浏览器当中
Session(服务端会话跟踪技术):数据存储在储在服务端
1.1 Cookie
Cookie是存储在客户端浏览器的,当我们在浏览器第一次向服务器发送请求时,就在服务器端来设置一个Cookie。
比如第一次请求了登录接口,登录接口执行完成之后,我们就可以设置一个cookie,在 cookie 当中我们就可以来存储用户相关的一些数据信息。比如我可以在 cookie 当中来存储当前登录用户的用户名,用户的ID。
服务器端在向客户端相应数据时,会自动的将Cookie响应给浏览器,浏览器接收到Cookie之后,会自动的将Cookie存储在本地。之后的每一次请求中,浏览器就会自动的将本地存储的Cookie带到服务器端。
服务器端接收到这个Cookie,就回去判断这个Cookie值是否存在,如果不存在,说明没有进行登录操作;如果存在,就说明该客户端已经连接成功,就可以基于Cookie在一次会话中的不同请求之间来共享数据。
这一切都是自动进行的,因为 cookie 它是 HTTP 协议当中所支持的技术,而各大浏览器厂商都支持了这一标准。在 HTTP 协议官方给我们提供了一个响应头和请求头:
响应头 Set-Cookie :设置Cookie数据的
请求头 Cookie:携带Cookie数据的
优缺点:
优点:HTTP协议中支持的技术(像Set-Cookie 响应头的解析以及 Cookie 请求头数据的携带,都是浏览器自动进行的,是无需我们手动操作的)
缺点:
移动端APP(Android、IOS)中无法使用Cookie
不安全,用户可以自己禁用Cookie
Cookie不能跨域,ip地址的协议不同、地址不同、端口不同都算跨域。只有协议、地址、端口都相同时,才算同一个域,例如:http://192.168.150.200/login.html ----------> http://192.168.150.200/login。
大白话来解释:假如你第一次去咖啡店,店员送你一张「积分卡」,上面记录你买了几杯咖啡(比如买5杯送1杯)。下次你去店里,只要出示这张卡,店员就知道你的消费记录。Cookie技术——你的消费数据存在你手里(浏览器里),每次访问网站都会自动带上这个“积分卡”给服务器看。
1.2 Session
Session是服务器端跟踪技术,Session的底层就是基于Cookie来实现的。
浏览器在第一次请求服务器时,会话对象是不存在的,服务器就会自动创建一个会话对象Session。每一个会话对象Session都会有一个ID(示意图中Session后面括号中的1,就表示ID)。
然后,服务器在向浏览器响应数据时,会将Session的ID通过Cookie响应给浏览器。然后浏览器会将Cookie保存在本地,Cookie中有Session的ID数据。
在之后的请求中,每次都会将Session的ID随着Cookie一起发送给服务器端,服务器端接收到ID,就会从众多的会话对象Session中找到当前ID的会话对象。从而进行数据共享。
优缺点
优点:Session是存储在服务端的,安全
缺点:
服务器集群环境下无法直接使用Session,1号服务器知道Session的ID,但是2号服务器并不知道
移动端APP(Android、IOS)中无法使用Cookie
用户可以自己禁用Cookie
Cookie不能跨域
大白话解释:还是同一家咖啡店,店员并不给你积分卡,而是给你一张「号码牌」,然后在店里的本子上用这个号码记录你的消费次数。你只需要记住号码牌,实际数据存在店里(服务器)。Session技术——服务器给你一个唯一的ID(比如JSESSIONID),存在你的Cookie里;服务器用这个ID在自己的“小本本”里查你的数据。
2. JWT令牌
令牌就是一个用户身份的标识,本质就是一段字符串。
当浏览器端进行登录请求的时候,服务器端就会生成一个令牌,响应给前端。然后前端程序就会将这个令牌存储起来,可以存储在 cookie 当中,也可以存储在其他的存储空间(比如:localStorage)当中。之后的每次请求,都需要将令牌携带到服务器端,服务器端对令牌进行校验,如果合法就放行。
优缺点
优点:
支持PC端、移动端
解决集群环境下的认证问题
减轻服务器的存储压力(无需在服务器端存储)
缺点:需要自己实现(包括令牌的生成、令牌的传递、令牌的校验)
大白话解释:想象你入住酒店时,前台给你一张「房卡」。这张房卡本身不值钱,但它能证明你是住客,能刷开对应楼层的电梯和房间门。令牌(Token)就是这个原理——它本质上是一个经过加密的字符串,用来代替真实的账户密码,证明"你是谁"以及"你能做什么"。
2.1 介绍
JWT的组成: (JWT令牌由三个部分组成,三个部分之间使用英文的点来分割)
第一部分:Header(头), 记录令牌类型、签名算法等。 例如:{"alg":"HS256","type":"JWT"}
第二部分:Payload(有效载荷),携带一些自定义信息、默认信息等。 例如:{"id":"1","username":"Tom"}
第三部分:Signature(签名),防止Token被篡改、确保安全性。将header、payload,并加入指定秘钥,通过指定签名算法计算而来。这一部分是通过前两个部分自动生成的。
JWT令牌是通过base64编码形式将JSON形式的数据转换为字符串的。
2.2 生成与校验
想使用JWT令牌,就需要首先引入JWT的依赖。
<!-- JWT依赖-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
生成JWT代码实现:
@Test
public void testGenJwt() {
Map<String, Object> claims = new HashMap<>();
claims.put("id", 10);
claims.put("username", "itheima");
// signWith方式指定签名算法(HS256)以及密钥(aXRjYXN0)
// 密钥是自己进行定义,然后通过base64编码形成的
String jwt = Jwts.builder().signWith(SignatureAlgorithm.HS256, "aXRjYXN0")
// addClaims方法用来添加自定义数据,形参的形式为Map
.addClaims(claims)
// setExpirationy用来定义JWT令牌的有效时间,一旦有效时间过了,就需要重新申请令牌
.setExpiration(new Date(System.currentTimeMillis() + 12 * 3600 * 1000))
// compact方法用于构建令牌
.compact();
System.out.println(jwt);
}
测试方法运行结果如下:
eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNjcyNzI5NzMwfQ.fHi0Ub8npbyt71UqLXDdLyipptLgxBUg_mSuGJtXtBk
解析JWT令牌代码如下:
@Test
public void testParseJwt() {
// parser方法说明要解析令牌,setSigningKey方法是指定密钥,该密钥必须和生成的时候密钥完全一致
Claims claims = Jwts.parser().setSigningKey("aXRjYXN0")
//parseClaimsJws方法是将你的JWT令牌进行输入
.parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MTAsInVzZXJuYW1lIjoiaXRoZWltYSIsImV4cCI6MTcwMTkwOTAxNX0.N-MD6DmoeIIY5lB5z73UFLN9u7veppx1K5_N_jS9Yko")
// getBody方法是获取令牌的第二部分,也就是自定义数据部分
.getBody();
System.out.println(claims);
}
运行测试方法结果如下:
{id=10, username=itheima, exp=1701909015}
与我们自己定义的数据是一致的。
篡改令牌中的任何一个字符,在对令牌进行解析时都会报错,所以JWT令牌是非常安全可靠的。
2.3 登录时下发令牌
在登录成功之后来生成一个JWT令牌,并且把这个令牌直接返回给前端。
根据接口文档,先有一个实体类LoginInfo用于接收用户登录传递给服务器的信息,然后服务器端再响应给前端。
public class LoginInfo {
private Integer id; // 用户id
private String username; // 用户的用户名
private String name; // 用户的姓名
private String token; // JWT令牌
}
我们先将生成与校验令牌的代码定义为一个工具类:
package com.itheima.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;
import java.util.Map;
public class JwtUtils {
private static String signKey = "SVRIRUlNQQ==";
private static Long expire = 43200000L;
/**
* 生成JWT令牌
* @return
*/
public static String generateJwt(Map<String,Object> claims){
String jwt = Jwts.builder()
.addClaims(claims)
.signWith(SignatureAlgorithm.HS256, signKey)
.setExpiration(new Date(System.currentTimeMillis() + expire))
.compact();
return jwt;
}
/**
* 解析JWT令牌
* @param jwt JWT令牌
* @return JWT第二部分负载 payload 中存储的内容
*/
public static Claims parseJWT(String jwt){
Claims claims = Jwts.parser()
.setSigningKey(signKey)
.parseClaimsJws(jwt)
.getBody();
return claims;
}
}
然后在登录逻辑的时候下发JWT令牌:
@Override
public LoginInfo login(Emp emp) {
// 根据用户的用户名和密码来获得用户信息
Emp empLogin = empMapper.getUsernameAndPassword(emp);
// 如果存在该用户
if(empLogin != null){
// 自定义信息,将用户id和用户名存入JWT令牌中
Map<String,Object> dataMap = new HashMap<>();
dataMap.put("id", empLogin.getId());
dataMap.put("username", empLogin.getUsername());
// 生成令牌
String jwt = JwtUtils.generateJwt(dataMap);
// 将用户信息和令牌信息存入LoginInfo对象当中,返回给前端
LoginInfo loginInfo = new LoginInfo(empLogin.getId(), empLogin.getUsername(), empLogin.getName(), jwt);
return loginInfo;
}
return null;
}
3. 过滤器Filter
3.1 概述
Filter表示过滤器,是 JavaWeb三大组件(Servlet、Filter、Listener)之一。过滤器可以把对资源的请求拦截下来,从而实现一些特殊的功能。使用了过滤器之后,要想访问web服务器上的资源,必须先经过滤器,过滤器处理完毕之后,才可以访问对应的资源。
使用过滤器的基本操作如下:
第1步,定义过滤器 :定义一个类,实现 Filter 接口,并重写其所有方法。
第2步,配置过滤器:Filter类上加 @WebFilter 注解,配置拦截资源的路径。引导类上加 @ServletComponentScan 开启Servlet组件支持。
定义一个类,实现Filter接口:
@WebFilter(urlPatterns = "/*") //配置过滤器要拦截的请求路径( /* 表示拦截浏览器的所有请求 )
public class DemoFilter implements Filter {
//初始化方法, web服务器启动, 创建Filter实例时调用, 只调用一次
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("init ...");
}
//拦截到请求时,调用该方法,可以调用多次
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
System.out.println("拦截到了请求...");
}
//销毁方法, web服务器关闭时调用, 只调用一次
public void destroy() {
System.out.println("destroy ... ");
}
}
启动类上加上@ServletComponentScan注解,表示这个启动类执行的web项目所发出的请求都会被拦截:
@ServletComponentScan //开启对Servlet组件的支持
@SpringBootApplication
public class TliasManagementApplication {
public static void main(String[] args) {
SpringApplication.run(TliasManagementApplication.class, args);
}
}
启动服务,执行请求,控制台给出了过滤器的信息:
3.2 登录校验过滤器
用户在进行登录操作时,服务器端会给前端下发一个令牌。然后在后续的每一次操作中,都需要将令牌下发到服务器端,服务器在过滤器中来校验令牌的有效性。如果令牌无效,就响应一个错误的信息,如果令牌有效,就放行去执行相应的操作。
具体流程如下:
代码如下:
/**
* 令牌校验过滤器
*/
@Slf4j
@WebFilter(urlPatterns = "/*")
public class TokenFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) resp;
//1. 获取请求url。
String url = request.getRequestURL().toString();
//2. 判断请求url中是否包含login,如果包含,说明是登录操作,放行。
if(url.contains("login")){ //登录请求
log.info("登录请求 , 直接放行");
// 这一句代码指的是放行操作
chain.doFilter(request, response);
return;
}
//3. 获取请求头中的令牌(token)。
String jwt = request.getHeader("token");
//4. 判断令牌是否存在,如果不存在,返回错误结果(未登录)。
if(!StringUtils.hasLength(jwt)){ //jwt为空
log.info("获取到jwt令牌为空, 返回错误结果");
// HttpStatus.SC_UNAUTHORIZED指的就是401响应码
response.setStatus(HttpStatus.SC_UNAUTHORIZED);
return;
}
//5. 解析token,如果解析失败,返回错误结果(未登录)。
try {
JwtUtils.parseJWT(jwt);
} catch (Exception e) {
e.printStackTrace();
log.info("解析令牌失败, 返回错误结果");
response.setStatus(HttpStatus.SC_UNAUTHORIZED);
return;
}
//6. 放行。
log.info("令牌合法, 放行");
chain.doFilter(request , response);
}
}
3.3 Filter详解
过滤器的执行流程如下:
过滤器当中我们拦截到了请求之后,如果希望继续访问后面的web资源,就要执行放行操作,放行就是调用 FilterChain对象当中的doFilter()方法,在调用doFilter()这个方法之前所编写的代码属于放行之前的逻辑。
在放行后访问完 web 资源之后还会回到过滤器当中,回到过滤器之后如有需求还可以执行放行之后的逻辑,放行之后的逻辑我们写在doFilter()这行代码之后。如果不再访问其他的资源,就会直接执行放行后的逻辑。
拦截路径:
执行流程我们搞清楚之后,接下来再来介绍一下过滤器的拦截路径,Filter可以根据需求,配置不同的拦截资源路径,就是配置@WebFilter 注解中的属性值:
过滤器链:
在一个Web程序中,我们可以设置多个过滤器,这些过滤器就形成了一个过滤器链。过滤器链的执行顺序如图所示:
过滤器链上过滤器的执行顺序:注解配置的Filter,优先级是按照过滤器类名(字符串)的自然排序。 比如:
AbcFilter
DemoFilter
这两个过滤器来说,AbcFilter 会先执行,DemoFilter会后执行。
4. 拦截器interceptor
4.1 令牌校验interceptor
拦截器是Spring框架提供的,类似于过滤器。也是检验用户请求是否携带令牌,若令牌有效,则放行。
过滤器Filter只需要定义一个类实现Filter接口即可,而拦截器interceptor需要分两步:
定义拦截器
注册配置拦截器
第一步是定义拦截器,我们需要定义一个类去实现HandlerInterceptor接口,然后去重写preHandle方法(其中的逻辑和Filter中的逻辑是相同的):
@Slf4j
@Component
public class TokenInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 获取请求url。
String url = request.getRequestURL().toString();
//2. 判断请求url中是否包含login,如果包含,说明是登录操作,放行。
if(url.contains("login")){ //登录请求
log.info("登录请求 , 直接放行");
return true;
}
//3. 获取请求头中的令牌(token)。
String jwt = request.getHeader("token");
//4. 判断令牌是否存在,如果不存在,返回错误结果(未登录)。
if(!StringUtils.hasLength(jwt)){ //jwt为空
log.info("获取到jwt令牌为空, 返回错误结果");
response.setStatus(HttpStatus.SC_UNAUTHORIZED);
return false;
}
//5. 解析token,如果解析失败,返回错误结果(未登录)。
try {
JwtUtils.parseJWT(jwt);
} catch (Exception e) {
e.printStackTrace();
log.info("解析令牌失败, 返回错误结果");
response.setStatus(HttpStatus.SC_UNAUTHORIZED);
return false;
}
//6. 放行。
log.info("令牌合法, 放行");
return true;
}
}
第二步是对拦截器进行配置,定义一个配置类去实现WebMvcConfigurer接口:
// Configuration注解表示当前是一个配置类
@Configuration
public class WebConfig implements WebMvcConfigurer {
// 拦截器对象
// 自己定义的拦截器对象
@Autowired
private TokenInterceptor tokenInterceptor;
// 重写的WebMvcConfigurer中的方法
@Override
public void addInterceptors(InterceptorRegistry registry) {
//注册自定义拦截器对象
//将对象传入,后面的/**表示对所有路径的请求都进行拦截
registry.addInterceptor(tokenInterceptor).addPathPatterns("/**");
}
}
4.2 拦截路径
在注册配置拦截器的时候,我们要指定拦截器的拦截路径,通过addPathPatterns("要拦截路径")
方法,就可以指定要拦截哪些资源。
/**表示拦截所有资源而在配置拦截器时,不仅可以指定要拦截哪些资源,还可以指定不拦截哪些资源,只需要调用excludePathPatterns("不拦截路径")
方法,指定哪些资源不需要拦截。
@Configuration
public class WebConfig implements WebMvcConfigurer {
//拦截器对象
@Autowired
private DemoInterceptor demoInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//注册自定义拦截器对象
registry.addInterceptor(demoInterceptor)
.addPathPatterns("/**")//设置拦截器拦截的请求路径( /** 表示拦截所有请求)
.excludePathPatterns("/login");//设置不拦截的请求路径
}
}
拦截路径设置如下:
4.3 执行流程
当我们在项目中同时定义过滤器Filter和拦截器interceptor时,请求会先被过滤器进行拦截,然后进入spring环境当中被拦截器拦截。请求执行完之后才会执行postHandle及其以后的逻辑。
5. 拦截器与过滤器的区别
它们之间的区别主要是两点:
接口规范不同:过滤器需要实现Filter接口,而拦截器需要实现HandlerInterceptor接口。
拦截范围不同:过滤器Filter会拦截所有的资源,而Interceptor属于Spring MVC组件,基于AOP实现,仅拦截Controller请求,即Spring环境中的请求。