关于java:面试官你给我说一下什么是时间轮吧

33次阅读

共计 10440 个字符,预计需要花费 27 分钟才能阅读完成。

你好呀,我是歪歪。

明天我带大家来卷一下工夫轮吧,这个玩意其实还是挺实用的。

常见于各种框架之中,偶现于面试环节,了解起来略微有点难度,然而晓得原理之后也就感觉:

大多数人谈到工夫轮的时候都会从 netty 开始聊。

我就不一样了,我想从 Dubbo 外面开始讲,毕竟我第一次接触到工夫轮其实是在 Dubbo 外面,过后就惊艳到我了。

而且,Dubbo 的工夫轮也是从 Netty 的源码外面拿进去的,根本截然不同。

工夫轮在 Dubbo 外面有好几次应用,比方心跳包的发送、申请调用超时工夫的检测、还有集群容错策略外面。

我就从 Dubbo 外面这个类说起吧:

org.apache.dubbo.rpc.cluster.support.FailbackClusterInvoker

Failback,属于集群容错策略的一种:

你不理解 Dubbo 也没有关系,你只须要知官网上是这样介绍它的就行了:

我想突出的点在于“定时重发”这四个字。

咱们先不去看源码,提到定时重发的时候,你想到了什么货色?

是不是想到了定时工作?

那么怎么去实现定时工作呢?

大家个别都能想到 JDK 外面提供的 ScheduledExecutorService 和 Timer 这两个类。

Timer 就不多说了,性能不够高,当初曾经不倡议应用这个货色。

ScheduledExecutorService 用的还是绝对比拟多的,它次要有三个类型的办法:

简略说一下 scheduleAtFixedRate 和 scheduleWithFixedDelay 这两个办法。

ScheduleAtFixedRate,是每次执行工夫为上一次工作开始起向后推一个工夫距离。

ScheduleWithFixedDelay,是每次执行工夫为上一次工作完结起向后推一个工夫距离。

前者强调的是上一个工作的开始工夫,后者强调的是上一个工作的完结工夫。

你也能够了解为 ScheduleAtFixedRate 是基于固定工夫距离进行任务调度,而 ScheduleWithFixedDelay 取决于每次工作执行的工夫长短,是基于不固定工夫距离进行任务调度。

所以,如果是咱们要基于 ScheduledExecutorService 来实现后面说的定时重发性能,我感觉是用 ScheduleWithFixedDelay 好一点,含意为前一次重试实现后才应该隔一段时间进行下一次重试。

让整个重试性能串行化起来。

那么 Dubbo 到底是怎么实现这个定时重试的需要的呢?

撸源码啊,源码之下无机密。

筹备发车。

撸源码

有的同学看到这里可能焦急了:不是说讲工夫轮吗,怎么又开始撸源码了呀?

你别猴急呀,我这不得循序渐进嘛。

我先带你手撕一波 Dubbo 的源码,让你晓得源码这样写的问题是啥,而后我再说解决方案嘛。

再说了,我间接,啪的一下,把解决方案扔你脸上,你也承受不了啊。

我喜爱温顺一点的教学方式。

好了,先看上面的源码。

这几行代码你要是没看明确没有关系,你次要关注 catch 外面的逻辑。

我把代码和官网上的介绍帮你对应了一下。

意思就是调用失败了,还有一个 addFailed 来兜底。

addFailed 是干了啥事呢?

干的就是“定时重发”这事:

org.apache.dubbo.rpc.cluster.support.FailbackClusterInvoker#addFailed

这个办法就能够答复后面咱们提出的问题:Dubbo 集群容错外面,到底是怎么实现这个定时重试的需要的呢?

从标号为 ① 的中央能够晓得,用的就是 ScheduledExecutorService,具体一点就是用的 scheduleWithFixedDelay 办法。

再具体一点就是如果集群容错采纳的是 failback 策略,那么在申请调用失败的 RETRY_FAILED_PERIOD 秒之后,以每隔 RETRY_FAILED_PERIOD 秒一次的频率发动重试,直到重试胜利。

RETRY_FAILED_PERIOD 是多少呢?

看第 52 行,它是 5 秒。

另外,你能够在后面 addFailed 办法中看到标号为 ③ 的中央,是在往 failed 外面 put 货色。

failed 又是一个什么货色呢?

看后面的 61 行,是一个 ConcurrentHashMap。

