嘘~ 正在从服务器偷取页面 . . .

FreshCup 开发


1. JWT

1.1 跨域问题

  1. 用户向服务器发送用户名和密码;

  2. 服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等;

  3. 服务器向用户返回一个 session_id,写入用户的 Cookie;

  4. 用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器;

  5. 服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。

以上是使用 session 进行的一般认证方式,但是对于集群服务器的跨域问题难以解决,这需要几个服务器共享 session。

解决方案:

  1. 配置 session 数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败;
  2. 服务器将 session 保存在客户端,每次请求时发回服务器。比如 JWT。

1.2 结构样式

对于 JWT,拥有三个组成部分:

  • Header(头部)
  • Payload(负载)
  • Signature(签名)

1.3.1 Header

{
  "alg": "HS256",
  "typ": "JWT"
}
//alg 表示签名的算法(algorithm)
//typ 表示这个令牌的类型,JWT 令牌统一写为 JWT

1.3.2 Payload

JWT 规定了 7 个官方字段:

  • iss (issuer):签发人;
  • exp (expiration time):过期时间;
  • sub (subject):主题;
  • aud (audience):受众;
  • nbf (Not Before):生效时间;
  • iat (Issued At):签发时间;
  • jti (JWT ID):编号。

当然,可以在 Payload 中添加私有字段,包括 name、uid、role 等。

1.3.3 Signature

Signature 部分是对前两部分的签名,防止数据篡改。需要指定一个密钥(secret),然后使用 Header 里指定的签名算法进行签名。

1.3 使用 JWT

导入对应的 maven 依赖:

<!--导入 maven 依赖--> 
<dependency>
     <groupId>com.auth0</groupId>
     <artifactId>java-jwt</artifactId>
     <version>3.18.3</version>
</dependency>
        
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

SpringBoot 配置文件:

jwt:
  #jwt需要的密钥
  secret: 239FJAS993JASLVKCLS02JGFS
  #跟前端固定请求头
  prefix: Bearer_
  #jwt设置过期时间
  expiration: 864000

整合提取工具类:

//jwt 实现工具类
@Component
@Data
public class JWTUtil {

    private final AccountMapper accountMapper;

    private final RedisUtil redisUtil;

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private long expiration;

    @Value("${jwt.prefix}")
    private String prefix;

    public static final String USER_LOGIN_TOKEN = "TOKEN";

    public JWTUtil(RedisUtil redisUtil, AccountMapper accountMapper) {
        this.redisUtil = redisUtil;
        this.accountMapper = accountMapper;
    }

    /**
     * 给每个账户生成一个 token
     *
     * @param account 用户信息
     * @return token
     */
    public String generateToken(Account account) {
        Date nowDate = new Date();
        Date expiredDate = new Date(nowDate.getTime() + expiration * 1000);
        JWTCreator.Builder builder = JWT.create();
        builder.withClaim("id", account.getId());
        builder.withClaim("username", account.getUsername());
        builder.withClaim("role", account.getRole());
        builder.withExpiresAt(expiredDate);
        return prefix + builder.sign(Algorithm.HMAC256(secret));
    }

    //获取 token 具体信息
    public Map<String, Claim> getClaims(String token) {
        try {
            JWTVerifier verifier = JWT.require(Algorithm.HMAC256(secret)).build();
            DecodedJWT decodedJWT = verifier.verify(token);
            return decodedJWT.getClaims();
        } catch (IllegalArgumentException | JWTVerificationException e) {
            throw new LocalRuntimeException(CustomError.TOKEN_ERROR);
        }
    }

    public Account getAccount(String token) {
        Map<String, Claim> map = getClaims(token);
        return new Account(
                map.get("id").asLong(),
                map.get("username").asString(),
                map.get("role").asInt());
    }

    //判断 token 是否过期
    /*public boolean isTokenExpired(String token) {
        Date expiresAt = getClaims(token).get("exp").asDate();
        return expiresAt.getTime() - System.currentTimeMillis() < 0;
    }*/

    //存储在 redis 中的 token 是否过期
    public boolean isTokenExpired(String token) {
        Account account = getAccount(token);
        long expiration = redisUtil.ddl(account.getUsername());
        return expiration <= 0;
    }

    //判断 token 是否需要刷新
    private boolean isTokenNeedRefresh(String key) {
        long expiration = redisUtil.ddl(key);
        return expiration < (this.expiration * 1000 >> 1);
    }

    //刷新 token 过期时间
    /*public String refreshToken(String token) {
        Date nowDate = new Date();
        Date expiredDate = new Date(nowDate.getTime() + expiration * 1000);
        Account account = getAccount(token);
        JWTCreator.Builder builder = JWT.create();
        builder.withClaim("id", account.getId());
        builder.withClaim("username", account.getUsername());
        builder.withClaim("role", account.getRole());
        builder.withExpiresAt(expiredDate);
        return prefix + builder.sign(Algorithm.HMAC256(secret));
    }*/

    //刷新 token 存在时间
    public void refreshExpiration(String key) {
        if (isTokenNeedRefresh(key)) {
            redisUtil.expire(key, expiration);
        }
    }

}

1.4 SSO

Single Sign On,单点登录,简称 SSO。在多个应用系统中,只需要登录一次,就可以访问其他相互信任的应用系统。

比如在学校的智慧校园登录之后,就可以不需要认证访问图书馆等其他系统。

普通的登录认证机制,都是通过浏览器中的 Cookie 携带对应的 SessionId,通过 session 来判断用户是否登录。

1.4.1 实现

常规的登录机制有两个问题需要解决:

  • Cookie 是不能跨域的,domain 属性设置之后不能作用于其他域;
  • 不同应用 Session 不共享。

Cookie 可以通过设置顶域解决问题,Session 也有很多共享的方案。同域下的单点登录就实现了,但这还不是真正的单点登录。

SSO 的主要实现方式有:

  1. 共享 cookies:基于共享同域的 cookie 是 Web 刚开始阶段时使用的一种方式,它利用浏览同域名之间自动传递 cookies 机制,实现两个域名之间系统令牌 传递问题;另外,关于跨域问题,虽然 cookies本身不跨域,但可以利用它实现跨域的 SSO 。如:代理、暴露 SSO 令牌值等;
  2. Broker-based(基于经纪人):这种技术的特点就是,有一个集中的认证和用户帐号管理的服务器。经纪人给被用于进一步请求的电子身份存取。中央数据库的使用减少了管理的代价,并为认证提供一个公共和独立的 “第三方 “ 。例如 Kerberos 、 Sesame、IBM KryptoKnight(凭证库思想)等;
  3. Agent-based(基于代理人):在这种解决方案中,有一个自动地为不同的应用程序认证用户身份的代理程序。这个代理程序需要设计有不同的功能。比如,它可以使用口令表或加密密钥来自动地将 认证的负担从用户移开。代理人被放在服务器上面,在服务器的认证系统和客户端认证方法之间充当一个 “ 翻译 “。例如 SSH 等;
  4. Token-based:例如 SecureID、WebID,现在被广泛使用的口令认证,比如 FTP 、邮件服务器的登录认证,这是一种简单易用的方式,实现一个口令在多种应用当中使用;
  5. 基于网关;
  6. 基于 SAML:SAML(Security Assertion Markup Language,安全断言标记语言)的出现大大简化了 SSO ,并被 OASIS 批准为 SSO 的执行标准 。开源组织 OpenSAML 实现了 SAML 规范。

1.4.2 角色

一般 SSO 体系主要角色有三种:

  • User(多个);
  • Web 应用(多个);
  • SSO 认证中心(1个)。

SSO 实现模式一般包括以下三个原则:

  • 所有的认证登录都在 SSO 认证中心进行;
  • SSO 认证中心通过一些方法来告诉 Web 应用当前访问用户究竟是不是已通过认证的用户;
  • SSO 认证中心和所有的 Web 应用建立一种信任关系,也就是说 web 应用必须信任认证中心(单点信任)。

1.4.3 CAS

CAS(Central Authentication Service),中央认证服务,给 SSO 提供了一种解决方法。

CAS 基本认证过程

访问服务:SSO 客户端发送请求访问应用系统提供的服务资源;

定向认证:SSO 客户端会重定向用户请求到 SSO 服务器;

用户认证:用户身份认证;

发放票据:SSO 服务器会产生一个随机的 Service Ticket;

验证票据:SSO 服务器验证票据 Service Ticket 的合法性,验证通过后,允许客户端访问服务;

传输用户信息:SSO 服务器验证票据通过后,传输用户认证结果信息给客户端。

实现 SSO

当用户访问另一个应用的服务再次被重定向到 CAS Server 的时候,CAS Server 会主动获到这个 TGC cookie,然后判断:

  • 如果 User 持有 TGC 且其还没失效,那么就走基础协议图的 Step4,达到了 SSO 的效果;

  • 如果 TGC 失效,那么用户还是要重新认证(走基础协议图的 Step3)。

CAS 请求认证时序图

CAS 的 SSO 实现方式可简化理解为: 1个 Cookie 和 N个 Session。CAS Server 创建 cookie,在所有应用认证时使用,各应用通过创建各自的 Session 来标识用户是否已登录。

CAS 代理模式

该模式形式为用户访问 App1,App1 又依赖于 App2 来获取一些信息,如:User —>App1 —>App2。

CAS 引入了一种 Proxy 认证机制,即 CAS Client 可以代理用户去访问其它 Web 应用。代理的前提是需要 CAS Client 拥有用户的身份信息(类似凭据)。之前我们提到的 TGC 是用户持有对自己身份信息的一种凭据,这里的 PGT 就是 CAS Client 端持有的对用户身份信息的一种凭据。凭借 TGC , User 可以免去输入密码以获取访问其它服务的 Service Ticket ,所以,这里凭借 PGT,Web 应用可以代理用户去实现后端的认证,而无需前端用户的参与 。

