2024年11月

1.概念

堆是JVM中最重要的一块区域,JVM规范中规定所有的对象和数组都应该存放在堆中,在执行字节码指令时,会把创建的对象存入堆中,对象的引用地址存入虚拟机栈的栈帧中。不过当方法执行完之后,刚刚所创建的对象并不会立马被回收,也就是说对象并不会随着栈帧的消失而消失,而是要等JVM后台执行GC后,对象才会被回收。

2.指定堆大小

-Xms:指定堆的初始内存大小,ms(memory start)。等价于 -XX:InitialHeapSize;

-Xmx:指定堆的最大内存大小,mx(memory max)。等价于 -XX:MaxHeapSize;

一般会把 -Xms 和 -Xmx 设置为一样,这样JVM就不需要在GC后去修改堆的内存大小了,提高了效率。默认情况下,-Xms等于物理内存大小/64,-Xmx等于物理内存大小/4。

3.新生代和老年代

垃圾回收算法有很多,但基本上都会把内存分为新生代和老年代两块区域。新生代存放新创建的对象,老年代存放执行了许多次GC(默认为15次)后还存活的对象。

可以通过 -XX:NewRatio 参数来配置老年代和新生代的比例,默认为 -XX:NewRatio=2,表示新生代占1,老年代占2。一般是不需要调整的,只有明确知道存活时间比较长的对象偏多或偏少,才需要调整 -XX:NewRatio 的比值。

3.1 新生代

新生代又可以分为 Eden(伊甸园区)和 S0、S1 区。

Eden:
伊甸园区,新对象都会先放到Eden区。

S0、S1区:
Survivor0、Survivor1区,也可以叫做from、to区,用来存放MinorGC(YGC)后存在的对象。

默认情况下 Eden、S0、S1 的比例为 8:1:1,也就是说Eden区占新生代大小的 8/10。可以通过 -XX:SurvivorRatio 来调整。

3.2 老年代

老年代存放执行了许多次GC后还存活的对象。老年代默认占内存区域的2/3。

3.3 动画演示

动画演示对象在内存各区域中的流转过程

  1. 对象会先被放到Eden区。
  2. 执行 Young GC 后会被放到S0或S1区,S0和S1不能同时非空,对象会在S0和S1之间反复跳跃。
  3. 在执行一定次数(默认为15次)的 Young GC 后假设对象还没有被回收掉,就会进入老年代区域。
  4. 如果新对象大小超过了 Eden 区剩余空间大小,则会直接进入S0或S1,如果S0或S1放不下则会直接进入老年代。
  5. 老年代继续执行 Old GC 对其中对象进行回收。
  6. 这里的 Young GC 和 Old GC 也可叫做 Minor GC 和 Major GC,它们并不是垃圾回收器的名字,只是代表年轻代和老年代的垃圾回过程

4.分代收集理念

上面的新生代老年代就是分代收集理念,有些时候会被叫做分代收集算法,但其实它是一种理念。默认几乎所有的垃圾回收算法都是采用分代收集理念。

为什么垃圾回收算法要把内存区域分为新生代和老年代,新生代里又包含Eden区、Survivor0、Survivor1区呢?

这是因为不同的对象存活时长是不一样的,所以要针对存活时长不同的对象采取不同的垃圾回收算法。

  • 新生代中的对象存活时间比较短,那么就可以采取“复制算法”(后面的章节会介绍)。
  • 老年代中的对象存活时间比较长,所以不太适合用复制算法,可以用“标记-清除算法”或“标记-整理算法”(后面的章节会介绍)。

二、Spring Security默认认证流程及其优缺点

1、Spring Security默认认证流程总结

四、Spring Boot集成Spring Security之认证流程
详细介绍了认证流程,其核心流程如下

  1. SecurityContextPersistenceFilter:chain.doFilter()前从安全上下文仓库中获取安全上下文,未登录状态时获取未认证的安全上下文;chain.doFilter()后从安全上下文持有者中获取安全上下文并更新到安全上下文仓库中
  2. LogoutFilter:如果是登出请求,清除安全上下文认证信息并重定向到登录页面,否则不处理
  3. UsernamePasswordAuthenticationFilter:如果是登录请求,校验请求参数中的用户名密码,校验成功后生成新的已认证的安全上下文并保存到安全上下文仓库中后重定向到目标URL,否则不处理
  4. DefaultLoginPageGeneratingFilter:如果是登录页面请求,返回默认登录页面,否则不处理
  5. DefaultLogoutPageGeneratingFilter:如果是登出页面请求,返回默认登出页面,否则不处理

2、优缺点

  1. 提供了完整的安全的认证流程
  2. 默认基于session实现非前后分离项目的认证流程,该流程已经慢慢退出历史舞台
  3. 未提供前后分离认证流程

三、前后分离项目认证思路

