Spring Cloud之多级缓存

目录

传统缓存

多级缓存

JVM进程缓存

Caffeine

缓存驱逐策略

实现进程缓存

常用Lua语法

数据类型

变量声明

循环使用

定义函数

条件控制

安装OpenResty

实现Nginx业务逻辑编写

请求参数解析

实现lua访问tomcat

JSON的序列化和反序列化

Tomcat的集群负载均衡

添加Redis缓存

启动Redis

查询Redis缓存

Nginx本地缓存


资料下载:day04-多级缓存

下载完成后跟着案例导入说明去做

传统缓存

传统的缓存策略一般是请求到达Tomcat后,先查询Redis,如果未命中则查询数据库,存在下面的问题

  • 请求要经过Tomcat处理,Tomcat的性能成为整个系统的瓶颈
  • Redis缓存失效时,会对数据库产生冲击

多级缓存

多级缓存主要压力在于nginx,在生产环境中,我们需要通过部署nginx本地缓存集群以及一个nginx反向代理到本地缓存

JVM进程缓存

缓存在日常开发中启动至关重要的作用,由于是存储在内存中,数据的读取速度是非常快的,能大量减少对数据库的访问,减少数据库的压力。我们把缓存分为两类:

  • 分布式缓存,例如Redis:
    • 优点:存储容量更大、可靠性更好、可以在集群间共享
    • 缺点:访问缓存有网络开销
    • 场景:缓存数据量较大、可靠性要求较高、需要在集群间共享
  • 进程本地缓存,例如HashMap、GuavaCache
    • 优点:读取本地内存,没有网络开销,速度更快
    • 缺点:存储容量有限、可靠性较低、无法共享
    • 场景:性能要求较高,缓存数据量较小

Caffeine

案例测试代码

java 复制代码
@Test
void testBasicOps() {
    // 创建缓存对象
    Cache<String, String> cache = Caffeine.newBuilder().build();

    // 存数据
    cache.put("name", "张三");

    // 取数据,不存在则返回null
    String name = cache.getIfPresent("name");
    System.out.println("name = " + name);

    // 取数据,不存在则去数据库查询
    String defaultName = cache.get("defaultName", key -> {
        // 这里可以去数据库根据 key查询value
        return "李四";
    });
    System.out.println("defaultName = " + defaultName);
}

运行结果如下

缓存驱逐策略

Caffeine提供了三种缓存驱逐策略:

  • 基于容量:设置缓存的数量上限
  • 基于时间:设置缓存的有效时间
  • 基于引用:设置缓存为软引用或弱引用,利用GC来回收缓存数据,性能较差。

默认情况下,当缓存数据过期时,并不会立即将其清理和驱逐,而是在一次读或写操作后,或是在空闲时间完成对失效数据的驱逐。

基于容量实现

java 复制代码
    /*
     基于大小设置驱逐策略:
     */
    @Test
    void testEvictByNum() throws InterruptedException {
        // 创建缓存对象
        Cache<String, String> cache = Caffeine.newBuilder()
                // 设置缓存大小上限为 1
                .maximumSize(1)
                .build();
        // 存数据
        cache.put("name1", "张三");
        cache.put("name2", "李四");
        cache.put("name3", "王五");
        // 延迟10ms,给清理线程一点时间
        Thread.sleep(10L);
        // 获取数据
        System.out.println("name1: " + cache.getIfPresent("name1"));
        System.out.println("name2: " + cache.getIfPresent("name2"));
        System.out.println("name3: " + cache.getIfPresent("name3"));
    }

运行结果如下

基于时间实现

java 复制代码
/*
     基于时间设置驱逐策略:
     */
    @Test
    void testEvictByTime() throws InterruptedException {
        // 创建缓存对象
        Cache<String, String> cache = Caffeine.newBuilder()
                .expireAfterWrite(Duration.ofSeconds(1)) // 设置缓存有效期为 10 秒
                .build();
        // 存数据
        cache.put("name", "张三");
        // 获取数据
        System.out.println("name: " + cache.getIfPresent("name"));
        // 休眠一会儿
        Thread.sleep(1200L);
        System.out.println("name: " + cache.getIfPresent("name"));
    }

运行结果如下

实现进程缓存

