Java程序员操作Redis的常用方式

1. 使用Jedis

1.1 准备工作

在maven项目的pom.xml文件中加上这个依赖

<dependency>
  <groupId>redis.clients</groupId>
  <artifactId>jedis</artifactId>
  <version>3.1.0</version>
</dependency>

我们之前操作redis的数据,都是通过redis-cli这个客户端。所以我们在用Java操作redis时,首先要获取这个客户端对象了。

Jedis jedis = new Jedis(ip, port);

这个jedis就相当于redis-cli了。

上面列举的所有指令,在Jedis中都有对应的方法实现它。
举一些例子,就不做注释了,大家看代码就能知道是什么操作命令。

1.2 key相关

Set<String> result1 = jedis.keys("*");
Boolean result2 = jedis.exists("key1");
Long result3 = jedis.expire("key", 20);
Long result4 = jedis.ttl("key");
String result5 = jedis.type("key");
String result6 = jedis.rename("oldkey", "newkey");
Long result7 = jedis.del("key1", "key2", "key3");

1.3 string相关

String result1 = jedis.set("key1", "1111");
Long result2 = jedis.setnx("key2", "2222");
String result3 = jedis.setex("key3", 30, "3333");
String result4 = jedis.mset("k1", "v1", "k2", "v2");
Long result5 = jedis.msetnx("k1", "v1", "k2", "v2", "k3", "v3");
String result6 = jedis.get("key1");
List<String> result7 = jedis.mget("k1", "k2");
Long result8 = jedis.append("key2", "222");
Long result9 = jedis.strlen("key3");
Long result10 = jedis.incr("key1");
Long result11 = jedis.decr("key1");
Long result12 = jedis.incrBy("key1", 200);
Long result13 = jedis.decrBy("key1", 200);
String result14 = jedis.getrange("key1", 0, -1);
Long result15 = jedis.setrange("key1", 2, "11111");

1.4 list相关

Long result1 = jedis.lpush("list1", "a", "b", "c");
Long result2 = jedis.rpush("list1", "d", "e", "f");
String result3 = jedis.lset("list1", 2, "p");
List<String> result4 = jedis.lrange("list1", 0, -1);
String result5 = jedis.lpop("list1");
String result6 = jedis.rpop("list1");
String result7 = jedis.lindex("list1", 1);
Long result8 = jedis.llen("list1");
Long result9 = jedis.lrem("list1", 0, "e");
String result10 = jedis.ltrim("list1", 0, -2);

1.5 hash相关

我们知道hash的value是field-value键值对,一个key对应多个键值对,这样我们想到,可以用Map去存储这些键值对。

我们创建一个Map

Map<String, String> map = new HashMap<>();
map.put("name", "zhangsan");
map.put("age", "30");
map.put("score", "98.5");

接下来就是用jedis的操作了

Long result1 = jedis.hset("hash1", map);
String result2 = jedis.hget("hash1", "age");
List<String> result3 = jedis.hmget("hash1", "name", "score");
Map<String, String> result4 = jedis.hgetAll("hash1");
Long result5 = jedis.hdel("hash1", "name");
Long result6 = jedis.hlen("hash1");
Boolean result7 = jedis.hexists("hash1", "school");
Set<String> result8 = jedis.hkeys("hash1");
List<String> result9 = jedis.hvals("hash1");
Long result10 = jedis.hsetnx("hash1", "score", "21.1");

1.6 set相关

Long result1 = jedis.sadd("set1", "name", "age", "score", "address");
Long anotherSet = jedis.sadd("set2", "cat", "dog", "age", "bird");
Set<String> result2 = jedis.smembers("set1");
Boolean result3 = jedis.sismember("set1", "age");
Long result4 = jedis.scard("set1");
Long result5 = jedis.srem("set1", "name", "age");
List<String> result6 = jedis.srandmember("set1", 1);
Set<String> result7 = jedis.spop("set1", 1);
Long result8 = jedis.smove("age", "set1", "set2");
Set<String> result9 = jedis.sdiff("set1", "set2");
Set<String> result10 = jedis.sinter("set1", "set2");
Set<String> result11 = jedis.sunion("set1", "set2");

1.7 zset相关

zset类似于hash,value部分存储的是value-score键值对,所以我们在创建zset对象时,也需要先创建一个Map。

Map<String, Double> map = new HashMap<>();
map.put("v1", 10.0);
map.put("v2", 15.0);
map.put("v3", 5.0);
map.put("v4", 12.0);

接下来是jedis操作zset的方法