1、前后分离项目认证流程(基于默认流程优化)

  1. 前端输入用户名密码提交到后端
  2. 后端获取到用户名密码并校验,校验成功后生成token(类似于sessionId)返回给前端,生成已认证的安全上下文(类似于session)存储到安全上下文仓库中
  3. 前端获取到token,后续每次请求的请求头中都携带该token(类似于cookie)
  4. 后端获取请求头中的token,通过token获取安全上下文,并设置到安全上下文持有者中
  5. 前端提交退出请求时,后端获取请求头中的token,并通过token删除安全上下文仓库中安全上下文

2、前后分离项目认证流程关键组件对应的默认实现

从前后分离项目认证流程可以看出有四个关键组件

  1. 每次请求时通过请求头中token从安全上下文仓库中获取安全上下文的过滤器(默认SecurityContextPersistenceFilter)
  2. 登出时通过请求头中的token从安全上下文仓库中清除安全上下文的过滤器(默认LogoutFilter)
  3. 登录时验证用户名密码并生成token和安全上下文,将安全上下文添加到安全上下文仓库中的过滤器(默认UsernamePasswordAuthenticationFilter)
  4. 安全上下文仓库(默认HttpSessionSecurityContextRepository)

3、默认实现的局限性

  1. UsernamePasswordAuthenticationFilter从form表单中获取请求参数,不符合RESTFUL开发规范
  2. 认证的关键组件AuthenticationManager未注入到Spring容器中,导致自定义认证过滤器无法直接从Spring容器中获取
  3. UsernamePasswordAuthenticationFilter只实现了认证部分,认证成功后生成的安全上下文并添加安全上下文仓库中过程无法控制,只能使用默认的HttpSession或RequestAttributes方式,无法自定义

4、整改思路

  1. 自定义SecurityContextRepositoryImpl实现安全上下文仓库SecurityContextRepository,实现基于分布式缓存的安全上下文仓库
  2. 自定义RestfulUsernamePasswordAuthenticationFilter继承AbstractAuthenticationProcessingFilter,实现符合RESTFUL开发规范的登录方式
  3. 自定义UserDetailsImpl实现UserDetails接口,方便添加自定义属性
  4. 自定义UserDetailsServiceImpl实现UserDetailsService接口,实现基于数据库的认证方式,并生成token设置到UserDetails中

5、整改后的认证流程

  1. 前端输入用户名密码提交到后端
  2. 后端AbstractAuthenticationProcessingFilter调用子类RestfulUsernamePasswordAuthenticationFilter的attemptAuthentication方法获取认证信息
  3. RestfulUsernamePasswordAuthenticationFilter获取请求中的用户名密码,并调用UserDetailsService的loadUserByUsername获取用户信息
  4. UserDetailsServiceImpl通过用户名查询用户,将用户信息设置到创建的UserDetailsImpl对象中,生成token设置到UserDetailsImpl对象中
  5. AbstractAuthenticationProcessingFilter调用SecurityContextRepositoryImpl保存安全上下文
  6. SecurityContextRepositoryImpl获取安全上下文及其认证信息中的token,将token和安全上下文添加到分布式缓存中
  7. 将token返回到前端
  8. 前端获取token,每次请求时都在请求头中携带该token
  9. SecurityContextPersistenceFilter/SecurityContextHolderFilter调用SecurityContextRepositoryImpl的loadContext获取安全上下文
  10. SecurityContextRepositoryImpl获取请求头中token,使用token从分布式缓存中获取安全上下文并返回
  11. 前端提交登出请求
  12. LogoutFilter调用SecurityContextRepositoryImpl的saveContext,其中参数安全上下文为空值安全上下文
  13. SecurityContextRepositoryImpl判断出空值安全上下文,获取请求头中的token,使用token删除分布式缓存中获取安全上下文

四、总结

1、设计前后分离项目认证流程原则

  1. 尽可能贴合原生Spring Security处理流程,尽量使用Spring Security提供的组件
  2. 接口设计符合RESTFUL接口规范
  3. 使用分布式缓存存储登录凭证,更适合分布式项目

2、其他说明

  1. 这里说的前后分离项目认证流程最佳方案,是个人认为的最佳方案,并非行业公认的最佳方案,一千个读者就有一千个哈姆雷特,欢迎在评论区或者私信讨论你心中的最佳方案
  2. 下文代码实现该方案,敬请期待

在使用
HttpClient
发起 HTTP 请求时,可能会遇到请求头丢失的问题,尤其是像
Accept-Language
这样的请求头丢失。这个问题可能会导致请求的内容错误,甚至影响整个系统的稳定性和功能。本文将深入分析这一问题的根源,并介绍如何通过
HttpRequestMessage
来解决这一问题。

1. 问题的背景:HttpClient的设计与共享机制