利用Caffeine实现下列需求:

  • 给根据id查询商品的业务添加缓存,缓存未命中时查询数据库
  • 给根据id查询商品库存的业务添加缓存,缓存未命中时查询数据库
  • 缓存初始大小为100
  • 缓存上限为10000

添加缓存对象

java 复制代码
@Configuration
public class CaffeineConfig {
    /**
     * 商品信息缓存
     * @return
     */
    @Bean
    public Cache<Long, Item> itemCache(){
        return Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)
                .build();
    }

    /**
     * 商品库存缓存
     * @return
     */
    @Bean
    public Cache<Long, ItemStock> itemStockCache(){
        return Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)
                .build();
    }
}

在ItemController中写入查询本地缓存的方法

java 复制代码
    @Autowired
    private Cache<Long, Item> itemCache;
    @Autowired
    private Cache<Long, ItemStock> itemStockCache;

    @GetMapping("/{id}")
    public Item findById(@PathVariable("id") Long id) {
        return itemCache.get(id, key -> {
                    return itemService.query()
                            .ne("status", 3).eq("id", key)
                            .one();
                }
        );
    }

    @GetMapping("/stock/{id}")
    public ItemStock findStockById(@PathVariable("id") Long id) {
        return itemStockCache.get(id,key->{
            return stockService.getById(id);
        });
    }

修改完成后,访问localhost:8081/item/10001,观察控制台

存在一次数据库查询。后续再次查询相同id数据不会再次查询数据库。至此实现了JVM进程缓存。

常用Lua语法

Nginx与Redis的业务逻辑编写并不是通过Java语言,而是通过Lua。Lua是一种轻量小巧的脚本语言,用标准的C语言编写并以源代码形式开放,其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

入门案例,输出hello world

在linux中创建一个文本文件

bash 复制代码
touch hello.lua
# 进入vi模式
vi hello.lua
# 打印hello world。输入以下内容
print("hello world")

# 保存退出后,运行lua脚本
lua hello.lua

或是直接输入命令启动lua控制台

lua

直接输入命令即可

数据类型

|----------|-----------------------------------------------------------------------------------------|
| 数据类型 | 描述 |
| nil | 表示一个无效值,类似于Java中的null,但在条件表达式中代表false |
| boolean | 包含:true与false |
| number | 表示双精度类型的实浮点数(简单来说,是数字都可以使用number表示) |
| string | 字符串,由单引号或双引号来表示 |
| function | 由C或是Lua编写的函数 |
| table | Lua中的表其实是一个"关联数组",数组的索引可以是数字,字符串或表类型。在 Lua里,table的创建是通过"构造表达式"来完成,最简单构造表达式是{},用来创建一个空表。 |

变量声明

Lua声明变量的时候,并不需要指定数据类型

Lua 复制代码
-- local代表局部变量,不加修饰词,代表全局变量
local str ='hello'
local num =10
local flag =true
local arr ={'java','python'} --需要注意的是,访问数组元素时,下标是从1开始
local table ={name='Jack',age=10} --类似于Java中的map类型,访问数据时是通过table['key']或是table.key

循环使用

Lua 复制代码
-- 声明数组
local arr={'zhangsan','lisi','wangwu'}
-- 进行循环操作
for index,value in ipairs(arr) do
	print(index,value)
end
-- lua 脚本中,for循环从do开始end结束,数组解析使用ipairs
-- 声明table
local table={name='zhangsan',age=10}
-- 进行循环操作
for key,value in pairs(table) do
        print(key,value)
end
-- table解析使用pairs

执行lua脚本

定义函数

Lua 复制代码
-- 声明数组
local arr={'zhangsan','lisi','wangwu'}
-- 定义函数
local function printArr(arr)
  for index,value in ipairs(arr) do
      print(index,value)
  end
end
-- 执行函数
printArr(arr)

执行lua脚本

条件控制

|-----|----------------------------|-------------------|
| 操作符 | 描述 | 实例 |
| and | 逻辑与操作符。若A为false,则返回A,否则返回B | (A and B)为false |
| or | 逻辑或操作符。若A为true,则返回A,否则返回B | (A or B)为true |
| not | 逻辑非操作符。与逻辑运算结果相反 | not(A and B)为true |

