Spring Boot笔记-Spring Boot与缓存(九)

1.JSR107

Java Caching定义了5个核心接口,分别是CachingProvider,CacheManager,Cache,Entry和Expiry。

  • CachingProvider定义了创建、配置、获取、管理和控制多个CacheManager。一个应用可以在运行期访问多个CachingProvider。
  • CacheManager定义了创建、配置、获取、管理和控制多个唯一命名的Cache,这些Cache存在于CacheManager的上下文中。一个CacheManager仅被一个CachingProvider所拥有。
  • Cache是一个类似Map的数据结构并临时存储以Key为索引的值。一个Cache仅被一个CacheManager所拥有。
  • Entry是一个存储在Cache中的key-value对。
  • Expiry 每一个存储在Cache中的条目有一个定义的有效期。一旦超过这个时间,条目为过期的状态。一旦过期,条目将不可访问、更新和删除。缓存有效期可以通过ExpiryPolicy设置。

2.Spring缓存抽象

Spring从3.1开始定义了org.springframework.cache.Cache和org.springframework.cache.CacheManager接口来统一不同的缓存技术,并支持使用JCache(JSR-107)注解简化我们开发。

  • Cache接口为缓存的组件规范定义,包含缓存的各种操作集合。
  • Cache接口下Spring提供了各种xxxCache的实现:如RedisCache,EhCacheCache,ConcurrentMapCache等。
  • 每次调用需要缓存功能的方法时,Spring会检查检查指定参数的指定的目标方法是否已经被调用过,如果被调用过,就直接从缓存中获取方法调用后的结果,如果没有就调用方法。并缓存结果后返回给用户,下次调用直接从缓存中获取。
  • 使用Spring缓存抽象时我们需要关注以下两点:
  1. 确定哪些方法需要被缓存以及它们的缓存策略
  2. 从缓存中读取之前缓存存储的数据

3.几个重要概念&缓存注解

Cache缓存接口,用于定义缓存操作。实现类:RedisCache,EhCacheCache,ConcurrentMapCache等
CacheManager缓存管理器,用于管理各种缓存(Cache)组件
@Cacheable此注解添加到方法上,能够根据方法的请求参数对结果进行缓存,用于查询方法
@CacheEvict此注解添加到方法上,清空缓存,用于删除方法
@CachePut此注解添加到方法上,保证方法被调用,同时结果也会被缓存,用于更新方法
@EnableCaching此注解添加到类上,开启基于注解的缓存
keyGenerator缓存数据key的生成策略
serialize缓存数据时value的序列化策略

@Cacheable,@CachePut,@CacheEvict主要参数含义

value或cacheNames

指定缓存组件的名称,理解为存储在哪个缓存库,支持多值,必须指定一个

例如:@Cacheable(value="myCache")

value

缓存的key,可以为空

如果为空,按照KeyGenerator进行生成,默认使用SimpleKeyGenerator.generateKey()来生成

如果指定了KeyGenerator,就按照自定义的KeyGenerator来生成

另外,还可以自己指定值,需要按照SpEL表达式的方式,或者使用KeyGenerator来生成

方法名+参数的形式可以避免key重复,例如:@Cacheable(key="#root.methodName + '[' + #id + ']'")
condition缓存的条件,可以为空,使用SpEL表达式书写,返回true或false,只有为true的时候,才会添加/删除缓存例如:@Cacheable(condition="#userName.length()>2")
allEntries(@CacheEvict)是否清空所有缓存,缺省为false,如果指定为true,则方法调用后立刻清空所有缓存例如:@CacheEvict(allEntries=true)
beforeInvocation(@CacheEvict)是否在方法执行前清除缓存,缺省为false,如果指定为true,则方法执行前清除缓存。在方法执行前清除缓存的作用:如果方法执行抛出异常,照样可以清除缓存例如:@CacheEvict(beforeInvocation=true)
unless(@CachePut,@Cacheable)用于否定缓存的,表达式只在方法执行后判断,因此可以拿到返回值进行判断,如果条件为true,则不缓存,条件为fasle,进行缓存例如:@Cacheable(unless="result == null")

Cache SpEL available metadata

名字位置描述示例
methodNameroot object当前被调用的方法名#root.methodName
methodroot object当前被调用的方法#root.method.name
targetroot object当前被调用的目标对象#root.target
targetClassroot object当前被调用的目标对象类#root.targetClass
argsroot object当前被调用的方法的参数列表#root.args[0]
cachesroot object当前方法调用使用的缓存列表(如@Cacheable(value={"cache1", "cache2"}) ),则有两个cache#root.caches[0].name
argument nameevaluation context方法参数的名字,可以直接#参数名,也可以使用#p0或#a0的形式,0代表参数的索引#iban、#a0、#p0
resultevaluation context方法执行后的返回值(仅当方法执行之后的判断有效,如unless,cachePut的表达式,cacheEvict的表达式beforeInvocation=false)#result