Long result1 = jedis.zadd("zset1", map);
Set<String> result2 = jedis.zrange("zset1", 1, 2);
Set<String> result3 = jedis.zrangeByScore("zset1", 4.0, 13.0);
Long result4 = jedis.zcard("zset1");
Long result5 = jedis.zcount("zset1", 4.0, 13.0);
Long result6 = jedis.zrank("zset1", "v2");
Double result7 = jedis.zscore("zset1", "v3");
Long result8 = jedis.zrem("zset1", "v1", "v5");

2. 使用SpringDataRedis

2.1 介绍说明

上面使用jedis能完美还原redis的所有基础操作命令,但我总感觉不太优雅,一是因为Jedis的方法实在是太多了,完全背下这么多操作命令也不太现实,费时费力;二是因为我们开发Java的web项目,都是使用Spring框架完成的,而Jedis是Redis官方推出的,并没有与Spring这个大家族融合在一起。为了解决这个问题,Spring官方推出了一个SpringDataRedis。

SpringDataRedis针对Jedis提供了如下功能:

  1. 提供了一个高度封装的RedisTemplate类,自动管理连接池。
  2. 针对Jedis客户端中大量api进行归类封装,同一类型操作封装为一个Operation接口。
ValueOperations           对应方法       redisTemplate.opsForValue();
ListOperations            对应方法       redisTemplate.opsForList();
HashOperations            对应方法       redisTemplate.opsForHash();
SetOperations             对应方法       redisTemplate.opsForSet();
ZSetOperations            对应方法       redisTemplate.opsForZSet();
  1. 提供对key的bound(绑定)便捷化操作api,通过bound封装指定类型的key,然后进行一系列操作而无需“显式”再次指定key。同样对应有五种bound类。
BoundValueOperations       对应方法       redisTemplate.boundValueOps(key);
BoundListOperations        对应方法       redisTemplate.boundListOps(key);
BoundHashOperations        对应方法       redisTemplate.boundHashOps(key);
BoundSetOperations         对应方法       redisTemplate.boundSetOps(key);
BoundZSetOperations        对应方法       redisTemplate.boundZSetOps(key);
  1. 将事务操作封装,由容器控制。
  2. 针对数据的序列化/反序列化,提供了多种可选择策略。

2.2 准备工作

在maven项目的pom.xml中加入这个依赖

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

在properties或yml配置文件中指定redis的服务地址

spring:
  redis:
    host: 10.40.18.10
    port: 6379
    database: 0

这只是一部分基础配置,其余的配置若需要,可自行查询添加。

这里需要说明一件事,在放这个配置前查看一下pom.xml文件parent标签里的版本,例如这里的是2.7.3版本。

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.7.3</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>

如果版本是3.0.0及以上,我们在写redis的配置文件时会有一点不一样的地方,后文同。

spring:
	data:
	  redis:
	    host: 10.40.18.10
	    port: 6379
	    database: 0

可能还有其他不同之处,待我慢慢发掘。

2.3 操作五种数据类型

首先使用@Autowired注入RedisTemplate。

@Autowired
private RedisTemplate redisTemplate;

2.3.1 key的操作

常用操作(key的操作)

Boolean result1 = redisTemplate.delete("key");
List<String> list = new ArrayList<>();
list.add("key1");
list.add("key2");
list.add("key3");
Long result2 = redisTemplate.delete(list);
Boolean result3 = redisTemplate.expire("key", 20, TimeUnit.SECONDS);
Long result4 = redisTemplate.getExpire("key");
Boolean result5 = redisTemplate.hasKey("key");

2.3.2 string的操作

string类型相关操作。我们这里只列出使用boundValueOps方法的操作,至于opsForValue方式,自行尝试即可,后文皆是如此。

//  之后都是操作这个strKey对象操作value,就免于再次指定key了,相当于把key封装起来了
BoundValueOperations strKey = redisTemplate.boundValueOps("strKey");

strKey.set("value1");
strKey.set("value1", 300, TimeUnit.SECONDS);
Boolean result1 = strKey.expire(1, TimeUnit.MINUTES);
String result2 = (String) strKey.get();
Boolean result3 = redisTemplate.delete("strKey");
Integer result4 = strKey.append("111");
Long result5 = strKey.size();

这里有一个问题,我们在set这个key为strKey的对象后,去redis客户端看一下,发现它的key是这样的

\xAC\xED\x00\x05t\x00\x06strKey

我们使用get获取value时,却没有问题。
原因:RedisTemplate模板类在操作redis时默认使用JdkSerializationRedisSerializer进行序列化。
解决方案:
在redisTemplate所处类加一个方法,把序列化方式改为stringRedis序列化方式。