HttpClient
是 .NET 中用于发送 HTTP 请求的核心类,它是一个设计为可复用的类,其目的是为了提高性能,减少在高并发情况下频繁创建和销毁 HTTP 连接的开销。
HttpClient
的复用能够利用操作系统底层的连接池机制,避免了每次请求都要建立新连接的性能损失。

但是,
HttpClient
复用的机制也可能导致一些问题,尤其是在多线程并发请求时。例如,如果我们在共享的
HttpClient
实例上频繁地修改请求头,可能会导致这些修改在不同的请求之间意外地“传递”或丢失。

2. 常见问题:丢失请求头

假设我们有如下的代码,其中我们希望在每次请求时设置
Accept-Language
头:

using System.Net.Http;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;

namespace ConsoleApp9
{
    internal class Program
    {
        private static readonly JsonSerializerSettings serializerSettings = new JsonSerializerSettings
        {
            ContractResolver = new CamelCasePropertyNamesContractResolver(),
            NullValueHandling = NullValueHandling.Ignore
        };

        private static readonly HttpClient httpClient = new HttpClient(); // 复用HttpClient实例
        private static readonly SemaphoreSlim semaphore = new SemaphoreSlim(100); // 限制并发请求数量为100

        static async Task Main(string[] args)
        {
            List<Task> tasks = new List<Task>();
            int taskNoCounter = 1; // 用于跟踪 taskno
            // 只使用一个HttpClient对象(全局共享)
            for (int i = 0; i < 50; i++)
            {
                tasks.Add(Task.Run(async () =>
                {
                    // 等待信号量,控制最大并发数
                    await semaphore.WaitAsync();

                    try
                    {
                        var postData = new
                        {
                            taskno = taskNoCounter++,
                            content = "等待翻译的内容"
                        };
                        var json = JsonConvert.SerializeObject(postData, serializerSettings);
                        var reqdata = new StringContent(json, Encoding.UTF8, "application/json");

                        // 设置请求头语言
                        httpClient.DefaultRequestHeaders.Add("Accept-Language", "en-US");                      
                        // 发送请求
                        var result = await httpClient.PostAsync("http://localhost:5000/translate", reqdata);

                        // 读取并反序列化 JSON 数据
                        var content = await result.Content.ReadAsStringAsync();
                        var jsonResponse = JsonConvert.DeserializeObject<Response>(content);
                        var response = jsonResponse.Data.Content;
                       
                        // 反序列化后,直接输出解码后的文本
                        Console.WriteLine($"结果为:{response}");
                    }
                    catch (Exception ex)
                    {
                        Console.WriteLine($"请求失败: {ex.Message}");
                    }
                    finally
                    {
                        // 释放信号量
                        semaphore.Release();
                    }
                }));
            }

            await Task.WhenAll(tasks);
        }
    }

    // 定义与响应结构匹配的类
    public class Response
    {
        public int Code { get; set; }
        public ResponseData Data { get; set; }
        public string Msg { get; set; }
    }

    public class ResponseData
    {
        public string Content { get; set; }
        public string Lang { get; set; }
        public int Taskno { get; set; }
    }
}

接收代码如下:

from flask import Flask, request, jsonify
from google.cloud import translate_v2 as translate

app = Flask(__name__)

# 初始化 Google Cloud Translate 客户端
translator = translate.Client()

@app.route('/translate', methods=['POST'])
def translate_text():
    try:
        # 从请求中获取 JSON 数据
        data = request.get_json()

        # 获取请求的文本内容
        text = data.get('content')
        taskno = data.get('taskno', 1)

        # 获取请求头中的 Accept-Language 信息,默认为 'zh-CN'
        accept_language = request.headers.get('Accept-Language', 'zh-CN')

        # 调用 Google Translate API 进行翻译
        result = translator.translate(text, target_language=accept_language)

        # 构造响应数据
        response_data = {
            "code": 200,
            "msg": "OK",
            "data": {
                "taskno": taskno,
                "content": result['translatedText'],
                "lang": accept_language
            }
        }

        # 返回 JSON 响应
        return jsonify(response_data), 200

    except Exception as e:
        return jsonify({"code": 500, "msg": str(e)}), 500


if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0", port=5000)

Accept-Language
请求头是通过
httpClient.DefaultRequestHeaders.Add("Accept-Language", language)
来设置的。这是一个常见的做法,目的是为每个请求指定特定的语言。然而,在实际应用中,尤其是当
HttpClient
被复用并发发送多个请求时,这种方法可能会引发请求头丢失或错误的情况。

测试结果:每20个请求就会有一个接收拿不到语言,会使用默认的zh-CN,这条请求就不会翻译。在上面的代码中,

3. 为什么会丢失请求头?

丢失请求头的问题通常出现在以下两种情况:

  • 并发请求之间共享
    HttpClient
    实例

    :当多个线程或任务共享同一个
    HttpClient
    实例时,它们可能会修改
    DefaultRequestHeaders
    ,导致请求头在不同请求之间互相干扰。例如,如果一个请求修改了
    Accept-Language
    ,它会影响到后续所有的请求,而不是每个请求都独立使用自己的请求头。
  • 头部缓存问题

    HttpClient
    实例可能会缓存头部信息。如果请求头未正确设置,缓存可能会导致丢失之前设置的头部。