解释

  • Ticket-granting cookie(TGC):存放用户身份认证凭证的 cookie ,在浏览器和 CAS Server 间通讯时使用,并且只能基于安全通道传输(Https)(截取 TGC 难度非常大,从而确保 CAS 的安全性),是 CAS Server 用来明确用户身份的凭证;
  • Service ticket(ST):服务票据,服务的惟一标识码,由 CAS Server 发出(Http 传送),通过客户端浏览器到达业务服务器端;一个特定的服务只能有一个惟一的 ST ;
  • Proxy-Granting ticket(PGT):由 CAS Server 颁发给拥有 ST 凭证的服务,PGT 绑定一个用户的特定服务,使其拥有向 CAS Server 申请,获得 PT 的能力;
  • Proxy-Granting Ticket I Owe You(PGTIOU): 作用是将通过凭证校验时的应答信息由 CAS Server 返回给 CAS Client ,同时,与该 PGTIOU 对应的 PGT 将通过回调链接传给 Web 应用。 Web 应用负责维护 PGTIOU 与 PGT 之间映射关系的内容表;
  • Proxy Ticket(PT):是应用程序代理用户身份对目标程序进行访问的凭证;
  • Ticket Granting ticket(TGT) :票据授权票据,由 KDC 的 AS 发放。即获取这样一张票据后,以后申请各种其他服务票据(ST)便不必再向 KDC 提交身份认证信息(Credentials)(TGT 的存活周期默认为 120分钟);
  • Authentication service(AS):认证用服务,索取 Credentials ,发放 TGT ;
  • Ticket-granting service(TGS):票据授权服务,索取 TGT ,发放 ST ;
  • KDC(Key Distribution Center):密钥发放中心。

1.5 OAuth

在日常使用中,比如在一个第三方软件中,通过官方的授权服务获取官方的受保护资源服务(最简单的例子就是 Google 可以授权多个应用获取用户的各种信息)。需要注意的是,OAuth 2.0 是授权协议,不是身份验证协议协议。

OAuth 2.0 的通用授权流程如下:

  1. 用户打开客户端以后,客户端要求用户给予授权;
  2. 用户同意给予客户端授权;
  3. 客户端使用上一步获得的授权,向认证服务器申请令牌;
  4. 认证服务器对客户端进行认证以后,确认无误,同意发放令牌;
  5. 客户端使用令牌,向资源服务器申请获取资源;
  6. 资源服务器确认令牌无误,同意向客户端开放资源。

1.5.1 授权模式

授权码许可

授权码模式(authorization code)是功能最完整、流程最严密的授权模式。它的特点就是通过客户端的后台服务器,与”服务提供商”的认证服务器进行互动。OAuth 诞生之初就是为了解决 Web 浏览器场景下的授权问题

OAuth 授权过程

第一次重定向是在授权服务时,第三方软件引导用户从而跳转到官方认证页面。

那为什么需要 code 呢,明明这个时候可以直接将 access_token 发给软件了。但是这样有个问题,这样的话 access_token 是前端(浏览器)在存储,这是非常不安全的行为(在开发中,不要信任客户端的安全,一切涉及安全性的东西要存储在后端)。

那这样又有一个衍生的问题,为什么不直接后端发给后端呢?这就考虑到第二次重定向的问题,用户在确认认证之后,不能一直停留在认证界面,通过第二次重定向就可以返回授权之后的正常应用界面,第二次重定向是考虑到用户的感受。

直接通信&间接通信

这里引入间接通信和直接通信两个概念。

在获取 code 值的这样一个过程中,就是间接通信的过程。软件需要用户进行操作才能进行通信的流程。

浏览器作为中间

后端直接向授权服务发起请求

申请过程

使用 OAuth 需要提前打入申请,允许应用使用。之后平台方会给软件提供 app_id 和 app_secret,方便之后校验使用。

申请流程

申请 code

在申请认证的过程中,URI 包含以下参数:

response_type:表示授权类型,必选项,此处的值固定为 “code”;

app_id:表示客户端的 ID,必选项;

redirect_uri:表示重定向 URI,可选项;

scope:表示申请的权限范围,可选项;

state:表示客户端的当前状态,可以指定任意值,认证服务器会原封不动地返回这个值(主要是作为安全性考虑);

GET /authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz
        &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1
Host: server.example.com

在用户发送验证请求之前,会进行第一次身份和权限检查(此时是第一次检查,针对这个软件),在发送之后授权服务会进行用户自定义申请的权限(此时是第二次验证)。

// 代码演示的各个过程
if(!appMap.get("redirect_uri").equals(redirectUri)){
}
String scope = request.getParameter("scope");
if(!checkScope(scope)){
}
String[] rscope =request.getParameterValues("rscope");
if(!checkScope(rscope)){
}
String responseType = request.getParameter("response_type");
if("code".equals(responseType)){
}

String code = generateCode(appId,"USERTEST");
private String generateCode(String appId,String user) {
    ...
    String code = strb.toString();
    codeMap.put(code,appId+"|"+user+"|"+System.currentTimeMillis());
    return code;
}

OAuth 官方推荐的 code 有效时间不超过 10 分钟,实际生产环境不会超过 5 分钟,并且这个 code 只能使用一次。之后整个 code 就会绑定对应的 app_id、user 等等信息。同时会在内部存储 code 和对应权限的映射,以便之后将权限存储至对应的 access_token。

在返回时会返回 code 和 state。

申请 token

code 只是换取 access_token 的临时凭证。在申请 token 的过程中 URI 有以下参数:

grant_type:表示使用的授权模式,必选项,此处的值固定为”authorization_code”;

code:表示上一步获得的授权码,必选项;

redirect_uri:表示重定向URI,必选项,且必须与A步骤中的该参数值保持一致;

app_id:表示客户端ID,必选项;

app_secret:注册 OAuth 时分发的应用密钥。

POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb

OAuth 2.0 对于 access_token 的要求为唯一性、不连续性、不可猜性。我们可以使用 JWT 来作为访问令牌,同时设置过期时间。之后在 Payload 就可以存储关键信息。此时这个 access_token 就会返回给软件的后端,之后就可以用来访问被保护资源。

令牌端点相应涉及的参数

这个过程还涉及到一个刷新令牌,这个时候会具体说明。

隐式许可

隐式许可相当于没有服务端的认证,所有的操作都在浏览器里执行,因为没有 code 的存在,所以安全性会大大降低。

直接在浏览器操作

response_type 的值变成了 token

客户端许可

部分不需要用户进行认证的资源,包括 LOGO 等,软件就可以直接请求访问授权服务。因为不需要用户端进行操作,所以不会同步生成刷新令牌(直接通过 app_id 和 app_secret 进行访问)。

这里 grant_type 的值为 client_credentials

软件直接访问授权服务

资源拥有者凭据许可

这是比较糟糕的许可方式,因为会收录用户的用户名和密码,一般会用在官方平台推出的软件中。并且为了防止泄露的风险,会使用 token 去代替频繁使用用户敏感信息去调用 api 的情况。

访问软件时会录入用户名和密码

1.5.2 刷新令牌

本质来说,只有拿到访问令牌,才能访问到受保护的资源。

但是令牌总会有一个过期时间,如果过期时间到了就不能访问,如果不做任何操作就会打断用户重新操作,这个对用户体验不好。

引入刷新令牌的概念,通过刷新令牌在后台重新申请访问令牌。客户端发出更新令牌的HTTP请求,包含以下参数:

granttype:表示使用的授权模式,此处的值固定为”refreshtoken”,必选项;

refresh_token:表示早前收到的更新令牌,必选项;

scope:表示申请的授权范围,不可以超出上一次申请的范围,如果省略该参数,则表示与上一次一致。

POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA

在申请之后,授权方需要将这个 token 废弃,使用新的 token。

1.5.3 受保护的资源

类权限类别

如果要访问多资源,一般通过网关进行权限校验之后再进行对应转发。

统一网关进行处理

1.5.4 针对 App

移动端可能存在没有 server 端的情况(纯客户端情况)。也许可以使用 web 内核嵌入 App 中,但 App 的问题在于如何保存对应的 app_secret。不可能将其直接保存至用户本地,这样会有失窃风险。

PKCE 协议,全称是 Proof Key for Code Exchange by OAuth Public Clients,针对这种情况。

使用 PKCE 协议

App 自己要生成一个随机的、长度在 43~128 字符之间的、参数为 code_verifier 的字符串验证码;接着,再利用这个 code_verifier,来生成一个被称为“挑战码”的参数code_challenge

code_challenge 的值 OAuth 2.0 规范里面给出了两种方法,就是看 code_challenge_method 这个参数的值:

code_challenge_method=plain,此时 code_verifier 的值就是 code_challenge 的值;

code_challenge_method=S256,就是将 code_verifier 值进行 ASCII 编码之后再进行哈希,然后再将哈希之后的值进行 BASE64-URL 编码。

code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))

这样在申请 token 时就能通过校验 code_challenge 的方式进行安全校验。

1.5.5 安全问题

CSRF 攻击

恶意软件让浏览器向已完成用户身份认证的网站发起请求,并执行有害的操作,就是跨站请求伪造攻击。

简单来说,就是在用户完成身份验证操作时候截取了 code 值,之后会用这个 code 进行验证,制作恶意网站,使用别人的 app_id,配合自己的 codeA 进行验证。这样,之后的 token 都被攻击者获取了。

比如在微信绑定时,收到 CSRF 攻击时,攻击者就可以用自己的微信绑定别人的账号(使用 codeA)。

CSRF 攻击

解决 CSRF 的方法就在于最开始的 state 参数(一般是一个六位数的随机参数,和 app_id 进行绑定),授权服务和第三方软件都需要使用这个 state 继续比对。

1.5.6 微服务

下面的例子是混合使用 OAuth 令牌和 JWT 令牌。

Web 应用层(网关之前)的安全机制主要基于 OAuth 2.0 访问令牌实现(它是一种透明令牌或者称引用令牌),微服务层(网关之后)的安全机制主要基于 JWT 令牌实现(它是一种不透明自包含令牌)。网关层在中间实现两种令牌的转换。

因为 Web 层靠近用户端,如果采用 JWT 令牌,会暴露用户信息,有一定的安全风险,所以采用 OAuth 2.0 访问令牌,它是一个无意义随机字符串。而在网关之后,安全风险相对低,同时很多服务需要用户信息,所以采用自包含用户信息的 JWT 令牌更合适。

基于 K8S 比较完整架构

IDP 是 Identity Provider 的简称,主要负责 OAuth 2.0 授权协议处理,OAuth 2.0 和 JWT 令牌颁发和管理,以及用户认证等功能。IDP 使用后台的 Login-Service 进行用户认证。

BFF 是 Backend for Frontend 的简称,主要实现对后台领域服务的聚合(Aggregation,有点类似数据库的 Join)功能,同时为不同的前端体验(PC/Mobile/开放平台等)提供更友好的 API 和数据格式。

