springcache
springcache 缓存集成对比 介绍了 caffeine / encache/guava等几种缓存实现 ,并介绍了几种缓存的集成.
spring中文网springcache 入门教程 介绍了spring和 springboot的集成方式.
集成
引入依赖
<!-- 该 Starter 包含了 spring-context-support 模块。-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- springcache 缓存包, 其中包含了 caffeine 和 encache的集成-->
<!-- <dependency>-->
<!-- <groupId>org.springframework</groupId>-->
<!-- <artifactId>spring-context-support</artifactId>-->
<!-- <version>5.1.6.RELEASE</version>-->
<!-- </dependency>-->
<!-- 除了导入support包,当然还得导入咖啡因的包 -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<!-- 2019.2最新版本 caffeine是2015年才面市的,发展还是很迅速的-->
<version>2.7.0</version>
</dependency>
开启缓存
只需在任何配置类中添加 @EnableCaching
注解,即可启用缓存功能:
@Configuration
@EnableCaching
public class CachingConfig {
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("addresses");
}
}
注意:在spring中启用缓存后,必须注册一个
cacheManager
,这是最基本的设置。而在使用 Spring Boot 时,只需在 classpath 上存在 Starter 依赖,并且与
@EnableCaching
注解一起使用,就会注册相同的ConcurrentMapCacheManager
,因此不需要单独的 cacheManage 声明
配置cacheManage
如果要使用其它的缓存框架,应该怎么做呢?
如果要使用其它的缓存框架,我们只需要重新定义好
CacheManager
和CacheResolver
这两个Bean就行了。事实上,Spring会自动检测我们是否引入了相应的缓存框架,如果我们引入了spring-data-redis,Spring就会自动使用spring-data-redis提供的RedisCacheManager,RedisCache。
如果我们要使用Caffeine框架。只需要引入Caffeine,Spring Cache就会默认使用CaffeineCacheManager和CaffeineCache。
// https://blog.csdn.net/hacker_lli/article/details/108632749
package org.example.cache.caffeine;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@EnableCaching
@Configuration
public class CacheConfig extends CachingConfigurerSupport {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
// 方案一(常用):定制化缓存Cache
cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.initialCapacity(100)
.maximumSize(10_000))
// 如果缓存种没有对应的value,通过createExpensiveGraph方法同步加载 buildAsync是异步加载
//.build(key -> createExpensiveGraph(key))
;
// 方案二:传入一个CaffeineSpec定制缓存,它的好处是可以把配置方便写在配置文件里
//cacheManager.setCaffeineSpec(CaffeineSpec.parse("initialCapacity=50,maximumSize=500,expireAfterWrite=5s"));
return cacheManager;
}
}
常用注解
Spring Cache有几个常用注解,分别为@Cacheable、@CachePut、@CacheEvict、@Caching、@CacheConfig
。除了最后一个CacheConfig
外,其余四个都可以用在类上或者方法级别上,如果用在类上,就是对该类的所有public方法生效
,下面分别介绍一下这几个注解。
cacheable
@Cacheble
注解表示这个方法有了缓存的功能,方法的返回值会被缓存下来,下一次调用该方法前,会去检查是否缓存中已经有值,如果有就直接返回,不调用方法
。如果没有,就调用方法,然后把结果缓存起来。这个注解一般用在查询方法上。
value、cacheNames
:两个等同的参数(cacheNames
为Spring 4
新增,作为value
的别名),用于指定缓存存储的集合名
。由于Spring 4中新增了@CacheConfig
,因此在Spring 3
中原本必须有的value
属性,也成为非必需项了key
:和cacheNames
共同组成一个key
,非必需,缺省按照函数的所有参数组合作为key
值,若自己配置需使用SpEL
表达式,比如:@Cacheable(key = "#p0")
:使用函数第一个参数作为缓存的key
值,更多关于SpEL表达式的详细内容可参考官方文档condition
:缓存对象的条件,非必需
,也需使用SpEL
表达式,只有满足表达式条件的内容才会被缓存,比如:@Cacheable(key = "#p0", condition = "#p0.length() < 3")
,表示只有当第一个参数的长度小于3的时候才会被缓存,若做此配置上面的AAA
用户就不会被缓存,读者可自行实验尝试,**在函数调用前进行判断,因此result这种spel里面进行判断时,永远为null.**
unless
:另外一个缓存条件参数,非必需
,需使用SpEL
表达式。它不同于condition
参数的地方在于它的判断时机,该条件是在函数被调用之后才做判断的,所以它可以通过对result进行判断
。keyGenerator
:用于指定key
生成器,非必需。若需要指定一个自定义的key
生成器,我们需要去实现org.springframework.cache.interceptor.KeyGenerator
接口,并使用该参数来指定。需要注意的是:该参数与key是互斥的
cacheManager
:用于指定使用哪个缓存管理器,非必需
。只有当有多个时才需要使用cacheResolver
:用于指定使用那个缓存解析器,非必需
。需通过org.springframework.cache.interceptor.CacheResolver
接口来实现自己的缓存解析器,并用该参数指定。
作用和配置方法
/** * 根据ID获取Tasklog * @param id * @return */
@Cacheable(value = CACHE_KEY, key = "#id",condition = "#result != null")
public Tasklog findById(String id){
System.out.println("FINDBYID");
System.out.println("ID:"+id);
return taskLogMapper.selectById(id);
}
缓存中spel表达式可取值
@CachePut
@CachePut
的作用 主要针对方法配置,能够根据方法的请求参数对其结果进行缓存,和@Cacheable
不同的是,它每次都会触发真实方法的调用使用
@CachePut
注解,可以更新缓存的内容,而不会影响方法的执行。也就是说,方法始终会被执行并将结果缓存起来
@Cacheable
和@CachePut
的区别在于,@Cacheable
会跳过运行方法,而@CachePut
会实际运行方法,然后将结果放入缓存。
作用和配置方法
/** * 添加tasklog * @param tasklog * @return */
@CachePut(value = CACHE_KEY, key = "#tasklog.id")
public Tasklog create(Tasklog tasklog){
System.out.println("CREATE");
System.err.println (tasklog);
taskLogMapper.insert(tasklog);
return tasklog;
}
@CacheEvict
一般用在更新或者删除的方法上
缓存的数据如果不进行清理,会保留大量陈旧或未使用甚至是过期的数据。
可以使用
@CacheEvict
注解来表示删除一个、多个或所有的值,以刷新缓存:
作用和配置方法
/** * 根据ID删除Tasklog * @param id */
@CacheEvict(value = CACHE_KEY, key = "#id")
public void delete(String id){
System.out.println("DELETE");
System.out.println("ID:"+id);
taskLogMapper.deleteById(id);
}
//使用参数 allEntries 与要清空的缓存结合使用;这将清除缓存 addresses 中的所有条目。
@CacheEvict(value="addresses", allEntries=true)
public String getAddress(Customer customer) {...}
@Caching
Java
注解的机制决定了,一个方法上只能有一个相同的注解生效。那有时候可能一个方法会操作多个缓存(这个在删除缓存操作中比较常见,在添加操作中不太常见)。
//@Caching注解就是用来解决这类情况的,大家一看它的源码就明白了
public @interface Caching {
Cacheable[] cacheable() default {};
CachePut[] put() default {};
CacheEvict[] evict() default {};
}
//有时候我们可能组合多个Cache注解使用;比如用户新增成功后,我们要添加id–>user;username—>user;email—>user的缓存;此时就需要@Caching组合多个注解标签了
@Caching(put = {
@CachePut(value = "user", key = "#user.id"),
@CachePut(value = "user", key = "#user.username"),
@CachePut(value = "user", key = "#user.email")
})
public User save(User user) {
}
@CacheConfig
前面提到的四个注解,都是Spring Cache
常用的注解。每个注解都有很多可以配置的属性。
但这几个注解通常都是作用在方法上的,而有些配置可能又是一个类通用的,这种情况就可以使用@CacheConfig
了,它是一个类级别的注解,可以在类级别上配置cacheNames、keyGenerator、cacheManager、cacheResolver
等。
例如:
所有的@Cacheable()
里面都有一个value=“xxx”
的属性,这显然如果方法多了,写起来也是挺累的,如果可以一次性声明完 那就省事了, 所以,有了@CacheConfig
这个配置,@CacheConfig is a class-level annotation that allows to share the cache names
,如果你在你的方法写别的名字,那么依然以方法的名字为准。
/** * 测试服务层 */
@Service
@CacheConfig(cacheNames= "taskLog")
public class TaskLogService {
@Autowired private TaskLogMapper taskLogMapper;
@Autowired private net.sf.ehcache.CacheManager cacheManager;
/** * 缓存的key */
public static final String CACHE_KEY = "taskLog";
/** * 添加tasklog * @param tasklog * @return */
@CachePut(key = "#tasklog.id")
public Tasklog create(Tasklog tasklog){
System.out.println("CREATE");
System.err.println (tasklog);
taskLogMapper.insert(tasklog);
return tasklog;
}
/** * 根据ID获取Tasklog * @param id * @return */
@Cacheable(key = "#id")
public Tasklog findById(String id){
System.out.println("FINDBYID");
System.out.println("ID:"+id);
return taskLogMapper.selectById(id);
}
}
自定义缓存注解
比如之前的那个@Caching
组合,会让方法上的注解显得整个代码比较乱,此时可以使用自定义注解把这些注解组合到一个注解中,如
@Caching(put = {
@CachePut(value = "user", key = "#user.id"),
@CachePut(value = "user", key = "#user.username"),
@CachePut(value = "user", key = "#user.email")
})
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface UserSaveCache {
}
@UserSaveCache
public User save(User user){}
基于 XML 的声明式缓存
如果无法访问应用的源码,或者想从外部注入缓存行为,也可以使用基于 XML 的声明式缓存。
<!-- 希望缓存的服务 -->
<bean id="customerDataService"
class="com.your.app.namespace.service.CustomerDataService"/>
<bean id="cacheManager"
class="org.springframework.cache.support.SimpleCacheManager">
<property name="caches">
<set>
<bean
class="org.springframework.cache.concurrent.ConcurrentMapCacheFactoryBean"
name="directory"/>
<bean
class="org.springframework.cache.concurrent.ConcurrentMapCacheFactoryBean"
name="addresses"/>
</set>
</property>
</bean>
<!-- 定义缓存行为 -->
<cache:advice id="cachingBehavior" cache-manager="cacheManager">
<cache:caching cache="addresses">
<cache:cacheable method="getAddress" key="#customer.name"/>
</cache:caching>
</cache:advice>
<!-- 将该行为应用于 CustomerDataService 接口的所有实现 -->
<aop:config>
<aop:advisor advice-ref="cachingBehavior"
pointcut="execution(* com.your.app.namespace.service.CustomerDataService.*(..))"/>
</aop:config>
使用缓存带来的问题
双写不一致
使用缓存会带来许多问题,尤其是高并发下,包括缓存穿透、缓存击穿、缓存雪崩、双写不一致等问题。
其中主要聊一下双写不一致的问题,这是一个比较常见的问题,其中一个常用的解决方案是,更新的时候,先删除缓存,再更新数据库。
所以Spring Cache的@CacheEvict会有一个beforeInvocation的配置。
但使用缓存通常会存在缓存中的数据和数据库中不一致的问题,尤其是调用第三方接口,你不会知道它什么时候更新了数据。但使用缓存的业务场景很多时候并不需求数据的强一致,比如首页的热点文章,我们可以让缓存一分钟失效,这样就算一分钟内,不是最新的热点排行也没关系。
占用额外的内存
这个是无可避免的。因为总要有一个地方去放缓存。不管是ConcurrentHashMap也好,Redis也好,Caffeine也好,总归是会占用额外的内存资源去放缓存的。但缓存的思想正是用空间去换时间,有时候占用这点额外的空间对于时间上的优化来说,是非常值得的。
这里需要注意的是,SpringCache默认使用的是ConcurrentHashMap,它不会自动回收key,所以如果使用默认的这个缓存,程序就会越来越大,并且得不到回收。最终可能导致OOM。
springcache +redis
maven 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<!--排除lettuce客户端(默认使用lettuce客户端)-->
<exclusion>
<artifactId>lettuce-core</artifactId>
<groupId>io.lettuce</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.7.1</version>
</dependency>
spring:
config:
activate:
on-profile:
- redisdev
cache:
type: REDIS #设置缓存组件类型
time-to-live: 3600000 #设置缓存过期时间
cache-names: redisUser
#指定默认前缀,如果此处我们指定了前缀则使用我们指定的前缀,推荐此处不指定前缀
#spring.cache.redis.key-prefix=CACHE_
#是否开始前缀,建议开启
use-key-prefix: true
cache-null-values: true #是否缓存空值,防止缓存穿透
redis:
database: 1
host: 127.0.0.1
port: 6379
password:
package org.example.cache.redis集成;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import redis.clients.jedis.JedisPoolConfig;
/**
* @author: noob
* @description :
* @Date : 13:38 2024/8/28
*/
@Configuration
public class JedisConfig {
@Value("${spring.redis.host}")
String redisHost;
@Value("${spring.redis.password}")
String password;
@Value("${spring.redis.port}")
int port;
@Value("${spring.redis.database}")
int database;
@Bean
public RedisStandaloneConfiguration standaloneConfig() {
RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
configuration.setHostName(redisHost);
configuration.setPort(port);
configuration.setDatabase(database);
return configuration;
}
@Bean
public JedisPoolConfig poolConfig() {
JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMinIdle(300);
poolConfig.setMaxIdle(500);
poolConfig.setMaxTotal(5000);
poolConfig.setMaxWaitMillis(1000);
poolConfig.setTestOnBorrow(true);
poolConfig.setTestOnReturn(true);
poolConfig.setTestWhileIdle(true);
return poolConfig;
}
@Bean
public JedisConnectionFactory connectionFactory(RedisStandaloneConfiguration standaloneConfig) {
JedisConnectionFactory factory = new JedisConnectionFactory(standaloneConfig);
// 添加redis连接池
factory.setPoolConfig(poolConfig());
factory.setUsePool(true);
return factory;
}
// jedis 一种简单的配置方式
// public JedisPoolConfig jedisPoolConfig() {
// JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
// return jedisPoolConfig;
// }
//
// @Bean
// JedisConnectionFactory jedisConnectionFactory() {
// JedisConnectionFactory jedisConnectionFactory = new JedisConnectionFactory();
// jedisConnectionFactory.setPoolConfig(jedisPoolConfig());
// jedisConnectionFactory.setHostName(redisHost);
// jedisConnectionFactory.setPassword(password);
// jedisConnectionFactory.setPort(port);
// jedisConnectionFactory.setDatabase(database);
// return jedisConnectionFactory;
// }
}
package org.example.cache.redis集成;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.interceptor.CacheErrorHandler;
import org.springframework.cache.interceptor.CacheResolver;
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.cache.interceptor.SimpleCacheErrorHandler;
import org.springframework.cache.interceptor.SimpleCacheResolver;
import org.springframework.cache.interceptor.SimpleKeyGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import javax.annotation.Resource;
import static org.springframework.data.redis.cache.RedisCacheConfiguration.defaultCacheConfig;
/**
* @author: noob
* @description : https://blog.csdn.net/echizao1839/article/details/102660649
* <p>
* 正常继承 support接口进行继承
* @Date : 14:09 2024/8/28
*/
@Configuration
@EnableCaching
public class RedisCacheConfig extends CachingConfigurerSupport {
/**
* 自定义缓存的redis的KeyGenerator【key生成策略】
* 注意: 该方法只是声明了key的生成策略,需在@Cacheable注解中通过keyGenerator属性指定具体的key生成策略
* 可以根据业务情况,配置多个生成策略
* 如: @Cacheable(value = "key", keyGenerator = "cacheKeyGenerator")
*/
@Override
public KeyGenerator keyGenerator() {
/**
* target: 类
* method: 方法
* params: 方法参数
*/
return (target, method, params) -> {
//获取代理对象的最终目标对象
StringBuilder sb = new StringBuilder();
sb.append(target.getClass().getSimpleName()).append(":");
sb.append(method.getName()).append(":");
//调用SimpleKey的key生成器
Object key = SimpleKeyGenerator.generateKey(params);
return sb.append(key);
};
}
/**
* 自定义生成redis-key
*/
// @Override
// public KeyGenerator keyGenerator() {
// return (o, method, objects) -> {
// StringBuilder sb = new StringBuilder();
// sb.append(o.getClass().getName()).append(".");
// sb.append(method.getName()).append(".");
// for (Object obj : objects) {
// sb.append(obj.toString());
// }
// return sb.toString();
// };
// }
@Override
public CacheResolver cacheResolver() {
return new SimpleCacheResolver(cacheManager());
}
@Override
public CacheErrorHandler errorHandler() {
// 用于捕获从Cache中进行CRUD时的异常的回调处理器。
return new SimpleCacheErrorHandler();
}
@Resource
private RedisConnectionFactory factory;
@Override
public CacheManager cacheManager() {
RedisCacheConfiguration cacheConfiguration =
defaultCacheConfig()
.disableCachingNullValues()
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(factory).cacheDefaults(cacheConfiguration).build();
}
}
package org.example.cache.redis集成;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
public interface RedisService {
public List<User> list() ;
public void del(Integer id) ;
public User select(Integer id) ;
}
package org.example.cache.redis集成;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
@Service
@CacheConfig(cacheNames = "user",keyGenerator = "keyGenerator")
public class RedisServiceImpl implements RedisService {
@Cacheable(value = "user", key = "'list'")
@Override
public List<User> list() {
System.out.println("=========list");
User user1 = new User();
user1.setId(1);
user1.setName("老大");
User user2 = new User();
user2.setId(2);
user2.setName("老二");
List<User> users = new ArrayList<>();
users.add(user1);
users.add(user2);
return users;
}
@CacheEvict(value = "user", key = "'list'")
@Override
public void del(Integer id) {
System.out.println("************************************+id");
List<User> users = new ArrayList<>();
Iterator<User> iterator = users.iterator();
while (iterator.hasNext()) {
User user = iterator.next();
if (user.getId().equals(id)) {
iterator.remove();
break;
}
}
}
@CachePut(value = "demo", key = "#result==null")
@Override
public User select(Integer id) {
System.out.println("===============dddd================");
if (id == 0) {
return null;
}
User user = new User();
user.setId(100);
user.setName("测试");
return user;
}
}
package org.example.cache.redis集成;
import org.example.cache.注解使用.CacheService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
/**
* https://blog.csdn.net/lingerlan510/article/details/121906813 集成redisTemplate
* redis 注解集成
* @author: noob
* @description :
* @Date : 9:05 2024/8/28
*/
@RestController
@RequestMapping("/redis")
public class TestRedisController {
@Resource
private RedisService redisService;
@Autowired
private CacheManager cacheManager;
@RequestMapping("/get")
public void test1() {
Object obj = redisService.list();
System.out.println("----------验证缓存是否生效----------");
Cache cache = cacheManager.getCache("user");
Cache cache2 = cacheManager.getCache("redisUser");
System.out.println(cache);
System.out.println(cache2);
System.out.println(cache.get(1));
System.out.println(cache2.get(1));
}
}
package org.example.cache.redis集成;
/**
* @author: noob
* @description :
* @Date : 11:16 2024/8/28
*/
public class User {
private Integer id;
private String name;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
springcache +caffenie
如前文所示
后记:
redisTemplate、jedis、lettuce、redission的对比
2.SpringCache的使用注意事项
@CacheEvict注解中的allEntries = true属性会将当前片区中的所有缓存数据全部清除,请谨慎使用
@CacheEvict注解适用用于失效模式,也即更新完数据库数据后删除缓存数据
@CachePut注解用于适用于双写模式,更新完数据库后写入到缓存中
SpringCache不是只能和Redis中间件进行整和,和其他缓存中间件也可以整合实现缓存管理
Redis的作用也不仅仅是用作缓存,也可以用于功能实现,实现分布式锁,注意区分redis分布式锁和SpringCache
配置文件中spring.cache.redis.key-prefix的配置一般不进行设置
配置文件中spring.cache.redis.cache-null-values=true一般需要设置(null值缓存),可以有效的防止缓存穿透
…
3.SpringCache的不足
SpringCache只对读模式下的缓存失效进行了处理,对于写模式下的缓存失效没有相应的处理,需要我们自己采取其他方式来处理。
缓存中常见的失效场景及解决方案:
缓存穿透:查询一个null数据 解决方案:缓存空数据
缓存击穿:大量并发同时查询一个刚好过期的数据,解决方案:加锁
缓存雪崩:大量的key同时过期,解决方案:所有key都添加上随机的过期时间
读模式下的缓存失效处理方案:
缓存穿透:cache-null-values: true,允许写入空值
缓存击穿:@Cacheable(sync = true),加锁
缓存雪崩:time-to-live:xxx,设置不同的过期时间
提示:
1、对于常规数据(读多写少,及时性、一致性要求不高的数据)完全可以使用 Spring Cache
2、对于特殊数据(比如要求高一致性)则需要特殊处理
原文链接:https://blog.csdn.net/lingerlan510/article/details/121906813