在这种情况下,丢失请求头或请求头不一致的现象就会发生,从而影响请求的正确性和响应的准确性。

4. 解决方案:使用
HttpRequestMessage

为了解决这个问题,我们可以使用
HttpRequestMessage
来替代直接修改
HttpClient.DefaultRequestHeaders

HttpRequestMessage
允许我们为每个请求独立地设置请求头,从而避免了多个请求之间共享头部的风险。

以下是改进后的代码:

using System.Net.Http;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;

namespace ConsoleApp9
{
    internal class Program
    {
        private static readonly JsonSerializerSettings serializerSettings = new JsonSerializerSettings
        {
            ContractResolver = new CamelCasePropertyNamesContractResolver(),
            NullValueHandling = NullValueHandling.Ignore
        };

        private static readonly HttpClient httpClient = new HttpClient(); // 复用HttpClient实例
        private static readonly SemaphoreSlim semaphore = new SemaphoreSlim(100); // 限制并发请求数量为100

        static async Task Main(string[] args)
        {
            List<Task> tasks = new List<Task>();
            int taskNoCounter = 1; // 用于跟踪 taskno
            // 只使用一个HttpClient对象(全局共享)
            for (int i = 0; i < 50; i++)
            {
                tasks.Add(Task.Run(async () =>
                {
                    // 等待信号量,控制最大并发数
                    await semaphore.WaitAsync();

                    try
                    {
                        var postData = new
                        {
                            taskno = taskNoCounter++,
                            content = "等待翻译的内容"
                        };
                        var json = JsonConvert.SerializeObject(postData, serializerSettings);
                        var reqdata = new StringContent(json, Encoding.UTF8, "application/json");

                        // 使用HttpRequestMessage确保每个请求都可以单独设置头
                        var requestMessage = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/translate")
                        {
                            Content = reqdata
                        };

                        // 设置请求头
                        requestMessage.Headers.Add("Accept-Language", "en-US");

                        // 发起POST请求
                        var result = await httpClient.SendAsync(requestMessage);

                        // 读取并反序列化 JSON 数据
                        var content = await result.Content.ReadAsStringAsync();
                        var jsonResponse = JsonConvert.DeserializeObject<Response>(content);
                        var response = jsonResponse.Data.Content;
                     
                        // 反序列化后,直接输出解码后的文本
                        Console.WriteLine($"结果为:{response}");
                    }
                    catch (Exception ex)
                    {
                        Console.WriteLine($"请求失败: {ex.Message}");
                    }
                    finally
                    {
                        // 释放信号量
                        semaphore.Release();
                    }
                }));
            }

            await Task.WhenAll(tasks);
        }
    }

    // 定义与响应结构匹配的类
    public class Response
    {
        public int Code { get; set; }
        public ResponseData Data { get; set; }
        public string Msg { get; set; }
    }

    public class ResponseData
    {
        public string Content { get; set; }
        public string Lang { get; set; }
        public int Taskno { get; set; }
    }
}

5. 解析解决方案:为何
HttpRequestMessage
更加可靠

  • 独立请求头

    HttpRequestMessage
    是一个每个请求都可以独立设置头部的类,它允许我们为每个 HTTP 请求单独配置请求头,而不会被其他请求所干扰。通过这种方式,我们可以确保每个请求都使用准确的请求头。
  • 高并发控制
    :当
    HttpClient
    实例被多个请求共享时,
    HttpRequestMessage
    确保每个请求都能够独立处理头部。即使在高并发环境下,每个请求的头部设置都是独立的,不会相互影响。
  • 请求灵活性

    HttpRequestMessage
    不仅可以设置请求头,还可以设置请求方法、请求体、请求的 URI 等,这使得它比直接使用
    DefaultRequestHeaders
    更加灵活和可控。

6. 小结:优化
HttpClient
请求头管理

总结来说,当使用
HttpClient
时,若多个请求共用一个实例,直接修改
DefaultRequestHeaders
会导致请求头丢失或不一致的问题。通过使用
HttpRequestMessage
来管理每个请求的头部,可以避免这个问题,确保请求头的独立性和一致性。

  • 使用
    HttpRequestMessage
    来独立设置请求头,是确保请求头正确性的最佳实践。
  • 复用
    HttpClient
    实例是提升性能的好方法,但要注意并发请求时请求头可能会丢失或错误,
    HttpRequestMessage
    是解决这一问题的有效工具。

通过这种方式,我们不仅避免了请求头丢失的问题,还提升了请求的可靠性和可控性,使得整个 HTTP 请求管理更加高效和精确。


总结