标号为 ③ 的中央,往 failed put 的 key 就是这一次须要重试的申请,value 是解决这一次申请对应的服务端。

failed 这个 map 是什么时候用呢?

请看标号为 ② 的 retryFailed 办法:

在这个办法外面会去遍历 failed 这个 map,全副拿进去再次调用一遍。

如果胜利了就调用 remove 办法移除这个申请,没有胜利的会抛出异样,打印日志,而后期待下次再次重试。

到这里咱们就算是揭开了 Dubbo 的 FailbackClusterInvoker 类的神秘面纱。

面纱之下,暗藏的就是一个 map 加 ScheduledExecutorService。

感觉如同也没啥难的啊,很惯例的解决方案嘛,我也能想到啊。

于是你缓缓的在屏幕上打出一个:

然而,敌人们,抓好坐稳,要“然而”了,要转弯了。

这外面其实是有问题的,最直观的就是这个 map,没有限度大小,因为没有限度大小,那么在一些高并发的场景下,是有可能呈现内存溢出的。

好,那么问题来了,怎么避免内存溢出呢?

很简略,首先咱们能够限度 map 的大小,对吧。

比方限度它的容量为 1000。

满了之后,怎么办呢?

能够搞一个淘汰策略嘛,先进先出(FIFO),或者后进先出(LIFO)。

而后也不能始终重试,如果重试超过了肯定次数应该被干掉才对。

下面说的内存溢出和解决方案,都不是我乱说的。

我都是有证据的,因为我是从 FailbackClusterInvoker 这个类的提交记录上看到了它的演进过程的,后面截图的代码也是优化之前版本的代码,并不是最新的代码:

这一次提交,提到了一个编号叫 2425 的 issue。

https://github.com/apache/dub…

这外面提到的问题和解决方案,就是我后面说的事件。

终于,铺垫实现,对于工夫轮的故事要正式开始了。

工夫轮原理

有的敌人又开始猴急了。

要我连忙上工夫轮的源码。

你别着急啊,我间接给你讲源码,你必定会看懵逼的。

所以我决定,先给你画图,看懂原理。

给大家画一下工夫轮的根本样子,了解了工夫轮的工作原理,上面的源码解析了解起来也就绝对轻松一点了。

首先工夫轮最根本的构造其实就是一个数组,比方上面这个长度为 8 的数组:

怎么变成一个轮呢?

首尾相接就能够了:

如果每个元素代表一秒钟,那么这个 数组一圈 能表白的工夫就是 8 秒,就是这样的:

留神我后面强调的是一圈,为 8 秒。

那么 2 圈就是 16 秒,3 圈就是 24 秒,100 圈就是 800 秒。

这个能了解吧?

我再给你配个图:

尽管数组长度只有 8,然而它能够在上叠加一圈又一圈,那么能示意的数据就多了。

比方我把下面的图的前三圈改成这样画:

心愿你能看明确,看不明确也没有关系,我次要是要你晓得这外面有一个“第几圈”的概念。

好了,我当初把后面的这个数组丑化一下,从视觉上也把它变成一个轮子。

轮子怎么说?

轮子的英文是 wheel,所以咱们当初有了一个叫做 wheel 的数组:

而后,把后面的数据给填进去大略是长这样的。

为了不便示意,我只填了下标为 0 和 3 的地位,其余中央也是一个意思:

那么问题就来了。假如这个时候我有一个须要在 800 秒之后执行的工作,应该是怎么样的呢?

800 mod 8 =0, 阐明应该挂在下标为 0 的中央:

假如又来一个 400 秒之后须要执行的工作呢?

同样的情理,持续往后追加即可:

不要误以为下标对应的链表中的圈数必须依照从小到大的程序来,这个是没有必要的。

好,当初又来一个 403 秒后须要执行的工作,应该挂在哪儿?

403 mod 8 = 3,那么就是这样的:

我为什么要不厌其烦的给你说怎么计算,怎么挂到对应的下标中去呢?

因为我还须要引出一个货色:待分配任务的队列。

下面画 800 秒、400 秒和 403 秒的工作的时候,我还省略了一步。

其实应该是这样的:

工作并不是实时挂到工夫轮上去的,而是先放到一个待调配的队列中,等到特定的工夫再把待调配队列中的工作挂到工夫轮上去。

具体是什么时候呢?