4.缓存的使用

1.搭建基本环境

新建Spring Boot项目,添加Spring Web、Mybatis Framework、MySQL Driver、Spring cache abstraction依赖。

新建数据库cache,导入SQL文件,添加两张表。在com.atguigu.cache.bean包下,添加对应的两个Bean。

SET FOREIGN_KEY_CHECKS=0;

-- ----------------------------
-- Table structure for department
-- ----------------------------
DROP TABLE IF EXISTS `department`;
CREATE TABLE `department` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `departmentName` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

-- ----------------------------
-- Table structure for employee
-- ----------------------------
DROP TABLE IF EXISTS `employee`;
CREATE TABLE `employee` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `lastName` varchar(255) DEFAULT NULL,
  `email` varchar(255) DEFAULT NULL,
  `gender` int(2) DEFAULT NULL,
  `d_id` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

配置数据库连接信息。

spring:
  datasource:
    username: root
    password: root
    url: jdbc:mysql://localhost:3306/cache?useSSL=true&useUnicode=true&characterEncoding=UTF8&serverTimezone=GMT
    driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
  configuration:
    map-underscore-to-camel-case: true
logging:
  level:
    com.atguigu.cache.mapper: debug

在com.atguigu.cache.mapper下添加mapper文件,在mapper上添加@Mapper注解,或者在主启动类上加上@MapperScan注解,@Mapper和@MapperScan有一个即可,用于保证能扫描到这个Mapper文件。然后使用注解的方式,在Mapper文件里添加对应的CRUD方法。随后,在测试类中测试效果,测试成功表明Mybatis配置成功。

在com.atguigu.cache.controller下添加Controller类,在com.atguigu.cache.service下添加Service类,编写相应代码,通过浏览器访问http://localhost:8080/emp/1,可以在浏览器看到返回的json数据。

package com.atguigu.cache.controller;

import com.atguigu.cache.bean.Employee;
import com.atguigu.cache.service.EmployeeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class EmployeeController {
    @Autowired
    EmployeeService employeeService;

    @GetMapping("/emp/{id}")
    public Employee getEmployeeById(@PathVariable("id") int id) {
        return employeeService.getEmployeeById(id);
    }
}
package com.atguigu.cache.service;

import com.atguigu.cache.bean.Employee;
import com.atguigu.cache.mapper.EmployeeMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class EmployeeService {
    @Autowired
    EmployeeMapper employeeMapper;

    public Employee getEmployeeById(int id) {
        System.out.println("查询" + id + "号员工");
        Employee employee = employeeMapper.getEmployeeById(id);
        return employee;
    }
}
package com.atguigu.cache.mapper;

import com.atguigu.cache.bean.Employee;
import org.apache.ibatis.annotations.*;

@Mapper
public interface EmployeeMapper {
    @Select("SELECT * FROM employee WHERE id = #{id}")
    public Employee getEmployeeById(int id);

    @Update("UPDATE employee SET lastName = #{lastName}, email = #{email}, gender = #{gender}, d_id = #{dId} WHERE id = #{id}")
    public void updateEmployee(Employee employee);

    @Delete("DELETE FROM employee WHERE id = #{id}")
    public void deleteEmplyeeById(int id);

    @Insert(("INSET INTO employee(lastName, email, gender, d_id) VALUES (#{lastName}, #{email}, #{gender}, #{dId})"))
    public void insertEmployee(Employee employee);
}

2.缓存的用法

在主启动类上加上@EnableCaching注解,用于开启基于注解的缓存。给对应的service方法上添加@Cacheable或@CacheEvict或@CachePut注解,进行测试。

@Cacheable注解

@Cacheable 将方法的运行结果进行缓存,以后再获取相同数据的时候,直接从缓存中获取,不需要再调用方法了

@Cacheable注解的几个属性:

  • cacheNames/value:指定缓存组件的名字,可以理解成缓存在哪个缓存库里面,可以指定多个缓存库,用大括号括起来,英文逗号分隔
  • key:缓存数据使用的key,默认使用方法的参数值,也可以编写SpEl表达式
  • keyGenerator:指定key的生成器,key和keyGenerator二选一即可
  • cacheManager:指定缓存管理器,它的getCache()方法返回一个cache
  • cacheResolver:指定缓存解析器,它的resolveCaches()方法返回一个cache的集合
  • cacheManager和cacheResolver制定一个即可
  • condition:指定缓存条件,当condition为true的时候,进行缓存
  • unless:指定不缓存条件,当unless为true的时候,不进行缓存
  • syne:缓存是否使用异步模式