BFF 中可以包含一些业务逻辑,甚至还可以有自己的数据库存储。通常,BFF 要调用两个或两个以上的领域服务,甚至还可能调用其它的 BFF(当然一般并不建议这样调用,因为这样会让调用关系变得错综复杂,无法理解)。如果 BFF 需要获取调用用户或者 OAuth 2.0 Scope 相关信息,它可以从传递过来的 JWT 令牌中直接获取。

BFF 服务可以用 Node.js 开发,也可以用 Java/Spring 等框架开发。

领域服务层在整个微服务架构的底层。这些服务包含业务逻辑,通常有自己独立的数据库存储,还可以根据需要调用外部的服务。

根据微服务分层原则,领域服务禁止调用其它的领域服务,更不允许反向调用 BFF 服务。这样做是为了保持微服务职责单一(Single Responsibility)和有界上下文(Bounded Context),避免复杂的领域依赖。领域服务是独立的开发、测试和发布单位。在电商领域,常见的领域服务有用户服务、商品服务、订单服务和支付服务等。和 BFF 一样,如果领域服务需要获取调用用户或者 OAuth 2.0 Scope 相关信息,它可以从传递过来的 JWT 令牌中直接获取。

可以看到,领域服务和 BFF 服务都是无状态的,它们本身并不存储用户状态,而是通过传递过来的 JWT 数据获取用户信息。所以在整个架构中,微服务都是无状态、可以按需水平扩展的,状态要么存在用户端(浏览器或者手机 App 中),要么存在集中的数据库中。

1.6 OIDC

联合登录和单点登录都是 OpenID Connect(简称 OIDC)的应用场景的实现,OIDC = 授权协议 + 身份验证。

在 OIDC 的官方标准框架中,有三个非常重要的角色:

EU(End User),代表最终用户;

RP(Relying Party),代表认证服务的依赖方,就是上面我提到的第三方软件;

OP(OpenID Provider),代表提供身份认证服务方。

OAuth 2.0 和 OIDC 角色对应关系

id 令牌会随着 access_token 一起返还给用户。

iss,令牌的颁发者,其值就是身份认证服务(OP)的 URL;

sub,令牌的主题,其值是一个能够代表最终用户(EU)的全局唯一标识符;

aud,令牌的目标受众,其值是三方软件(RP)的 app_id;

exp,令牌的到期时间戳,所有的 ID 令牌都会有一个过期时间;

iat,颁发令牌的时间戳。

而 SSO 可以通过重复 OIDC 来实现(已经存储对应的 id_token、access_token):

单点通信流程

2. Redis

2.1 什么是 Redis

我们使用 Redis 来实现 NoSQL (not only sql)。

主要是作为一个中间件,在真实的服务中,有时像后端发送的信息量会很大,如果这期间还是使用数据库的话,读取速度会很慢。Redis 提供了一个缓存,这样写入和读取数据都要快上不少。

1.方便扩展(数据之间没有关系,很好扩展);

2.大数据高性能(Redis一秒写八万次,读取11万,NoSQL 缓存记录级);

3.数据类型是多样的;

4.传统 RDBMS 和 NoSQL。

Redis 是单线程的,但是运行速度十分的快,一秒钟读取接近十万条读取数据。

  • 访问速度:CPU > 内存 > 硬盘;
  • 核心:Redis 将所有数据全部放在内存中,所以读写速度非常快。

在之后的八股文整合中,会更加详细的描述 MySQL 以及 Redis 的内容。

2.2 启动 Redis

Windows 的 Redis 少有人去维护更新,现在的版本停留在5版本,所以还是建议 Linux Docker 部署 redis 服务。

Redis 的容器启动时,如果没有指定配置文件,会默认无配置文件启动(当然这本身是被允许的,Redis 本身就有很多被注释掉的配置)。

在 Redis 的配置文件中,在注释中有详细的解释。

本身的配置有着多达一千多行的完整说明

# 首先创建好一个本地的 Redis 配置文件
mkdir conf
cd conf
touch redis.conf
vim redis.conf

# 创建 data 目录,用于存储持久化缓存的 redis 数据
mkdir data

# 配置文件内容:
protected-mode no
requirepass sast_forever
# 我们只需要关闭保护模式和配置密码就可以直接使用了
# 具体配置信息可以参考 Windows 端的 redis 的配置文件说明

# 指定本地的 redis.conf 文件和容器的配置文件绑定
docker run -p 7000:6379 --name redis -v $PWD/conf/redis.conf:/etc/redis/redis.conf -v $PWD/data:/redis/data -d redis redis-server /etc/redis/redis.conf

# 可以直接在启动的时候直接使用参数配置连接密码(不推荐)
--requirepass "sast_forever"
redis-server /etc/redis/redis.conf:使用指定的配置文件启动redis

可以使用 Redis DeskTop Manager 对 Redis 的连接管理(可以像 navicat 那样进行桥接连接)。

2.3 SpringBoot 整合 redis

jedis 使用 Java 来操作 Redis。(在 springboot2.x 之后已经被改成 lettuce)

jedis:采用的直连,多个线程操作话是不安全的,如果想要避免;

lettuce:采用netty(高性能网络结构,异步传值),实例可以在多个线程中进行共享,不存在线程不安全的情况,可以减少线程数据。

原子性(atomicity):

  • 一个事务是一个不可分割的最小工作单位,事务中包括的诸操作要么都做,要么都不做;

  • Redis 所有单个命令的执行都是原子性的,这与它的单线程机制有关;

  • Redis 命令的原子性使得我们不用考虑并发问题,可以方便的利用原子性自增操作INCR实现简单计数器功能。

/**
 * @program: atSast
 * @summary: 对 jedis 进行测试
 * @author: cxy621
 * @create: 2021-07-23 13:45
 **/
@SpringBootTest
public class JedisTest {

    @Autowired
    private RedisTemplate redisTemplate;
	//opsforValue String
    //opsforList List
    //opsforSet Set
    //opsforHash Hash
    //opsforZSet Zset
    @Test
    public void lettuceTest() {
//        RedisConnection redisConnection = redisTemplate.getConnectionFactory().getConnection();
//        redisConnection.flushAll();
//        redisConnection.flushDb();

        redisTemplate.opsForValue().set("name", "cxy");
        System.out.println(redisTemplate.opsForValue().get("name"));

    }

}

所有的对象需要序列化如果没有序列化就会报错。对于没有序列化的值,Java 中会有自带的 jdk 自带的序列化,但是如果不自己配置redis,会自动出现转义字符。可以自己自己去设置 redis 工具类,重写 redisUtil,就不需要调用原生麻烦的包了。

配置文件中的配置:

spring:
  redis:
    # Redis 数据库索引(默认为0)
    database: 0
    host: localhost
    port: 6379	
    timeout: 180000
    jedis:
      pool:
        # 连接池最大连接数(使用负值表示没有限制)
        max-active: 8
        # 连接池最大阻塞等待时间(使用负值表示没有限制)
        max-wait: -1
        # 连接池中的最大空闲连接
        max-idle: 8
        # 连接池中的最小空闲连接
        min-idle: 0

通过源码可以看出,SpringBoot 自动帮我们在容器中生成了一个 RedisTemplate 和一个 StringRedisTemplate。但是,这个RedisTemplate 的泛型是 ,写代码不方便,需要写好多类型转换的代码;我们需要一个泛型为 形式的 RedisTemplate。并且,这个RedisTemplate 没有设置数据存在 Redis 时,key 及 value 的序列化方式。

编写 redis 配置类,内容如下,在该类中完成 Jedis 池、Redis 连接和 RedisTemplate 序列化三个配置完成 SpringBoot 整合 redis 的进一步配置。其中 RedisTemplate 对 key 和 value 的序列化类,各人结合自己项目情况进行选择即可。

配置 redis config 文件,防止出现转义字符的问题:

@EnableCaching
@Configuration
public class RedisConfig extends CachingConfigurerSupport {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        RedisSerializer<String> redisSerializer = new StringRedisSerializer();
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        // 解决查询缓存转换异常的问题
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        // 配置序列化(解决乱码的问题),过期时间600秒
        RedisCacheConfiguration config = RedisCacheConfiguration
                .defaultCacheConfig().entryTtl(Duration.ofSeconds(600))
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(redisSerializer)).serializeValuesWith(RedisSerializationContext
                        .SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
                .disableCachingNullValues();
        RedisCacheManager cacheManager = RedisCacheManager.builder(factory).cacheDefaults(config).build();
        return cacheManager;
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        RedisSerializer<String> redisSerializer = new StringRedisSerializer();
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        template.setConnectionFactory(factory);
        // key序列化方式
        template.setKeySerializer(redisSerializer);
        // value序列化
        template.setValueSerializer(jackson2JsonRedisSerializer);
        // value hashmap序列化
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        return template;
    }
    
}

除此之外,直接这样使用 RedisTemplate 还是会比较麻烦,我们可以自己写一个工具类:

@Component
public class RedisUtil {

    private final StringRedisTemplate redisTemplate;

    public RedisUtil(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void set(String key, String value) {
        redisTemplate.opsForValue().set(key, value);
    }

    public void set(String key, String value, long time) {
        redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);//TimeUnit.SECONDS 设置时间单位为秒
    }
    
    public void set(String key, String value, long time, TimeUnit unit) {
        redisTemplate.opsForValue().set(key, value, time, unit);
    }

    // 设置过期时间
    public void expire(String key, long time) {
        redisTemplate.expire(key, time, TimeUnit.SECONDS);
    }
    
    public void expire(String key, long time, TimeUnit unit) {
        redisTemplate.expire(key, time, unit);
    }

    // 获取过期截止时间
    public long ddl(String key) {
        if (hasKey(key)) {
            return redisTemplate.getExpire(key);
        }
        throw new LocalRuntimeException(CustomError.TOKEN_ERROR);
    }

    public String get(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    public boolean hasKey(String key) {
        String token = get(key);
        return token != null;
    }
    
    /**
     * 删除缓存
     *
     * @param key 可以传一个值或多个
     * 使用 ... 表示不确定
     */
    @SuppressWarnings("unchecked")
    // 忽略编译器中的警告错误,比如 unused
    public void del(String... key) {
        if (key != null && key.length > 0) {
            for (String s : key) {
                redisTemplate.delete(s);
            }
        }
    }
    
}

2.4 整合 JWT 和 Redis

yaml 自定义配置 jwt 参数:

jwt:
  # jwt需要的密钥
  secret: 239FJAS993JASLVKCLS02JGFS
  # 跟前端固定请求头
  header: Token
  # jwt设置过期时间
  expiration: 864000

配置 jwt 工具类:

// jwt 实现工具类
@Component
@Data
public class JwtUtil {