Lua 复制代码
-- 声明数组
local table={name='zhangsan',sex='boy',age=15}
-- 定义函数
local function printTable(arr)
  if(not arr) then
    print('table中不存在该字段')
    return nil
  end
  print(arr)
end
-- 执行函数
printTable(table.name)
printTable(table.addr)

执行lua脚本

安装OpenResty

是基于Nginx的一个组件,主要作用是对Nginx编写业务逻辑

bash 复制代码
yum install -y pcre-devel openssl-devel gcc --skip-broken

yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo
# 如果失败则先执行下面一条语句后再执行上面这条
yum install -y yum-utils 

yum install -y openresty

yum install -y openresty-opm

配置nginx的环境变量

bash 复制代码
vi /etc/profile

# 在最下面插入如下信息
export NGINX_HOME=/usr/local/openresty/nginx
export PATH=${NGINX_HOME}/sbin:$PATH

# 保存后刷新配置
source /etc/profile

修改/usr/local/openresty/nginx/conf/nginx.conf配置文件如下

bash 复制代码
#user  nobody;
worker_processes  1;
error_log  logs/error.log;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;

    server {
        listen       8081;
        server_name  localhost;
        location / {
            root   html;
            index  index.html index.htm;
        }
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
}

启动nginx

启动nginx

nginx

重新加载配置

nginx -s reload

停止

nginx -s stop

启动后,访问虚拟机的8081端口,如果正常跳转页面如下

实现Nginx业务逻辑编写

先分析请求转发流程。打开win系统上的nginx路由配置文件

接下来就需要对虚拟机中的nginx添加业务逻辑了

对虚拟机Nginx中的配置文件添加如下代码

bash 复制代码
    # 放入http模块下
	#lua 模块
	lua_package_path "/usr/local/openresty/lualib/?.lua;;";
	#c模块     
	lua_package_cpath "/usr/local/openresty/lualib/?.so;;";  

    # 放入server模块下
		location /api/item {
			# 响应类型为json
			default_type application/json;
			# 响应结果来源
			content_by_lua_file lua/item.lua;
		}

编写lua脚本

在nginx目录下创建lua文件夹,并创建lua脚本

复制代码
mkdir lua
touch lua/item.lua

先使用假数据测试是否可以正常响应

复制代码
ngx.say('{"id":10001,"name":"SALSA AIR","title":"RIMOWA 21寸托运箱拉杆箱 SALSA AIR系列果绿色 820.70.36.4","price":17900,"image":"https://m.360buyimg.com/mobilecms/s720x720_jfs/t6934/364/1195375010/84676/e9f2c55f/597ece38N0ddcbc77.jpg!q70.jpg.webp","category":"拉杆箱","brand":"RIMOWA","spec":"","status":1,"createTime":"2019-04-30T16:00:00.000+00:00","updateTime":"2019-04-30T16:00:00.000+00:00","stock":2999,"sold":31290}')

访问localhost/item.html?id=10001。查看控制台是否正常响应。如果出现如下错误,去观察win系统下的nginx日志,我的打印了如下错误

2023/11/07 19:29:38 [error] 16784#2812: *34 connect() failed (10061: No connection could be made because the target machine actively refused it) while connecting to upstream, client: 127.0.0.1, server: localhost, request: "GET /api/item/10001 HTTP/1.1", upstream: "http://192.168.10.10:8081/api/item/10001", host: "localhost", referrer: "http://localhost/item.html?id=10001"

解决方法,打开任务管理器,将所有关于nginx的服务全部结束再次重启win系统下的nginx即可。如果不是此类错误,请查看linux系统下的错误日志。

请求参数解析

|----------|--------------|--------------------------------------------------------------------------------------------------------------------------|
| 参数格式 | 参数实例 | 参数解析代码示例 |
| 路径占位符 | /item/1001 | 拦截路径中:location ~ /item/(\d+){} ~:表示使用正则表达式 (\d+):表示至少有一位数字 Lua脚本中:local id = ngx.var[1] 匹配到的参数会存入ngx.var数组中,通过下标获取 |
| 请求头 | id:1001 | 获取请求头,返回值是table类型 local headers = ngx.req.get_headers() |
| Get请求参数 | ?id=1001 | 获取GET请求参数,返回值是table类型 local getParams = ngx.req.get_uri_args() |
| Post表单参数 | id=1001 | 读取请求体:ngx.req.read_body() 获取POST表单参数,返回值是table类型 local postParams = ngx.req.get_post_args() |
| JSON参数 | {"id": 1001} | 读取请求体:ngx.reg.read bodv() 获取body中的ison参数,返回值是string类型 local jsonBody = ngx.req.get_body_data() |

