地址:[https://www.apache.org/dyn/closer.cgi?path=rocketmq/4.5.2/rocketmq-all-4.5.2-bin-release.zip]
选择‘Binary’进行下载
解压已下载工程
新增系统变量
ROCKETMQ_HOME -> F:\RocketMQ\rocketmq-4.5.2
JAVA_HOME -> F:\Java_JDK\JDK1.8
Path 系统变量新增:Maven/bin目录
PS:RocketMQ 消息存储在C:\Users\Administrator\store store目录中 文件占用较大,注意删除不必要的内容
start mqnamesrv.cmd start mqbroker.cmd -n 127.0.0.1:9876 autoCreateTopicEnable=true
Rocket可视化监控插件 增加Topic | 自动增加Topic(4.5.2版本)
4.5.2 版本 支持自动创建Topic 4.3.0 版本 必须通过监控程序配置Topic,否则执行程序报错,没有此路由
<!--RocketMQ--><dependency> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-client</artifactId> <version>4.5.2</version></dependency>
基于RocketMQ的分布式系统,一般可以分为四个集群:Name server、broker、producer、consumer
提供Broker管理;Routing管理(路由管理)
NameServer,很多时候称为命名发现服务,其在RocketMQ中起着中转承接的作用,是一个无状态的服务,多个NameServer之间不通信。任何Producer,Consumer,Broker与所有NameServer通信,向NameServer请求或者发送数据。而且都是单向的,Producer和Consumer请求数据,Broker发送数据。正是因为这种单向的通信,RocketMQ水平扩容变得很容易
总结:相比于ZooKeeper提供的分布式锁,发布和订阅,数据一致性,选举等,在RocketMQ是不适用的,因此重写了一套更加轻量级的发现服务,主要用以存储 Broker相关信息以及当前Broker上的topic信息,路由信息等
提供Remoting Module、客户端管理、存储服务、HA服务(主从)、索引服务
生产者发送业务系统产生的消息给broker, RocketMQ提供了多种发送方式:同步的、异步的、单向的
具有相同角色的生产者被分到一组, 假如原始的生产者在事务后崩溃,broker会联系 同一生产者组中的不同生产者实例,继续提交或回滚事务
一个消费者从broker拉取信息,并将信息返还给应用。为了我们应用的正确性,提供了两种消费者类型:
拉式消费者:拉式消费者从broker拉取消息,一旦一批消息被拉取,用户应用系统将发起消费过程。
推式消费者:推式消费者,从另一方面讲,囊括了消息的拉取、消费过程,并保持了内部的其他工作,留下了一个回调 接口给终端用户去实现,实现在消息到达时要执行的内容。
具有相同角色的消费者被组在一起,称为消费者组,它完成了负载均衡和容错的目标
一个消费组中的消费者实例必须有确定的相同的订阅topic
Topic是一个消息的目录,在这个目录中,生产者传送消息,消费者拉取消息,可以多个消费者订阅同一个topic,一个生产者也可以发送多个topic
PS:RocketMQ 基于发布订阅模式,发布订阅的核心即 Topic 主题
消息是被传递的信息。一个消息必须有一个Topic,它可以理解为信件上的地址。一个消息也可以有一个可选的tag,和额外的key-value对。 例如:你可以设置业务中的键到你的消息中,在broker服务中查找消息,以便在开发期间诊断问题
Topic被分割成一个或多个消息队列。队列分为3中角色:异步主、同步主、从。如果你不能容忍消息丢失,我们建议你部署同步主,并加一个从队列。 如果你容忍丢失,但你希望队列总是可用,你可以部署异步主和从队列。如果你想最简单,你只需要一个异步主,不需要从队列。 消息保存磁盘的方式也有两种,推荐使用的是异步保存,同步保存是昂贵的并会导致性能损失,如果你想要可靠性,我们推荐你使用同步主+从的方式。
标签,用另外一个词来说,就是子主题,为用户提供额外的灵活性。具有相同Topic的消息可以有不同的tag。
Broker是RocketMQ的一个主要组件,它接收生产者发送的消息,存储它们并准备处理消费者的拉取请求。它也存储消息相关的元数据, 包括消费组,消费成功的偏移量,主题、队列的信息。
名称服务主要提供路由信息。生产者/消费者客户端寻找topic,并找到通信的队列列表。
当DefaultMQPushConsumer
被使用,你就要决定消费消息时,是顺序消费还是同时消费
顺序消费消息的意思是 消息将按照生产者发送到队列时的顺序被消费掉。如果你被强制要求使用全局的顺序,你要确保你的topic只有一个消息队列。
如果指定顺序消费,消息被同时消费的数量就是订阅这个topic的消费组的数量。
当同时消费消息时,消息同时消费的最大数量取决于消费客户端指定的线程池的大小。
解决的核心问题主要是:异步、解耦、削峰
但是引入消息队列也会有很多额外的问题,比如系统复杂性会大大增加,同时需要解决重复下发,重复消费,消费顺序,消息丢失,重试机制等等问题,因此不能滥用,合适的场景用合适的技术
一、消息队列的演进
1、初始阶段
最初的消息队列,就是一个严格意义上的队列。队列是一种数据结构,先进先出,在消息入队出队过程中,保证这些消息严格有序。早期的消息队列就是按照“队列”的数据结构设计的。
队列模型:
生产者(Producer)发消息就是入队操作,消费者(Consumer)收消息就是出队也就是删除操作,服务端存放消息的容器自然就称为“队列”。
2、发布 - 订阅模型阶段
如果需要将一份消息数据分发给多个消费者,要求每个消费者都能收到全量的消息,例如,对于一份订单数据,风控系统、分析系统、支付系统等都需要接收消息。
这个时候,单个队列就满足不了需求,一个可行的解决方式是,为每个消费者创建一个单独的队列,让生产者发送多份。但是同样的一份消息数据被复制到多个队列中会浪费资源,更重要的是,生产者必须知道有多少个消费者。为每个消费者单独发送一份消息,这实际上**违背了消息队列“解耦”**这个设计初衷。
为了解决这个问题,演化出了另外一种消息模型:发布 - 订阅模型(Publish-Subscribe Pattern)
消息的发送方称为发布者(Publisher),消息的接收方称为订阅者(Subscriber),服务端存放消息的容器称为主题(Topic)。
3、总结:
二、RabbitMQ 的消息模型
少数依然坚持使用队列模型的产品之一。
RabbitMQ 使用 Exchange 模块解决多个消费者的问题。Exchange 位于生产者和队列之间,生产者并不关心将消息发送给哪个队列,而是将消息发送给 Exchange,由 Exchange 上配置的策略来决定将消息投递到哪些队列中。
三、RocketMQ 的消息模型
RocketMQ 使用的消息模型是标准的发布 - 订阅模型。在 RocketMQ 也有队列(Queue)这个概念。
消息队列的消费机制:
几乎所有的消息队列产品都使用一种非常朴素的“请求 - 确认”机制,确保消息不会在传递过程中由于网络或服务器故障丢失。
在生产端,生产者先将消息发送给服务端,也就是 Broker,服务端在收到消息并将消息写入主题或者队列中后,会给生产者发送确认的响应。如果生产者没有收到服务端的确认或者收到失败的响应,则会重新发送消息。
在消费端,消费者在收到消息并完成自己的消费业务逻辑(比如,将数据保存到数据库中)后,也会给服务端发送消费成功的确认,服务端只有收到消费确认后,才认为一条消息被成功消费,否则它会给消费者重新发送这条消息,直到收到对应的消费成功确认。
这个确认机制很好地保证了消息传递过程中的可靠性,但是,引入这个机制在消费端带来了一个问题:为了确保消息的有序性,在某一条消息被成功消费之前,下一条消息是不能被消费的,也就是说,每个主题在任意时刻,至多只能有一个消费者实例在进行消费,那就没法通过水平扩展消费者的数量来提升消费端总体的消费性能。
为了解决这个问题,RocketMQ 在主题下面增加了队列的概念:
四、Kafka 的消息模型
Kafka 的消息模型和 RocketMQ 是完全一样的,唯一的区别是,在 Kafka 中,队列这个概念的名称不一样,Kafka 中对应的名称是“分区(Partition)”,含义和功能是没有任何区别的。
五、总结
首先如何验证消息是否丢失?
即保证消息消费顺序的情况下,根据消息的序号,在消费段判断是否连续
解决方案:
消息从生产到消费的过程中,可以划分三个阶段:
1、生产阶段
消息队列通过最常用的请求确认机制,来保证消息的可靠传递:当你代码调用发消息方法时,消息队列客户端会把消息发送到Broker,Broker收到消息后,会给客户端返回一个确认响应,表明消息已收到。客户端收到响应后,完成了一次正常消息的发送。
有些消息队列在长时间没收到发送确认响应后,会自动重试,如果重试失败,就会以返回值或者异常的方式告知用户。在编写发送消息的代码时,需要注意,正确处理返回值或者捕获异常,就可以保证这个阶段的消息不会丢失。
同步发送时,只要注意捕获异常即可。
异步发送时,则需要在回调方法里进行检查。这个地方需要特别注意,很多丢消息的原因就是,我们使用了异步发送,却没有在回调中检查发送结果。
2、存储阶段
在存储阶段正常情况下,只要Broker在正常运行,就不会出现丢消息的问题;但是如果Broker出现故障,比如进程死掉或者服务器宕机,还是可能会丢失消息的。
如果对消息的可靠性要求非常高,可以通过配置Broker参数来避免因为宕机丢消息:
3、消息阶段
消费阶段采用和生产阶段类似的确认机制来保证消息的可靠传递,客户端从 Broker 拉取消息后,执行用户的消费业务逻辑,成功后,才会给 Broker 发送消费确认响应。如果 Broker 没有收到消费确认响应,下次拉消息的时候还会返回同一条消息,确保消息不会在网络传输过程中丢失,也不会因为客户端在执行消费逻辑中出错导致丢失。
在编写消费代码时需要注意的是:不要在收到消息后就立即发送消费确认,而是应该在执行完所有消费业务逻辑之后,再发送消费确认。
在消息传递过程中,如果出现传递失败的情况,发送方会执行重试,重试过程中就有可能产生重复的消息。如果没有对重复消息进行处理,就可能导致系统的数据出现错误。
比如,一个消费订单消息,统计下单金额的微服务,如果没有正确处理重复消息,那就会出现重复统计,导致统计结果错误。
一、消息重复的情况必然存在
在MQTT协议中,给出了三种传递消息时能够提供的服务质量标准:
这个服务质量标准不仅适用于 MQTT,对所有的消息队列都是适用的。常用的绝大部分消息队列提供的服务质量都是 At least once,包括 RocketMQ、RabbitMQ 和 Kafka 。也就是说,消息队列很难保证消息不重复。
注意:Kafka 支持的“Exactly once”和我们刚刚提到的消息传递的服务质量标准“Exactly once”是不一样的,它是 Kafka 提供的另外一个特性,Kafka 中支持的事务也和我们通常意义理解的事务有一定的差异。在 Kafka 中,事务和 Excactly once 主要是为了配合流计算使用的特性。
二、用幂等性解决重复消息问题
幂等本来是一个数学上的概念,它的定义是:如果一个函数f(x)满足:f(f(x)) = f(x),则函数f(x)满足米幂等性。扩展到计算机领域,被用来描述一个操作、方法或者服务。
举例:
1、在不考虑并发的情况下,“将账户 X 的余额设置为 100 元”,执行一次后对系统的影响是,账户 X 的余额变成了 100 元。只要提供的参数 100 元不变,那即使再执行多少次,账户 X 的余额始终都是 100 元,不会变化,这个操作就是一个幂等的操作。
2、“将账户 X 的余额加 100 元”,这个操作它就不是幂等的,每执行一次,账户余额就会增加 100 元,执行多次和执行一次对系统的影响(也就是账户的余额)是不一样的。
如果消费消息的业务逻辑具备幂等性,那就不用担心消息重复的问题,因为同一条消息,消费一次和消费多次对系统的影响是完全一样的。消费多次等于消费一次。从对系统的影响结果来说:At least once + 幂等消费 = Exactly once。
实现幂等操作最好的方式是,从业务逻辑设计上入手,将消费的业务逻辑设计成具备幂等性的操作。
常用的设计幂等操作的方法:
(1)利用数据库的唯一约束实现幂等
上面提到的那个不具备幂等特性的转账的例子:将账户 X 的余额加 100 元。在这个例子中,我们可以通过改造业务逻辑,让它具备幂等性。
首先,我们可以限定,对于每个转账单每个账户只可以执行一次变更操作,在分布式系统中,这个限制实现的方法非常多,最简单的是我们在数据库中建一张转账流水表,这个表有三个字段:转账单 ID、账户 ID 和变更金额,然后给转账单 ID 和账户 ID 这两个字段联合起来创建一个唯一约束,这样对于相同的转账单 ID 和账户 ID,表里至多只能存在一条记录。
这样,我们消费消息的逻辑可以变为:“在转账流水表中增加一条转账记录,然后再根据转账记录,异步操作更新用户余额即可。”在转账流水表增加一条转账记录这个操作中,由于我们在这个表中预先定义了“账户 ID 转账单 ID”的唯一约束,对于同一个转账单同一个账户只能插入一条记录,后续重复的插入操作都会失败,这样就实现了一个幂等的操作。
基于这个思路,不光是可以使用关系型数据库,只要是支持类似“INSERT IF NOT EXIST”语义的存储类系统都可以用于实现幂等,比如,你可以用 Redis 的 SETNX 命令来替代数据库中的唯一约束,来实现幂等消费。
(2)为更新的数据设置前置条件
给数据变更设置一个前置条件,如果满足条件就更新数据,否则拒绝更新数据,在更新数据的时候,同时变更前置条件中需要判断的数据。这样,重复执行这个操作时,由于第一次更新数据的时候已经变更了前置条件中需要判断的数据,不满足前置条件,则不会重复执行更新数据操作。
比如,“将账户 X 的余额增加 100 元”这个操作并不满足幂等性,我们可以把这个操作加上一个前置条件,变为:“如果账户 X 当前的余额为 500 元,将余额加 100 元”,这个操作就具备了幂等性。对应到消息队列中的使用时,可以在发消息时在消息体中带上当前的余额,在消费的时候进行判断数据库中,当前余额是否与消息中的余额相等,只有相等才执行变更操作。
但是,如果我们要更新的数据不是数值,或者我们要做一个比较复杂的更新操作怎么办?用什么作为前置判断条件呢?更加通用的方法是,给你的数据增加一个版本号属性,每次更数据前,比较当前数据的版本号是否和消息中的版本号一致,如果不一致就拒绝更新数据,更新数据的同时将版本号 +1,一样可以实现幂等更新。
(3)记录并检查操作
如果上面提到的两种实现幂等方法都不能适用于你的场景,还有一种通用性最强,适用范围最广的实现幂等性方法:记录并检查操作,也称为“Token 机制或者 GUID(全局唯一 ID)机制”,实现的思路特别简单:在执行数据更新操作之前,先检查一下是否执行过这个更新操作。这种方法适用范围最广,但是实现难度和复杂度也比较高,一般不推荐使用。
具体的实现方法是,在发送消息时,给每条消息指定一个全局唯一的 ID,消费时,先根据这个 ID 检查这条消息是否有被消费过,如果没有消费过,才更新数据,然后将消费状态置为已消费。
在分布式系统中,这个方法其实是非常难实现的。首先,给每个消息指定一个全局唯一的 ID 就是一件不那么简单的事儿,方法有很多,但都不太好同时满足简单、高可用和高性能,或多或少都要有些牺牲。更加麻烦的是,在“检查消费状态,然后更新数据并且设置消费状态”中,三个操作必须作为一组操作保证原子性,才能真正实现幂等,否则就会出现 Bug。
比如说,对于同一条消息:“全局 ID 为 8,操作为:给 ID 为 666 账户增加 100 元”,有可能出现这样的情况:
这样就会导致账户被错误地增加了两次 100 元,这是一个在分布式系统中非常容易犯的错误,一定要引以为戒。对于这个问题,当然我们可以用事务来实现,也可以用锁来实现,但是在分布式系统中,无论是分布式事务还是分布式锁都是比较难解决问题。
一、消息事务
其实很多场景下,我们“发消息”这个过程,目的往往是通知另外一个系统或者模块去更新数据,消息队列中的“事务”,主要解决消息生产者和消息消费者的数据一致性问题。
用户在电商APP上购物时,先把商品加到购物车里,然后几件商品一起下单,最后支付,完成购物流程。
这个过程中有一个需要用到消息队列的步骤,订单系统创建订单后,发消息给购物车系统,将已下单的商品从购物车中删除。因为从购物车删除已下单商品这个步骤,并不是用户下单支付这个主要流程中必要的步骤,使用消息队列来异步清理购物车是更加合理。
对于订单系统,它创建订单的过程实际执行了2个步骤的操作:
对于购物车系统:
在分布式系统中,上面提到的步骤,任何一个都有可能失败,如果不做任何处理,那就有可能出现订单数据与购物车数据不一致的情况,比如:
所以我们需要解决的问题为:在上述任意步骤都有可能失败的情况下,还要保证订单库和购物车库这两个库的数据一致性。
二、分布式事务
分布式事务就是要在分布式系统中实现事务。在分布式系统中,在保证可用性和不严重牺牲性能的前提下,光是要实现数据的一致性就已经非常困难了,显然实现严格的分布式事务是更加不可能完成的任务。所以目前大家所说的分布式事务,更多情况下,是在分布式系统中事务的不完整实现,在不同的应用场景中,有不同的实现,目的都是通过一些妥协来解决实际问题。
常见的分布式事务实现:
每一种实现都有其特定的使用场景,也有各自的问题,都不是完美的解决方案。
事务消息适用的场景主要是那些需要异步更新数据,并且对数据实时性要求不太高的场景。比如在创建订单后,如果出现短暂的几秒,购物车里的商品没有被及时情况,也不是完全不可接受的,只要最终购物车的数据和订单数据保持一致就可。
三、消息队列实现分布式事务
事务消息需要消息队列提供相应的功能才能实现,kafka和RocketMQ都提供了事务相关功能。
对于订单系统:
对于购物车系统:
如果在第四步提交事务消息时失败了怎么办?Kafka 和 RocketMQ 给出了 2 种不同的解决方案:
1、Kafka 的解决方案:
直接抛出异常,让用户自行处理。我们可以在业务代码中反复重试提交,直到提交成功,或者删除之前创建的订单进行补偿。
2、RocketMQ 的解决方案:
在 RocketMQ 中的事务实现中,增加了事务反查的机制来解决事务消息提交失败的问题。如果 Producer 也就是订单系统,在提交或者回滚事务消息时发生网络异常,RocketMQ 的 Broker 没有收到提交或者回滚的请求,Broker 会定期去 Producer 上反查这个事务对应的本地事务的状态,然后根据反查结果决定提交或者回滚这个事务。为了支撑这个事务反查机制,我们的业务代码需要实现一个反查本地事务状态的接口,告知 RocketMQ 本地事务是成功还是失败。
综合上面讲的通用事务消息的实现和 RocketMQ 的事务反查机制,使用 RocketMQ 事务消息功能实现分布式事务的流程如下图:
当我们说顺序时,我们在说什么?
日常思维中,顺序大部分情况会和时间关联起来,即时间的先后表示事件的顺序关系。
比如事件A发生在下午3点一刻,而事件B发生在下午4点,那么我们认为事件A发生在事件B之前,他们的顺序关系为先A后B。
上面的例子之所以成立是因为他们有相同的参考系,即他们的时间是对应的同一个物理时钟的时间。如果A发生的时间是北京时间,而B依赖的时间是东京时间,那么先A后B的顺序关系还成立吗?
如果没有一个绝对的时间参考,那么A和B之间还有顺序吗,或者说怎么断定A和B的顺序?
显而易见的,如果A、B两个事件之间如果是有因果关系的,那么A一定发生在B之前(前因后果,有因才有果)。相反,在没有一个绝对的时间的参考的情况下,若A、B之间没有因果关系,那么A、B之间就没有顺序关系。
那么,我们在说顺序时,其实说的是:
在分布式环境中讨论顺序
当把顺序放到分布式环境(多线程、多进程都可以认为是一个分布式的环境)中去讨论时:
(点表示事件,波浪线箭头表示事件间的消息)
上图中,进程P中的事件顺序为p1->p2->p3->p4(时间推断)。而因为p1给进程Q的q2发了消息,那么p1一定在q2之前(因果推断)。但是无法确定p1和q1之间的顺序关系。
推荐阅读《Time, Clocks, and the Ordering of Events in a Distributed System》,会透彻的分析分布式系统中的顺序问题。
消息中间件中的顺序消息
什么是顺序消息
有了上述的基础之后,我们回到本篇文章的主题中,聊一聊消息中间件中的顺序消息。
顺序消息(FIFO 消息)是 MQ 提供的一种严格按照顺序进行发布和消费的消息类型。顺序消息由两个部分组成:顺序发布和顺序消费。 顺序消息包含两种类型: 分区顺序:一个Partition内所有的消息按照先进先出的顺序进行发布和消费 全局顺序:一个Topic内所有的消息按照先进先出的顺序进行发布和消费
这是阿里云上对顺序消息的定义,把顺序消息拆分成了顺序发布和顺序消费。那么多线程中发送消息算不算顺序发布?
如上一部分介绍的,多线程中若没有因果关系则没有顺序。那么用户在多线程中去发消息就意味着用户不关心那些在不同线程中被发送的消息的顺序。即多线程发送的消息,不同线程间的消息不是顺序发布的,同一线程的消息是顺序发布的。这是需要用户自己去保障的。
而对于顺序消费,则需要保证哪些来自同一个发送线程的消息在消费时是按照相同的顺序被处理的(为什么不说他们应该在一个线程中被消费呢?)。
全局顺序其实是分区顺序的一个特例,即使Topic只有一个分区(以下不在讨论全局顺序,因为全局顺序将面临性能的问题,而且绝大多数场景都不需要全局顺序)。
如何保证顺序
在MQ的模型中,顺序需要由3个阶段去保障:
发送时保持顺序意味着对于有顺序要求的消息,用户应该在同一个线程中采用同步的方式发送。存储保持和发送的顺序一致则要求在同一线程中被发送出来的消息A和B,存储时在空间上A一定在B之前。而消费保持和存储一致则要求消息A、B到达Consumer之后必须按照先A后B的顺序被处理。
如下图所示:
对于两个订单的消息的原始数据:a1、b1、b2、a2、a3、b3(绝对时间下发生的顺序):
开源RocketMQ中顺序的实现
上图是RocketMQ顺序消息原理的介绍,将不同订单的消息路由到不同的分区中。文档只是给出了Producer顺序的处理,Consumer消费时通过一个分区只能有一个线程消费的方式来保证消息顺序,具体实现如下。
Producer端
Producer端确保消息顺序唯一要做的事情就是将消息路由到特定的分区,在RocketMQ中,通过MessageQueueSelector来实现分区的选择。
比如如下实现就可以保证相同的订单的消息被路由到相同的分区:
long orderId = ((Order) object).getOrderId;return mqs.get(orderId % mqs.size());
Consumer端
RocketMQ消费端有两种类型:MQPullConsumer和MQPushConsumer。
MQPullConsumer由用户控制线程,主动从服务端获取消息,每次获取到的是一个MessageQueue中的消息。PullResult中的List msgFoundList自然和存储顺序一致,用户需要再拿到这批消息后自己保证消费的顺序。
对于PushConsumer,由用户注册MessageListener来消费消息,在客户端中需要保证调用MessageListener时消息的顺序性。RocketMQ中的实现如下:
保证消费顺序的核心思想是:
顺序和异常的关系
顺序消息需要Producer和Consumer都保证顺序。Producer需要保证消息被路由到正确的分区,消息需要保证每个分区的数据只有一个线程消息,那么就会有一些缺陷:
不能更换MessageQueue重试就需要MessageQueue有自己的副本,通过Raft、Paxos之类的算法保证有可用的副本,或者通过其他高可用的存储设备来存储MessageQueue。
热点问题好像没有什么好的解决办法,只能通过拆分MessageQueue和优化路由方法来尽量均衡的将消息分配到不同的MessageQueue。
消费并行度理论上不会有太大问题,因为MessageQueue的数量可以调整。
消费失败的无法跳过是不可避免的,因为跳过可能导致后续的数据处理都是错误的。不过可以提供一些策略,由用户根据错误类型来决定是否跳过,并且提供重试队列之类的功能,在跳过之后用户可以在“其他”地方重新消费到这条消息。
感谢极客时间所属的《消息队列高手课》 链接
本篇是一篇大合集,中间肯定参考了许多其他人的文章内容或图片,但由于时间比较久远,当时并没有一一记录,为此表示歉意,如果有作者发现了自己的文章或图片,可以私聊我,我会进行补充。
如果你发现写的还不错,可以搜索公众号「是Kerwin啊」,一起进步!
也可以查看Kerwin的GitHub主页
领取专属 10元无门槛券
私享最新 技术干货