• SpringBoot学习小结之Redis


    前言

    Redis是一个持久化在磁盘上的内存数据库,支持多种数据类型

    RedisMencashed对比

    RedisMencashed
    创建时间由Salvatore Sanfilippo于2009年创建由BradFitzpatrick于2003年开发
    数据类型支持多种不同的数据类型:字符串,散列,列表,集合,有序集,bitmap,hyperLogLog和geo只支持字符串
    持久化支持两种持久化策略:RDB快照和AOF日志不支持
    线程单线程多线程
    数据存储并不是所有数据都一直在内存中,可以将很久没用的value交换到磁盘所有数据都一直在内存中
    高可用主从复制+哨兵不支持
    淘汰策略LRU,LFU等多种LRU

    总而言之,Redis提供了非常丰富的功能,而且性能基本上与Memcached相差无几,这也是它最近这几年占领内存数据库鳌头的原因。

    在技术选型方面,如果你的业务需要各种数据结构给予支撑,同时要求数据的高可用保障,那么选择Redis是比较合适的,但是如果你的业务非常简单,只是简单的set/get,并且对于内存使用并不高,那么使用Memcached足够了。

    一、SpringBoot使用Redis

    1.1 pom依赖

    <dependency>
    	<groupId>org.springframework.boot</groupId>
    	<artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    
    <!-- 如果使用连接池需要加这个依赖 -->
    <dependency>
    	<groupId>org.apache.commons</groupId>
    	<artifactId>commons-pool2</artifactId>
    </dependency>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    1.2 两种连接方案

    springboot2.0后默认使用lettuce,可选jedis。

    letture和jedis区别

    • Jedis 是直连模式,每个线程都去拿自己的 Jedis 实例,当连接数量增多时,连接成本就较高了。

    • Lettuce的连接是基于Netty的,连接实例可以在多个线程间共享,通过异步的方式可以让我们更好地利用系统资源。

    官方提供的两者差别:

    Supported FeatureLettuceJedis
    Standalone ConnectionsXX
    Master/Replica ConnectionsX
    Redis SentinelMaster Lookup, Sentinel Authentication, Replica ReadsMaster Lookup
    Redis ClusterCluster Connections, Cluster Node Connections, Replica ReadsCluster Connections, Cluster Node Connections
    Transport ChannelsTCP, OS-native TCP (epoll, kqueue), Unix Domain SocketsTCP
    Connection PoolingX (using commons-pool2)X (using commons-pool2)
    Other Connection FeaturesSingleton-connection sharing for non-blocking commandsJedisShardInfo support
    SSL SupportXX
    Pub/SubXX
    PipeliningXX
    TransactionsXX
    Datatype supportKey, String, List, Set, Sorted Set, Hash, Server, Stream, Scripting, Geo, HyperLogLogKey, String, List, Set, Sorted Set, Hash, Server, Scripting, Geo, HyperLogLog
    Reactive (non-blocking) APIX

    X表示支持,可以看到Letture支持更多的特性,所以一般选择Letture

    1.3 配置

    application.yaml配置

    spring:
      redis:
        host: localhost
        password:
        port: 6379
        ssl: false
        lettuce:
          pool:
            max-active: 8
            max-idle: 8
            min-idle: 0
            max-wait: 1000ms
          shutdown-timeout: 100ms
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    更细粒度配置

    @Configuration
    public class RedisConfig {
    
        /**
         * RedisTemplate配置
         */
        @Bean
        public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory) {
            // 设置序列化
            Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<Object>(
                    Object.class);
            ObjectMapper om = new ObjectMapper();
            om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
            jackson2JsonRedisSerializer.setObjectMapper(om);
            // 配置redisTemplate
            RedisTemplate<String, Object> redisTemplate = new RedisTemplate<String, Object>();
            redisTemplate.setConnectionFactory(lettuceConnectionFactory);
            RedisSerializer<?> stringSerializer = new StringRedisSerializer();
            // key序列化
            redisTemplate.setKeySerializer(stringSerializer);
            // value序列化
            redisTemplate.setValueSerializer(new JdkSerializationRedisSerializer());
            // Hash key序列化
            redisTemplate.setHashKeySerializer(stringSerializer);
            // Hash value序列化
            redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
            redisTemplate.afterPropertiesSet();
            return redisTemplate;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    1.4 简单使用

    	private static final Logger logger = LoggerFactory.getLogger(DemoRedisApplicationTests.class);
    
    
        @Autowired
        private RedisTemplate redisTemplate;
    
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
    
        @Test
        void stringTest() {
            stringRedisTemplate.opsForValue().set("test1", "test2");
            String test = stringRedisTemplate.opsForValue().get("test1");
            logger.info("test1:{}", test);
            stringRedisTemplate.delete("test1");
            String testDel = stringRedisTemplate.opsForValue().get("test1");
            logger.info("after Delete, test1:{}", testDel);
        }
    
        @Test
        void objectTest() {
            User user = new User();
            user.setId(1);
            user.setUsername("张三");
            redisTemplate.opsForValue().set("user1", user);
            User user1 = (User)redisTemplate.opsForValue().get("user1");
            logger.info("user:{}", user1);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    public class User implements Serializable {
        private Integer id;
        private String username;
    
        public Integer getId() {
            return id;
        }
    
        public void setId(Integer id) {
            this.id = id;
        }
    
        public String getUsername() {
            return username;
        }
    
        public void setUsername(String username) {
            this.username = username;
        }
    
        @Override
        public String toString() {
            return "User{" +
                    "id=" + id +
                    ", username='" + username + '\'' +
                    '}';
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    二、各种场景

    redis有多个不同数据类型,命令有两百多个,下面可以根据不同场景来使用不同的数据结构和命令

    在线练习网站

    2.1 缓存数据

    • 这也是最常见的使用场景,可以使用set命令(大小写不敏感)来存储字符串

      SET key value [EX seconds] [PX milliseconds] [NX|XX]
      
      • 1

      可以通过EX或PX来设置过期时间

    • 通过get命令获取数据

      GET key
      
      • 1
    • 通过del删除key

      DEL key [key …]
      
      • 1

    2.2 分布式锁

    2.2.1 通过setnx来实现分布式锁

    setnx只在键 key 不存在的情况下, 将键 key 的值设置为 value 。若键 key 已经存在, 则 SETNX 命令不做任何动作

    SETNX key value
    
    • 1

    分布式锁会面临超时问题,而setnx不可以设置过期时间。其实可以通过set命令的 [nx]来实现过期时间

    SET key value ex 30 nx
    
    • 1

    2.2.2 lua脚本

    spring自带分布式锁功能,位于spring-integration包内。从源码可以发现,分布式锁有jdbc,redis,zookeeper等实现,redis实现正是通过lua脚本,这个脚本位于org.springframework.integration.redis.util.RedisLockRegistry这个类里

    2.3 全局ID

    • 可以通过INCR keyINCRBY key increment来实现,它们俩是给key指定的value自增1或指定值。redis单线程,天生原子性,不用担心重复

    2.4 限流

    2.4.1 incr

    以ip为key, incr访问次数,到了限制次数,就不让访问. 还可以通过expire设置时间,实现固定时间限制访问次数

    EXPIRE key seconds
    
    • 1

    2.4.2 zset滑动窗口

    ​ 1. 限流需求中存在一个滑动的时间窗口,而zset的score值可以用来圈定时间窗口,窗口之外的数据都可以删除

    ​ 2、zset的value需要是一个唯一的值,只需要保证唯一性即可

    ​ 3、如果按照某个接口单位时间允许访问次数,那么key可以用接口路径,如果是限制单个用户那么key可以结合userId

    ZADD key score member [[score member] [score member]]
    ZINCRBY key increment member
    ZSCORE key member
    
    • 1
    • 2
    • 3

    2.4.3 漏斗算法

    Redis 4.0 提供了一个限流 Redis 模块redis-cell,提供一个cl.throttle指令来实现限流,使用的是漏斗算法

    具体使用可以查看文档:https://github.com/brandur/redis-cell

    CL.THROTTLE user123 15 30 60 1
                   ▲     ▲  ▲  ▲ ▲
                   |     |  |  | └───── apply 1 token (default if omitted)
                   |     |  └──┴─────── 30 tokens / 60 seconds
                   |     └───────────── 15 max_burst
                   └─────────────────── key "user123"
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    如何使用模块:http://www.redis.cn/topics/modules-intro.html

    2.5 位统计

    2.5.1 实现统计DAU

    通过SETBIT key offset value给key所指的字符串所在offset位设置value(0或1)。offset >= 0 && offset<2^32

    然后通过BITCOUNT key [start] [end]获取所有的1

    #设置key 第1位 值为1
    setbit dau_0101 1 1
    #设置key 第99位 值为1
    setbit dau_0101 99 1
    # 统计这一天01-01所有的1
    bitcount dau_0501
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    2.5.2 计算出7天都在线的用户

    可通过bittop

    BITOP operation destkey key [key …]
    
    • 1

    计算7天内bit都为1的用户

    BITOP "AND" "7_days_both_online_users" dau_0101" "dau_0102" "dau_0103" "dau_0104" "dau_0105" "dau_0106" "dau_0107"
    
    • 1

    2.6 消息队列

    2.6.1 List实现

    List是简单的字符串列表,按照插入顺序排序,可以用来实现简单的消息队列

    常见的用法有LPUSHRPOP 左进右出,RPUSHLPOP 右进左出

    LPUSH key value [value …]
    RPOP key
    RPUSH key value [value …]
    LPOP key
    
    • 1
    • 2
    • 3
    • 4

    阻塞问题,可通过设置超时时间解决

    BRPOP key [key …] timeout
    BLPOP key [key …] timeout
    
    • 1
    • 2

    2.6.2 Stream实现

    Stream是Redis 5.0版本引入的一个新的数据类型

    可以通过xadd添加消息,xread读取消息

    XADD key ID field string [field string ...] 
    XRANGE key start end [COUNT count] 
    XDEL key ID [ID ...]  
    XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...] 
    
    • 1
    • 2
    • 3
    • 4
    xadd mystream * f1 v1 f2 v2 f3 v3 # 返回1656058155355-0
    xadd mystream 1656058155355-1 f4 v4 #id必须比之前大
    xrange mystream - + #-最小 +最大
    
    xread count 2 streams mystream 0
    # 以阻塞方式获取,block 0代表无限等待
    xread block 0 streams mystream $
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    2.6.3 订阅发布实现

    通过subscribe命令订阅频道,publish命令将消息发布到指定频道实现消息发送和接收

    SUBSCRIBE channel [channel …]
    PUBLISH channel message
    
    • 1
    • 2

    2.7 即时聊天

    可通过上述的消息队列来实现

    2.8 附近的人

    可通过添加经纬度命令geoadd, 将一个或多个经度(longitude)、纬度(latitude)、位置名称(member)添加到指定的 key 中

    GEOADD key longitude latitude member [longitude latitude member …]
    
    • 1

    georadius 以给定的经纬度为中心, 返回键包含的位置元素当中, 与中心的距离不超过给定最大距离的所有位置元素。georadiusbymember 和 georadius 命令一样, 都可以找出位于指定范围内的元素, 但是 georadiusbymember 的中心点是由给定的位置元素决定的, 而不是使用经度和纬度来决定中心点。

    GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [ASC|DESC] [COUNT count]
    GEORADIUSBYMEMBER key member radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
    
    • 1
    • 2
    • m :米,默认单位。
    • km :千米。
    • mi :英里。
    • ft :英尺。
    • WITHDIST: 在返回位置元素的同时, 将位置元素与中心之间的距离也一并返回。
    • WITHCOORD: 将位置元素的经度和纬度也一并返回。
    • WITHHASH: 以 52 位有符号整数的形式, 返回位置元素经过原始 geohash 编码的有序集合分值。 这个选项主要用于底层应用或者调试, 实际中的作用并不大。
    • COUNT 限定返回的记录数。
    • ASC: 查找结果根据距离从近到远排序。
    • DESC: 查找结果根据从远到近排序。
    geoadd china:city 121.472644 31.231706 shanghai
    geoadd china:city 120.619585 31.299379 suzhou
    geoadd china:city 116.405285 39.904989 beijing
    // 上海黄浦区周围30km,返回上海 
    georadius china:city 121.49295 31.22337 30 km
    // 周围300km 包含上海和苏州
    georadius china:city 121.49295 31.22337 300 km
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    2.9 关注和推荐

    sadd 将一个或多个 member 元素加入到集合 key 当中,已经存在于集合的 member 元素将被忽略

    srem 移除集合 key 中的一个或多个 member 元素

    sinter 返回一个集合的全部成员,该集合是所有给定集合的交集

    sdiff 返回一个集合的全部成员,该集合是所有给定集合之间的差集

    SADD key member [member …]
    SREM key member [member …]
    SINTER key [key …]
    SDIFF key [key …]
    
    • 1
    • 2
    • 3
    • 4

    follow:userid 关注 fans:userid 粉丝

    • 添加关注

      # 1、将对方id添加到自己的关注列表中
      SADD follow:1 2
      # 2、将自己的id添加到对方的粉丝列表中
      SADD fans:2 1
      
      • 1
      • 2
      • 3
      • 4
    • 取消关注

      SREM follow:1 2
      SREM fans:2 1
      
      • 1
      • 2
    • 关注/粉丝列表

      SMEMBERS follow:1
      SMEMBERS fans:1
      
      • 1
      • 2
    • 共同关注

      SINTER folow:1 follow:2
      
      • 1
    • 可能认识的人

      # 用户2是用户1粉丝,用户2关注set减去用户1关注set
      SDIFF follow:2 follow:1
      
      • 1
      • 2

    2.10 排行榜

    zincrby ,为有序集 key 的成员 memberscore 值加上增量 increment

    zrevrange,返回有序集 key 中,指定区间内的成员。其中成员的位置按 score 值递减(从大到小)来排列。 具有相同 score 值的成员按字典序的逆序排序

    ZINCRBY key increment member
    ZREVRANGE key start stop [WITHSCORES]
    
    • 1
    • 2

    2.11 抽奖

    可通过spop 移除并返回集合中的一个随机元素, 如果不想移除可以使用srandmember

    SPOP key
    SRANDMEMBER key [count]
    
    • 1
    • 2

    三、Redis源码分析

    由于各个版本源码可能会不同,下述源码分析选择版本为6.2

    3.1 简单动态字符串 sds

    源码位于 https://github.com/redis/redis/blob/6.2/src/sds.c

    3.1.1 结构

    SDS(Simple Dynamic String), 有两个版本,在Redis 3.2之前使用的是第一个版本,其数据结构如下所示:

    typedef char *sds;      
    
    struct sdshdr {
        unsigned int len;   //buf中已经使用的长度
        unsigned int free;  //buf中未使用的长度
        char buf[];         //柔性数组buf
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    v3.2开始使用第二个版本,针对字符串长度,动态选择不同结构体。__attribute__ ((__packed__)) 是gcc语法,告诉编译器取消字节对齐,压缩内存空间

    typedef char *sds;
    
    /* Note: sdshdr5 is never used, we just access the flags byte directly.
     * However is here to document the layout of type 5 SDS strings. */
    struct __attribute__ ((__packed__)) sdshdr5 { 
        unsigned char flags; /* 3 lsb of type, and 5 msb of string length(低3位表示类型,高5位表示长度) */
        char buf[];
    };
    struct __attribute__ ((__packed__)) sdshdr8 {
        uint8_t len; /* used */
        uint8_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    };
    struct __attribute__ ((__packed__)) sdshdr16 {
        uint16_t len; /* used */
        uint16_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    };
    struct __attribute__ ((__packed__)) sdshdr32 {
        uint32_t len; /* used */
        uint32_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    };
    struct __attribute__ ((__packed__)) sdshdr64 {
        uint64_t len; /* used */
        uint64_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    3.1.2 sds长度(sdslen)

    有关如何计算len,可以查看下面代码,

    #define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T)))) 
    #define SDS_TYPE_5_LEN(f) ((f)>>SDS_TYPE_BITS)
    #define SDS_TYPE_MASK 7
    #define SDS_TYPE_BITS 3
    static inline size_t sdslen(const sds s) {
        unsigned char flags = s[-1];
        switch(flags&SDS_TYPE_MASK) {
            case SDS_TYPE_5:
                return SDS_TYPE_5_LEN(flags);
            case SDS_TYPE_8:
                return SDS_HDR(8,s)->len;
            case SDS_TYPE_16:
                return SDS_HDR(16,s)->len;
            case SDS_TYPE_32:
                return SDS_HDR(32,s)->len;
            case SDS_TYPE_64:
                return SDS_HDR(64,s)->len;
        }
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • ##:在一个宏(macro)定义表示连接两个子串(token),连接之后##两边的子串就被编译器识别为一个

    • s[-1]:这里实际上是s指针左移一位的意思,由于禁用了内存对齐,s指针指向的是buf[]数组,左移一位刚好是sdshdr的flags成员变量

    3.2 跳跃表skiplist

    源码位于 https://github.com/redis/redis/blob/6.2/src/server.hhttps://github.com/redis/redis/blob/6.2/src/t_zset.c

    跳跃表(skiplist)是一种有序数据结构, 它通过在每个节点中维持多个指向其他节点的指针, 从而达到快速访问节点的目的

    跳跃表支持平均 O(log N) 最坏 O(N) 复杂度的节点查找, 还可以通过顺序性操作来批量处理节点

    在大部分情况下, 跳跃表的效率可以和平衡树相媲美, 并且因为跳跃表的实现比平衡树要来得更为简单, 所以有不少程序都使用跳跃表来代替平衡树

    Redis 使用skiplist作为zset键的底层实现之一: 如果一个zset包含的元素数量比较多, 又或者zset中元素的成员(member)是比较长的字符串时, Redis 就会使用跳跃表来作为zset键的底层实现

    单链表

    单链表

    跳跃表

    跳跃表

    3.2.1 结构

    /* ZSETs use a specialized version of Skiplists */
    typedef struct zskiplistNode {
        sds ele;
        double score;
        struct zskiplistNode *backward;
        struct zskiplistLevel {
            struct zskiplistNode *forward;
            unsigned long span;
        } level[];
    } zskiplistNode;
    
    typedef struct zskiplist {
        struct zskiplistNode *header, *tail;
        unsigned long length;
        int level;
    } zskiplist;
    
    typedef struct zset {
        dict *dict;
        zskiplist *zsl;
    } zset;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    根据源码,可以查看这张redis中的跳跃表示意图
    redis_skip_list

    • 最左边的就是zskiplist, 其他就是zskiplistNode
    • L1… 表示zskiplistLevel level
    • BW 表示backward后退指针
    • 1.0,2.0,3.0 表示score分值
    • o1,o2,o3 表示存储的数据ele
    • level中forward, 表示前进指针,用于从表头向表尾访问
    • level中span, 表示跨度,用于计算2个节点间距离
    • 每个跳跃表节点的层高都是 132 之间的随机数

    3.2.2 和平衡树比较

    skiplistavl
    插入和删除只需要修改相邻节点的指针,简单快速引发子树的调整,逻辑复杂
    内存占用平均每个节点包含1.33个指针,占用更少内存平衡树每个节点包含2个指针(分别指向左右子树)
    算法难度更简单较复杂
    范围查找找到小值之后,对第1层链表进行若干步的遍历找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点,中序遍历并不容易实现,较复杂

    3.3 压缩列表ziplist

    源码位于 https://github.com/redis/redis/blob/6.2/src/ziplist.h

    压缩列表 (ziplist) 是哈希(hash)和有序集合(zset)的底层实现之一。当hash或zset中的元素个数比较少并且每个元素的值占用空间比较小的时候,Redis就会使用ziplist做为内部实现

    ziplist是由一个连续内存组成的顺序型数据结构。一个压缩列表可以包含任意多个节点(ziplistEntry),每个节点上可以保存一个字节数组或整数值。它是Redis为了节省内存空间而开发的

    参考

    1. Redis 16 个常见使用场景
    2. Redis 命令参考
    3. http://www.redis.cn
    4. Redis源码分析(sds)
    5. Redis源码(一)——字符串sds
    6. redis设计与实现 黄健宏
    7. Redis 为什么用跳表而不用平衡树?
  • 相关阅读:
    VsCode同时编译多个C文件
    Android仿QQ消息拖拽效果(一)(二阶贝塞尔曲线使用)
    翻译像机翻?4点教会你ChatGPT高质量翻译
    7.1-WY22 Fibonacci数列
    关于 Vue-iClient-MapboxGL 的使用注意事项
    32个关键字详解①(C语言)
    若依框架:登录时如何解决404和验证码问题
    防止数据冒用的方法
    Ubuntu 配置repo环境
    中文转拼音的方法
  • 原文地址:https://blog.csdn.net/qq_23091073/article/details/125453013