@Autowired(required = false)
public void setRedisTemplate(RedisTemplate redisTemplate) {
    RedisSerializer stringSerializer = new StringRedisSerializer();
    redisTemplate.setKeySerializer(stringSerializer);
    redisTemplate.setValueSerializer(stringSerializer);
    redisTemplate.setHashKeySerializer(stringSerializer);
    redisTemplate.setHashValueSerializer(stringSerializer);
    this.redisTemplate = redisTemplate;
}

当然最好还是创建一个RedisTempleteUtil类,把上面这个方法放到这个类里面。
@autowired(required = false)作用在方法上,当方法有参数时,如果IOC容器中有方法参数的对象,那么会自动注入并执行方法一次;如果IOC容器中没有方法的参数对象,那么这个方法不会被执行,不管这个方法上有多少个参数,只要有一个参数对象是IOC容器中没有的,这个方法便不会被执行。如果方法没有参数,那么会被执行一次。

2.3.3 list的操作

BoundListOperations listKey = redisTemplate.boundListOps("listKey");
Long result1 = listKey.leftPush("v1");
Long result2 = listKey.rightPush("v2");
Long result3 = listKey.leftPushAll("v3", "v4", "v5");
Long result4 = listKey.rightPushAll("v3", "v4", "v5");
Boolean result5 = listKey.expire(1, TimeUnit.MINUTES);
List result6 = listKey.range(0, -1);
String result7 = (String) listKey.leftPop();
String result8 = (String) listKey.rightPop();
String result9 = (String) listKey.index(3);
Long result10 = listKey.size();
listKey.set(3, "v6");
Long result11 = listKey.remove(4L, "v3");

2.3.4 hash的操作

BoundHashOperations hashKey = redisTemplate.boundHashOps("hashKey");
hashKey.put("field1", "value1");
Boolean result1 = hashKey.expire(1, TimeUnit.MINUTES);
Map<String, String> map = new HashMap<>();
map.put("field2", "value2");
map.put("field3", "value3");
map.put("field4", "value4");
hashKey.putAll(map);
Set keys = hashKey.keys();
List values = hashKey.values();
String result2 = (String) hashKey.get("field3");
Map result3 = hashKey.entries();
Long result4 = hashKey.delete("field3");
Boolean result5 = hashKey.hasKey("field3");

2.3.5 set的操作

BoundSetOperations setKey = redisTemplate.boundSetOps("setKey");
setKey.add("v1", "v2", "v3", "v4");
Boolean result1 = setKey.expire(3, TimeUnit.MINUTES);
Set result2 = setKey.members();
Boolean result3 = setKey.isMember("v4");
Long result4 = setKey.size();
Long result5 = setKey.remove("v3");
Boolean result6 = redisTemplate.delete("setKey");

2.3.6 zset的操作

BoundZSetOperations zsetKey = redisTemplate.boundZSetOps("zsetKey");
Boolean result1 = zsetKey.add("v1", 30);
Boolean result2 = zsetKey.add("v2", 60);
Boolean result3 = zsetKey.add("v3", 50);
Boolean result4 = zsetKey.add("v4", 100);
Set result5 = zsetKey.range(0, -1);
Double result6 = zsetKey.score("v1");
Long result8 = zsetKey.size();
Long result9 = zsetKey.count(40, 80);
Set result10 = zsetKey.rangeByScore(40, 80);
Set result11 = zsetKey.rangeWithScores(0, -1);
Long result12 = zsetKey.rank("v1");
Long result13 = zsetKey.remove("v2");
Long result14 = zsetKey.removeRange(1, 2);
Double result15 = zsetKey.incrementScore("v4", 100);

2.4 搭配MySQL使用

上面都是SpringDataRedis一些基础操作,并没有投入实战,我们这边联合MySQL数据库一起使用,看看redis在实际开发中起到了什么样的作用。
我们做一个简单的例子,建一张表t_goods,写几个接口对其进行增删改查。
商品表

2.4.1 准备工作

首先在依赖方面,因为我们要用Java连接MySQL数据库,使用的是阿里巴巴的durid连接池,所以我们加入这两个依赖

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.1.23</version>
</dependency>

我们使用mybatis-plus框架操作数据库命令,所以导入这个依赖

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.4.2</version>
</dependency>

使用redis、Spring Cache,导入这两个依赖

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.1.0</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

配置文件方面,首先要指定访问端口,然后指定MySQL和redis的连接。这里要注意,我们要指定连接池为druid连接池。

server:
  port: 8081
spring:
  redis:
    host: 10.40.18.10
    port: 6379
    database: 0
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    druid:
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://10.40.18.10:3306/test?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
      username: root
      password: root