上面讲源码的时候再说。

其实除了待调配队列外,还有一个工作勾销的队列。

因为放入到工夫轮的工作是能够被勾销的。

比方在 Dubbo 外面,测验调用是否超时也用的是工夫轮机制。

假如一个调用的超时工夫是 5s,5s 之后须要触发工作,抛出超时异样。

然而如果申请在 2s 的时候就收到了响应,没有超时,那么这个工作是须要被勾销的。

对应的源码就是这块,看不明确没关系,看一眼就行了,我只是为了证实我没有骗你:

org.apache.dubbo.remoting.exchange.support.DefaultFuture#received

原理画图进去大略就是这样,而后我还差一张图。

把源码外面的字段的名称给你对应到下面的图中去。

次要把这几个对象给你对应上,前面看源码就不会太吃力了:

对应起来是这样的:

留神左上角的“worker 的工作范畴”把整个工夫轮包裹了起来,前面看源码的时候你会发现其实整个工夫轮的外围逻辑外面没有线程平安的问题,因为 worker 这个单线程把所有的活都干完了。

最初,再提一嘴:比方在后面 FailbackClusterInvoker 的场景下,工夫轮触发了重试的工作,然而还是失败了,怎么办呢?

很简略,再次把工作放进去就行了,所以你看源码外面,有一个叫做 rePut 的办法,干的就是这事:

org.apache.dubbo.rpc.cluster.support.FailbackClusterInvoker.RetryTimerTask#run

这里的含意就是如果重试出现异常,且没有超过指定重试次数,那么就能够再次把工作仍回到工夫轮外面。

等等,我这里晓得“重试次数”之后,还能干什么事儿呢?

比方如果你对接过微信领取,它的回调告诉有这样的一个工夫距离:

我晓得以后重试的次数,那么我就能够在第 5 次重试的时候把工夫设置为 10 分钟,扔到工夫轮外面去。

工夫轮就能够实现下面的需要。

当然了,MQ 的提早队列也能够,然而不是本文的探讨范畴。

然而用工夫轮来做下面这个需要还有一个问题:那就是工作在内存中,如果服务挂了就没有了,这是一个须要留神的中央。

除了 FailbackClusterInvoker 外,其实我感觉工夫轮更适合的中央是做心跳。

这可太适合了,Dubbo 的心跳就是用的工夫轮来做。

org.apache.dubbo.remoting.exchange.support.header.HeartbeatTimerTask#doTask

从上图能够看到,doTask 办法就是发送心跳包,每次发送实现之后调用 reput 办法,而后再次把发送心跳包的工作仍回给工夫轮。

好了,不再扩大利用场景了。

接下来,进入源码剖析,跟上节奏,不要乱,大家都能学。

开卷!

工夫轮源码

后面把原理了解到位了,接下来就可以看一下咱们的源码了。

先阐明一下,为了不便我截图,上面的局部截图我是挪动了源码的地位,所以可能和你看源码的时候有点不一样。

咱们再次扫视 Dubbo 的 FailbackClusterInvoker 类中对于工夫轮的用法。

首先 failTimer 这个对象,是一个很眼生的双重查看的单例模式:

这里初始化的 failTimer 就是 HashedWheelTimer 对象要害的逻辑是调用了它的构造方法。

所以,咱们先从它的构造方法动手,开始撕它。

先说一下它的几个入参别离是干啥的:

  • threadFactory:线程工厂,能够指定线程的名称和是否是守护过程。
  • tickDuration:两个 tick 之间的工夫距离。
  • unit:tickDuration 的工夫单位。
  • ticksPerWheel:工夫轮外面的 tick 的个数。
  • maxPendingTimeouts:工夫轮中最大期待工作的个数。

所以,Dubbo 这个工夫轮的含意就是这样的:

创立一个线程名称为 failback-cluster-timer 的守护线程,每隔一秒执行一次工作。这个工夫轮的大小为 32,最大的期待解决工作个数是 failbackTasks,这个值是能够配置的,默认值是 100。

然而很多其余的应用场景下,比方 Dubbo 查看调用是否超时,就没有送 maxPendingTimeouts 这个值:

org.apache.dubbo.remoting.exchange.support.DefaultFuture#TIME_OUT_TIMER

它甚至连 ticksPerWheel 都没有上送。