以上从
HttpClient
设计和并发请求的角度,详细探讨了请求头丢失的问题,并通过实例代码展示了如何通过
HttpRequestMessage
来优化请求头管理。通过这种方式,能够确保在高并发或多线程环境中每个请求的请求头都能够独立设置,从而避免了请求头丢失或错误的问题。

大家好,我是 V 哥。Apache Shiro 是一个强大且灵活的 Java 安全框架,专注于提供认证、授权、会话管理和加密功能。它常用于保护 Java 应用的访问控制,特别是在 Web 应用中。相比于 Spring Security,Shiro 的设计更简洁,适合轻量级应用,并且在许多方面具有更好的易用性和扩展性,今天 V 哥就来聊聊 Shiro 安全框架。

Shiro 的核心概念

按照惯例,和 V 哥一起来了解一下 Shiro 的核心概念:

  1. Subject
    Subject 是 Shiro 框架中一个核心的接口,表示应用中的“用户”或“实体”,用于交互和存储认证状态。通常通过
    SecurityUtils.getSubject()
    获取当前的 Subject。它代表了用户的身份信息和权限数据。

  2. SecurityManager
    SecurityManager 是 Shiro 的核心控制器,负责管理所有的安全操作和认证。通过配置 SecurityManager,可以控制用户的认证、授权、会话等管理。

  3. Realm
    Realm 是 Shiro 从数据源获取用户、角色和权限信息的途径。通过实现自定义的 Realm,可以将 Shiro 与数据库、LDAP、文件等数据源整合。Shiro 会把用户的认证和授权数据从 Realm 中获取。

  4. Session
    Shiro 自带会话管理,不依赖于 Servlet 容器提供的会话。即使在非 Web 环境下,也可以使用 Shiro 的会话管理。Shiro 的会话管理提供了更细致的控制,比如会话超时、存储和共享等功能。

  5. Authentication(认证)
    认证是指验证用户身份的过程。Shiro 提供了简单的 API 来实现认证过程,比如
    subject.login(token)
    。在实际应用中,通常通过用户名和密码的组合进行认证,但 Shiro 也支持其他方式(如 OAuth2、JWT 等)。

  6. Authorization(授权)
    授权是指验证用户是否具备某些权限或角色的过程。Shiro 支持基于角色和基于权限的授权,允许更精细的权限控制。通过
    subject.hasRole

    subject.isPermitted
    方法,开发者可以检查用户的角色和权限。

  7. Cryptography(加密)
    Shiro 内置了加密功能,提供对密码和敏感信息的加密和解密支持。它支持多种加密算法,并且在密码存储时支持散列和盐值。

Shiro 的主要功能和优势

V 哥总结几点Shiro 的主要功能和优势,这个在面试时吹牛逼用得到。

  1. 易于集成
    Shiro 的 API 设计简单,易于集成到各种 Java 应用中。开发者可以基于 Shiro 提供的默认实现快速搭建一个基本的安全架构,也可以根据需要自定义各种功能。

  2. 独立的会话管理
    与基于 Web 容器的会话管理不同,Shiro 提供了跨环境的会话管理,可以应用于 Web 和非 Web 的环境,增加了应用的灵活性。

  3. 权限控制简单而灵活
    Shiro 的权限管理可以通过配置文件、注解或代码实现,提供了细粒度的访问控制。通过权限和角色的组合,开发者可以非常灵活地控制访问权限。

  4. 支持多种数据源
    Shiro 可以从多种数据源(如数据库、LDAP、文件等)获取用户和权限信息,方便与各种现有系统整合。

  5. 支持 Web 和非 Web 环境
    Shiro 不仅可以在 Web 应用中使用,也支持在桌面应用或微服务等环境中使用。

Shiro 的基本使用示例

光讲概念不是 V 哥风格,接下来,通过一个典型的 Shiro 应用来了解一下如何使用,包含配置 SecurityManager、配置 Realm、进行认证和授权等步骤。

  1. 配置 Shiro 环境
    可以通过
    shiro.ini
    文件配置 Shiro,也可以通过代码进行配置。
   [main]
   # 配置 SecurityManager
   securityManager = org.apache.shiro.mgt.DefaultSecurityManager

   # 配置 Realm
   myRealm = com.wg.MyCustomRealm
   securityManager.realms = $myRealm
  1. 创建自定义 Realm

    自定义 Realm 通过继承
    AuthorizingRealm
    并实现
    doGetAuthenticationInfo

    doGetAuthorizationInfo
    方法来提供用户和权限数据。

   public class MyCustomRealm extends AuthorizingRealm {
       @Override
       protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
           // 获取用户名和密码等信息,查询数据库进行认证
           return new SimpleAuthenticationInfo(username, password, getName());
       }

       @Override
       protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
           // 获取用户角色和权限信息
           SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
           info.addRole("admin");
           info.addStringPermission("user:read");
           return info;
       }
   }
  1. 使用 Shiro 进行认证和授权
   Subject currentUser = SecurityUtils.getSubject();
   if (!currentUser.isAuthenticated()) {
       UsernamePasswordToken token = new UsernamePasswordToken("username", "password");
       try {
           currentUser.login(token);
           System.out.println("认证成功");
       } catch (AuthenticationException ae) {
           System.out.println("认证失败");
       }
   }

   // 检查权限
   if (currentUser.hasRole("admin")) {
       //用输出模拟一下哈
       System.out.println("用户拥有 admin 角色");
   }
   if (currentUser.isPermitted("user:read")) {
       //用输出模拟一下哈
       System.out.println("用户具有 user:read 权限");
   }

