第四阶段(三)

35 阅读42分钟

关于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

JWTJson 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中存入认证信息:

1680762112678.png

然后,在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数据:

1680766122673.png

1680766170043.png

此时,进行调试时,所有请求的反馈结果都是一片空白,并且,在服务器端的控制台中可以看到输出了客户端提交请求时携带的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的结果创建当事人对象:

1680775033798.png

并且,将此对象用为Authentication的当事人:

1680775091012.png

后续,当需要获取当事人信息时,直接注入即可,例如在AdminController中:

1680775150134.png

关于权限

当数据需要输出且后续还需要读取时,可能会涉及序列化的问题,因为,当数据离开内存,就不再具有“数据类型”的含义了,包括将数据直接转换成字符串(例如将某数据存入到JWT中),则后续希望将字符串还原成原本的类型时,可能是无法做到的!

业内用于解决序列化和反序列化问题的常见手段就是使用JSON,先将对象转换成JSON格式的字符串,后续,需要还原时,再将JSON格式的字符串反序列化为对象。

fastjson是一款可以实现对象与JSON的相互转换的工具库:

<!-- fastjson:实现对象与JSON的相互转换 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.75</version>
</dependency>

当需要在JWT中存入管理员的权限列表时:

1680835416819.png

在解析JWT时,得到的也会是一个JSON格式的字符串,可以将其还原成管理员的权限列表:

1680835561590.png

完成后,可以重启项目,使用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取出数据。

例如,在登录成功后:

1681096782503.png

后续,在其它页面中提交请求时,需要从localStorage中取出数据,并作为请求头的参数,再提交请求。

使用axios时,需要调用create()来自定义请求头,此方法将返回一个新的axios对象,例如:

1681096890458.png

**注意:**当自定义请求头后,向服务器端发起请求,默认情况下将是失败的,需要在服务器端Security配置类中调用http.cors()才可以正常访问。

关于复杂请求的跨域访问

当客户端自定义请求头中的特定属性(例如Authorization)并提交跨域访问时,此请求会被视为“复杂请求”!

当浏览器尝试发出复杂请求时,浏览器会自动先发出一个同URL的OPTIONS的请求,执行预检(PreFlight),如果此请求没有被服务器正确的响应(响应200即为正确),浏览器将视为“预检失败”,会提示跨域访问错误!

要解决此问题,可以在Security配置类中,将所有OPTIONS请求直接放行,例如:

1681097738312.png

或者,调用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

例如:

1681108030267.png

在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

IoCInversion of Control,控制反转,在没有使用Spring这类框架之前,对象的控制权是完全在开发者手中的,开发者可以自行决定何时、何地、通过哪种方式来创建对象、为属性赋值、设计此类对象的单例状态、在指定的时间调用特定的方法等等,所以,开发者对此类的对象有完全的控制权,当使用了Spring框架后,开发者可以不必再处理这些细节,也可以理解为将控制权交给了Spring框架,这就是一种“控制反转”的表现。

DIDependency 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框架的作用

MVCModel + View + Controller

Spring MVC框架主要解决了 VC 相关的问题,并且,在目前主流的前后端分离的开发模式下,也不再需要服务器端处理 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语句中的usernamewangkejing都被视为“字段名”!

如果需要表示某个部分只是字段的值,需要使用一对单引号将它框住!

**注意:**在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/>标签,例如:

1681271651193.png

注意:使用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类型数据是一种先进后出、后进先出的栈结构,例如:

1681351540954.png

你应该把Redis中的List按照以上图示想像成旋转了90度的栈。

在向Redis中的List中写入数据时,可以从左侧压栈,例如:

1681351648943.png

也可以从右侧压栈,例如:

1681351687140.png

**注意:**读取List数据时,始终从左侧向右侧读取!

查询列表数据时,需要提供期望的结果在原List中的最左侧元素的下标和最右侧元素的下标,以查询出原List的区间段的列表,关于List中各元素的下标如图:

1681353414973.png

可以看到,在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…