其实这两个参数都是有默认值的。ticksPerWheel 默认为 512。maxPendingTimeouts 默认为 -1,含意为对期待解决的工作个数不限度:

好了,当初咱们整体看一下这个工夫轮的构造方法,每一行的作用我都写上了正文:

有几个中央,我也独自拿进去给你说一下。

比方 createWheel 这个办法,如果你八股文背的相熟的话,你就晓得这里和 HashMap 外面确认容量的外围代码是一样一样的。

这也是我在源码正文外面提到的,工夫轮外面数组的大小必须是 2 的 n 次方。

为什么,你问我为什么?

别问,问就是为了前面做位运算,操作骚,速度快,逼格高。

我置信上面的这一个代码片段不须要我来解释了,你要是不了解,就再去翻一番 HashMap 的八股文:

然而这一行代码我还是能够多说一句的 mask = wheel.length - 1

因为咱们曾经晓得 wheel.length 是 2 的 n 次方。

那么假如咱们的定时工作的提早执行工夫是 x,那么它应该在工夫轮的哪个格子外面呢?

是不是应该用 x 对长度取余,也就是这样计算:x % wheel.length。

然而,取余操作的效率其实不算高。

那么怎么能让这个操作快起来呢?

就是 wheel.length – 1。

wheel.length 是 2 的 n 次方,减一之后它的二级制的低位全部都是 1,举个例子就是这样式儿的:

所以 x % wheel.length = x & (wheel.length – 1)。

在源码外面 mask =wheel.length – 1。

那么 mask 在哪用的呢?

其中的一个中央就是在 Worker 类的 run 办法外面:

org.apache.dubbo.common.timer.HashedWheelTimer.Worker

这里计算出来的 idx 就是以后须要解决的数组的下标。

我这里只是通知你 mask 的确是参加了 & 位运算,所以你看不懂这块的代码也没有关系,因为我还没讲到这里来。

所以没跟上的同学不要慌,咱们接着往下看。

后面咱们曾经有一个工夫轮了,那么怎么调用这个工夫呢?

其实就是调用它的 newTimeout 办法:

这个办法有三个入参:

含意很明确,即指定工作(task)在指定工夫(delay,unit)之后开始触发。

接下来解读一下 newTimeout 办法:

外面最要害的代码是 start 办法,我带大家看一下到底是在干啥:

分成高低两局部讲。

下面其实就是保护或者判断以后 HashedWheelTimer 的状态,从源码中咱们晓得状态有三个取值:

  • 0:初始化
  • 1:已启动
  • 2:已敞开

如果是初始化,那么通过一个 cas 操作,把状态更新为已启动,并执行 workerThread.start() 操作,启动 worker 线程。

上面这个局部就略微有一点点费解了。

如果 startTime 等于 0,即没有被初始化的话,就调用 CountDownLatch 的 await 期待一下下。

而且这个 await 还是在主线程上的 await,主线程在这里等着 startTime 被初始化,这是个什么逻辑呢?

首先,咱们要找一下 startTime 是在哪儿被初始化的。

就是在 Worker 的 run 办法外面,而这个办法就是在后面 workerThread.start() 的时候触发的:

org.apache.dubbo.common.timer.HashedWheelTimer.Worker

能够看到,对 startTime 初始化实现后,还判断了是否等于 0。也就是说 System.nanoTime() 办法是有可能返回为 0,一个小细节,如果你去要深究一下的话,也是很乏味的,我这里就不开展了。

startTime 初始化实现之后,立马执行了 startTimeInitialized.countDown() 操作。

这不就和这里响应起来了吗?

主线程不马上就能够跑起来了吗?

那么问题就来了,这里大费周章的搞一个 startTime 初始化,搞不到主线程还不能持续往下执行是干啥呢?

当然是有用啦,回到 newTimeout 办法接着往下看:

咱们剖析一下下面这个等式哈。

首先 System.nanoTime() 是代码执行到这个中央的实时工夫。

因为 delay 是一个固定值,所以 unit.toNanos(delay) 也是一个固定值。

那么 System.nanoTime()+unit.toNanos(delay) 就是这个工作须要被触发的纳秒数。

举个例子。

假如 System.nanoTime() = 1000,unit.toNanos(delay)=100。

那么这个工作被触发的工夫点就是 1000+100=1100。

这个能跟上吧?

那么为什么要减去 startTime 呢?