通过这个简单的案例学习,咱们可以了解 Shiro 的基本使用,但这不是全部,听V哥继续慢慢道来。

场景案例

这点很重要,强调一下哈,
Shiro 适合需要简洁易用、安全控制要求灵活的 Java 应用,如中小型 Web 应用、桌面应用、分布式微服务等。对于大型企业应用或需要集成多种认证方式(如 OAuth2、JWT 等)的项目,Spring Security 可能会更合适。

要在微服务架构中实现基于 Apache Shiro 的安全认证和授权,比如一个订单管理系统为例。这个系统包含两个主要服务:

  1. 用户服务
    :负责用户的注册、登录、认证等操作。
  2. 订单服务
    :允许用户创建、查看、删除订单,并限制访问权限。

咱们来看一下,这个应该怎么设计呢?

微服务案例设计

在这个场景中,我们需要以下几项核心功能:

  1. 用户认证
    :用户通过用户名和密码登录。
  2. 权限控制
    :仅管理员能删除订单,普通用户只能查看和创建订单。
  3. Token机制
    :使用 JWT Token(JSON Web Token)来管理用户的登录状态,实现无状态认证,使得在分布式环境下不依赖于单一会话。
  4. 跨服务认证
    :订单服务在接收到请求时,检查并验证用户的身份和权限。

架构和技术选型

  • Spring Boot
    :用于快速搭建微服务。
  • Shiro
    :实现认证、授权。
  • JWT
    :生成和验证 Token,保持无状态认证。
  • Spring Data JPA
    :访问数据库存储用户和订单数据。

系统结构

+------------------+      +---------------------+
|   用户服务        |      |     订单服务        |
|                  |      |                     |
| 用户注册、登录    |      |   查看、创建、删除订单|
+------------------+      +---------------------+
            |                    |
            |----用户 Token ------|

步骤:实现微服务中的认证和授权

1. 引入必要的依赖


pom.xml
文件中,添加 Shiro、JWT 和 Spring Data JPA 等依赖:

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring</artifactId>
    <version>1.8.0</version>
</dependency>

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
</dependency>

2. 配置 Shiro 与 JWT 过滤器

使用 Shiro 的自定义 JWT 过滤器实现无状态认证,通过 Token 验证用户。

public class JwtFilter extends BasicHttpAuthenticationFilter {

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String token = httpServletRequest.getHeader("Authorization");

        if (StringUtils.isBlank(token)) {
            return false;
        }

        try {
            // 解析 JWT token
            JwtToken jwtToken = new JwtToken(token);
            getSubject(request, response).login(jwtToken);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

3. 实现自定义 Realm

自定义
Realm
,从数据库获取用户和角色信息,并使用 JWT Token 进行无状态认证。

public class JwtRealm extends AuthorizingRealm {

    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        String jwtToken = (String) token.getPrincipal();

        // 验证 Token
        String username = JwtUtil.getUsernameFromToken(jwtToken);
        if (username == null) {
            throw new AuthenticationException("Token 无效");
        }

        // 查询用户
        User user = userService.findByUsername(username);
        if (user == null) {
            throw new AuthenticationException("用户不存在");
        }

        return new SimpleAuthenticationInfo(jwtToken, jwtToken, getName());
    }

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        String username = JwtUtil.getUsernameFromToken(principals.toString());

        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        User user = userService.findByUsername(username);

        // 添加角色和权限
        authorizationInfo.addRole(user.getRole());
        authorizationInfo.addStringPermission(user.getPermission());

        return authorizationInfo;
    }
}

4. 创建 JWT 工具类

编写一个工具类,用于生成和解析 JWT Token。

public class JwtUtil {

    //这里替换一下你自己的secret_key
    private static final String SECRET_KEY = "这里打码了"; 

    public static String generateToken(String username) {
        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + 3600000)) // 1 hour
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                .compact();
    }

    public static String getUsernameFromToken(String token) {
        Claims claims = Jwts.parser()
                .setSigningKey(SECRET_KEY)
                .parseClaimsJws(token)
                .getBody();
        return claims.getSubject();
    }

    public static boolean isTokenExpired(String token) {
        Claims claims = Jwts.parser()
                .setSigningKey(SECRET_KEY)
                .parseClaimsJws(token)
                .getBody();
        return claims.getExpiration().before(new Date());
    }
}