在getEmployee()的service方法上,添加注解@Cacheable(cacheNames="employee"),重启进行测试,第一次访问http://localhost:8080/emp/1,在控制台可以看到SQL信息,第二次访问,就不会打印SQL信息了,表明第二次访问走的缓存。

自定义KeyGenerator

编写一个MyCacheConfig的类,可以在里面自定义KeyGenerator。

package com.atguigu.cache.config;

import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.lang.reflect.Method;
import java.util.Arrays;

@Configuration
public class MyCacheConfig {
    // 指定KeyGenerator的名称,用于在@Cacheable中,使用KeyGenerator属性进行指定
    @Bean("myKeyGenerator")
    public KeyGenerator keyGenerator() {
        return new KeyGenerator() {
            @Override
            public Object generate(Object o, Method method, Object... objects) {
                return method.getName() + Arrays.asList(objects);
            }
        };
    }
}

@CachePut

同理,编写controller方法和service方法做更新操作。

@CachePut 既调用方法,又更新缓存数据

应用场景:修改数据库的某条数据,同时更新缓存

@CachePut的调用时机和@Cacheable不同,@Cacheable在方法执行前调用,@CachePut在方法执行完后再调用

@CachePut先调用目标方法,再将目标方法的结果缓存起来。

// Controller
@GetMapping("/emp")
public Employee updateEmployee(Employee employee) {
    return employeeService.updateEmployee(employee);
}

// Service
// @CachePut没有带key属性,后面更新时候,更新的key是employee对象对应的缓存对象,和查询时候的缓存对象的key不一致
@CachePut(cacheNames = "employee")
public Employee updateEmployee(Employee employee) {
    System.out.println("更新员工信息:" + employee);
    employeeMapper.updateEmployee(employee);
    return employee;
}

做测试,访问http://localhost:8080/emp/1,第一次在控制台可以看到SQL输出,第二次访问,正常走缓存,发送更新请求:http://localhost:8080/emp?id=1&lastName=lisi&email=lisi@gmail.com&gender=1&dId=2,执行更新请求,再次访问http://localhost:8080/emp/1,却发现并没有拿到更新后的结果,这是什么问题呢?

此时回看@Cacheable方法上的注解,因为前面使用了@KeyGenerator自定义key,所以,查询时候,向缓存map中放缓存的时候,使用的是key是getEmployeeById[1],再看更新方法的@CachePut注解,没有使用Key或KeyGenerator,于是,更新缓存时候,用的key是Employee对象,于是,更新的缓存并不是原来key=getEmployeeById[1]的缓存,所以第二次请求http://localhost:8080/emp/1的时候,继续拿key=getEmployeeById[1]的缓存,也就是旧的内容了,因为这个地方,对于同一个缓存对象,它的key要是一致的。

简单起见,这里将@Cacheable的注解改成@Cacheable(cacheNames = "employee", key = "#id"),将@CachePut的注解改成@CachePut(cacheNames = "employee", key = "#employee.id"),此时对于同一个缓存对象,它的key都是id值,再次按照上面的测试方法,更新缓存后,再次获取的结果就是更新后的结果了。

@CacheEvict

用于清除缓存。编写controller和service方法。因为上面更新时候,出过问题,所以这里的id一定要注意,保证同一缓存对象,前后的id是一样的,才能操作成功。

// Controller
@DeleteMapping("/deleteEmp")
public void deleteEmployeeById(int id) {
    employeeService.deleteEmployeeById(id);
}

// Service
@CacheEvict(cacheNames = "employee", key = "#id")
public void deleteEmployeeById(int id) {
    System.out.println("删除员工:" + id);
    // employeeMapper.deleteEmplyeeById(id);
}

 首先访问一下1号员工http://localhost:8080/emp/1,通过控制台可知,执行了目标方法,并将结果进行了缓存,再发送http://localhost:8080/deleteEmp?id=1请求,将1号员工的缓存信息删除,再访问1号员工http://localhost:8080/emp/1,通过控制台,可以发现,又一次执行了目标方法,说明前面的一次清除缓存是成功的。

@CacheEvict里,还有两个属性:

  1. allEntries,默认是false,当allEntries=true的时候,表示删除全部缓存。
  2. beforeInvocation,默认是false,当beforeInvocation=true,表示缓存的清除在方法之前执行。

