关于Session
服务器端的程序通常是基于HTTP协议的,而HTTP协议是一种“无状态”的通信协议,所以,它并不能保存来访的客户端的状态,只是简单的“请求、响应”的处理而已!也就是说,当同一个客户端多次访问同一个服务器端时,服务器并不能识别来访的客户端就是前序曾经来访过的客户端!
在开发实践中,是需要识别客户端身份的,所以,在编程技术上,可以使用Session机制来解决此问题。
Session的本质是存储在服务器端的内存中的一个K-V结构的数据,服务器端会为每一个来访的客户端的首次访问分配一个Session ID(本质上是一个UUID值,如果客户端的请求中没有携带Session ID,则服务器端生成并发回给客户端,如果客户端的请求中已经携带Session ID,则服务器端不会生成)此Session ID就是客户端访问服务器端的Session数据时使用的Key,所以,每个客户端在服务器上都有一份对应的Session数据(K-V中的Value)。
由于Session是存储在服务器端的内存中的数据,内存是非常重要的,且容量相对较小的存储设备,所以,必须设置一些清除Session的机制,默认的典型的清除机制就是“超时自动清除”,也就是说,某个客户端在最后一次提交请求后的多长时间内(常见的超时时间是15分钟或30分钟)没有再次提交请求,则服务器端会自动清除此客户端对应的Session数据。
由于Session是存储在服务器端的内存中的数据,所以,必然存在一些缺点:
- 不适合存储大量的数据
- 可以通过规范的开发,避免此问题
- 不便于应用到集群或分布式系统中
- 可以通过共享Session解决此问题
- 不可以长时间存储
- 无解
关于Token
**Token:**令牌,或票据
使用Token机制时,当客户端第1次向服务器提交请求时,或提交登录请求时,客户端直接发起请求,而服务器端会在验证登录成功后,生成此客户端对应的Token数据并响应到客户端,后续,客户端会携带此Token数据向服务器端发起请求,而服务器端会根据Token来识别客户端的身份。
在处理过程中,服务器端只需要检查Token、从Token中解析出客户端身份相关的数据即可,并不是必须在服务器端保存各Token数据,所以,Token可以设置较长时间的有效期,并不会长时间持续消耗服务器端的存储资源!所以,Token可以用于长时间表示用户的身份!
Token天生就适用于集群或分布式系统,因为各服务器端只需要具有相同的验证并解析Token的程序,就可以识别客户端的身份。
其实,Token的传输流程与Session ID基本上是相同的,最大的区别在于Session ID只是一个UUID数据,具有唯一性、随机性(不可预测性),但是,本身并不表示数据含义,而Token本身就是有数据含义的!
关于JWT
JWT:Json Web Token
JWT的官网:jwt.io/
每个JWT数据都包含3个组成部分:
- Header(头部信息):声明算法与Token的类型
- Payload(载荷):数据
- Signature:验证签名
关于JWT编程的工具包:jwt.io/libraries?l…
例如,在项目的pom.xml
中添加依赖项:
<!-- JJWT(Java JWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
接下来,可以在项目中尝试生成、解析JWT:
package cn.tedu.csmall.passport;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.junit.jupiter.api.Test;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JwtTests {
String secretKey = "kU4jrFA3iuI5jn25u743kfDs7a8pFEwS54hm";
@Test
public void generate() {
Date exp = new Date(System.currentTimeMillis() + 10 * 24 * 60 * 60 * 1000);
Map<String, Object> claims = new HashMap<>();
claims.put("id", 9527);
claims.put("username", "ZhangSan");
String jwt = Jwts.builder()
// Header(头部信息):声明算法与Token的类型
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
// Payload(载荷):数据,表现为Claims
.setClaims(claims)
.setExpiration(exp)
// Signature:验证签名
.signWith(SignatureAlgorithm.HS256, secretKey)
// 完成
.compact();
System.out.println(jwt);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjgwNzUwMjkwLCJ1c2VybmFtZSI6IlpoYW5nU2FuIn0.UV8rukk8kt9wMb0_n7xgxmjEG-ra2O32vL_7T572xXw
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjgxNjE1ODY4LCJ1c2VybmFtZSI6IlpoYW5nU2FuIn0.vzZFkGQ8mZu0dPiRlXOWma0rr9Cvz9Hn6PWov3b8wNQ
}
@Test
public void parse() {
try {
String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjgxNjE1ODY4LCJ1c2VybmFtZSI6IlpoYW5nU2FuIn0.vzZFkGQ8mZu0dPiRlXOWma0rr9Cvz9Hn6PWov3b8wNQ";
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
Long id = claims.get("id", Long.class);
String username = claims.get("username", String.class);
System.out.println("id = " + id);
System.out.println("username = " + username);
} catch (Throwable e) {
e.printStackTrace();
}
}
}
当尝试解析JWT时,如果JWT已经过期,会出现错误:
io.jsonwebtoken.ExpiredJwtException: JWT expired at 2023-04-06T11:04:50Z. Current time: 2023-04-06T11:29:13Z, a difference of 1463885 milliseconds. Allowed clock skew: 0 milliseconds.
当尝试解析JWT时,如果使用的secretKey与生成JWT时使用的不相同,会出现错误:
io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
当尝试解析JWT时,JWT数据如果是篡改后的数据,可能出现以上SignatureException
,也可能会出现以下错误:
io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"id":9527,"exp":1681615�͕ɹ������"}
**注意:**JWT数据是可能被篡改的,所以,一旦解析失败,应该不信任此JWT数据,例如向服务器直接响应错误,而不处理客户端的请求!并且,即使不知道生成JWT时使用的secretKey的情况下,仍有很多办法可以解析出JWT中的内容,所以,不要在JWT中存入敏感数据!
在项目中使用JWT识别用户的身份
核心流程概述
sequenceDiagram
participant Client as 客户端
participant Server as 服务器端
Client ->> + Server: 请求登录时,不携带JWT
activate Client
Server -->> - Client: 验证登录通过,响应JWT
deactivate Client
Client ->> Server: 携带JWT
note right of Server: 尝试解析JWT,将解析的结果创建为认证对象,并存入到SecurityContext
Server -->> Client: 响应结果
大致需要:
- 验证用户登录时,如果视为登录成功,服务器端应该生成此用户对应的JWT数据,并响应到客户端
- 不再需要将验证登录成功后的结果存入到
SecurityContext
中
- 不再需要将验证登录成功后的结果存入到
- 当用户尝试执行某些需要认证的操作时,用户应该携带JWT,服务器端应该尝试解析JWT,并且验证JWT的真伪、识别用户的身份,将用户的相关信息存入到
SecurityContext
中
验证登录成功后响应JWT
首先,在AdminServiceImpl
中验证登录时,如果通过验证,不再向SecurityContext
中存入认证信息:
然后,在IAdminService
接口中,将登录的方法的返回值类型改为String
,表示此方法在验证登录成功后,将返回JWT(String类型
)数据:
/**
* 验证管理员登录
* @param adminLoginDTO 管理员的登录信息,至少封装用户名与密码原文
* @return 验证登录通过后的JWT
*/
String login(AdminLoginDTO adminLoginDTO);
并且,也修改AdminServiceImpl
中的重写的方法,在验证登录成功后,生成并返回JWT数据:
@Override
public String login(AdminLoginDTO adminLoginDTO) {
log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
// 创建认证信息对象
Authentication authentication = new UsernamePasswordAuthenticationToken(
adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
// 调用认证管理器执行认证
Authentication authenticationResult
= authenticationManager.authenticate(authentication);
log.debug("验证登录成功,返回的Authentication为:{}", authenticationResult);
// 如果没有出现异常,则表示验证登录成功,需要将认证信息存入到Security上下文
// log.debug("即将向SecurityContext中存入Authentication");
// SecurityContext securityContext = SecurityContextHolder.getContext();
// securityContext.setAuthentication(authenticationResult);
// ========== 以下是新增的代码片段 ==========
// 处理验证登录成功后的结果中的当事人
Object principal = authenticationResult.getPrincipal();
log.debug("获取验证登录成功后的结果中的当事人:{}", principal);
AdminDetails adminDetails = (AdminDetails) principal;
// 需要写入到JWT中的数据
Map<String, Object> claims = new HashMap<>();
claims.put("id", adminDetails.getId());
claims.put("username", adminDetails.getUsername());
log.debug("即将生成JWT数据,包含的账号信息:{}", claims);
// 生成JWT,并返回JWT
String secretKey = "kU4jrFA3iuI5jn25u743kfDs7a8pFEwS54hm";
Date exp = new Date(System.currentTimeMillis() + 10 * 24 * 60 * 60 * 1000);
String jwt = Jwts.builder()
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
.setClaims(claims)
.setExpiration(exp)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
log.debug("生成了JWT数据,并将返回此JWT数据:{}", jwt);
return jwt;
}
然后,还要调整AdminController
中处理登录请求的方法,将Service中返回的JWT数据响应到客户端去:
@PostMapping("/login")
public JsonResult login(AdminLoginDTO adminLoginDTO) {
log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginDTO);
String jwt = adminService.login(adminLoginDTO);
return JsonResult.ok(jwt);
}
通过API文档的调试功能测试登录,当登录成功后,响应的结果例如:
{
"state": 20000,
"data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiZXhwIjoxNjgxNjI2ODQwLCJ1c2VybmFtZSI6InJvb3QifQ.9atdNiIRsGb6Ll4g58rLOBi5BoGQb1MoHFNsraCjwTo"
}
以上JWT数据也可以放在测试方法中尝试解析。
解析客户端携带的JWT
客户端提交若干种不同的请求时,可能都需要携带JWT,在服务器端,处理若干种不同的请求之前也需要尝试接收并解析JWT,则应该使用**过滤器(Filter)**组件进行处理!
提示:过滤器是Java服务器端的组件中,最早接收到请求的组件,它执行在其它任何组件之前!在同一个项目中,允许存在若干个过滤器,形成过滤器链(Filter Chian),任何一个请求,必须被所有过滤器“放行”才可以被后续的组件(例如Controller等)进行处理!
在项目的根包下创建filter.JwtAuthorizationFilter
类,继承自OncePerRequestFilter
抽象类(将间接的实现Filter
接口),并在类上添加组件注解:
package cn.tedu.csmall.passport.filter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
}
}
在处理过程中,首先,需要尝试接收客户端携带的JWT:
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 根据业内惯用的做法,客户端应该将JWT保存在请求头(Request Header)中的名为Authorization的属性名
String jwt = request.getHeader("Authorization");
log.debug("尝试接收客户端携带的JWT数据,JWT:{}", jwt);
}
要使得以上过滤器生效,还需要在Spring Security的配置类中,将其添加在Spring Security的过滤器链中!则先在配置类中自动装配以上过滤器:
@Slf4j
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
// 新增代码
@Autowired
private JwtAuthorizationFilter jwtAuthorizationFilter;
// 暂不关心其它代码
}
然后,在configurer(HttpSecurity http)
方法中,添加此过滤器:
// 将自定义的JWT过滤器添加在Spring Security的UsernamePasswordAuthenticationFilter之前
http.addFilterBefore(jwtAuthorizationFilter,
UsernamePasswordAuthenticationFilter.class);
在API文档中,通过“全局参数设置”中的“添加参数”,可以配置每个请求都将携带JWT数据:
此时,进行调试时,所有请求的反馈结果都是一片空白,并且,在服务器端的控制台中可以看到输出了客户端提交请求时携带的JWT数据!
然后,尝试解析JWT,并将解析得到的数据创建为Authentication
存入到SecurityContext
中:
package cn.tedu.csmall.passport.filter;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
/**
* <p>处理JWT的过滤器类</p>
*
* <p>此过滤器类的主要职责:</p>
* <ul>
* <li>尝试接收客户端携带的JWT</li>
* <li>尝试解析接收到的JWT</li>
* <li>将解析成功后得到的结果创建为Authentication并存入到SecurityContext中</li>
* </ul>
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
public static final int JWT_MIN_LENGTH = 113;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 根据业内惯用的做法,客户端应该将JWT保存在请求头(Request Header)中的名为Authorization的属性名
String jwt = request.getHeader("Authorization");
log.debug("尝试接收客户端携带的JWT数据,JWT:{}", jwt);
// 判断客户端是否携带了基本有效的JWT
if (!StringUtils.hasText(jwt) || jwt.length() < JWT_MIN_LENGTH) {
// 客户端没有携带有铲的JWT,则“放行”,交由后续的组件继续处理
filterChain.doFilter(request, response);
// 【重要】终止当前方法的执行,不执行接下来的代码
return;
}
// TODO:1-声明secretKey不合理,应该集中管理
// TODO:2-解析JWT时可能出现异常,需要处理
// 客户端携带了基本有效的JWT,则尝试解析JWT
String secretKey = "kU4jrFA3iuI5jn25u743kfDs7a8pFEwS54hm";
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
Long id = claims.get("id", Long.class);
String username = claims.get("username", String.class);
log.debug("从JWT中解析得到的管理员ID:{}", id);
log.debug("从JWT中解析得到的管理员用户名:{}", username);
// TODO:3-使用用户名的字符串作为“当事人”并不是最优解
// TODO:4-需要调整使用真实的权限
// 基于解析JWT的结果创建Authentication对象
Object principal = username; // 当事人:暂时使用用户名
Object credentials = null; // 凭证:应该为null
Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("暂时放一个山寨的权限"));
Authentication authentication = new UsernamePasswordAuthenticationToken(
principal, credentials, authorities);
// 将Authentication存入到SecurityContext中
log.debug("向SecurityContext中存入Authentication:{}", authentication);
SecurityContext securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(authentication);
// 过滤器链继续执行,相当于“放行”
filterChain.doFilter(request, response);
}
}
目前的测试结果表现为:
- 携带有效的JWT,可以访问任何请求(需要删除各处理请求的方法上的获取当事人、检查权限的代码)
- 成功的处理了某个请求后,在接下来的一段时间里,不携带JWT也可以请求成功
- 如果重启服务器后,第1次发起的请求就没有携带JWT,会响应403
关于SecurityContext
中的认证信息
因为Spring Security是根据SecurityContext
中的Authentication
来识别用户的身份的,而SecurityContext
本身是基于Session机制的,所以,当携带有效的JWT成功访问后,以上过滤器就已经将Authentication
存入到了SecurityContext
中,也就存在于Session中了,在接下来的一段时间内(在Session的有效期内),即使不携带JWT也可以成功访问!
以上表现并不能算是一种“错误”,不一定是必须解决的问题!
如果希望实现“携带JWT就可以访问,不携带JWT就不可以访问”那些需要登录才允许访问的资源,可以:
-
在JWT过滤器刚刚开始执行时,就直接清空
SecurityContext
,即:// 清空SecurityContext,避免【此前携带JWT成功访问后,在接下来的一段时间内不携带JWT也能访问】 SecurityContextHolder.clearContext();
-
【推荐】不使用Session,在Spring Security的配置类中的
configurer(HttpSecurity http)
方法中,将Session策略设置为“从不使用”即可:// 将Session策略设置为“从不使用”:STATELESS=无状态,即从不使用Session,NEVER=从不主动创建Session http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
关于当事人
通常,当事人信息中应该包含用户的ID和用户名,而Authentication
中的Principal
的类型是Object
,所以,你可以使用任何类型的数据作为当事人,并且,在需要获取当事人信息时,添加@AuthenticationPrincipal
注解的参数也是你自行决定的当事人类型。
在项目的根包下创建security.LoginPrincipal
类型,用于封装当事人信息,例如:
@Data
public class LoginPrincipal implements Serializable {
/**
* 当事人ID
*/
private Long id;
/**
* 当事人用户名
*/
private String username;
}
在JwtAuthorizationFilter
中,基于解析JWT的结果创建当事人对象:
并且,将此对象用为Authentication
的当事人:
后续,当需要获取当事人信息时,直接注入即可,例如在AdminController
中:
关于权限
当数据需要输出且后续还需要读取时,可能会涉及序列化的问题,因为,当数据离开内存,就不再具有“数据类型”的含义了,包括将数据直接转换成字符串(例如将某数据存入到JWT中),则后续希望将字符串还原成原本的类型时,可能是无法做到的!
业内用于解决序列化和反序列化问题的常见手段就是使用JSON,先将对象转换成JSON格式的字符串,后续,需要还原时,再将JSON格式的字符串反序列化为对象。
fastjson是一款可以实现对象与JSON的相互转换的工具库:
<!-- fastjson:实现对象与JSON的相互转换 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.75</version>
</dependency>
当需要在JWT中存入管理员的权限列表时:
在解析JWT时,得到的也会是一个JSON格式的字符串,可以将其还原成管理员的权限列表:
完成后,可以重启项目,使用root
管理员登录,可以执行所有操作,使用其他管理员,部分操作是不允许的!(注意:更换登录的管理员后,在调试发送请求之前,需要更换为对应的JWT数据)
处理解析JWT时的异常
由于解析JWT是在过滤器中执行的,而过滤器是整个服务器端中最早接收到任何请求的组件,此时,其它组件尚未开始处理当前请求,所以,不可以使用“全局异常处理器”来处理解析JWT时的异常(全局异常处理器只能处理控制器抛出的异常),则只能使用try...catch
语法来处理异常!
首先,在ServiceCode
中补充新的业务状态码:
/**
* 错误:JWT已过期
*/
ERR_JWT_EXPIRED(60000),
/**
* 错误:验证签名失败
*/
ERR_JWT_SIGNATURE(60100),
/**
* 错误:JWT格式错误
*/
ERR_JWT_MALFORMED(60200),
然后,调整JwtAuthorizationFilter
中解析JWT的代码片段:
// 客户端携带了基本有效的JWT,则尝试解析JWT
Claims claims = null;
response.setContentType("application/json; charset=utf-8;");
try {
claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
} catch (ExpiredJwtException e) {
String message = "您的登录信息已过期,请重新登录!";
log.warn(message);
JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_EXPIRED, message);
String jsonResultString = JSON.toJSONString(jsonResult);
PrintWriter writer = response.getWriter();
writer.println(jsonResultString);
writer.close();
return;
} catch (SignatureException e) {
String message = "非法访问!";
log.warn(message);
JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_SIGNATURE, message);
String jsonResultString = JSON.toJSONString(jsonResult);
PrintWriter writer = response.getWriter();
writer.println(jsonResultString);
writer.close();
return;
} catch (MalformedJwtException e) {
String message = "非法访问!";
log.warn(message);
JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_MALFORMED, message);
String jsonResultString = JSON.toJSONString(jsonResult);
PrintWriter writer = response.getWriter();
writer.println(jsonResultString);
writer.close();
return;
} catch (Throwable e) {
String message = "服务器忙,请稍后再次尝试!(开发过程中,如果看到此提示,请检查控制台的信息,并在JWT过滤器补充处理此异常)";
log.warn(message);
JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_UNKNOWN, message);
String jsonResultString = JSON.toJSONString(jsonResult);
PrintWriter writer = response.getWriter();
writer.println(jsonResultString);
writer.close();
}
处理未登录的错误
当客户端提交请求时没有携带JWT,请求的目标却是需要通过认证的资源,则服务器端默认会响应403
错误!
此问题需要在Spring Security的配置类中的configurer(HttpSecurity http)
方法中添加配置来解决:
// 处理“当客户端提交请求时没有携带JWT,请求的目标却是需要通过认证的资源”的问题
http.exceptionHandling().authenticationEntryPoint(new AuthenticationEntryPoint() {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
response.setContentType("application/json; charset=utf-8;");
String message = "未检测到登录信息,请登录!(在开发阶段,看到此提示时,请检查客户端是否携带了有效的JWT数据)";
log.warn(message);
JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED, message);
String jsonResultString = JSON.toJSONString(jsonResult);
PrintWriter writer = response.getWriter();
writer.println(jsonResultString);
writer.close();
}
});
周末作业
内容1:
在csmall-passport
中,完成以下功能:
- 添加管理员
- 业务规则:用户名必须唯一,手机号码必须唯一,电子邮箱必须唯一
- 处理业务时,可以在Service中自动装配
PasswordEncoder
,并将原密码加密后再插入到数据库中 - 暂不考虑新增的管理员的角色或权限问题
- 删除管理员
- 业务规则:管理员数据必须存在,如果ID=1,不允许删除(可抛出NOT_FOUND)
- 启用管理员:
- 业务规则:管理员数据必须存在,如果ID=1,不允许删除(可抛出NOT_FOUND)
- 禁用管理员:
- 业务规则:管理员数据必须存在,如果ID=1,不允许删除(可抛出NOT_FOUND)
- 根据ID查询管理员详情
- 业务规则:管理员数据必须存在,如果ID=1,不允许删除(可抛出NOT_FOUND)
- 查询管理员列表
- 业务规则:从Mapper的查询结果中去除ID=1的管理员数据
以上功能,需要完成Mapper、Service、Controller等各层及相关代码。
内容2:
在csmall-web-client
中,参考页面设计,完成:
- 添加管理员
- 显示管理员列表
不要求实现对应的数据,只需要完成页面设计即可。
客户端携带JWT提交请求
在客户端,当用户提交登录请求,且服务器端验证登录通过后,会向客户端响应“登录成功”的业务状态码及JWT数据,则客户端需要将此JWT数据保存下来,以便于后续在其它页面中可以取出,并用于提交后续的请求。
在客户端保存JWT可以使用localStorage
,这是客户端浏览器提供的存储机制,是一种K-V结构的数据,所以,可以在登录成功后,自定义Key值,将JWT数据保存下来,后续,仍根据这个Key取出数据。
例如,在登录成功后:
后续,在其它页面中提交请求时,需要从localStorage
中取出数据,并作为请求头的参数,再提交请求。
使用axios
时,需要调用create()
来自定义请求头,此方法将返回一个新的axios
对象,例如:
**注意:**当自定义请求头后,向服务器端发起请求,默认情况下将是失败的,需要在服务器端Security配置类中调用http.cors()
才可以正常访问。
关于复杂请求的跨域访问
当客户端自定义请求头中的特定属性(例如Authorization
)并提交跨域访问时,此请求会被视为“复杂请求”!
当浏览器尝试发出复杂请求时,浏览器会自动先发出一个同URL的OPTIONS
的请求,执行预检(PreFlight
),如果此请求没有被服务器正确的响应(响应200
即为正确),浏览器将视为“预检失败”,会提示跨域访问错误!
要解决此问题,可以在Security配置类中,将所有OPTIONS
请求直接放行,例如:
或者,调用HttpSecurity
对象的cors()
方法,此方法会配置Spring Security框架自带的CorsFilter
,此过滤器也会对OPTIONS
请求放行,也能解决此问题,例如:
// 此方法会配置Spring Security框架自带的CorsFilter,此过滤器会对OPTIONS请求放行
http.cors();
注意:即使使用Spring Security的配置类对复杂请求的预检进行了“放行”,Spring MVC配置类中关于允许跨域访问的配置也是必须的!
**提示:**对于复杂请求的预检(提交同一个URL的OPTIONS
请求)是客户端的浏览器的自主行为,并不是服务器端的要求,并且,对于同一个URL,如果预检通过,浏览器会缓存“预检通过”这个结果,并且,在后续的访问中,不再执行预检。
关于Spring框架
Spring框架的作用
Spring框架的基础依赖项是spring-context
。
Spring框架主要解决了创建对象与管理对象的相关问题。
Spring框架的核心是IoC和AOP。
由于Spring框架会创建并管理许多对象,在使用过程中,也可以通过Spring框架来获取这些对象,所以,Spring框架也可以称之为“Spring容器”。
由Spring创建并管理的每个对象,都可以称之为一个Spring Bean。
Spring框架创建对象
创建对象的方式--组件扫描
在配置类上添加@ComponentScan
注解,表示开启组件扫描,示例代码如下:
@Configuration
@ComponentScan
public class SpringConfiguration {}
当开启组件扫描后,Spring框架会自动扫描当前配置类所在的包,查找此包及其子孙包下的组件类,如果找到组件类,就会自动创建此类的对象!
在Spring Boot项目中,启动类都添加了@SpringBootApplication
注解,此注解中就包含了@ComponentScan
,并且,还包含@SpringBootConfiguration
,而@SpringBootConfiguration
中包含@Configuration
,其关系大致是:
@SpringBootApplication
-- @ComponentScan
-- @SpringBootConfiguration
-- -- @Configuration
所以,在Spring Boot项目,启动类本身就是一个配置类,并且,开启了组件扫描。
仅当添加了@Component
注解的类才会被视为组件类,例如:
@Component
public class ComponentDemo {}
在使用@ComponentScan
时,也可以指定扫描的(若干个)包,例如:
@Configuration
@ComponentScan({
"cn.tedu.csmall.product.config",
"cn.tedu.csmall.product.controller",
"cn.tedu.csmall.product.service.impl"
})
public class SpringConfiguration {}
以上做法可以使得组件扫描的范围更加精准,避免扫描到其它不需要创建对象的包,以节约组件扫描的耗时,但是,由于组件扫描的效率非常高,节约的耗时并不明显,并且,这些消耗是发生在启动项目的过程中的,启动项目的耗时一般都不必纠结。
在Spring框架中,@Component
注解的衍生注解还有:
@Controller
@Service
@Repository
@Configuration
例如:
在Spring MVC框架中,新增了更多的组件注解,例如:
@RestController
@ControllerAdvice
@RestControllerAdvice
创建对象的方式--@Bean方法
在配置类中,可以自定义方法返回你希望Spring创建并管理的对象,并在方法上添加@Bean
注解,例如:
@Configuration
public class SpringConfiguration {
@Bean
public IAdminService adminService() {
return new AdminServiceImpl();
}
}
以上方法将由Spring框架自动调用,并获取返回的结果,接下来,Spring框架会管理所返回的结果。
创建对象的方式的选取
在开发实践中,对于2种创建对象的方式的选取:
- 如果是自定义的类,优先采取组件扫描的做法,因为更加简单、直接
- 对于非自定义的类,只能采取
@Bean
注解的做法,因为你无法在非自定义的类上添加组件注解,就不可以使用组件扫描的做法
关于Spring Bean的名称
Spring Bean的名称为:
- 如果使用组件扫描创建的Spring Bean,如果类名的第1个字母是大写,且第2个字母是小写的,则Spring Bean的名称默认是将类名的首字母改为小写,例如
AdminController
类的Spring Bean默认的名称是adminController
,如果不满足以上类名的大小写条件,则Spring Bean的名称默认就是类名,例如AAtest
类的Spring Bean默认的名称就是AAtest
- 如果使用
@Bean
方法创建的Spring Bean,默认的名称就是方法名称 - 也可以自定义Spring Bean的名称,如果使用组件扫描创建的Spring Bean,可以通过
@Component
或其衍生注解的value
属性来指定名称,如果使用@Bean
方法创建的Spring Bean,可以通过@Bean
注解的value
属性来指定名称
Spring管理的对象的作用域
Spring管理的对象默认是单例的,如果你希望某个被Spring管理的对象不是单例的,可以配置@Scope("prototype")
注解,则每次尝试使用此类的对象时才会创建对象,并且,方法运行结束时就会销毁,相当于每次创建出来的只是一个局部变量。
- 如果使用组件扫描的做法创建对象,则在组件类上使用以上注解
- 如果使用
@Bean
注解的做法创建对象,则在方法上使用以上注解
在Spring管理单例的对象时,默认都是“预加载”的,相当于单例模式中的“饿汉式”,在发生组件扫描时就创建了所有预加载的类的对象,如果你希望某个被Spring管理的对象是“懒加载”的,相当于单例模式中的“懒汉式”,可以配置@Lazy
注解,则会在第1次尝试使用此对象时创建对象。
- 如果使用组件扫描的做法创建对象,则在组件类上使用以上注解
- 如果使用
@Bean
注解的做法创建对象,则在方法上使用以上注解
Spring管理的对象的生命周期
学习生命周期的意义在于:了解有哪些方法会在哪种特定的时间被执行。
Spring管理的对象涉及的生命周期方法有2个,分别是:
- 初始化方法:会在创建对象之后自动执行
- 销毁方法:会在销毁对象之前自动执行
如果使用组件扫描的做法创建对象,可以在此类中自定义方法,表示初始化方法或销毁方法,关于方法的声明:
- 应该是
public
权限 - 必须是
void
返回值类型- 销毁方法可以使用
boolean
,但是,并不多见
- 销毁方法可以使用
- 方法名称可以自定义
- 参数列表应该为空
需要在初始化方法上添加@PostConstruct
注解,在销毁方法上添加@PreDestroy
注解。
例如:
@RestController
public class AdminController {
@PostConstruct
public void init() {
log.debug("自动执行了AdminController的生命周期方法中的初始化方法");
}
@PreDestroy
public void destroy() {
log.debug("自动执行了AdminController的生命周期方法中的销毁方法");
}
}
如果使用@Bean
注解的做法创建对象,则需要配置@Bean
注解的initMethod
参数和destroyMethod
参数,取值为生命周期方法的方法名称,例如:
@Configuration
public class SpringConfiguration {
@Bean(initMethod = "init", destoryMethod = "destroy")
public IAdminService adminService() {
return new AdminServiceImpl();
}
}
**注意:**初始化方法会在创建对象,且完成自动装配后,再自动执行!
阿里巴巴Java开发手册:
【强制】构造方法里面禁止加入任何业务逻辑,如果有初始化逻辑,请放在 init 方法中。
自动装配机制
自动装配机制的特点
自动装配:如果被Spring管理的类对象的属性需要值,或者,如果被Spring自动调用的方法的参数需要值,Spring框架可以自动从容器中找到合适的值,并为此属性或参数注入值。
依赖注入的实现
**依赖注入:**为依赖项注入值。例如,在AdminController
中使用到了IAdminService
类型的属性,则IAdminService
就是AdminController
的依赖项,通过Spring框架使得AdminController
中的IAdminService
属性有值的做法,就可以称之为依赖注入。
在Spring框架中,依赖注入有3种实现手段:
-
字段注入:在属性上添加自动装配的注解,例如:
@RestController public class AdminController { @Autowired private IAdminService adminService; }
-
Setter注入:为属性添加Setter方法,并在此方法上添加
@Autowired
注解,例如:@RestController public class AdminController { private IAdminService adminService; @Autowired public void setAdminService(IAdminService adminService) { this.adminService = adminService; } }
-
构造方法注入:通过带参数的构造方法为属性注入值
@RestController public class AdminController { private IAdminService adminService; public AdminController(IAdminService adminService) { this.adminService = adminService; } }
学术观点认为构造方法注入是最安全的做法,而字段注入是最不合适的做法!
在常规开发中,字段注入是最便捷的做法!
关于Spring调用构造方法
Spring框架自动调用构造方法的规则是:
- 如果类中仅有1个构造方法,无论这个构造方法是否有参数,Spring都会自动调用
- 如果类中有多个构造方法,默认情况下,会自动调用无参数构造方法(如果存在的话),如果你希望Spring自动调用某个构造方法,需要在构造方法上添加
@Autowired
注解
关于“合适的值”
通常,当自动装配时,如果Spring Bean的类型与被装配的属性或参数的类型是匹配的,就可以视为“合适的值”。
如果存在多个Spring Bean与被装配的属性的类型相同,如果存在某个Spring Bean的名称与被装配的属性名称相同,则此Spring Bean是“合适的值”。
关于名称对应的问题,可以是某个Spring Bean的名称保持与属性名相同,也可以是属性名保持与某个Spring Bean的名称相同,如果双方的名称都不可协调,可以在属性上补充添加@Qualifier
注解来指定某个Spring Bean的名称。
另外,@Qualifier
也可以添加在方法的参数上。
关于@Autowired
的装配机制
Spring框架在处理@Autowired
的自动装配时,会先查找Spring容器中符合类型的Spring Bean的数量:
- 0个:检查
@Autowired
注解的required
参数的值true
(默认):无法装配,在加载Spring时就会报错,通常会在启动项目时就加载Spring,则启动项目时就会报错false
:放弃装配,在加载Spring时不会报错,但尝试装配的属性值为null
(除非你通过其它方式为其赋值),在后续的使用过程中,可能出现NPE
- 1个:直接装配,且成功
- 多个:如果在Spring Bean中存在某个名称“合适的值”,如果存在,则装配成功,如果不存在(每个Spring Bean的名称与需要装配的属性或参数的名称都不匹配),则无法装配,在加载Spring时就会报错
关于@Resource
注解
此注解是javax.annotation
包中的注解,也可以实现自动装配(你可以不使用@Autowired
而改为使用@Resource
),它是先根据名称查找Spring Bean,再检查类型是否匹配的。
@Resource
注解也可以添加在属性上、Setter方法上,但不可以添加在构造方法上。
通过@Resource
注解的name
属性可以指定装配的Spring Bean的名称。
附:单例模式
饿汉式代码示例:
public class Singleton {
private static Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
懒汉式代码示例:
public class Singleton {
private static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 判断是否有必要用“锁”
synchronized ("haha") {
if (instance == null) { // 判断是否有必要创建对象
instance = new Singleton();
}
}
}
return instance;
}
}
关于Spring框架(续)
关于IoC与DI
IoC:Inversion of Control,控制反转,在没有使用Spring这类框架之前,对象的控制权是完全在开发者手中的,开发者可以自行决定何时、何地、通过哪种方式来创建对象、为属性赋值、设计此类对象的单例状态、在指定的时间调用特定的方法等等,所以,开发者对此类的对象有完全的控制权,当使用了Spring框架后,开发者可以不必再处理这些细节,也可以理解为将控制权交给了Spring框架,这就是一种“控制反转”的表现。
DI:Dependency Injection,依赖注入
Spring框架通过DI完善了IoC,IoC是框架希望实现的目标,而DI是实现此目标的过程中必不可少的手段。
Spring AOP
AOP:面向切面的编程
AOP是AspectJ的技术,并不是Spring框架特有的技术,而Spring很好的支持了AspectJ,结合出来的框架就是Spring AOP。
AOP主要解决了横切关注的问题,即:若干个不同的方法,都需要去关注并解决的问题(都需要执行类似的一段代码)!
在项目中的具体表现可能是:事务管理、安全管理、日志等。
假设存在某个业务需求:在任何Service方法中,都需要统计各Service方法的执行耗时。
在Spring Boot项目中,使用Spring AOP需要添加依赖项:
<!-- Spring Boot支持Spring AOP的依赖项,用于实现AOP编程 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
然后,在项目的根包下创建aop.TimerAspect
类,在类上添加@Aspect
注解和@Component
注解,然后,在类中实现通过AOP统计所有Service方法的执行耗时:
@Aspect
@Component
public class TimerAspect {
@Around("execution(* cn.tedu.csmall.product.service.*.*(..))")
public Object xxx(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
Object result = pjp.proceed();
long end = System.currentTimeMillis();
System.out.println("执行耗时:" + (end - start));
return result;
}
}
代码解析如下:
package cn.tedu.csmall.product.aop;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
/**
* 统计Service方法执行耗时的切面类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Slf4j
@Aspect
@Component
public class TimerAspect {
// 【AOP的核心概念】
// -- 连接点(JoinPoint):数据处理过程中的某个节点,可能是抛出了某个方法,也可能是抛出了某个异常
// -- 切入点(PointCut):选择1个或若干个连接点的表达式
// -------------------------------------------------
// 【通知(Advice)注解】
// -- @Around:环绕,重要
// -- @Before:在……之前
// -- @After:在……之后,无论方法成功返回或抛出异常
// -- @AfterReturning:在方法成功返回(执行到了return,或自然运行结束)之后
// -- @AfterThrowing:在方法抛出异常之后
// 以上各通知(Advice)的执行类似于:
// @Around--开始
// try {
// @Before
// 执行连接点方法
// @AfterReturning
// } catch (Throwable e) {
// @AfterThrowing
// } finally {
// @After
// }
// @Around--结束
// -------------------------------------------------
// 【切面方法 -- 基于使用@Around】
// -- 访问权限:应该是公有的访问权限
// -- 返回值类型:重要:返回值类型使用Object,并且,在方法内部,需要获取参数对象调用proceed()方法的返回结果,并作为切面方法的返回值,否则,相当于连接点方法没有返回值
// -- 方法名称:自定义
// -- 参数列表:固定为ProceedingJoinPoint类型的1个参数
// -- 异常:重要:应该抛出调用proceed()时的异常,除非,你捕获后自行抛出了另一个异常,不允许仅捕获却不抛出
// -------------------------------------------------
// 【切入点表达式】
// 配置在@Around或相关注解的参数中的execution表达式
// 切入点表达式在execution内部的基本格式是:[修饰符] 返回值类型 [包名.]类名.方法名(参数列表)
// 在表达式中,可以使用通配符:
// -- 星号(*):匹配任何内容,只匹配1次
// -- 连接2个小数点(..):匹配任何内容,可以匹配n次(n的最小值为0),只能用于包名和参数列表
// 注意:如果需要指定类型(例如返回值类型、参数列表),除非是基本数据类型或java.lang包下的类,否则,必须写全限定名
// ↓ 任意返回值类型
// ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 指定的根包名
// ↓ 任意接口名或类名
// ↓ 任意方法名
// ↓↓ 任意n个参数
@Around("execution(* cn.tedu.csmall..product.service.*.*(..))")
public Object timer(ProceedingJoinPoint pjp) throws Throwable {
log.debug("开始执行切面方法,即将处理连接点……");
String typeName = pjp.getTarget().getClass().getName(); // 类型名称
String methodName = pjp.getSignature().getName();// 方法名称
Object[] args = pjp.getArgs(); // 方法的参数列表
log.debug("类型:{}", typeName);
log.debug("方法:{}", methodName);
log.debug("参数列表:{}", args);
long start = System.currentTimeMillis();
// 调用参数对象的proceed()方法,相当于执行了连接点方法
// 注意事项-1:
// 调用proceed()方法时,必须获取返回值,相当于获取了连接点方法的返回结果
// 获取到的返回结果必须作为切面方法的返回结果,否则,相当于拦截下来连接点方法的返回结果
// 注意事项-2:
// 调用的proceed()方法被声明为抛出Throwable,调用此方法时,必须抛出异常
// 不可以使用try...catch捕获并处理,如果获取并处理,则异常相当于不存在的,对于原本的调用者(Service原本的调用者是Controller),将不会知道曾经出现过此异常
// 当然,你可以选择先使用try...catch捕获到异常,然后,在catch内部再抛出异常
Object result = pjp.proceed();
long end = System.currentTimeMillis();
log.debug("处理连接点结束,执行耗时:{}ms", end - start);
return result;
}
}
关于Spring MVC框架
Spring MVC框架的作用
MVC:Model + View + Controller
Spring MVC框架主要解决了 V 和 C 相关的问题,并且,在目前主流的前后端分离的开发模式下,也不再需要服务器端处理 V 相关的问题,所以,在项目中使用Spring MVC框架更多的是用于解决 C 相关的问题。
Spring MVC框架具体的解决了:接收请求、响应结果、统一处理异常。
Spring MVC框架的基础依赖项是:spring-webmvc
Spring MVC框架的知识点
参考思维导图
关于MyBatis框架
MyBatis框架的作用
MyBatis框架的主要作用是:简化持久层编程。
持久层:处理数据持久化的层。通常,在开发领域,讨论数据时,默认指的是内存中的数据,而内存无法永久保存数据(一旦断电,RAM中的数据将全部丢失),为了使得数据能永久保存,需要将数据存储到永久存储数据的存储介质中,例如:硬盘、U盘、光盘等,当数据存储到这些存储介质中,是以文件的形式存在的,所以,只要是将内存中处理的数据存储到硬盘这类设备上的文件中,就可以称之为“数据持久化”的操作!至于文件格式,可以是文本文件,或XML文件,或数据库系统(本质上是由数据库软件管理的一系列文件),由于只有数据库是最便于实现增删改查所有操作的,所以,没有明确的说明时,“数据持久化”都是指将数据存储到数据库中,而持久层默认都表示处理数据库编程的层。
MyBatis中#{}
与${}
格式的占位符
在使用MyBatis时,配置的SQL语句中的参数,可以使用#{}
或${}
格式的占位符来表示。
例如:
<select id="getStandardById" resultMap="StandardResultMap">
SELECT
<include refid="StandardQueryFields"/>
FROM
ams_admin
WHERE
id=#{id}
</select>
如果把以上代码中的#{id}
换成${id}
,执行效果完全相同。
如果有以下查询:
<select id="countByUsername" resultType="int">
SELECT count(*) FROM ams_admin WHERE username=#{username}
</select>
把以上#{username}
换成${username}
,执行时会报错:
Caused by: java.sql.SQLSyntaxErrorException: Unknown column 'wangkejing' in 'where clause'
以上错误信息中的'wangkejing'
是执行测试时传入的参数值。
观察更多的报错信息,可以看到,出错时执行的SQL语句其实是:
SELECT count(*) FROM ams_admin WHERE username=wangkejing
如果将测试时传入的参数由String username = "wangkejing";
改为String username = "'wangkejing'"
,则执行时不会报错!
或者,在配置SQL语句时,使用一对单引号将${username}
框住,例如:
<select id="countByUsername" resultType="int">
SELECT count(*) FROM ams_admin WHERE username='${username}'
</select>
需要注意:在SQL语句中,某些部分是可以直接被MySQL直接识别出来的,例如SELECT
等关键字是有特殊意义的,例如在FROM
关键字的右侧的名称必然是表名(或另一个查询结果),例如*
等特殊符号也是有特殊意义的,例如123
等直接常量是一些值,除此以外,所以直接出现在SQL语句中的部分,都会被作为字段名!
例如:
SELECT count(*) FROM ams_admin WHERE username=wangkejing
^^^^^^ SELECT关键字
^^^^^^^^ 内置函数
^^^^ FROM关键字
^^^^^^^^ FROM的右侧是表名
^^^^^ WHERE关键字
^ 等于符号
所以,除去固定能直接识别的以外,以上SQL语句中的username
和wangkejing
都被视为“字段名”!
如果需要表示某个部分只是字段的值,需要使用一对单引号将它框住!
**注意:**在SQL语句中,添加一对单引号并不是表示“它是一个字符值”,而是“它是一个值”,只不过,数值、布尔值等直接值不可能是字段名,所以,默认可以被识别为值,这些类型就可以不添加单引号。
在使用#{}
格式的占位符时,并不需要使用单引号来表示传入的是一个值!因为#{}
格式的占位符会被预编译处理!
其实,当某个SQL语句需要被执行时,对于MySQL而言,需要先对此SQL语句进行词法分析、语义分析,然后执行编译,最终执行!
在预编译的做法中,会使用问号?
表示SQL语句中的值,然后就开始词法分析、语义分析、编译,当完成后,再将值代入到编译结果中执行!由于在执行之前经过了语义分析,所以,问号部分必然是一个值,而不会被误解字段名,所以,不需要使用单引号框住!
如果使用的是${}
格式的占位符,是先将占位符的值拼接到SQL语句中,再执行词法分析、语义分析、编译的过程,所以,如果传入的值不是数值或布尔值,只要没有添加单引号,就会被误解为字段名!
综合来看,使用#{}
格式的占位符,由于是预编译的,所以,不必关心值的类型(不需要考虑是否添加单引号的问题),并且,没有SQL注入的风险,但是,#{}
只能表示某个值!
而${}
格式的占位符不是预编译的,所以,需要关心值的类型,对于非数值、非布尔值的值,需要使用单引号框住,并且,存在SQL注入的风险,但是,${}
可以表示SQL语句中的任何片段,只需要保证原SQL与此参数拼接的结果是完整、有效的SQL即可!
MyBatis的缓存机制
MyBatis有2种缓存机制,分别称之为一级缓存和二级缓存,当应用了缓存机制后,在执行查询时,查询结果并不会在使用过后直接清除,而是会暂时保存下来,以便于下次查询时直接返回此前的查询结果,以提高查询效率!
MyBatis的一级缓存也称之为会话缓存,是基于SqlSession
的,默认是开启的,且无法人为关闭,并且,如果后续的查询想要使用前序的查询结果,必须满足:多次的查询是同一个SqlSession
的、是同一个Mapper的、执行同样的查询、传入的参数是相同的!
例如:
@Autowired
SqlSessionFactory sqlSessionFactory;
@Test
void cacheL1() {
SqlSession sqlSession = sqlSessionFactory.openSession();
AdminMapper mapper = sqlSession.getMapper(AdminMapper.class);
System.out.println("准备执行第1次查询:id=1");
AdminStandardVO queryObject1 = mapper.getStandardById(1L);
System.out.println("第1次查询结束:" + queryObject1);
System.out.println();
System.out.println("准备执行第2次查询:id=1");
AdminStandardVO queryObject2 = mapper.getStandardById(1L);
System.out.println("第2次查询结束:" + queryObject2);
System.out.println();
System.out.println("准备执行第3次查询:id=1");
AdminStandardVO queryObject3 = mapper.getStandardById(1L);
System.out.println("第3次查询结束:" + queryObject3);
System.out.println();
System.out.println("准备执行第4次查询:id=2");
AdminStandardVO queryObject4 = mapper.getStandardById(2L);
System.out.println("第4次查询结束:" + queryObject4);
System.out.println();
System.out.println("准备执行第5次查询:id=2");
AdminStandardVO queryObject5 = mapper.getStandardById(2L);
System.out.println("第5次查询结束:" + queryObject5);
System.out.println();
System.out.println("准备执行第6次查询:id=1");
AdminStandardVO queryObject6 = mapper.getStandardById(1L);
System.out.println("第6次查询结束:" + queryObject6);
System.out.println();
}
随着程序的运行,MySQL中的数据可能被修改,而MyBatis缓存的数据可能与MySQL中修改后的数据并不一致,所以,MyBatis定义一些规则,在特定的情况下会清除MyBatis缓存的数据,如果清除,后续再尝试获取数据时,就会从MySQL中查询到最新的数据,以保证MyBatis返回的结果是准确的!
MyBatis清除一级缓存数据的规则有:
-
调用了
SqlSession
对象的清除方法:sqlSession.clearCache();
-
执行了任何写操作(增/删/改)
- 无论执行的写操作是否对数据库中的数据产生了影响
MyBatis的二级缓存也可以称之为namespace
缓存,并不要求是同一个SqlSession
了,只要求是同一个Mapper、执行相同的查询、传入相同的参数即可,在Spring Boot项目中,二级缓存默认是全局开启的,但各namespace
默认没有开启,如果需要开启,则需要在对应的XML中添加<cache/>
标签,例如:
注意:使用MyBatis的二级缓存时,查询结果的类型必须实现Serializable
接口!
在各<select>
标签上,还可以配置useCache
属性,取值为true
(默认)或false
,作用是配置此查询是否启用二级缓存!
二级缓存也会因为当前Mapper执行了任何写操作而自动清除!
MyBatis在处理每次查询时,都会优先检查二级缓存,如果命中,将返回缓存数据,如果未命中,则检查一级缓存,如果仍未命中,则会执行SQL查询。
无论是哪一级的缓存,都会因为执行了写操作而自动清除,所以,即使使用了MyBatis的缓存机制,查询出来的数据也是准确的(除非在过程中,通过其它渠道修改了数据)。
基于Spring Security与JWT的单点登录
单点登录(SSO = Single Sign On):在集群或分布式系统中,用户只需要在某1个服务器上完成身份验证(登录),在访问其它服务器时,其它服务器都可以识别此用户的身份。
单点登录的实现方案主要有2种:
- 共享Session:登录时将Session存储到专门的服务器,并且,其它各应用程序服务器也都从这个专门的服务器上读写Session,所以,无论是哪个应用程序服务器,都访问相同的Session数据,从而实现“单点登录”
- 不合适长时间存储Session数据
- JWT:登录时服务器端将向客户端响应JWT数据,并且,后续的访问中,客户端将携带此JWT来提交请求,每个应用程序服务器只需要具有相同的解析JWT的程序,就可以从JWT中获取来访用户的信息,从而实现“单点登录”
- 适合长时间表示用户身份
目前,已经在csmall-passport
中实现了管理员登录、验证JWT的相关功能,只需要将相关代码复制到csmall-product
项目中,也可以使得csmall-product
是需要管理员登录、需要具有相关访问才可以访问的!需要复制的文件和代码包括:
- 补充:
pom.xml
中的相关依赖项spring-boot-starter-security
jjwt
fastjson
- 补充:配置文件中关于JWT的配置值
- 更新:
ServiceCode
- 更新:
JsonResult
- 新增:
LoginPrincipal
- 新增:
JwtAuthorizationFilter
- 新增:
SecurityConfiguration
- 删除
PasswordEncoder
的@Bean
方法 - 删除
AuthenticationManager
的@Bean
方法 - 删除“白名单”中的
/admins/login
- 删除
**具体实现:**Spring Security框架是根据SecurityContext
中的Authentication
对象来处理认证的,所以,要想Spring Security识别用户的身份,必须将相关信息创建为Authentication
对象,然后存入到SecurityContext
中;JWT与传统的Session不同,它本身可以解析出有意义的数据;在开发时,应该在验证用户登录成功后,将用户的相关信息用于生成JWT数据,然后响应到客户端,并且,用户在后续的访问过程中,应该携带JWT数据,服务器端就可以使用Filter
组件接收到JWT数据,并解析出用户身份相关的信息,用于创建Authentication
对象,最终将此对象存入到SecurityContext
中。
Redis
关于Redis
Redis是一款基于内存的,使用K-V结构存储数据的NoSQL非关系型数据库。
Redis的核心价值在于:提高查询效率,保护关系型数据库。
基于内存的:在读写Redis中的数据时,都是在内存中直接操作的,内存(RAM)是整个计算机硬件系统中,除了运算单元中内置的缓存(例如CPU的缓存、显卡的缓存)以外,存取效率最高的存储设备!
K-V结构:存入数据时,需要定义Key,后续,可以根据Key取出此前存入的数据。
NoSQL:存取数据都是通过Key来操作的,所以,不需要使用SQL语句。
非关系型数据库:存储在Redis中的各数据之间没有必然的关系。
提示:Redis也会占用磁盘空间(例如硬盘的空间),并自动将数据同步到磁盘上,所以,存储到Redis中的数据,即使重启电脑,Redis中仍有此前存入的数据!
Redis的主要作用是缓存数据,通常,会将关系型数据库(例如MySQL)中的某些数据读取出来,并写入到Redis中,后续,当需要获取这些数据时,会优先从Redis中读取数据,而不是直接从关系型数据库中读取数据!
因为Redis是基于内存的,读取效率远高于基于磁盘存储数据的关系型数据库,所以,单次查询耗时更短,可以承受非常大的访问量,并减少对关系型数据库的访问次数,从而起到“保护”关系型数据库的作用!
Redis的数据类型
Redis的经典数据类型有:
- string:字符串,对应Java语言中的简单数据类型,不只是
String
,如果存入的是数值等,也视为Redis中的string - list:列表
- set:集合
- hash:对象,对应Java语言中的
Map
- z-set:排序的集合
另外,还有:bitmap / hyperloglog / Geo / 流
Redis的常用命令
在终端窗口中,可以执行redis-cli
命令,登录Redis客户端(提示符会变成127.0.0.1:6379>
状态),在Redis客户端中可以:
set KEY VALUE
:存入数据,例如set username1 root
,如果反复使用同一个KEY执行此命令,后续存入的VALUE会覆盖前序存入的VALUE,相当于“修改数据”,如果使用的KEY是从未使用过的,相当于“新增数据”get KEY
:读取数据,例如get username1
,如果KEY存在,则取出对应的数据,如果KEY不存在,则返回(nil)
,相当于Java中的null
keys PATTERN
:根据模式(PATTERN)获取KEY的列表,例如keys username1
,如果KEY存在,则返回,如果不存在,则返回(empty list or set)
,在PATTERN中,可以使用星号作为通配符,例如keys username*
,可以返回所有以username
作为前缀的KEY的列表,甚至,你还可以使用keys *
获取当前数据库中所有的KEY- **注意:**在生产环境中,禁止使用此命令
del KEY [KEY ...]
:删除指定KEY的数据,例如del username1
,或例如del username1 username2 username3
,将返回成功删除了多少条数据flushdb
:清空当前数据库
更多命令可参考:www.cnblogs.com/antLaddie/p…
Redis中的List数据
Redis中的List类型数据是一种先进后出、后进先出的栈结构,例如:
你应该把Redis中的List按照以上图示想像成旋转了90度的栈。
在向Redis中的List中写入数据时,可以从左侧压栈,例如:
也可以从右侧压栈,例如:
**注意:**读取List数据时,始终从左侧向右侧读取!
查询列表数据时,需要提供期望的结果在原List中的最左侧元素的下标和最右侧元素的下标,以查询出原List的区间段的列表,关于List中各元素的下标如图:
可以看到,在Redis中的List中的每个元素都有2个下标值,正数下标是以最左侧元素为0开始从左至右顺序编号的,负数下标是以最右侧元素为-1开始从右至左递减编号的。
在调用RedisTemplate
相当API获取List区间段时,起始元素必须在结束元素的左侧!例如你尝试调用ops.range(key, 7, 3)
将无法查询到有效的数据!
与Java中的List相同,在Redis中的List允许存在相同的元素,所以,在添加元素之前应该考虑是否合适!
Redis编程
在Spring Boot项目中,要实现Redis编程,需要添加spring-boot-starter-data-redis
依赖项:
<!-- Spring Boot支持Redis编程的依赖项 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Spring系列提供了RedisTemplate
类,用于读写Redis,则应该在配置类中使用@Bean
方法配置此类的对象:
package cn.tedu.csmall.product.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
import java.io.Serializable;
@Slf4j
@Configuration
public class RedisConfiguration {
@Bean
public RedisTemplate<String, Serializable> redisTemplate(
RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Serializable> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setValueSerializer(RedisSerializer.json());
return redisTemplate;
}
}
则后续需要使用时,可以直接自动装配RedisTemplate
类的对象!
典型的访问示例:
package cn.tedu.csmall.product;
import cn.tedu.csmall.product.pojo.entity.Album;
import cn.tedu.csmall.product.pojo.entity.Brand;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.ListOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Slf4j
@SpringBootTest
public class RedisTests {
@Autowired
RedisTemplate<String, Serializable> redisTemplate;
@Test
void setValue() {
String key = "email2";
String value = "李四@qq.com";
ValueOperations<String, Serializable> ops = redisTemplate.opsForValue();
ops.set(key, value);
log.debug("写入数据成功!");
}
@Test
void getValue() {
String key = "email2";
ValueOperations<String, Serializable> ops = redisTemplate.opsForValue();
Serializable value = ops.get(key);
log.debug("根据Key={}读取Redis中的数据,读取到的Value={}", key, value);
}
@Test
void getEmptyValue() {
String key = "email9999999";
ValueOperations<String, Serializable> ops = redisTemplate.opsForValue();
Serializable value = ops.get(key);
log.debug("根据Key={}读取Redis中的数据,读取到的Value={}", key, value);
}
@Test
void setObjectValue() {
String key = "brand1";
Brand value = new Brand();
value.setId(666L);
value.setName("中国人民银行");
ValueOperations<String, Serializable> ops = redisTemplate.opsForValue();
ops.set(key, value);
log.debug("写入数据成功!");
}
@Test
void getObjectValue() {
String key = "brand1";
ValueOperations<String, Serializable> ops = redisTemplate.opsForValue();
Serializable value = ops.get(key);
log.debug("根据Key={}读取Redis中的数据,读取到的Value的数据类型:{}", key, value.getClass().getName());
log.debug("读取到的Value={}", value);
}
List<Album> albumList = new ArrayList<>();
{
for (int i = 1; i <= 8; i++) {
Album album = new Album();
album.setId(0L + i);
album.setName("测试相册的名称" + i);
albumList.add(album);
}
}
@Test
void rightPush() {
String key = "albumList";
ListOperations<String, Serializable> ops = redisTemplate.opsForList();
for (Album album : albumList) {
ops.rightPush(key, album);
}
log.debug("写入数据成功!");
}
@Test
void range() {
String key = "albumList";
long start = 7;
long end = 3;
ListOperations<String, Serializable> ops = redisTemplate.opsForList();
List<Serializable> list = ops.range(key, start, end);
log.debug("根据Key={}读取列表成功,列表中的数据的数量:{}", key, list.size());
for (Serializable serializable : list) {
log.debug("列表项:{}", serializable);
}
}
@Test
void delete() {
String key = "email1";
Boolean result = redisTemplate.delete(key);
log.debug("根据Key={}执行删除,结果:{}", key, result);
}
@Test
void deleteBatch() {
Set<String> keys = new HashSet<>();
keys.add("email1");
keys.add("email2");
keys.add("brand1");
Long deleteCount = redisTemplate.delete(keys);
log.debug("根据多个Key({})执行删除,成功删除的数据的数量:{}", keys, deleteCount);
}
@Test
void keys() {
String pattern = "*";
Set<String> keys = redisTemplate.keys(pattern);
log.debug("根据模式({})查找Key的集合,结果:{}", pattern, keys);
}
}
使用Redis时的数据一致性问题
通常,会使用Redis解决查询的问题,以提高查询效率,并保护MySQL数据库。
当数据需要修改时,为了确保所执行的修改是持久性的,通常会对MySQL执行写操作,但是,此时可能并不会直接对Redis中的数据同步修改,或来不及修改,就发生查询,则此时查询到的结果是不准确的!
所以,当Redis中的数据与MySQL中的数据不同时,称之为“数据一致性问题”。
需要注意:数据一致性问题并不一定是需要急迫的解决的问题!
另外,数据修改频率低的数据(例如电商平台中的品牌数据、类别数据),对数据的时效不敏感的数据(例如大部分列表),都不必过度关注数据一致性问题,通常,只需要周期性的更新Redis中的数据即可。
使用ApplicationRunner实现缓存预热
在Spring Boot项目中,可以自定义组件类,实现ApplicationRunner
接口,重写其中的run()
方法,此方法会在启动项目之后自动执行。
可以在ApplicationRunner
类的run()
方法中执行重建缓存的操作,以实现缓存预热。
首先,应该定义读写Redis的接口,并在接口中声明必要的抽象方法,例如:
package cn.tedu.csmall.product.repository;
import cn.tedu.csmall.product.pojo.vo.BrandListItemVO;
import java.util.List;
public interface IBrandCacheRepository {
/**
* 将品牌列表写入到Redis中
*
* @param brandList 品牌列表
*/
void save(List<BrandListItemVO> brandList);
/**
* 删除Redis中的品牌列表
*/
Boolean deleteList();
/**
* 从Redis中读取品牌列表
*
* @return 品牌列表
*/
List<BrandListItemVO> list();
}
然后,自定义类,实现以上接口,并基于RedisTemplate
实现各抽象方法,例如:
package cn.tedu.csmall.product.repository.impl;
import cn.tedu.csmall.product.pojo.vo.BrandListItemVO;
import cn.tedu.csmall.product.repository.IBrandCacheRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.ListOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Repository
public class BrandCacheRepositoryImpl implements IBrandCacheRepository {
@Autowired
private RedisTemplate<String, Serializable> redisTemplate;
@Override
public void save(List<BrandListItemVO> brandList) {
log.debug("准备向Redis中写入【品牌列表】数据……");
String key = "brandList";
ListOperations<String, Serializable> ops = redisTemplate.opsForList();
for (BrandListItemVO item : brandList) {
ops.rightPush(key, item);
log.debug("写入【品牌列表】数据项:{}", item);
}
log.debug("向Redis中写入【品牌列表】数据,完成!");
}
@Override
public Boolean deleteList() {
log.debug("准备删除Redis中的【品牌列表】……");
String key = "brandList";
Boolean result = redisTemplate.delete(key);
log.debug("删除Redis中的【品牌列表】完成,操作结果:{}", result);
return result;
}
@Override
public List<BrandListItemVO> list() {
String key = "brandList";
long start = 0;
long end = -1;
ListOperations<String, Serializable> ops = redisTemplate.opsForList();
List<Serializable> list = ops.range(key, start, end);
List<BrandListItemVO> brandList = new ArrayList<>();
for (Serializable serializable : list) {
brandList.add((BrandListItemVO) serializable);
}
return brandList;
}
}
**需要注意:**以上使用的Repository组件的定位与Mapper相同,都是访问数据库的组件(当前项目中,Mapper访问MySQL这个关系型数据库,Repository访问Redis这个非关系型数据库),所以,Repository的调用者只能是Service组件,不允许在其它组件(例如Controller或ApplicationRunner
等)中直接调用Repository实现数据访问。
则在IBrandService
中添加“重建缓存”的抽象方法:
/**
* 重建缓存
*/
void rebuildCache();
并在BrandServiceImpl
中实现此方法:
@Override
public void rebuildCache() {
log.debug("开始处理【重建品牌数据缓存】的业务,无参数");
brandCacheRepository.deleteList();
List<BrandListItemVO> list = brandMapper.list();
brandCacheRepository.save(list);
}
最后,在自定义的ApplicationRunner
类中调用以上业务,即可实现缓存预热,例如:
package cn.tedu.csmall.product.preload;
import cn.tedu.csmall.product.service.IBrandService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class CachePreLoad implements ApplicationRunner {
@Autowired
private IBrandService brandService;
@Override
public void run(ApplicationArguments args) throws Exception {
log.debug("开始处理【品牌】数据的缓存预热……");
brandService.rebuildCache();
}
}
通常,使用ApplicationRunner
实现了“启用项目时就加载缓存”后,还需要设计对应的“更新缓存”的机制,典型的做法是“手动更新”,例如,在BrandController
中添加“处理重建缓存的请求”的方法:
// http://localhost:9080/brands/rebuild-cache
@PostMapping("/rebuild-cache")
@ApiOperation("重建品牌缓存数据")
@ApiOperationSupport(order = 500)
public JsonResult rebuildCache() {
log.debug("开始处理【重建品牌数据缓存】的请求,无参数");
brandService.rebuildCache();
return JsonResult.ok();
}
当需要重建缓存时,只需要向以上URL发起请求即可!
Spring Boot中的计划任务
在Spring Boot项目中,可以自定义组件类,并在类中自定义方法,然后,在方法上添加@Scheduled
注解,则此方法就会是一个计划任务方法,会根据@Scheduled
参数的配置周期性的执行。
计划任务通常是可能耗时较长的,所以,默认并不允许执行,需要在配置类上添加@EnableScheduling
注解以开启。
例如,创建新的配置类,以启用计划任务:
package cn.tedu.csmall.product.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
@Configuration
@EnableScheduling
public class ScheduleConfiguration {
}
然后,编写计划任务类:
package cn.tedu.csmall.product.schedule;
import cn.tedu.csmall.product.service.IBrandService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class CacheSchedule {
@Autowired
private IBrandService brandService;
// 关于@Schedule注解参数
// fixedRate:计划任务的执行频率,以上一次的起始时间来计算下一次的起始时间,以毫秒为单位
// fixedDelay:计划任务的执行间隔,以上一次的结束时间来计算下一次的起始时间,以毫秒为单位
// cron:使用1个字符串作为值,此字符串是一个表达式,由6~7部分组成,各部分使用空格分隔
// -- 在cron中的配置值,各部分表示的意义,从左至右分别是:秒 分 时 日 月 周 [年]
// -- 各部分值都可以使用通配符
// -- 使用星号作为通配符:表示任意值
// -- 使用问号作为通配符:表示不关心此值,只能用于“日”和“周”
// -- 例如:"56 34 12 13 4 ? 2023" 表示 >> 2023年4月13日 12:34:56执行此计划任务,无视当天星期几
@Scheduled(fixedRate = 1 * 60 * 1000)
public void rebuildCache() {
log.debug("计划任务开始执行……");
brandService.rebuildCache();
}
}
**注意:**计划任务的首次执行是在项目启动完成之前的那一刻。对于同样的任务,不要同时使用ApplicationRunner
和计划任务一起实现。
关于cron
表达式的参考:www.cnblogs.com/dyppp/p/749…