指定mybatis-plus的映射规则,我们这里主键选用uuid的方式。

mybatis-plus:
  configuration:
    #在映射实体或者属性时,将数据库中表名和字段名中的下划线去掉,按照驼峰命名法映射
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      id-type: assign_uuid

这里我们就做4个简单的接口,增加商品、根据id查询商品信息、更改商品信息、删除商品。

2.4.2 查询逻辑

查询商品信息时,我们要求在根据id查询商品名时先从redis中查询,如果没有,再从数据库中查询并更新到redis,设置5分钟有效期。

@Override
public GoodsDTO getGoods(String id) {
    log.info("从redis中查询数据");
    String key = "goods_" + id.substring(0, 3);
    GoodsDTO goodsDTO = (GoodsDTO) redisTemplate.boundValueOps(key).get();
    if (goodsDTO != null) {
        return goodsDTO;
    }

    log.info("从mysql中查询数据");
    GoodsPO goodsPO = goodsMapper.selectById(id);
    goodsDTO = new GoodsDTO();
    goodsDTO.setName(goodsPO.getName());
    goodsDTO.setStock(goodsPO.getStock());
    goodsDTO.setPrice(goodsPO.getPrice());
    redisTemplate.boundValueOps(key).set(goodsDTO, 5, TimeUnit.MINUTES);
    return goodsDTO;
}

这里我们需要注意一点,就是我们知道string数据类型存储的key和value都是字符串,那怎么用value存储对象呢?我们想到了json的格式,但是RedisTemplate的默认序列化方式为JdkSerializationRedisSerializer,我们需要给他改成Jackson2JsonRedisSerialize,我们把以下代码放到上文创建的RedisTempleteUtil类中。

@Bean
public RedisTemplate<Serializable, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
    RedisTemplate<Serializable, Object> redisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(redisConnectionFactory);
    // 使用Jackson2JsonRedisSerialize替换默认序列化
    Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
    objectMapper.activateDefaultTyping(new LaissezFaireSubTypeValidator(),
            ObjectMapper.DefaultTyping.EVERYTHING);
    jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setHashKeySerializer(new StringRedisSerializer());
    redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
    redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
    redisTemplate.afterPropertiesSet();
    return redisTemplate;
}

对象在redis中是这样存储的

"["com.suye.dto.GoodsDTO",{"name":"刀哥的牛至","price":88.8,"stock":60}]"

格式转换一下

["com.suye.dto.GoodsDTO", {
	"name": "刀哥的牛至",
	"price": 88.8,
	"stock": 60
}]

我们发现这就是标准的json格式,这样我们就能顺利地把Java对象存到字符串中了。

2.4.3 修改逻辑

更改商品信息时,需要查看redis有无缓存,若有,更新缓存,重置有效时间。

@Override
public String updateGoods(UpdateGoodsRO updateGoodsRO) {
    String key = "goods_" + updateGoodsRO.getId().substring(0, 3);
    GoodsPO goodsPO = goodsMapper.selectById(updateGoodsRO.getId());
    goodsPO.setName(updateGoodsRO.getName());
    goodsPO.setStock(updateGoodsRO.getStock());
    goodsPO.setPrice(updateGoodsRO.getPrice());
    int result = goodsMapper.updateById(goodsPO);
    GoodsDTO goodsDTO = (GoodsDTO) redisTemplate.boundValueOps(key).get();
    if (goodsDTO != null) {
        goodsDTO.setName(updateGoodsRO.getName());
        goodsDTO.setPrice(updateGoodsRO.getPrice());
        goodsDTO.setStock(updateGoodsRO.getStock());
        redisTemplate.boundValueOps(key).set(goodsDTO, 5, TimeUnit.MINUTES);
    }
    return result == 1 ? "成功" : "失败";
}

2.4.4 删除逻辑

删除商品信息时,需要删除redis中对应的缓存。

@Override
public String delGoods(String id) {
    String key = "goods_" + id.substring(0, 3);
    GoodsDTO goodsDTO = (GoodsDTO) redisTemplate.boundValueOps(key).get();
    if (goodsDTO != null) {
        redisTemplate.delete(key);
    }
    int result = goodsMapper.deleteById(id);
    return result == 1 ? "成功" : "失败";
}

3. 使用Spring Cache

3.1 介绍说明

我们在上面使用redis做缓存时:

  1. 查询缓存中有无要找的数据,如果有,直接返回结果。
  2. 如果无,查询数据库,将结果存入缓存,并返回结果。
  3. 数据更新时,先更新数据库,然后更新缓存或删除缓存。
  4. 数据删除时,删除对应的缓存。