    private final AccountMapper accountMapper;

    private final RedisUtil redisUtil;

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private long expiration;

    @Value("${jwt.prefix}")
    private String prefix;

    public static final String USER_LOGIN_TOKEN = "TOKEN";

    public JwtUtil(RedisUtil redisUtil, AccountMapper accountMapper) {
        this.redisUtil = redisUtil;
        this.accountMapper = accountMapper;
    }

    // 给每个账户生成一个 token
    public String generateToken(Account account) {
        Date nowDate = new Date();
        Date expiredDate = new Date(nowDate.getTime() + expiration * 1000);
        JWTCreator.Builder builder = JWT.create();
        builder.withClaim("id", account.getId());
        builder.withClaim("username", account.getUsername());
        builder.withClaim("role", account.getRole());
        builder.withExpiresAt(expiredDate);
        return prefix + builder.sign(Algorithm.HMAC256(secret));
    }

    // 获取 token 具体信息
    public Map<String, Claim> getClaims(String token) {
        try {
            JWTVerifier verifier = JWT.require(Algorithm.HMAC256(secret)).build();
            DecodedJWT decodedJWT = verifier.verify(token);
            return decodedJWT.getClaims();
        } catch (IllegalArgumentException | JWTVerificationException e) {
            throw new LocalRuntimeException(CustomError.TOKEN_ERROR);
        }
    }

    public Account getAccount(String token) {
        Map<String, Claim> map = getClaims(token);
        return new Account(
                map.get("id").asLong(),
                map.get("username").asString(),
                map.get("role").asInt());
    }

    // 判断 token 是否过期
    /*public boolean isTokenExpired(String token) {
        Date expiresAt = getClaims(token).get("exp").asDate();
        return expiresAt.getTime() - System.currentTimeMillis() < 0;
    }*/

    // 存储在 redis 中的 token 是否过期
    public boolean isTokenExpired(String token) {
        Account account = getAccount(token);
        long expiration = redisUtil.ddl(account.getUsername());
        return expiration <= 0;
    }

    // 判断 token 是否需要刷新
    private boolean isTokenNeedRefresh(String key) {
        long expiration = redisUtil.ddl(key);
        return expiration < (this.expiration * 1000 >> 1);
    }

    // 刷新 token 过期时间
    /*public String refreshToken(String token) {
        Date nowDate = new Date();
        Date expiredDate = new Date(nowDate.getTime() + expiration * 1000);
        Account account = getAccount(token);
        JWTCreator.Builder builder = JWT.create();
        builder.withClaim("id", account.getId());
        builder.withClaim("username", account.getUsername());
        builder.withClaim("role", account.getRole());
        builder.withExpiresAt(expiredDate);
        return prefix + builder.sign(Algorithm.HMAC256(secret));
    }*/

    // 刷新 token 存在时间
    public void refreshExpiration(String key) {
        if (isTokenNeedRefresh(key)) {
            redisUtil.expire(key, expiration);
        }
    }

}

上面其实是一定程度有悖 JWT 的设计初衷,因为 JWT 的本意是将 token 存放在客户端,但其实我们将 token 存放在服务端。但是这也是为了安全性的考虑,因为我们需要验证是否是用户本人。

并且,由于 token 不像 cookie,不可以进行手动过期,对于过期 token 必须重新生成一个新的 token。在 FC 中,将 token 存入 redis,采取 redis 的过期时间来对 token 进行反复刷新操作。

在和一些已经工作的学长的交流中,对于登录的验证来说,不建议使用 token。在权限管理中,可能更加具有普适性。并且 token 和 session 并非是完全的替代关系,对于一些不规范的程序开发来说,可能会写出非常混乱的代码。

为了维护 JWT 的安全性,需要有一系列的考虑,之后可能会说。

3. AOP

3.1 定义

面向对象编程(OOP)的好处是显而易见的,缺点也同样明显。当需要为多个不具有继承关系的对象添加一个公共的方法的时候,例如日志记录、性能监控等,如果采用面向对象编程的方法,需要在每个对象里面都添加相同的方法,这样就产生了较大的重复工作量和大量的重复代码,不利于维护。面向切面编程(AOP)是面向对象编程的补充,简单来说就是统一处理某一“切面”的问题的编程思想。

AOP 结构图片

与面向对象的顺序流程不同,AOP采用的是横向切面的方式,注入与主业务流程无关的功能,例如事务管理和日志管理。如果使用AOP的方式进行日志的记录和处理,所有的日志代码都集中于一处,不需要再每个方法里面都去添加,极大减少了重复代码。

3.2 AOP 专业术语

通知(Advice)包含了需要用于多个应用对象的横切行为,完全听不懂,没关系,通俗一点说就是定义了“什么时候”和“做什么”;

连接点(Join Point)是程序执行过程中能够应用通知的所有点;

切点(Poincut)是定义了在“什么地方”进行切入,哪些连接点会得到通知。显然,切点一定是连接点;

切面(Aspect)是通知和切点的结合。通知和切点共同定义了切面的全部内容——是什么,何时,何地完成功能;

引入(Introduction)允许我们向现有的类中添加新方法或者属性;

织入(Weaving)是把切面应用到目标对象并创建新的代理对象的过程,分为编译期织入、类加载期织入和运行期织入。

示例图片

3.3 AOP 使用

3.3.1 定义切面和切点

Spring 采用@Aspect注解对 POJO 进行标注,该注解表明该类不仅仅是一个 POJO,还是一个切面。切面是切点和通知的结合,那么定义一个切面就需要编写切点和通知。在代码中,只需要添加 @Aspect 注解即可。

切点是通过@Pointcut注解和切点表达式定义的。

@Pointcut注解可以在一个切面内定义可重用的切点。

由于Spring切面粒度最小是达到方法级别,而execution表达式可以用于明确指定方法返回类型,类名,方法名和参数名等与方法相关的部件,并且实际中,大部分需要使用AOP的业务场景也只需要达到方法级别即可,因而execution表达式的使用是最为广泛的。如图是execution表达式的语法:

execution表示在方法执行的时候触发。以*开头,表明方法返回值类型为任意类型。然后是全限定的类名和方法名,* 可以表示任意类和任意方法。对于方法参数列表,可以使用“..”表示参数为任意类型。如果需要多个表达式,可以使用“&&”、“||”和“!”完成与、或、非的操作。

execution( 方法修饰符 返回类型 方法所属的包.类名.方法名称(方法参数) )  
  "*"表示不限     ".."表示参数不限
  方法修饰符不写表示不限,不用"*" 

对指定路径的方法操作

3.3.2 使用通知

通知有五种类型,分别是:

前置通知(@Before):在目标方法调用之前调用通知;

后置通知(@After):在目标方法完成之后调用通知;

环绕通知(@Around):在被通知的方法调用之前和调用之后执行自定义的方法;

返回通知(@AfterReturning):在目标方法成功执行之后调用通知;

异常通知(@AfterThrowing):在目标方法抛出异常之后调用通知。

3.3.3 简单例子

首先是简单写几个控制器,方便我们之后随机进行测试。

写了一个带传递的参数是为了方面之后的切面中的演示,下面对日志功能进行简单演示:

@RestController
public class TestController {
    @GetMapping("/hello")
    public String sayHello() {
        System.out.println("Hello, World");
        return "Hello";
    }

    @GetMapping("/hello/{name}")
    public String sayLove(@PathVariable("name") String name) {
        System.out.println("Activate Successfully");
        return "i love you " + name;
    }
}

首先定义切入点,切入点的位置定好之后之后便可使用注释将对应的通知插入。

这里使用 @Slf4j 的注释,使用日志(这里使用夏佬的例子进行简单的说明)。

@Component
@Slf4j
@Aspect
public class AopAdvice {
    // 表示实体类中所有的方法
    @Pointcut("execution(* com.example.demo.controller.*.*(..))")
    public void MyPointCut() {
    }

    @Before("MyPointCut()")
    public void BeforeAdvice() {
        log.info("this is before");
    }

    @After("MyPointCut()")
    public void AfterAdvice() {
        log.info("this is after");
    }

    @Around("MyPointCut()")
    public Object AroundAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        String methodName = proceedingJoinPoint.getSignature().getName();
        String className = proceedingJoinPoint.getTarget().getClass().toString();
        ObjectMapper objectMapper = new ObjectMapper();
        Object[] array = proceedingJoinPoint.getArgs(); // 获取其中的参数
        log.info("调用前:" + className + ":" + methodName + " args=" + objectMapper.writeValueAsString(array));
        Object object = proceedingJoinPoint.proceed(); // 对应事件开始处理,这里需要抛出异常
        log.info("调用后:" + className + ":" + methodName + " args=" + objectMapper.writeValueAsString(array));
        return object;
    }
}

调用 /hello

调用 /hello/cxy

3.3.4 AOP 结合 JWT

整体思路就是通过拦截器获取对应的 token 存放到 ThreadLocal 中,之后与枚举类中定义的权限进行比对。AOP 通过获取注解中的 value 和

@annotation

创建自定义注释,这个注释作为开发人员输入的身份类型:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
// 创建一个自定义注释,里面的内容规定为需要的权限
public @interface AuthHandler {
    AuthEnum value();
}

创建一个 AOP 的切面,绑定到注释(这样的方法不能作用在类)。在 AspectJ 中,@annotation 注解只能用于切点表达式的拦截类型部分,用于指定要拦截的是带有指定注解的方法。如果将 @annotation 注解用在类上,那么就无法起到拦截作用。

@Component
@Aspect
public class AuthAspect {
    @Pointcut("@annotation(sast.freshcup.annotation.AuthHandle)")
    public void myPoint() {
    }
    
    // 绑定切入点和切入点形参
    @Before("myPoint()&&@annotation(authHandler)")
    public Object authJudge(JoinPoint joinPoint, AuthHandler authHandler) {
        Account account = LoginInterceptor.accountThreadLocal.get();
        if (!AuthEnum.checkAuth(account, authHandler.value())) {
            throw new LocalRuntimeException(CustomError.AUTHENTICATION_ERROR);
        }
        return joinPoint;
    }
}

配置拦截器,对发送的 Token 进行判断(已经通过 Optional 进行改写)。如果 token 可以获取到对应的 account,将 account 传入 ThreadLocal 中,之后执行后面的判断:

public class AccountInterceptor implements HandlerInterceptor {
    public static ThreadLocal<Account> accountHolder = new ThreadLocal<>();
    private final JwtUtil jwtUtil;
    private final AccountMapper accountMapper;