5. 编写用户服务和订单服务接口

用户服务接口

用户服务提供注册和登录 API。

@RestController
@RequestMapping("/user")
public class UserController {

    @PostMapping("/register")
    public ResponseEntity<?> register(@RequestBody User user) {
        userService.save(user);
        return ResponseEntity.ok("用户注册成功");
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody User user) {
        User dbUser = userService.findByUsername(user.getUsername());
        if (dbUser != null && dbUser.getPassword().equals(user.getPassword())) {
            String token = JwtUtil.generateToken(user.getUsername());
            return ResponseEntity.ok(token);
        }
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("登录失败");
    }
}
订单服务接口

订单服务在操作订单时会验证用户的角色和权限。

@RestController
@RequestMapping("/order")
public class OrderController {

    @GetMapping("/{orderId}")
    public ResponseEntity<?> getOrder(@PathVariable Long orderId) {
        Subject currentUser = SecurityUtils.getSubject();
        if (currentUser.isPermitted("order:read")) {
            // 查询订单
            return ResponseEntity.ok("订单详情");
        }
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body("无权限查看订单");
    }

    @DeleteMapping("/{orderId}")
    public ResponseEntity<?> deleteOrder(@PathVariable Long orderId) {
        Subject currentUser = SecurityUtils.getSubject();
        if (currentUser.hasRole("admin")) {
            // 删除订单
            return ResponseEntity.ok("订单已删除");
        }
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body("无权限删除订单");
    }
}

最后

这个案例中咱们通过如何使用 Shiro、JWT 和 Spring Boot 来构建一个无状态的微服务认证授权机制。通过 Shiro 实现用户认证和权限控制,使用 JWT 实现无状态 Token 验证。在轻量级的分布式微服务应用中,是不是使用 Shiro 感觉更加清爽呢,欢迎评论区一起讨论,关注威哥爱编程,爱上Java,一辈子。

在电商或服务平台中,
缓存
的使用是提高系统性能和响应速度的关键。然而,
缓存穿透
是一个常见的性能瓶颈问题,尤其是在查询不存在的数据时,系统会直接访问数据库,这不仅影响性能,还可能造成数据库负担过重。为了有效解决这个问题,我们提出了一种结合
布隆过滤器

空值缓存

分布式锁
的缓存穿透防护方案。以下是该方案的工作流程。

工作流程

1. 用户请求优惠券模板信息

用户首先发起对优惠券模板信息的请求。该请求包括一个优惠券模板ID,系统需要根据该ID返回相应的优惠券信息。

2. 缓存查询:Redis缓存

系统首先会在
Redis缓存
中查询是否已经缓存了相关的优惠券信息。Redis 是一个高效的缓存系统,通常可以极大地提高查询速度。如果缓存中存在相应的模板信息,系统直接返回给用户,查询过程结束。

3. 缓存未命中:布隆过滤器的使用

如果 Redis 缓存中没有找到对应的优惠券模板信息,系统会进一步通过
布隆过滤器
检查该模板ID是否有效。布隆过滤器是一种空间效率极高的数据结构,用来快速判断某个元素是否在集合中。

  • 如果布隆过滤器中没有该模板ID
    ,说明该优惠券模板ID不合法或已经失效,系统直接返回给用户
    “失败:无效的优惠券模板ID”
  • 如果布隆过滤器中存在该模板ID
    ,表示该优惠券模板ID可能有效,系统会继续查询数据库。

4. 空值缓存:防止重复查询

在布隆过滤器判断模板ID有效的情况下,系统继续检查 Redis 缓存中是否存在空值缓存。空值缓存是指对于某些查询,数据库返回了“空”结果(例如优惠券模板ID不存在于数据库中),为了避免重复查询数据库,这类空结果会被缓存一段时间。

  • 如果 Redis 缓存中存在空值
    ,系统会直接返回
    “失败:无效的优惠券模板ID”
    ,避免重复的数据库查询。
  • 如果 Redis 缓存中没有空值
    ,系统继续进行数据库查询操作。

5. 分布式锁:保证数据一致性

为了防止多个请求同时查询数据库,造成数据库压力过大,或者多个线程同时执行相同查询操作,系统使用了
分布式锁
来确保在同一时间只有一个请求会访问数据库查询数据。

  • 如果分布式锁可用
    ,系统获取锁,并进行以下步骤:


    1. 查询数据库获取优惠券模板信息。
    2. 如果数据库返回数据,系统将数据缓存到 Redis 中,减少后续请求对数据库的访问。
    3. 如果数据库返回空数据,系统在 Redis 中缓存空结果,并设置短时间过期,防止短时间内重复查询。
    4. 最后释放分布式锁。
  • 如果分布式锁不可用
    ,表示其他请求正在进行相同的数据库查询操作,系统会等待锁释放或返回错误信息。