startTime 咱们后面剖析了,其实初始化的时候也是 System.nanoTime(),初始化实现后就是一个固定值了。

那岂不是 System.nanoTime()-startTime 简直趋近于 0?

这个等式 System.nanoTime()+unit.toNanos(delay)-startTime 的意义是什么呢?

是的,这就是我过后看源码的一个疑难。

然而前面我剖析进去,其实整个等式外面只有 System.nanoTime() 是一个变量。

第一次计算的时候 System.nanoTime()-startTime 的确趋近于 0,然而当第二次触发的时候,即第二个工作来的时候,计算它的 deadline 的时候,System.nanoTime() 可是远大于 startTime 这个固定值的。

所以,第二次工作的执行工夫点应该是以后工夫加上指定的延迟时间减去 worker 线程的启动工夫,前面的工夫以此类推。

后面 newTimeout 办法就剖析完了,也就是主线程在这个中央就执行完工夫轮相干的逻辑了。

接下来该剖析什么呢?

必定是该轮到工夫轮的 worker 线程上场施展了啊。

worker 线程的逻辑都在 run 办法外面。

而外围逻辑就在一个 do-while 外面:

循环完结的条件是以后工夫轮的状态不是启动状态。

也就是说,只有工夫轮没有被调用 stop 逻辑,这个线程会始终在运行。

接下来咱们逐行看一下循环外面的逻辑,这部分逻辑就是工夫轮的外围逻辑。

首先是 final long deadline = waitForNextTick() 这一行,外面就很有故事:

首先你看这个办法名你就晓得它是干啥的了。

是在这外面期待,直到下一个时刻的到来。

所以办法进来第一行就是计算下一个时刻的纳秒值是啥。

接着看 for 循环外面,后面局部都看的比拟懵逼,只有标号为 ③ 的中央好了解的多,就是让以后线程睡眠指定工夫。

所以后面的局部就是在算这个指定工夫是什么。

怎么算的呢?

标号为 ① 的中央,后面局部还能看懂,

deadline – currentTime 算进去的就是还须要多长时间才会到下一个工夫刻度。

前面间接就看不懂了。

外面的 1000000 好了解,单位是纳秒,换算一下就是 1 毫秒。

这个 999999 是啥玩意?

其实这里的 999999 是为了让算进去的值多 1 毫秒。

比方,deadline – currentTime 算进去是 1000123 纳秒,那么 1000123/1000000=1ms。

然而(1000123+999999)/1000000=2ms。

也就是说要让上面标号为 ③ 的中央,多睡 1ms。

这是为什么呢?

我也不晓得,所以我先临时不论了,留个坑嘛,问题不大,接着往下写。

上面就到了标号为 ② 的中央,看起来是对 windows 操作系统进行了非凡的解决,要把 sleepTimeMs 换算为 10 的倍数。

为啥?

这里我就得批评一下 Dubbo 了,把 Netty 的实现拿过去了,还把要害信息给暗藏了,这不适合吧。

这中央在 Netty 的源码中是这样的:

这里很清晰的指了个路:

https://github.com/netty/nett…

而顺着这条路,一路往下跟,会找到这样一个中央:

https://www.javamex.com/tutor…

没想到还有意外播种。

第一个划线的中央大略意思是说当线程调用 Thread.sleep 办法的时候,JVM 会进行一个非凡的调用,将中断周期设置为 1ms。

因为 Thread.sleep 办法的实现是依靠于操作系统提供的中断查看,也就是操作系统会在每一个中断的时候去查看是否有线程须要唤醒并且提供 CPU 资源。所以我感觉后面多睡 1ms 的起因就能够用这个起因来解释了。

后面留的坑,这么快就填上了,难受。

而第二个划线的中央说的是,如果是 windows 的话,中断周期可能是 10ms 或者 15ms,具体和硬件相干。

所以,如果是 windows 的话,须要把睡眠工夫调整为 10 的倍数。

一个没啥卵用的常识,送给你。

后面几个问题理解分明了,waitForNextTick 办法也就了解到位了,它干的事儿就是等,等一个工夫刻度的工夫,等一个 tick 长度的工夫。

等到了之后呢?

就来到了这一行代码 int idx = (int) (tick & mask)

咱们后面剖析过,计算以后工夫对应的下标,位运算,操作骚,速度快,逼格高,不多说。

而后代码执行到这个办法 processCancelledTasks()