    public AccountInterceptor(JwtUtil jwtUtil, AccountMapper accountMapper) {
        this.jwtUtil = jwtUtil;
        this.accountMapper = accountMapper;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String token = request.getHeader("TOKEN");
        if (!StringUtils.hasLength(token)) {
            throw new LocalRunTimeException(ErrorEnum.TOKEN_ERROR);
        }
        Account account = Optional
                .ofNullable(jwtUtil.getAccount(token))
                .orElseThrow(() -> new LocalRunTimeException(ErrorEnum.TOKEN_ERROR));
        // 登录过期
        if (jwtUtil.isExpired(account)) {
            throw new LocalRunTimeException(ErrorEnum.EXPIRED_LOGIN);
        }
        // 判断是否是已知用户
        Optional.ofNullable(accountMapper.selectById(account.getUid()))
                .orElseThrow(() -> new LocalRunTimeException(ErrorEnum.TOKEN_ERROR));
        accountHolder.set(account);
        // 更新 redis 中的 token 持续时间
        jwtUtil.reFreshToken(account);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response,
                                Object handler, Exception ex) {
        accountHolder.remove();
    }
}

配置拦截器拦截的 url:

@Configuration
@Component
public class WebMvcConfig extends WebMvcConfigurationSupport {

    private final CustomJsonHttpMessageConverter converter;

    private final LoginInterceptor loginInterceptor;

    public WebMvcConfig(CustomJsonHttpMessageConverter converter,
                        LoginInterceptor loginInterceptor) {
        this.converter = converter;
        this.loginInterceptor = loginInterceptor;
    }

    @Override
    protected void addInterceptors(InterceptorRegistry registry) {
        //这里需要使用自动装配的拦截器,需要公用 ThreadLocal
        //所以这里不能每次都创建新的拦截器,而是需要使用 IOC 帮我们feng'z
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns("/user/login", "/user/register");
    }
    
}

@execution

直接在 AOP 中使用 @annotation 是没有办法直接作用类,只能定义在对应方法。如果需要直接在类中使用 AOP 仍然需要 @execution。通过其他方法间接获取注解中的值。

@Slf4j
@Aspect
@Component
public class AuthAspect {
    /**
     * 获得方法或类上的注解
     *
     * @param method          方法
     * @param annotationClass 注解
     */
    @Nullable
    public static <T extends Annotation> T getAnnotation(
        Method method,
        Class<T> annotationClass) {
        if (method == null) {
            return null;
        }
        // 判断方法中是否存在对应注解
        if (method.isAnnotationPresent(annotationClass)) {
            return AnnotationUtils.getAnnotation(method, annotationClass);
        } else {
            // 如果方法中没有注解,就获取对应类上注解
            // 通过这种方式,实现在类上注解奏效于所有方法
            return AnnotationUtils.getAnnotation(method.getDeclaringClass(), annotationClass);
        }
    }

    @Pointcut("execution(* sast.freshcup.controller.*.*(..))" +
              "&&" +
              "!execution(* sast.freshcup.controller.LoginController.*(..))")
    public void start() {
    }

    @Before("start()")
    public Object auth(JoinPoint joinPoint) {
        final MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        final AuthHandle auth = getAnnotation(signature.getMethod(), AuthHandle.class);
        AuthEnum authEnum = Optional.ofNullable(auth)
            .map(AuthHandle::value)
            .orElseThrow(() -> new LocalRunTimeException(ErrorEnum.NO_USER));
        Account account = AccountInterceptor.accountHolder.get();
        if (!AuthEnum.checkAuth(account, authEnum)) {
            throw new LocalRunTimeException(ErrorEnum.AUTHORITY_ERROR);
        }
        return joinPoint;
    }
}

Java 中一个方法由方法名称和参数类型组成(重载也是通过这两个决定),他们的组合就是 MethodSignature(方法签名)。MethodSignature 是一个接口,继承自 Signature 接口,表示一个可执行方法的签名(方法名、参数类型列表、返回类型)。

public double calculateAnswer(double wingSpan, int numberOfEngines,
                              double length, double grossTons) {
    //do the calculation here
}
// calculateAnswer(double, int, double, double) 这是上述方法的方法签名

aspectj 中通过 MethodSignature 可以获得 joinPoint 的方法的 Signature。MethodSignature 接口和 Signature 接口并不是 Java 原生的,而是 Spring AOP 的一部分。

joinPoint.getSignature()

ThreadLocal 使用

对于 Java 中的多线程中,我们需要传递同一个参数,这个时候就可以使用 ThreadLocal,对每个一线程共享这个对象。使用 static 修饰避免重复创建 TSO(Thread Specific Object)所导致的浪费。

ThreadLocal 实例本身不存储值,它只是提供了一个在当前线程中找到副本值得 key。ThreadLocal 包含在 Thread 中,而不是 Thread 包含在 ThreadLocal 中每个线程有一个自己的 ThreadLocalMap。每个线程在往 ThreadLocal 里放值的时候,都会往自己的 ThreadLocalMap 里存,读也是以 ThreadLocal 作为引用,在自己的 map 里找对应的 key,从而实现了线程隔离。

static ThreadLocal<User> threadLocalUser = new ThreadLocal<>();
//一般写法

实际上,可以把 ThreadLocal 看成一个全局 Map<Thread, Object>:每个线程获取 ThreadLocal 变量时,总是使用 Thread 自身作为 key。因此我们就没必要在每个线程都创建一个 ThreadLocal(虽然 Spring 的 Bean 都是单例模式)。

ThreadLocal 结构

Object threadLocalValue = threadLocalMap.get(Thread.currentThread());

最后注意使用 threadLocalUser.remove(); 将线程清除或者将 threadLocalUser = null。调用 threadLocalUser.remove() 方法时,会移除当前线程中的 threadLocalUser 变量的值。这个方法通常用于清理线程本地变量的值,以避免内存泄露。

3.4 AOP 和 Controller

在 Controller 中,我们一般都将方法修饰为 public。但是有没有思考过,如果换为 private 和 protected 会有什么变化?

一般是没有问题,在“笼统”中文互联网回答中,一般会告诉你因为 CGLIB 动态代理的问题导致 private 的方法无法注入,所以 Service 的 field 值会为 null。

但是在一般前后端分离中,一般都会加上 @RestController 表示我们返回的是一个 body。并没有对应的基类,也就是说 Spring 并不会因此生成代理。所以,也就不会出现使用 private 时,Service 层 field NPE 的情况。

然而,如果我们使用对应的 AOP 就不可以了。使用了 AOP,也就是使用动态代理,底层默认调用的是 CGLIB 作为动态代理,其本质是:调用某个类的方法时,实际上是先为该类生成一个子类,然后再在子类中通过反射等,达到方法拦截的目的,对于子类,其父类中,private 修饰的方法,子类如果与父类不在同一包下,是没有访问的权限的,此场景下,CGLIB 生成的子类,不会和父类在同一包下,也就是 private 修饰的方法,不能进行动态代理,所以会报空指针异常。

3.5 操作日志

针对于复杂操作日志,可以通过 AOP 实现,方便进行 trace 和 debug(可以 MDC 获取用户各项信息)。

3.5.1 MDC

MDC ( Mapped Diagnostic Contexts ),它是一个线程安全的存放诊断日志的容器。

Logback 设计的一个目标之一是对分布式应用系统的审计和调试。在现在的分布式系统中,需要同时处理很多的请求。可以为每一个请求生一个 logger,但是这样子最产生大量的资源浪费,并且随着请求的增多这种方式会将服务器资源消耗殆尽,所以这种方式并不推荐。

一种更加轻量级的实现是使用MDC机制,在处理请求前将请求的唯一标示放到 MDC 容器中如 sessionId,这个唯一标示会随着日志一起输出,以此来区分该条日志是属于那个请求的。并在请求处理完成之后清除 MDC 容器。

3.5.2 实现

实现操作日志的大致想法就是通过拦截器获取用户信息,之后使用 MDC 结合注解 AOP,完成一系列接口调用的日志输出。

工具类

这个工具类的主要目的是帮助 interceptor 获取信息,帮助 AOP 获取内容参数(CodeSignature 是 Spring AOP 的一个类,它扩展了 MethodSignature 类,并提供了访问方法参数名的能力)。

使用 CodeSignature 获取方法的参数名是因为方法的参数名称可能在编译后丢失。通常,在编译 Java 代码时,方法的参数名称不会包含在字节码中。因此,在运行时使用反射获取方法的参数名称是不可能的。CodeSignature 类提供了一种解决方案,它使用 AspectJ 提供的编译时信息(通常是使用 AspectJ 编译器编译的代码)来提供方法参数名称(使用 CodeSignature 获取方法参数名称的性能可能略低于使用反射获取方法参数名称的性能。因为 CodeSignature 使用了 AspectJ 提供的编译时信息,所以在运行时会多花费一些时间来解析和处理这些信息)。

public class CommonUtil {
    /**
     * 获取 User-Agent
     *
     * @param request 请求
     * @return java.lang.String
     */
    public static String getUserAgent(HttpServletRequest request) {
        String header = request.getHeader("User-Agent");
        // hutool 的工具类,用于解析 ua
        UserAgent ua = UserAgentUtil.parse(header);
        return "浏览器:" + ua.getBrowser() + " " +
            ua.getVersion() + ",os:" + ua.getOs() + " " +
            ua.getOsVersion() + ",是否移动设备:" + ua.isMobile();
    }

    /**
     * AOP 获取请求参数
     *
     * @param joinPoint  切点
     * @param excludeSet 排查参数set
     * @return java.util.Map<java.lang.String, java.lang.Object>
     */
    public static Map<String, Object> getRequestParamMap(
        JoinPoint joinPoint, Set<String> excludeSet) {
        Map<String, Object> param = new HashMap<>();
        Object[] paramValues = joinPoint.getArgs();
        String[] paramNames = ((CodeSignature) joinPoint.getSignature()).getParameterNames();
        // 使用 stream + Optional 进行代码块优化
        IntStream.range(0, paramNames.length)
            .filter(i -> !Optional.ofNullable(excludeSet)
                    .map(set -> set.contains(paramNames[i]))
                    .orElse(false))
            .forEach(i -> param.put(paramNames[i], paramValues[i]));
        return param;
    }
}

在 TraceLog 的使用中,使用了 Lombok 的实验性功能 @Accessors。chain 参数表示类中的方法可以进行链式调用。