6. 返回结果:缓存数据或数据库数据

  • 如果 Redis 缓存中有数据
    ,系统直接返回缓存的数据给用户。
  • 如果缓存中没有数据且查询成功
    ,系统将数据库中的数据返回给用户,并缓存该数据以提高后续查询的效率。
  • 如果查询失败
    (例如模板ID无效或数据库无数据),系统返回错误信息。


流程图

image

代码实现

public CouponTemplateQueryRespDTO getCouponTemplate(CouponTemplateQueryReqDTO requestParam) {
    // 查询 Redis 缓存中是否存在优惠券模板信息
    String cacheKey = String.format(RedisConstants.COUPON_TEMPLATE_KEY, requestParam.getTemplateId());
    Map<Object, Object> cacheMap = stringRedisTemplate.opsForHash().entries(cacheKey);

    // 如果缓存存在直接返回,否则通过布隆过滤器、空值缓存以及分布式锁查询数据库
    if (MapUtil.isEmpty(cacheMap)) {
        // 判断布隆过滤器是否存在指定模板 ID,不存在则返回错误
        if (!bloomFilter.contains(requestParam.getTemplateId())) {
            throw new ClientException("Coupon template does not exist");
        }

        // 查询 Redis 缓存中是否存在空值信息,如果存在则直接返回
        String nullCacheKey = String.format(RedisConstants.COUPON_TEMPLATE_NULL_KEY, requestParam.getTemplateId());
        Boolean isNullCached = stringRedisTemplate.hasKey(nullCacheKey);
        if (isNullCached) {
            throw new ClientException("Coupon template does not exist");
        }

        // 获取分布式锁
        RLock lock = redissonClient.getLock(String.format(RedisConstants.LOCK_COUPON_TEMPLATE_KEY, requestParam.getTemplateId()));
        lock.lock();

        try {
            // 双重检查空值缓存
            isNullCached = stringRedisTemplate.hasKey(nullCacheKey);
            if (isNullCached) {
                throw new ClientException("Coupon template does not exist");
            }

            // 使用双重检查锁避免并发查询数据库
            cacheMap = stringRedisTemplate.opsForHash().entries(cacheKey);
            if (MapUtil.isEmpty(cacheMap)) {
                LambdaQueryWrapper<CouponTemplate> queryWrapper = Wrappers.lambdaQuery(CouponTemplate.class)
                        .eq(CouponTemplate::getShopId, Long.parseLong(requestParam.getShopId()))
                        .eq(CouponTemplate::getId, Long.parseLong(requestParam.getTemplateId()))
                        .eq(CouponTemplate::getStatus, TemplateStatusEnum.ACTIVE.getStatus());
                CouponTemplate couponTemplate = couponTemplateMapper.selectOne(queryWrapper);

                // 如果模板不存在或已过期,设置空值缓存并抛出异常
                if (couponTemplate == null) {
                    stringRedisTemplate.opsForValue().set(nullCacheKey, "", 30, TimeUnit.MINUTES);
                    throw new ClientException("Coupon template does not exist or has expired");
                }

                // 将数据库记录序列化并存入 Redis 缓存
                CouponTemplateQueryRespDTO responseDTO = BeanUtil.toBean(couponTemplate, CouponTemplateQueryRespDTO.class);
                Map<String, Object> responseMap = BeanUtil.beanToMap(responseDTO, false, true);
                Map<String, String> cacheData = responseMap.entrySet().stream()
                        .collect(Collectors.toMap(
                                Map.Entry::getKey,
                                entry -> entry.getValue() != null ? entry.getValue().toString() : ""
                        ));

                // 使用 Lua 脚本将数据存入 Redis 并设置过期时间
                String luaScript = "redis.call('HMSET', KEYS[1], unpack(ARGV, 1, #ARGV - 1)) " +
                        "redis.call('EXPIREAT', KEYS[1], ARGV[#ARGV])";

                List<String> keys = Collections.singletonList(cacheKey);
                List<String> args = new ArrayList<>(cacheData.size() * 2 + 1);
                cacheData.forEach((key, value) -> {
                    args.add(key);
                    args.add(value);
                });

                // 设置优惠券活动的过期时间
                args.add(String.valueOf(couponTemplate.getEndTime().getTime() / 1000));

                // 执行 Lua 脚本
                stringRedisTemplate.execute(
                        new DefaultRedisScript<>(luaScript, Long.class),
                        keys,
                        args.toArray()
                );
                cacheMap = cacheData.entrySet()
                        .stream()
                        .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
            }
        } finally {
            lock.unlock();
        }
    }

    // 返回从缓存中获取的数据
    return BeanUtil.mapToBean(cacheMap, CouponTemplateQueryRespDTO.class, false, CopyOptions.create());
}