修改linux中nginx的配置文件,实现参数解析

bash 复制代码
		location ~ /api/item/(\d+) {
			# 响应类型为json
			default_type application/json;
			# 响应结果来源
			content_by_lua_file lua/item.lua;
		}

修改lua脚本

Lua 复制代码
-- 获取参数
local id = ngx.var[1]
-- 返回结果
ngx.say('{"id":'..id..',"name":"SALSA AIR","title":"RIMOWA 21寸托运箱拉杆箱 SALSA AIR系列果绿色 820.70.36.4","price":17900,"image":"https://m.360buyimg.com/mobilecms/s720x720_jfs/t6934/364/1195375010/84676/e9f2c55f/597ece38N0ddcbc77.jpg!q70.jpg.webp","category":"拉杆箱","brand":"RIMOWA","spec":"","status":1,"createTime":"2019-04-30T16:00:00.000+00:00","updateTime":"2019-04-30T16:00:00.000+00:00","stock":2999,"sold":31290}')

访问id为10002的参数,可以发现id随着参数改变,而不是伪数据了

实现lua访问tomcat

nginx提供了内部API用来发送http请求

复制代码
local resp = ngx.location.capture("/path",{
	method = ngx.HTTP_GET,-- 请求方式
  args = {a=1,b=2},-- get方式传参数
  body ="c=3&d=4" -- post方式传参数
})

返回响应结果内容包括:

  • resp.status:响应状态码
  • resp.header:响应头,是一个table
  • resp.body:响应体,就是响应数据

需要注意的是,/path不会指定IP地址和端口而是会被内部拦截,这个时候我们还需要编写一个路由器,发送到对应的服务器。修改linux中的nginx.conf文件添加如下配置

bash 复制代码
		location /item {
			proxy_pass http://192.168.10.11:8081;
		}

发起Http请求我们可以封装成一个方法,让其他请求发起时也可以调用,因此,我们可以在lualib文件夹下,创建lua脚本。

Lua 复制代码
-- 封装函数,发送http请求,并解析响应
local function read_http(path, params)
    local resp = ngx.location.capture(path,{
        method = ngx.HTTP_GET,
        args = params,
    })
    if not resp then
        -- 记录错误信息,返回404
        ngx.log(ngx.ERR, "http not found, path: ", path , ", args: ", args)
        ngx.exit(404)
    end
    return resp.body
end
-- 将方法导出
local _M = {  
    read_http = read_http
}  
return _M

修改item.lua脚本,不再返回伪数据,而是查询真实的数据

Lua 复制代码
-- 导入common函数库
local common = require('common')
local read_http = common.read_http

-- 获取参数
local id = ngx.var[1]
-- 查询商品信息
local itemJSON = read_http('/item/'..id,nil)
-- 查询库存信息
local stockJSON = read_http('/item/stock/'..id,nil)
-- 返回结果
ngx.say(itemJSON)

这里只返回了商品信息,接下来访问其他id的商品,查看是否可以查询出商品信息

JSON的序列化和反序列化

引入cjson模块,实现序列化与反序列化

Lua 复制代码
-- 导入common函数库
local common = require('common')
local cjson = require('cjson')
local read_http = common.read_http

-- 获取参数
local id = ngx.var[1]
-- 查询商品信息
local itemJSON = read_http('/item/'..id,nil)
-- 查询库存信息
local stockJSON = read_http('/item/stock/'..id,nil)
-- 反序列化JSON商品信息为table类型数据
local item = cjson.decode(itemJSON)
local stock = cjson.decode(stockJSON)
-- 数据组合
item.stock = stock.stock
item.sold = stock.sold

-- 序列化为JSON
-- 返回结果
ngx.say(cjson.encode(item))

Tomcat的集群负载均衡