@Data
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
public class TraceLog {
    // 操作描述
    private String description;
    // 请求平台
    private String env;
    // UA
    private String userAgent;
    // 操作用户
    private Object user;
    // 消耗时间
    private String spendTime;
    // URL
    private String url;
    // 请求参数
    private Object params;
    // 请求返回的结果
    private Object result;

    public String toLogFormat(Boolean requestStatus) {
        String strResult = getResult(200);
        String strParam = String.valueOf(params);
        String format = requestStatus ? commonFormat : errorFormat;
        String strUser = String.valueOf(user);
        return String.format(format, description, 
                             env, url, strParam, 
                             strResult, strUser, spendTime, userAgent);
    }

    /**
     * 截取指定长度的返回值
     *
     * @param factor
     * @return
     */
    public String getResult(int factor) {
        String result = String.valueOf(this.result);
        if (factor == 0 || result.length() < factor) {
            return result;
        }
        return result.substring(0, factor - 1) + "...}";
    }

    private final static String commonFormat = "\n===========捕获响应===========\n" +
        "操作描述:%s\n" +
        "请求平台:%s\n" +
        "请求地址:%s\n" +
        "请求参数:%s\n" +
        "请求返回:%s\n" +
        "请求用户:%s\n" +
        "请求耗时:%s\n" +
        "请求UA:%s\n" +
        "===========释放响应===========";
    private final static String errorFormat = "\n===========捕获异常===========\n" +
        "操作描述:%s\n" +
        "请求平台:%s\n" +
        "请求地址:%s\n" +
        "请求参数:%s\n" +
        "请求异常:%s\n" +
        "请求用户:%s\n" +
        "请求耗时:%s\n" +
        "请求UA:%s\n" +
        "===========释放异常===========";

}

拦截器

配置拦截器,使用 MDC 存储日志信息,通过 ThreadLocal 传入 AOP 中。

@Component
public class RequestInterceptor implements HandlerInterceptor {
    public static ThreadLocal<TraceLog> requestHolder = new ThreadLocal<>();
    public static String TRACE_ID = "TRACE_ID";

    /**
     * 每次收到请求时,记录该次请求
     *
     * @param request
     * @param response
     * @param handler
     * @return
     */
    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {
        // MDC 机制 https://www.jianshu.com/p/1dea7479eb07
        // 每一系列请求添加一个 trace_id 方便之后追踪
        MDC.put(TRACE_ID, UUID.randomUUID().toString());
        TraceLog preTraceLog = new TraceLog()
                .setEnv(getEnv(request.getCookies()))
                .setSpendTime(System.currentTimeMillis() + "")
                .setUrl(request.getRequestURI())
                .setUserAgent(CommonUtil.getUserAgent(request));
        requestHolder.set(preTraceLog);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response,
                                Object handler,
                                @Nullable Exception ex) {
        requestHolder.remove();
        MDC.clear();
    }

    /**
     * 由于只做了PC端网页,所以暂时只放PC
     *
     * @param cookies
     * @return
     */
    private String getEnv(Cookie[] cookies) {
        return "PC";
    }
}

切片

@AfterThrowing(pointcut = "operateLog()&&@annotation(log)", throwing = "exception") 是一个切面的后置通知,在指定的切入点异常抛出后执行的通知方法。当满足条件的连接点抛出异常时,就会执行后置通知方法 throwHandler,其中,异常对象会传入到通知方法的第三个参数 exception 中。

环绕通知(@Around("operateLog()&&@annotation(log)"))是在连接点执行前后都会执行的通知,可以通过调用 ProceedingJoinPointproceed 方法来执行连接点,即调用被通知的方法。在环绕通知中,调用 proceed 方法后的代码就是在连接点执行后执行的。

@Aspect
@Component
public class OperateLogAspect {
    private final static Logger logg = LoggerFactory.getLogger(OperateLogAspect.class);
    private final static Set<String> EXCLUDE_SET;

    // 设置排除敏感字段
    static {
        EXCLUDE_SET = new HashSet<>();
        EXCLUDE_SET.add("password");
    }

    @Pointcut("@annotation(sast.freshcup.annotation.OperateLog)")
    public void operateLog() {
    }

    @Around("operateLog()&&@annotation(log)")
    public Object aroundMethod(ProceedingJoinPoint proceedingJoinPoint,
                               OperateLog log) throws Throwable {
        Map<String, Object> paramMap = CommonUtil.getRequestParamMap(proceedingJoinPoint, EXCLUDE_SET);
        Object returnValue = proceedingJoinPoint.proceed();
        try {
            Account account = Optional.ofNullable(AccountInterceptor.accountHolder.get())
                    .orElseThrow(() -> new LocalRunTimeException(ErrorEnum.NO_LOGIN));
            Optional.ofNullable(RequestInterceptor.requestHolder.get())
                    .ifPresent((preTrack) -> {
                preTrack.setSpendTime(
                    System.currentTimeMillis() - Long.parseLong(preTrack.getSpendTime()) + "ms")
                        .setDescription(log.operDesc())
                        .setParams(paramMap)
                        .setResult(returnValue)
                        .setUser(account);
                logg.info(preTrack.toLogFormat(true));
            });
        } catch (Exception e) {
            logg.error("error message is {0}", e);
        }
        return returnValue;
    }

    @AfterThrowing(pointcut = "operateLog()&&@annotation(log)", throwing = "exception")
    public void throwHandler(JoinPoint joinPoint, OperateLog log, Throwable exception) {
        Map<String, Object> paramMap = CommonUtil.getRequestParamMap(joinPoint, EXCLUDE_SET);
        try {
            Account account = Optional.ofNullable(AccountInterceptor.accountHolder.get()).orElseThrow(() -> {
                throw new LocalRunTimeException(ErrorEnum.NO_LOGIN);
            });

            Optional.ofNullable(RequestInterceptor.requestHolder.get()).ifPresent(preTrack -> {
                preTrack.setSpendTime(
                    System.currentTimeMillis() - Long.parseLong(preTrack.getSpendTime()) + "ms")
                        .setDescription(log.operDesc())
                        .setParams(paramMap)
                        .setResult(exception)
                        .setUser(account);
                logg.info(preTrack.toLogFormat(false));
            });
        } catch (Exception e) {
            logg.error("error message is {0}", e);
        }
    }
}

4. 登录逻辑

4.1 验证码

<!--对应依赖包-->
<dependency>
    <groupId>com.github.penggle</groupId>
    <artifactId>kaptcha</artifactId>
    <version>2.3.2</version>
</dependency>

设计生成图片验证码的接口,将生成的验证码存放在 redis 中。通过 UUID 给二维码生成独一无二的 uid。

UUID 由以下几部分的组合:

  1. 当前日期和时间;

  2. 时钟序列;

  3. 全局唯一的 IEEE 机器识别号,如果有网卡,从网卡 MAC 地址获得,没有网卡以其他方式获得。

配置图片验证码 Config 类:

@Configuration
public class KaptchaConfig {
    @Bean
    public DefaultKaptcha getDefaultKaptcha() {
        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
        Properties properties = new Properties();
        // 图片边框
        properties.setProperty("kaptcha.border", "no");
        // 边框颜色
        properties.setProperty("kaptcha.border.color", "black");
        //边框厚度
        properties.setProperty("kaptcha.border.thickness", "1");
        // 图片宽
        properties.setProperty("kaptcha.image.width", "200");
        // 图片高
        properties.setProperty("kaptcha.image.height", "50");
        //图片实现类
        properties.setProperty("kaptcha.producer.impl", "com.google.code.kaptcha.impl.DefaultKaptcha");
        //文本实现类
        properties.setProperty("kaptcha.textproducer.impl", "com.google.code.kaptcha.text.impl.DefaultTextCreator");
        //文本集合,验证码值从此集合中获取
        properties.setProperty("kaptcha.textproducer.char.string", "01234567890");
        //验证码长度
        properties.setProperty("kaptcha.textproducer.char.length", "4");
        //字体
        properties.setProperty("kaptcha.textproducer.font.names", "宋体");
        //字体颜色
        properties.setProperty("kaptcha.textproducer.font.color", "black");
        //文字间隔
        properties.setProperty("kaptcha.textproducer.char.space", "5");
        //干扰实现类
        properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.DefaultNoise");
        //干扰颜色
        properties.setProperty("kaptcha.noise.color", "blue");
        //干扰图片样式
        properties.setProperty("kaptcha.obscurificator.impl", "com.google.code.kaptcha.impl.WaterRipple");
        //背景实现类
        properties.setProperty("kaptcha.background.impl", "com.google.code.kaptcha.impl.DefaultBackground");
        //背景颜色渐变,结束颜色
        properties.setProperty("kaptcha.background.clear.to", "white");
        //文字渲染器
        properties.setProperty("kaptcha.word.impl", "com.google.code.kaptcha.text.impl.DefaultWordRenderer");
        Config config = new Config(properties);
        defaultKaptcha.setConfig(config);
        return defaultKaptcha;
    }

}

4.2 登录

设计的过程是用户在登录之后获得对应 token 并存放到 Redis 中(token 无法重设过期时间),之后的操作统一需要读取 token 获得对应权限。

创建工具类,获取存储在 redis 中的 token(token 通过 “TOKEN”+ username 进行拼接):

public class RedisKeyFetch {
    public static String getTokenKey(Account account){
        return "TOKEN:" + account.getUsername();
    }
}

使用 LoginController:

@Slf4j
@RestController
public class LoginController {
    public static final String LOGIN_VALIDATE_CODE = "VAL_CODE:";
    private final DefaultKaptcha kaptchaProducer;
    private final RedisUtil redisUtil;
    private final AccountMapper accountMapper;
    private final JWTUtil jwtUtil;
    
    public LoginController(DefaultKaptcha kaptchaProducer, 
                           RedisUtil redisUtil, 
                           AccountMapper accountMapper, 
                           JWTUtil jwtUtil) {
        this.kaptchaProducer = kaptchaProducer;
        this.redisUtil = redisUtil;
        this.accountMapper = accountMapper;
        this.jwtUtil = jwtUtil;
    }