@Caching

@Caching里有cachable()、put()、evict()三个方法,可以自定义cache的规则。

编写controller,service,mapper,重启服务,请求http://localhost:8080/emp/lastName/lisi,第一次会调用目标方法,并将key=lastName,key=result.id,key=result.email,value=employee对象放到缓存中,我们再次请求http://localhost:8080/emp/1,可以发现,是没有走缓存的,说明此时@Caching是生效的,同时以key=result.id添加了缓存。

// Controller
@GetMapping("/emp/lastName/{lastName}")
public Employee getEmployeeByLastName(@PathVariable("lastName") String lastName) {
    return employeeService.getEmployeeByLastName(lastName);
}

// Service
// 在执行方法的时候,根据key=#lastName查询,如果查询不到,添加key=#lastName,value=Employee对象的缓存
// 同时,还会把key=#result.id和key=#result.email,value=Employee的缓存对象,也放进缓存中
@Caching(
        cacheable = {@Cacheable(cacheNames = "employee", key = "#lastName")},
        put = {
                @CachePut(cacheNames = "employee", key = "#result.id"),
                @CachePut(cacheNames = "employee", key = "#result.email")
        }
)
public Employee getEmployeeByLastName(String lastName) {
    return employeeMapper.getEmployeeByLastName(lastName);
}

// Mapper
@Select("SELECT * FROM employee WHERE lastName = #{lastName}")
Employee getEmployeeByLastName(String lastName);

@CacheConfig

@CacheConfig注解作用于类上,使用它来做一个类内的全局的配置,可以配置cacheNames、keyGenerator、cacheManager、cacheResolver,那么下面的方法上的注解属性就可以去掉了。

3.原理:

找到CacheAutoConfiguration类,它上面有一个@Import({CacheAutoConfiguration.CacheConfigurationImportSelector.class, CacheAutoConfiguration.CacheManagerEntityManagerFactoryDependsOnPostProcessor.class})注解,我们打开CacheConfigurationImportSelector类,在结尾处打一个断点,启动之后,可以看到,这里导入了10个配置类。

  • org.springframework.boot.autoconfigure.cache.GenericCacheConfiguration
  • org.springframework.boot.autoconfigure.cache.JCacheCacheConfiguration
  • org.springframework.boot.autoconfigure.cache.EhCacheCacheConfiguration
  • org.springframework.boot.autoconfigure.cache.HazelcastCacheConfiguration
  • org.springframework.boot.autoconfigure.cache.InfinispanCacheConfiguration
  • org.springframework.boot.autoconfigure.cache.CouchbaseCacheConfiguration
  • org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration
  • org.springframework.boot.autoconfigure.cache.CaffeineCacheConfiguration
  • org.springframework.boot.autoconfigure.cache.SimpleCacheConfiguration
  • org.springframework.boot.autoconfigure.cache.NoOpCacheConfiguration

在yml里加上debug: true后,重启项目,在控制台可以看到,默认情况下,是SimpleCacheConfiguration匹配,也就是只有它的配置是生效的。找到SimpleCacheConfiguration类,可以看到,它向容器中注册了一个ConcurrentMapCacheManager的Bean。打开ConcurrentMapCacheManager类,可以看到有一个cacheMap,这个就是用来存储缓存的。

流程:

  1. 在Service方法运行之前,先按照注解指定的名称cacheName查找缓存,对应在ConcurrentMapCacheManager中的getCache()方法。第一次访问的时候,cache是null,后面会调用一个createConcurrentMapCache()方法创建一个cacheMap。
  2. 调用ConcurrentMapCache.lookup()方法在cacheMap中查找缓存,使用一个key,默认是方法的参数,key是按照某种策略生成的。默认使用SimpleKeyGenerator来生成key,也就是SimpleKeyGenerator.generateKey()方法,方法里会根据参数的个数以及参数的类型生成key。
  3. 第一次调用的时候,lookup()为空,会调用Service方法,请求数据库,之后会执行ConcurrentMapCache.put()方法,将数据库查到的内容存入cacheMap中。第二次调用的时候,lookup()不为空,直接将缓存拿到的结果返回。

5.整合Redis实现缓存

1.搭建基本环境&测试Redis基本用法

从虚拟机上下载Redis镜像:docker pull docker.io/redis。

启动Redis镜像:docker run -d -p 6379:6379 redis。

使用Redis DeskTop Manager连接,便于查看里面的数据。下面在项目中进行引入Redis。