看办法名称就晓得了,是解决被勾销的工作的队列:

逻辑很简略,高深莫测,就是把 cancelledTimeouts 队列给清空。

这里是在 remove,在清理。

那么哪里在 add,在增加呢?

就是在上面这个办法中:

org.apache.dubbo.common.timer.HashedWheelTimer.HashedWheelTimeout#cancel

如果调用了 HashedWheelTimeout 的 cancel 办法,那么这个工作就算是被勾销了。

后面画图的时候就提到了这个办法,逻辑也很清晰,所以不多解释了。

然而你留神我画了下划线的中央:MpscLinkedQueue。

这是个啥?

这是一个十分牛逼的无锁队列。

然而 Dubbo 这里的 cancelledTimeouts 队列的数据结构明明用的是 LinkedBlockingQueue 呀?

怎么回事呢?

因为这里的正文是 Netty 外面的,Netty 外面用的是 MpscLinkedQueue。

你看我给你比照一下 Netty 和 Dubbo 这里的区别:

所以这里的注解是有误导的,你有工夫的话能够给 Dubbo 提给 pr 批改一下。

又拿捏了一个小细节。

好了,咱们接着往下卷,来到了这行代码 HashedWheelBucket bucket=wheel[idx]

高深莫测,没啥说的。

从工夫轮外面获取指定下标的 bucket。

次要看看它上面的这一行代码 transferTimeoutsToBuckets()

我还是每一行都加上正文:

所以这个办法的外围逻辑就是把期待调配的工作都发配到指定的 bucket 下来。

这里也就答复了我画图的时候留下的一个问题:什么时候把期待调配队列外面的工作挂到工夫轮上去呢?

就是这个时候。

接下来剖析 bucket.expireTimeouts(deadline) 这一行代码。

你看这个办法的调用方就是 bucket,它代表的含意就是筹备开始解决这个 bucket 外面的这个链表中的工作了:

最初,还有一行代码 tick++

示意以后这个 tick 曾经解决实现了,开始筹备下一个工夫刻度。

要害代码就剖析完了。

一遍看不懂就多看一遍,然而我倡议你本人也对照着源码一起看,很快就能搞懂。

置信当前面试官问到工夫轮的时候你能够和他战斗上一个回合了。

为什么是一个回合呢?

因为得你答复完这个工夫轮后,一般来说,面试官会诘问一个:

嗯,说的很不错,那你再介绍一下层级工夫轮吧?

过后你就懵逼了:什么,层级工夫轮是什么鬼,歪歪没写啊?

是的,怪我,我没写,下次,下次肯定。

然而我能够给你指条路,去看看 kafka 对于工夫轮的优化。你会看的鼓起掌来。

几个相干的 issues

最初,对于 Dubbo 工夫轮,在 issues 外面有一个探讨:

https://github.com/apache/dub…

大家有趣味的能够去看看。

其中提到了一个有意思的问题:

Netty 在 3.x 中有大量应用 HashedWheelTimer,然而在 4.1 中,咱们能够发现,Netty 保留了 HashedWheelTimer,但在其源码中并未应用它,而是抉择了 ScheduledThreadPoolExecutor,不晓得它的用意是什么。

这个问题失去了 Netty 的维护者的亲自答:

https://github.com/netty/nett…

他的意思是工夫轮其实没有任何故障,我没有用只是因为咱们心愿与通道的 EventLoop 位于同一线程上。

在 Netty 外面,有个老哥发现工夫轮并没有用上了,甚至想把它给干掉:

我寻思这属于工具类啊,你留着呗,总是会有用的。

另外,后面的 issue 还提到了另外一个问题:

https://github.com/apache/dub…

这也是 Dubbo 引入工夫轮之后进行的优化。

带你看一眼,下面是优化之后的,上面是之前的写法:

在之前的写法中,就是后盾起一个线程,而后搞个死循环,一遍遍的去扫整个汇合:

这种计划也能实现需求,然而和工夫轮的写法比起来,高下立判。

操作骚,速度快,逼格高。

最初说一句

好了,看到了这里了,转发、在看、点赞轻易安顿一个吧,要是你都安顿上我也不介意。写文章很累的,须要一点正反馈。

给各位读者敌人们磕一个了:

本文已收录自集体博客,欢送大家来玩。

https://www.whywhy.vip/

正文完
 0