    /**
     * 返回验证码图片,并将验证码存入Redis
     *
     * @param response 设置响应头参数信息
     */
    @GetMapping("/getValidateCode")
    public void getImgValidateCode(HttpServletResponse response) {
        String uuid = UUID.randomUUID().toString().replaceAll("-", "");

        response.setDateHeader("Expires", 0);
        response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
        response.addHeader("Cache-Control", "post-check=0, pre-check=0");
        response.setHeader("Pragma", "no-cache");
        //为每个请求生成一个唯一的验证码
        response.addHeader("CAPTCHA", uuid);
        response.setContentType("image/jpeg");

        String capText = kaptchaProducer.createText();
        BufferedImage bi = kaptchaProducer.createImage(capText);

        try {
            ServletOutputStream out = response.getOutputStream();
            ImageIO.write(bi, "jpg", out);
            out.flush();
        } catch (Exception e) {
            e.printStackTrace();
        }

        redisUtil.set(LOGIN_VALIDATE_CODE + uuid, capText, 60 * 5);
    }


    @PostMapping("/login")
    public Map<String, String> login(@RequestBody Account account,
                                     @RequestHeader("User-Agent") String agent,
                                     @RequestParam("validateCode") String validateCode,
                                     @RequestHeader("CAPTCHA") String uuid) {
        //验证验证码
        String currentCode = redisUtil.get(LOGIN_VALIDATE_CODE + uuid);
        if (currentCode == null) {
            throw new LocalRuntimeException("验证码失效");
        } else if (!currentCode.equals(validateCode)) {
            throw new LocalRuntimeException("验证码错误");
        }
        redisUtil.del(LOGIN_VALIDATE_CODE + uuid);

        //登录处理
        QueryWrapper<Account> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("username", account.getUsername());
        Account accountFromDB = accountMapper.selectOne(queryWrapper);
        if (accountFromDB == null) {
            throw new LocalRuntimeException("账号不存在");
        } else if (!SecureUtil.md5(account.getPassword()).equals(accountFromDB.getPassword())) {
            throw new LocalRuntimeException("密码错误");
        }
        String token = jwtUtil.generateToken(accountFromDB);
        Map<String, String> map = new HashMap<>();
        map.put("role", accountFromDB.getRole().toString());
        map.put("token", token);

        log.info("===============================================");
        log.info("用户登录:{},role:{}", accountFromDB.getUsername(), accountFromDB.getRole());
        log.info("登录 Agent:{}", agent);
        log.info("===============================================");

        // 用 Redis 中的过期时间代替 JWT 的过期时间,每次经过拦截器时更新过期时间
        // 先设置为 30 天
        redisUtil.set(RedisKeyFetch.getTokenKey(account), token, 30, TimeUnit.DAYS);
        return map;
    }

}

5. open api

通过接口内容自动生成接口文档,比手动更新更具有时效性。

5.1 swagger

配置 swagger 配置文件:

@Configuration
@EnableOpenApi//开启 Swagger 功能
public class SwaggerConfig {
    @Bean
    public Docket docket() {
        return new Docket(DocumentationType.OAS_30)
                .apiInfo(apiInfo()).enable(true)
                .select()
                //apis: 添加swagger接口提取范围
                .apis(RequestHandlerSelectors.basePackage("com.sast.jwt.controller"))
                .apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
                .paths(PathSelectors.any())
                .build();
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("toy_Backend 项目接口文档")
                .description("toy_Backend 项目描述")
                .contact(new Contact("cxy621", "https://hexo.cxy621.top", "1580779474@qq.com"))
                .version("1.0")
                .build();
    }

}

但在 SpringBoot 更新到新版本之后,springfox 已经不再更新了,所以之后 swagger 的使用频率一定会减少,甚至停用。在 SpringBoot 2.6.X 版本及以上使用 swagger 时,会出现 Failed to start bean 'documentationPluginsBootstrapper'; 的错误。

因为 Springfox 使用的路径匹配是基于 AntPathMatcher 的,而 SpringBoot 2.6.X 使用的是 PathPatternMatcher。我们需要修改配置文件:

spring.mvc.pathmatch.matching-strategy: ANT_PATH_MATCHER

注意:对于 swagger 来说,返回的页面会被全局异常给捕获,所以无法正常显示。好消息是,有一个新的 open api 生成工具。

5.2 springdoc

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-ui</artifactId>
    <version>1.6.7</version>
</dependency>

springdoc 相比于单纯的 swagger,不需要配置 config 文件就能够直接使用。

默认 json 数据访问链接🔗为:/v3/api-docs/

默认 swagger 页面访问链接🔗为:swagger-ui.html

可以在对应的 yaml 文件中配置制定自定义路径:

springdoc:
  swagger-ui:
    path: /swagger-ui-custom.html
  api-docs:
    path: /api-docs

之后打开对应链接,就可以打开 open-api 界面。

之后,需要在对应 controller 中加入 swagger 注释,让生成 api 具有一定的可读性。

@RestController
//使用 @Tag 对 controller 进行确认
@Tag(name = "测试接口", description = "第一次使用 swagger 形式进行书写")
public class TestController {
    @GetMapping("/hello")
    @Operation(summary = "输出 hello world")
    public String index() {
        return "Hello World!";
    }

    @PostMapping("")
    public String test() {
        return "Hello!";
    }

    @GetMapping("/call/{name}")
    @Operation(summary = "对特定的用户问好", description = "需要传入对应的用户名",
               //对需要的参数进行声明,会通过注释来判断参数的类型
            parameters = {
                    @Parameter(name = "name", description = "用户名", required = true)
            })
    public String call(@PathVariable String name) {
        return "Hello " + name + "!";
    }
}

6. poi

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi</artifactId>
    <version>3.17</version>
</dependency>

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>3.15</version>
</dependency>

poi 是通过层层调用 excel 各项,最低细化到单个单元格进行操作。从 Workbook -> Sheet -> Row -> Cell。FreshCup 过程中的示例:

public Workbook importAdmin(Long contestId, MultipartFile file)
    throws IOException {
    if (contestMapper.selectOne(new LambdaQueryWrapper<Contest>()
                                .eq(Contest::getId, contestId)) == null) {
        throw new LocalRunTimeException(ErrorEnum.NO_CONTEST);
    }
	
    AtomicInteger success = new AtomicInteger(0);
    AtomicInteger failure = new AtomicInteger(0);

    // 创建一个新的工作簿
    Workbook output = new XSSFWorkbook();
    // 创建一个“结果” sheet
    Sheet sheetOutput = output.createSheet("结果");
    Row rowOutput = sheetOutput.createRow(0);
    rowOutput.createCell(0).setCellValue("账号");
    rowOutput.createCell(1).setCellValue("密码");

    //通过文件流,读取文件的文件流
    Workbook workbook = new XSSFWorkbook(file.getInputStream());
    Sheet sheet = workbook.getSheetAt(0);
    int rowNum = sheet.getLastRowNum();
    for (int i = 1; i <= rowNum; i++) {
        Row row = sheet.getRow(i);
        Cell cell = row.getCell(0);
        String username = cell.getStringCellValue();
        Account account = accountMapper.selectOne(new LambdaQueryWrapper<Account>()
                                                  .eq(Account::getUsername, username));
        if (account != null) {
            if (!account.getRole().equals(1)) {
                throw new LocalRunTimeException(ErrorEnum.ROLE_ERROR);
            }
            log.info(username + " 管理员已导入,无需再次导入");
            failure.getAndIncrement();
        } else {
            log.info(username + " 管理员导入成功");
            success.getAndIncrement();

            rowOutput = sheetOutput.createRow(success.get());
            rowOutput.createCell(0).setCellValue(username);
            String password = createAdmin(username);
            rowOutput.createCell(1).setCellValue(password);
        }
        addContestUser(contestId, username, accountMapper, accountContestManagerMapper);
    }
    //判断是否有创建成功的学生账号
    if (sheetOutput.getLastRowNum() == 0) {
        sheetOutput.createRow(1);
    }
    sheetOutput.getRow(0).createCell(3).setCellValue("成功导入" + success.get() + "个");
    sheetOutput.getRow(1).createCell(3).setCellValue("失败" + failure.get() + "个");
    return output;
}

7. fastjson

fastjson 已经升级到 fastjson2,为了向未来 10 年提供高性能的 JSON 库。

<dependency>
    <groupId>com.alibaba.fastjson2</groupId>
    <artifactId>fastjson2</artifactId>
</dependency>

通过 @JSONField,设置指定字段的序列化规则,包括名称(name),顺序(ordinal),是否序列化等等。

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Person {
    @JSONField(name = "AGE", ordinal = 2)
    private int age;
    @JSONField(name = "FULL NAME", ordinal = 1)
    private String name;
    /**
     * 防止序列化
     */
    @JSONField(serialize = false)
    private String password;
}

通过 fastjson 将 Object 转为 String,再由 String 转为 JSON。使用 filter 和 Config 进行多重配置。

@Slf4j
public class FastJsonTest {
    private final List<Person> listOfPerson = new ArrayList<>();

    @BeforeEach
    public void initial() {
        listOfPerson.add(new Person(15, "cxy", "abc"));
        listOfPerson.add(new Person(20, "JC", "123"));
    }

    @Test
    public void jsonToString() {
        String jsonOutput = JSON.toJSONString(listOfPerson);
        var list = JSON.parseObject(jsonOutput, List.class);
        log.info(jsonOutput);
        log.info("==========================");
        for (var i : list) {
            log.info(i.toString());
        }
    }
    /*
    23:27:05.735 [main] INFO fun.sast.websocket.FastJsonTest - [{"FULL NAME":"cxy","AGE":15}
    ,{"FULL NAME":"JC","AGE":20}]
    23:27:05.736 [main] INFO fun.sast.websocket.FastJsonTest - ==========================
    23:27:05.736 [main] INFO fun.sast.websocket.FastJsonTest - {"FULL NAME":"cxy","AGE":15}
    23:27:05.736 [main] INFO fun.sast.websocket.FastJsonTest - {"FULL NAME":"JC","AGE":20}
    */

    @Test
    public void useConfig() {
        // 修改生成 key 的规则
        NameFilter formatName = (o, s, o1) -> s.toLowerCase().replace(" ", "_");
        // BeanToArray 表明使用数组转化的方式
        var jsonOutput = JSON.toJSONString(listOfPerson, formatName, JSONWriter.Feature.BeanToArray);
        log.info(jsonOutput);
    }
    /*
    23:27:05.698 [main] INFO fun.sast.websocket.FastJsonTest - [["cxy",15],["JC",20]]
    */
}

8. Websocket