我们发现一个问题,所有使用到缓存的api都要按这个步骤去书写,这样就有很多逻辑相似的代码。并且我们也需要显式调用redis的api,这样增加了项目和redis的耦合度。
我们想到,我们可以把查询缓存、写入缓存这种操作的代码封装起来,用框架实现,这让我们可以更加专注于处理业务逻辑。进而我们发现,这就是AOP的思想。

Spring Cache就是这样的框架,利用AOP,实现基于注解的缓存功能,并进行合理抽象,让业务代码不用关心底层使用的是什么缓存(redis、mongoDB等),只需要加注解,就可以使用缓存功能了。

3.2 准备工作

导入Spring Cache的依赖

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

在yml文件里添加配置

spring:
  redis:
    host: 10.40.18.10
    port: 6379
    database: 0
  cache:
    type: redis
    redis:
      time-to-live: 3600000
      cache-null-values: true
      use-key-prefix: true

在主启动类上添加注解,开启缓存功能

@SpringBootApplication
@EnableCaching
public class RedisPracticeApplication {
    public static void main(String[] args) {
        SpringApplication.run(RedisPracticeApplication.class, args);
    }
}

3.3 Spring Cache常用注解

3.3.1 @EnableCaching

放在Springboot的启动类或者Cache配置类上,开启缓存功能,上文有操作演示。

3.3.2 @Cacheable

在查询时,会先从缓存中取,若取不到再发起对数据库的访问,方法返回值加入缓存。
这个注解以及@CacheEvict、@CachePut注解都有两个重要的属性,value和key。value是缓存的名称,比如一张表的增删改查等都可以用一个value。每个value下面可以有多个key,key是每条数据的唯一标识。存到redis里实际的key就是这样的:value::key。
这个key可以通过SpEL内置对象动态获取,可以获取方法返回值、方法参数等。

3.3.3 @CacheEvict

通常配置在删除的方法上,用于从缓存中移除一条或多条对应数据。

3.3.4 @CachePut

通常配置在新增和修改操作上,将方法的返回值放到缓存中。
这里更多还是用在修改操作上,因为新增数据操作一般不需要放数据入缓存,还有就是我们一般都会以唯一主键作为key,而现在的key多数都是id自增或者UUID生成,而不需要前端传入,所以我们无法通过SpEL获取id作为key的值。

3.3.5 @CacheConfig

用于配置该类中会用到的一些共用的缓存配置。

3.3.6 @Caching

配置在方法上,组合多个Cache注解使用。也可以放在定义注解上。

3.3.7 自定义缓存注解

例如这个@Caching注解,如果内部注解较多,会显得比较乱,而且复用性较差。所以我们可以使用自定义注解把这些注解组合到一个注解中,之后在方法上使用这个自定义缓存注解就可以了。

3.4 在项目中使用Spring Cache

我们还是以上文的增删改查接口上进行Spring Cache的添加。

3.4.1 查询

@Override
@Cacheable(value = "goods", key = "#id.substring(0, 3)")
public GoodsDTO getGoods(String id) {
    log.info("从mysql中查询数据");
    GoodsPO goodsPO = goodsMapper.selectById(id);
    GoodsDTO goodsDTO = new GoodsDTO();
    goodsDTO.setName(goodsPO.getName());
    goodsDTO.setStock(goodsPO.getStock());
    goodsDTO.setPrice(goodsPO.getPrice());
    return goodsDTO;
}

这里需要注意一个问题,我们需要给goodsDTO类实现序列化接口,否则会导致序列化错误。这里存入redis数据库的value就不是json格式,而是我们看不懂的格式了。

3.4.2 更改

@Override
@CachePut(value ="goods", key = "#updateGoodsRO.id.substring(0, 3)")
public String updateGoods(UpdateGoodsRO updateGoodsRO) {
    GoodsPO goodsPO = goodsMapper.selectById(updateGoodsRO.getId());
    goodsPO.setName(updateGoodsRO.getName());
    goodsPO.setStock(updateGoodsRO.getStock());
    goodsPO.setPrice(updateGoodsRO.getPrice());
    int result = goodsMapper.updateById(goodsPO);
    return result == 1 ? "成功" : "失败";
}

这里逻辑发生了改动,上面我们需求是如果有缓存,则更新缓存,但是这里无论有无缓存,我们都更新了缓存。这也体现了Spring Cache没有SpringDataRedis灵活的特点。

3.4.3 删除

@Override
@CacheEvict(value = "goods", key = "#id.substring(0, 3)")
public String delGoods(String id) {
    int result = goodsMapper.deleteById(id);
    return result == 1 ? "成功" : "失败";
}
  • 1
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值