这里我们访问的服务端口是写死的,但通常tomcat是一个集群,因此,我们需要修改我们linux的配置文件,配置tomcat集群

由于Tomcat的负载均衡策略为轮询,那么就会产生一个问题,tomcat集群的进程缓存是不共享的,也就是说,第一次访问8081生成的缓存,在第二次访问8082时,是不存在的,会在8082也生成一份相同的缓存。所以我们需要保证访问同一个id的请求,会被路由到存在缓存的那个tomcat服务器上。这就需要我们修改负载均衡算法。实际实现很简单,只需要在tomcat集群配置添加一行

实现原理是,nginx会对拦截到的请求进行hash算法,然后对集群数量进行取余。从而保证对同一个id的请求都会被路由到同一个tomcat服务器。

添加Redis缓存

本地缓存在访问进程缓存之间,应该先去查询Redis缓存,在添加Redis缓存时,又存在冷启动与缓存预热问题。

  • 冷启动:服务刚刚启动时,Redis中并没有缓存,如果所有商品数据都在第一次查询时添加缓存,可能会给数据库带来较大压力。
  • 缓存预热:在实际开发中,我们可以利用大数据统计用户访问的热点数据,在项目启动时将这些热点数据提前查询并保

启动Redis

在docker中输入如下命令

bash 复制代码
docker run --name redis -p 6379:6379 -d redis redis-server --appendonly yes

启动成功后使用RESP连接redis

成功连接后,我们需要进行预热,我们的数据不多,将所有的数据全都缓存进去即可,编写一个初始化Handler

java 复制代码
@Component
public class RedisHandler implements InitializingBean {
    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private ItemService itemService;

    @Autowired
    private ItemStockService itemStockService;

    private final static ObjectMapper MAPPER = new ObjectMapper();

    /**
     * Bean生命周期之生成Bean对象之后属性填充
     *
     * @throws Exception
     */
    @Override
    public void afterPropertiesSet() throws Exception {
        //将数据库中的数据进行填充
        //查询商品数据并填充
        List<Item> listItems = itemService.list();
        List<ItemStock> listStock = itemStockService.list();
        for (Item listItem : listItems) {
            String itemJson = MAPPER.writeValueAsString(listItem);
            redisTemplate.opsForValue().set("itemInfo:id:"+listItem.getId(),itemJson);
        }

        for (ItemStock itemStock : listStock) {
            String itemJson = MAPPER.writeValueAsString(itemStock);
            redisTemplate.opsForValue().set("itemStock:id:"+itemStock.getId(),itemJson);
        }
    }
}

重启项目,我们就可以看到redis中已经存在了商品数据

查询Redis缓存

启动成功并添加数据后,我们接下来去实现本地缓存查询Redis缓存。这个时候我们还需要编写lua脚本

修改common.lua脚本

Lua 复制代码
-- 引入redis的函数库
local redis = require('resty.redis')
-- 初始化redis对象
local red = redis:new()
red:set_timeouts(1000,1000,1000)

-- 关闭redis连接的工具方法,其实是放入连接池
local function close_redis(red)
    local pool_max_idle_time = 10000 -- 连接的空闲时间,单位是毫秒
    local pool_size = 100 --连接池大小
    local ok, err = red:set_keepalive(pool_max_idle_time, pool_size)
    if not ok then
        ngx.log(ngx.ERR, "放入redis连接池失败: ", err)
    end
end


-- 查询redis的方法 ip和port是redis地址,key是查询的key
local function read_redis(ip, port, key)
    -- 获取一个连接
    local ok, err = red:connect(ip, port)
    if not ok then
        ngx.log(ngx.ERR, "连接redis失败 : ", err)
        return nil
    end
    -- 查询redis
    local resp, err = red:get(key)
    -- 查询失败处理
    if not resp then
        ngx.log(ngx.ERR, "查询Redis失败: ", err, ", key = " , key)
    end
    --得到的数据为空处理
    if resp == ngx.null then
        resp = nil
        ngx.log(ngx.ERR, "查询Redis数据为空, key = ", key)
    end
    close_redis(red)
    return resp
end

local _M = {  
    read_http = read_http,
		read_redis = read_redis
}  

修改item.lua脚本