WebSocket 是一种全双工通信的协议,既允许客户端向服务器主动发送消息,也允许服务器主动向客户端发送消息。在 WebSocket 中,浏览器和服务器只需要完成一次握手,两者之间就可以建立持久性的连接,进行双向数据传输。

  • 连接握手阶段使用 HTTP 协议;
  • 协议标识符是 ws,如果采用加密则是 wss(WebSocket Secure);
  • 数据格式比较轻量,性能开销小,通信高效;
  • 没有同源限制,客户端可以与任意服务器通信;
  • 建立在 TCP 协议之上,服务器端的实现比较容易;
  • 通过 WebSocket 可以发送文本,也可以发送二进制数据;
  • 与 HTTP 协议有着良好的兼容性。默认端口也是 80 和 443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。

8.1 其他方法

Web 项目中,经常存在“聊天室”等需要实时性的内容。实现这样的需求常用的有以下几种解决方案:

8.1.1 短轮询

短轮询是指客户端每隔一段时间就询问一次服务器是否有新的消息,如果有就接收消息。这样方式会增加很多次无意义的发送请求信息,每次都会耗费流量及处理器资源。

  • 优点:短连接,服务器处理简单,支持跨域、浏览器兼容性较好;
  • 缺点:有一定延迟、服务器压力较大,浪费带宽流量、大部分是无效请求。

8.1.2 长轮询

长轮询是段轮询的改进,客户端执行 HTTP 请求发送消息到服务器后,等待服务器回应,如果没有新的消息就一直等待,知道服务器有新消息传回或者超时。这也是个反复的过程,这种做法只是减小了网络带宽和处理器的消耗,但是带来的问题是导致消息实时性低,延迟严重。而且也是基于循环,最根本的带宽及处理器资源占用并没有得到有效的解决。

  • 优点:减少轮询次数,低延迟,浏览器兼容性较好;
  • 缺点:服务器需要保持大量连接。

8.1.3 服务器发送事件

服务器发送事件是一种服务器向浏览器客户端发起数据传输的技术。一旦创建了初始连接,事件流将保持打开状态,直到客户端关闭。该技术通过传统的 HTTP 发送,并具有 WebSockets 缺乏的各种功能,例如”自动重新连接”、”事件ID” 及 “发送任意事件”的能力。

服务器发送事件是单向通道,只能服务器向浏览器发送,因为流信息本质上就是下载。

  • 优点:适用于更新频繁、低延迟并且数据都是从服务端发到客户端;
  • 缺点:浏览器兼容难度高。

8.2 连接流程

客户端先用带有 Upgrade:Websocket 请求头的 HTTP 请求,向服务器端发起连接请求,实现握手(HandShake)。客户端 HTTP 请求的 Header 头信息如下:

Connection: Upgrade
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: IRQYhWINfX5Fh1zdocDl6Q==
Sec-WebSocket-Version: 13
Upgrade: websocket
  • Connection:Upgrade 表示要升级协议;
  • Upgrade:Websocket 要升级协议到 Websocket 协议;
  • Sec-WebSocket-Extensions:表示客户端所希望执行的扩展(如消息压缩插件);
  • Sec-WebSocket-Key:主要用于 WebSocket 协议的校验,对应服务端响应头的 Sec-WebSocket-Accept;
  • Sec-WebSocket-Version:表示 WebSocket 的版本。如果服务端不支持该版本,需要返回一个Sec-WebSocket-Versionheader,里面包含服务端支持的版本号。

握手成功后,由 HTTP 协议升级成 Websocket 协议,进行长连接通信,两端相互传递信息。服务端响应的 HTTP Header 头信息如下:

Connection: upgrade
Sec-Websocket-Accept: TSF8/KitM+yYRbXmjclgl7DwbHk=
Upgrade: websocket
  • Connection:Upgrade 表示要升级协议;
  • Upgrade:Websocket 要升级协议到 Websocket 协议;
  • Sec-Websocket-Accept:对应 Sec-WebSocket-Key 生成的值,主要是返回给客户端,让客户端对此值进行校验,证明服务端支持 WebSocket。

8.3 STOMP 协议

STOMP(Simple Text-Orientated Messaging Protocol)是一种简单的面向文本的消息传递协议。它提供了一个可互操作的连接格式,允许 STOMP 客户端与任意 STOMP 消息代理(Broker)进行交互。STOMP 协议由于设计简单,易于开发客户端,因此在多种语言和多种平台上得到广泛地应用。

  • STOMP 是基于帧的协议,其帧以 HTTP 为模型;
  • STOMP 框架由命令,一组可选的标头和可选的主体组成;
  • STOMP 基于文本,但也允许传输二进制消息;
  • STOMP 的默认编码为 UTF-8,但它支持消息正文的替代编码的规范。

STOMP 客户端是一种用户代理,可以分为两种模式运行:

  • 作为生产者,通过 SEND 帧将消息发送到目标服务器上;
  • 作为消费者,对目标地址发送 SUBSCRIBE 帧,并作为 MESSAGE 帧从服务器接收消息。

8.3.1 STOMP 帧

STOMP 是基于帧的协议,其帧以 HTTP 为模型。STOMP 结构为:

COMMAND
header1:value1
header2:value2

Body^@

客户端可以使用 SEND 或 SUBSCRIBE 命令发送或订阅消息,还可以使用 “destination” 头来描述消息的内容和接收者。这支持一种简单的发布-订阅机制,可用于通过代理将消息发送到其他连接的客户端,或将消息发送到服务器以请求执行某些工作。

STOMP 的客户端和服务器之间的通信是通过”“(Frame)实现的,每个帧由多”“(Line)组成,其包含的帧如下:

8.3.2 STOMP & WebSocket

直接使用 WebSocket 就很类似于使用 TCP 套接字来编写 Web 应用,因为没有高层级的应用协议(wire protocol),因而就需要我们定义应用之间所发送消息的语义,还需要确保连接的两端都能遵循这些语义。同 HTTP 在 TCP 套接字上添加请求-响应模型层一样,STOMP 在 WebSocket 之上提供了一个基于帧的线路格式层,用来定义消息语义。

使用 STOMP 作为 WebSocket 子协议的好处

  • 无需发明自定义消息格式;
  • 在浏览器中 使用现有的 stomp.js 客户端;
  • 能够根据目的地将消息路由到;
  • 可以使用成熟的消息代理(例如 RabbitMQ,ActiveMQ 等)进行广播的选项;
  • 使用 STOMP(相对于普通 WebSocket)使 Spring Framework 能够为应用程序级使用提供编程模型,就像 Spring MVC 提供基于 HTTP 的编程模型一样。

8.4 SpringBoot 实现

使用 Spring 的 STOMP 支持时,Spring WebSocket 应用程序充当客户端的 STOMP 代理。消息被路由到 @Controller 消息处理方法或简单的内存中代理,该代理跟踪订阅并向订阅的用户广播消息。还可以将 Spring 配置为与专用的 STOMP 代理(例如 RabbitMQ,ActiveMQ 等)一起使用,以实际广播消息。在那种情况下,Spring 维护与代理的 TCP 连接,将消息中继到该代理,并将消息从该代理向下传递到已连接的 WebSocket 客户端。因此 Spring Web 应用程序可以依赖基于统一 HTTP 的安全性,通用验证以及熟悉的编程模型消息处理工作。

Spring 官方提供的处理流图

  • Message: 消息,里面带有 header 和 payload;

  • MessageHandler: 处理 client 消息的实体;

  • MessageChannel:

    解耦消息发送者与消息接收者的实体

    • clientInboundChannel:用于从 WebSocket 客户端接收消息;
    • clientOutboundChannel:用于将服务器消息发送给 WebSocket 客户端;
    • brokerChannel:用于从服务器端、应用程序中向消息代理发送消息
  • Broker: 存放消息的中间件,client 可以订阅 broker 中的消息。

结合 RabbitMQ 使用

8.4.1 实现广播

在 Config 文件中实现 EndPoint、Broker 等配置,配置应用到之后 WebSocket de

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    /**
     * 配置 WebSocket 进入点,及开启使用 SockJS,这些配置主要用配置连接端点,用于 WebSocket 连接
     * @param registry STOMP 端点
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // 通过 /mydlq 和 WebSocket 进行连接
        registry.addEndpoint("/mydlq")
                // 设置允许跨域,高版本不再支持这样的写法
//                .setAllowedOrigins("*")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }

    /**
     * 配置消息代理选项
     * @param registry 消息代理注册配置
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 设置一个或者多个代理前缀,在 Controller 类中的方法里面发生的消息,会首先转发到代理从而发送到对应广播或者队列中
        // 订阅的客户端需要以此为前缀才能接受到信息
        registry.enableSimpleBroker("/topic");
        // 配置客户端发送请求消息的一个或多个前缀,该前缀会筛选消息目标转发到 Controller 类中注解对应的方法里
        registry.setApplicationDestinationPrefixes("/app");
    }
}
@RestController
public class MessageController {
    /**
     * `@MessageMapping` 类似 `@RequestMapping`
     * `@SendTo` 表示发送到的 Broker
     * @param messageBody 自定义的返回 body
     */
    @MessageMapping("/test")
    @SendTo("/topic/message")
    public String sendTopicMessage(MessageBody messageBody) {
        return JSON.toJSONString(messageBody);
    }
}

参考文章

  1. JSON Web Token 入门教程
  2. 单点登录(SSO)
  3. 前后端鉴权二三事
  4. CAS 实现单点登录(SSO)原理
  5. 理解 OAuth 2.0
  6. AOP 获取方法上的注解,并修改注解内容
  7. Java 中什么是方法签名?
  8. SpringBoot 整合 Redis 以及工具类撰写
  9. 使用 ThreadLocal
  10. 关于 ThreadLocal 你一定要知道的
  11. ThreadLocal 为什么要设计成 private static
  12. SpringBoot 中 Controller 层中的方法为什么只能是 public?
  13. 在 Controller 层 private 修饰的方法导致 Service 注入为空
  14. SpringBoot 整合 Captcha 验证码
  15. Java 生成 UUID
  16. Slf4j MDC 机制
  17. 升级 SpringBoot 2.6.x 版本后,Swagger 没法用了
  18. SpringBoot 集成 swagger3
  19. SpringDoc 生成 OpenAPI3.0
  20. SpringBoot2 集成 springdoc-openapi-ui
  21. SpringBoot 中 poi 操作合集
  22. Redis 数据结构快速链表
  23. Redis 学习记录
  24. 干掉 fastjson 国产新一代 fastjson 2
  25. Fastjson 简明教程
  26. OAuth 2.0 实战
  27. SpringBoot 实现 Websocket 通信详解

文章作者: 陈鑫扬
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 陈鑫扬 !
评论
  目录