在pom.xml里加入redis的依赖,修改yml配置文件,加入spring:redis:host: 192.168.0.123。编写测试类。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Redis的五大数据类型:String(字符串)、List(列表)、Set(集合)、Hash(散列)、ZSet(有序集合)。

@Test
public void test01() {
    // stringRedisTemplate.opsForValue();// 字符串
    // stringRedisTemplate.opsForList();// 列表
    // stringRedisTemplate.opsForSet();// 集合
    // stringRedisTemplate.opsForHash();// 散列
    // stringRedisTemplate.opsForZSet();// 有序集合
    // 向Redis里保存数据
    // stringRedisTemplate.opsForValue().append("msg", "hello");
    // 从Redis里读取数据
    // String msg = stringRedisTemplate.opsForValue().get("msg");
    // System.out.println(msg);
    // 向list中放数据
    // stringRedisTemplate.opsForList().leftPush("myList", "1");
    // stringRedisTemplate.opsForList().leftPush("myList", "2");
}
@Test
public void test02() {
    // 先让Employee类实现Serializable接口,否则会报错
    Employee employee = employeeMapper.getEmployeeById(1);
    // redis保存对象,默认使用JDK的序列化机制,保存的序列化后的对象
    redisTemplate.opsForValue().set("employee1", employee);
}

因为JDK的序列化器处理过后的信息不便于其他环境的转换,建议改成json数据格式进行存储。在config包下创建MyRedisConfig.java文件。

package com.atguigu.cache.config;

import com.atguigu.cache.bean.Employee;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;

import java.net.UnknownHostException;

@Configuration
public class MyRedisConfig {
    @Bean
    public RedisTemplate<Object, Employee> EmployeeRedisTemplate(RedisConnectionFactory redisConnectionFactory) throws UnknownHostException {
        RedisTemplate<Object, Employee> template = new RedisTemplate();
        template.setConnectionFactory(redisConnectionFactory);
        template.setDefaultSerializer(new Jackson2JsonRedisSerializer<Employee>(Employee.class));
        return template;
    }
}

在测试类中,注入添加了Serializer的RedisTemplate进行测试,通过Redis Desktop Manager查看结果,可以发现是json格式。

@Autowired
RedisTemplate<Object, Employee> employeeRedisTemplate;

@Test
public void test02() {
    Employee employee = employeeMapper.getEmployeeById(1);
    // redis保存对象,默认使用JDK的序列化机制,保存的序列化后的对象
    // redisTemplate.opsForValue().set("employee1", employee);
    employeeRedisTemplate.opsForValue().set("employee1", employee);
}

2.测试Redis缓存

原理:CacheManager缓存组件来实际给缓存中存取数据

  1. 引入Redis的starter后,容器中保存的是RedisCacheManager。
  2. RedisCacheManager帮我们创建RedisCache作为缓存组件,RedisCache是通过操作Redis缓存数据。
  3. 保存数据k-v都是Object时候,默认使用JDK的序列化。
  4. 自定义CacheManager,实现json序列化。

在MyRedisConfig.java里加上如下代码,实现对象的json序列化。 

@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
    RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofDays(1))
            .disableCachingNullValues()
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
    return RedisCacheManager.builder(factory).cacheDefaults(cacheConfiguration).build();
}

编写controller,service,mapper进行测试。

package com.atguigu.cache.controller;

import com.atguigu.cache.bean.Department;
import com.atguigu.cache.service.DepartmentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class DepartmentController {
    @Autowired
    DepartmentService departmentService;

    @GetMapping("/department/{id}")
    public Department getDepartmentById(@PathVariable("id") int id) {
        return departmentService.getDepartmentById(id);
    }

    @GetMapping("save")
    public void saveDepartment() {
        departmentService.saveDepartment();
    }
}
package com.atguigu.cache.service;

import com.atguigu.cache.bean.Department;
import com.atguigu.cache.mapper.DepartmentMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
public class DepartmentService {
    @Autowired
    DepartmentMapper departmentMapper;
    @Autowired
    CacheManager cacheManager;

    @Cacheable(cacheNames = "department")
    public Department getDepartmentById(int id) {
        System.out.println("查询部门" + id);
        return departmentMapper.getDepartmentById(id);
    }

    public void saveDepartment() {
        System.out.println("编码方式保存到Redis");
        Cache cache = cacheManager.getCache("department");
        cache.put("key", "value");
    }
}
package com.atguigu.cache.mapper;

import com.atguigu.cache.bean.Department;
import org.apache.ibatis.annotations.*;

@Mapper
public interface DepartmentMapper {
    @Select("SELECT * FROM department WHERE id = #{id}")
    Department getDepartmentById(int id);
}

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值