Lua 复制代码
-- 导入common函数库
local common = require('common')
local cjson = require('cjson')
local read_http = common.read_http
local read_redis = common.read_redis

-- 封装函数
function read_data(key,path,params)
	--查询Redis
	local resp = read_redis('127.0.0.1',6379,key)
	if not resp then
		ngx.log("查询redis失败,key为:",key)
		resp = read_http(path,params)
	end
	return resp
end
-- 获取参数
local id = ngx.var[1]

-- 查询商品信息
local itemJSON = read_data('itemInfo:id:'..id,'/item/'..id,nil)
-- 查询库存信息
local stockJSON = read_data('itemStock:id:'..id,'/item/stock/'..id,nil)
-- 反序列化JSON商品信息为table类型数据
local item = cjson.decode(itemJSON)
local stock = cjson.decode(stockJSON)
-- 数据组合
item.stock = stock.stock
item.sold = stock.sold

-- 序列化为JSON
-- 返回结果
ngx.say(cjson.encode(item))

我们关闭tomcat服务,直接访问,测试是否是通过Redis获取到内容

Nginx本地缓存

接下来我们去实现在本地缓存中进行查询

OpenResty为Nginx提供了shard dict的功能,可以在nginx的多的worker之间共享数据,实现缓存功能。

修改CentOS中的nginx.conf文件,开启该功能。

bash 复制代码
	#开启共享字典,名字叫item_cache,缓存大小150兆
	lua_shared_dict item_cache 150m;

接下来修改item.lua中的read_data代码,先进行本地查询

Lua 复制代码
-- 导入common函数库
local common = require("common")
local cjson = require('cjson')
local read_http = common.read_http
local read_redis = common.read_redis
-- 获取本地缓存对象
local item_cache = ngx.shared.item_cache

-- 封装函数
function read_data(key,expire,path,params)
  --先去查询本地缓存
  local val = item_cache:get(key)
  if not val then 
    --查询Redis
    ngx.log(ngx.ERR,"本地缓存不存在,去查询redis")
    val = read_redis("127.0.0.1",6379,key)
    if not val then
      ngx.log(ngx.ERR,"查询redis失败,key为:",key)
      val = read_http(path,params)
    end
  end
  -- 将数据写入本地缓存,并设置过期时间
  item_cache:set(key,val,expire)
  return val
end
-- 获取参数
local id = ngx.var[1]
-- 查询商品信息
local itemJSON = read_data("itemInfo:id:"..id,1800,'/item/'..id,nil)
ngx.log(ngx.ERR,"itmeJson的信息为:",itemJSON)
-- 查询库存信息
local stockJSON = read_data("itemStock:id:"..id,60,'/item/stock/'..id,nil)
-- 反序列化JSON商品信息为table类型数据
local item = cjson.decode(itemJSON)
local stock = cjson.decode(stockJSON)
-- 数据组合
item.stock = stock.stock
item.sold = stock.sold

-- 序列化为JSON
-- 返回结果
ngx.say(cjson.encode(item))

接下来进行页面访问。第一次访问结果如下,后续再次访问不会再打印日志。说明的确是走了本地缓存

相关推荐
沐浴露z1 小时前
分布式场景下防止【缓存击穿】的不同方案
redis·分布式·缓存·redission
鸽鸽程序猿2 小时前
【项目】基于Spring全家桶的论坛系统 【下】
后端·spring·restful
Lisonseekpan3 小时前
Spring Boot 中使用 Caffeine 缓存详解与案例
java·spring boot·后端·spring·缓存
小许学java3 小时前
Spring AI快速入门以及项目的创建
java·开发语言·人工智能·后端·spring·ai编程·spring ai
kfepiza5 小时前
Spring 如何解决循环依赖 笔记251008
java·spring boot·spring
kfepiza6 小时前
Spring的三级缓存原理 笔记251008
笔记·spring·缓存
jun71186 小时前
msi mesi moesi cpu缓存一致性
缓存
popoxf16 小时前
spring容器启动流程(反射视角)
java·后端·spring
谷哥的小弟16 小时前
Spring Framework源码解析——ApplicationContextAware
spring·源码
AAA修煤气灶刘哥17 小时前
监控摄像头?不,我们管这个叫优雅的埋点艺术!
java·后端·spring cloud