2019年北航OO第二单元(多线程电梯任务)总结

一、三次作业总结

1. 说在前面

对于这次的这三次电梯作业,我采用了和几乎所有人都不同的架构:将每个人当作一个线程。这样做有一定的好处:它使得整个问题的建模更加自然,并且在后期人员调度变得复杂时,可以将调度器上纷繁的逻辑判断分布在不同的人身上,大大简化了代码逻辑。对于程序复杂度,将人作为某个容器中的PersonRequest时需要在电梯到达某一层时进行遍历,而将人作为线程池中的一个任务则是通过wait()notify()机制实现了类似的线程遍历,对于此次最多40人的简单任务而言并不会在时间上损失太多;在debug时,通过将每个人的线程进行重命名,我可以轻易地使用JProfiler等工具查看究竟是哪一个线程没有正常结束,省去了部分调试输出的麻烦。综合了建模的简化和时间上可接受的损失,我选择了将每个人作为一个线程进行处理。

对于设计,我不希望将自己的设计仅仅局限于每次作业中课程组提出的设计目标,而是尽可能留出可以扩展的余地,这也是为何我从第五次作业开始就采用了和最后一次作业极为相近的设计结构,这样可以使得我每次作业的代码复用率尽可能高。实际上,在这几次作业中我的调度器类Scheduler、输入类InputHandler、几个自己通过继承和实现接口写的数据结构都几乎没有改动,甚至任务的关键部分乘客类Person和电梯类Elevator的改动也很小且复用率很高。当能够在更早的时候预想未来的可能需求,便能在尽可能最早的时间通过设计上的优化为未来做出准备。

2. 第五次作业

2.1 需求分析

本次作业是需要写一个傻瓜调度电梯:一个电梯,每次只有一人乘坐,采用FCFS先来先服务策略。

2.2 实现方案

第五次作业是一个再显然不过的单生产者-单消费者模型。在Java中,解决生产者-消费者模型的标准方法就是使用java.util.concurrent包中提供的阻塞队列BlockingQueue。因此,本次作业基本上是围绕着两个线程共享的阻塞队列LinkedBlockingQueue进行操作的。为了给后续作业留出余地,我从第五次作业中就加入了调度器Scheduler(尽管这在第五次作业中是不必要的,但是为了减轻后续debug的压力,我选择将这个潜在的易错点从最简单的第五次作业开始引入)。需要维护的阻塞队列有两个:输入线程和调度器之间的队列,以及调度器和电梯之间的队列,而这两个也正是两个分别的生产者-消费者模型。

对于每个人线程Person,在第五次作业中同时只会有一个Person线程由电梯发起运行,所以不需要维护线程池。人会对所需要乘坐的电梯进行等待(即elevator.wait()),当电梯到达某一层开门后即notifyAll()唤醒等待该电梯的乘客。乘客在被唤醒后会判断是否开门以及是否到达所需楼层,若满足要求则进出电梯。

对于调度策略,电梯在此次作业中运行模式是最简单的先来先服务,每次只需要考虑在运行的Person线程的单一需求即可。

对于同步策略,本次作业中通过使用两个线程安全的BlockingQueue分别实现了两个生产者-消费者模型的线程安全。对于人和电梯之间的交互则是通过对电梯加内部锁synchronized(elevator)完成的。由于monitor只会在Person线程中,因此这种同步是没有问题的。对于所有属性,我都尽可能加了final标识以降低同步风险。其实内部锁synchronized已经足以处理大多数情况了。

读过《Java Concurrency in Practice》的小伙伴应该都知道,同步问题分为互斥问题和可见性问题,对于部分不需要同步但是可能出现可见性问题的变量,声明为volatile是一个很好的选择。诸如电梯是否开门的标识(boolean isOpen)这类符合“独立观察”模式的变量是很适合使用这种轻量级同步机制的。这里推荐大家读一读由《Java Concurrency in Practice》的作者Brian Goetz在IBM Developer中写的关于volatile的使用指南

对于结束策略,首先由输入线程在接到EOF后利用自定义的结束提示类FinishReminder向调度器发出输入结束信号。调度器在判断队列为空且输入结束后利用和电梯共享的另外一个FinishReminder向电梯发出调度结束信号。电梯在运行完其队列后即结束。人线程在下电梯后自动结束。

本次作业的UML类图如下:

Project5ClassGraph

本次作业的UML时序图如下:

Project5SequenceGraph

2.3 度量分析

本次作业的代码度量如下:

(标识:LOC-行数,CONTROL-控制语句数,ev(G)-本质复杂度,iv(G)-设计复杂度,v(G)-循环复杂度,LCOM-类内聚性,NAAC-添加属性数,NOAC-添加方法数,OCavg-平均方法复杂度,OSavg-平均方法语句数(规模),WMC-加权方法复杂度,FILES-文件数,v(G)avg-平均循环复杂度,v(G)tot-总循环复杂度)

MethodLOCCONTROLev(G)iv(G)v(G)
Elevator.Elevator()81111
Elevator.close()50111
Elevator.getCurrentFloor()30111
Elevator.isOpen()30111
Elevator.move()61122
Elevator.open()81111
Elevator.run()327467
FinishReminder.isUnfinished()30111
FinishReminder.setFinished()30111
InputHandler.InputHandler()70111
InputHandler.run()205344
Person.Person()50111
Person.run()33811111
RequestQueue.RequestQueue()40111
RequestQueue.addRequest()71122
RequestQueue.getRequest()81222
RequestQueue.getRequestCount()30111
Scheduler.Scheduler()100111
Scheduler.getNextRequest()30111
Scheduler.run()257456
Scheduler.startElevator()30111
TestElevator.main()130111
ClassLCOMLOCNAACNOACOCavgOSavgWMC
Elevator175851.575.4311
FinishReminder1912112
InputHandler1323027.54
Person1422038.56
RequestQueue126231.52.756
Scheduler148521.7567
TestElevator115011101
ProjectFILESLOCv(G)avgv(G)tot
project82882.2349

从统计中可以看出Person.run()Elevator.run()两个方法的复杂度偏高,这种将大量操作(尤其是包含有同步操作)放在run()方法的写法应该尽量避免。

2.4 出错分析

强测无错误出现。

这一次在本地测试中偶尔会出现无法读入请求的情况,用JProfiler观察后发现0秒输入的请求不会产生Person线程。分析发现,虽然通过阻塞队列使得两个生产者-消费者模型分别实现了同步安全,但是在两个模型的交接处依然存在同步问题。这也就是上课时老师强调的“两个线程安全的操作联合起来就不是线程安全的”,此处需要多加留意。

3. 第六次作业

3.1 需求分析

本次作业需要实现多人电梯:一个电梯,每次可以有多人乘坐。

3.2 实现方案

在本次作业中生产者-消费者模型依然存在,故依然利用BlockingQueue在输入线程和调度器之间传请求。但是,这次人请求不能再由电梯进行启动:由于可能连续开始多个人的请求,所以对于电梯请求的加入变成:人到来→调度器启动Person线程→人将请求楼层发送给电梯(相当于人在电梯面板上按下了叫电梯按钮)→电梯收到请求。这样的建模显然十分符合实际上乘电梯的过程。

对于Person线程,由于此次可能同时有多个人等待电梯,所以在调度器中维护了一个装入所有Person的线程池。考虑到最大请求人数为30,故所需线程池为固定的Executors.newFixedThreadPool(30)。等待电梯依然是对唯一的一台电梯在未开门/未到所需楼层时进行wait()

对于调度策略,我并没有按照指导书那样考虑捎带方法。由于我将每个人作为一个线程,这种建模非常贴近现实生活,于是我采取了类似于现实中(就是新主楼XD)的电梯运行模式:当高层有人叫电梯且电梯在向上运行时,忽略所有向下请求,直接运行到存在请求的最高楼层,在到达每一层时都查看一下本层是否有请求,若有则停下开门,否则继续向上,向下的运行同理。这样的模式有一点像是后来大家所说的LOOK算法。我维护了一个boolean isUp的私有变量指示先前运行方向,当电梯运行到了某个目的地后,会检测是否有与先前运行方向相同的更高楼层,若有,则将同方向最高楼层设为新的目的地,否则将isUp置反,将反方向最高楼层设为新的目的地。运行过程中经过每一层时决定是否停靠。在停靠后,各个Person线程会收到电梯发来的notifyAll()信号,每个人分别判断自己是否应该进出电梯。

对于同步策略,我依然沿用了先前的策略,变化只有两处。

第一是增加了电梯的请求楼层容器,这里我扩展了线程安全容器CopyOnWriteArraySet,并实现了求其中最高和最低楼层的方法。(现在回想起来,如果使用一个NavigableSet就可以免去实现获取最高楼层和最低楼层的方法。刚好Java中提供了线程安全且实现了NavigableSet接口的容器ConcurrentSkipListSet,利用其floor()ceiling()方法加上楼的最高最低层即可直接实现对集合中最大最小元素的获取。果然回看代码还是有收获的)

第二是在电梯队列为空时的等待策略变化。第五次作业中电梯的请求队列由调度器维护,此次变成由人维护后,电梯在队列为空时的等待需要由第一个到达的人唤醒。此处理应唤醒的是电梯的调度队列,但是仅出于充当同步锁的原因将调度队列公布是违反了封装原则的。因此,我在电梯中设置了一个空对象Object requestLock作为电梯等待队列的锁,并配置了相应获取锁的方法,这样人和电梯只需要在这个无用的对象上保持互斥同步即可,既保证了线程安全又免去了公布容器恶意修改的风险。

对于结束策略,依然与上一次一致。

本次作业的UML类图如下(为了方便作业间对比,我将本次作业新加的关系用橙色线表示,新加的注解用粗体字表示):

Project6ClassGraph

本次作业的UML时序图如下(为了方便作业间对比,我将本次作业新加的关系用橙色线表示,新加的注解用粗体字表示):

Project6SequenceGraph

3.3 度量分析

本次作业的代码度量如下:

MethodLOCCONTROLev(G)iv(G)v(G)
Elevator.Elevator()91111
Elevator.addRequest()40111
Elevator.close()50111
Elevator.delRequest()30111
Elevator.getCurrentFloor()30111
Elevator.getRequestLock()30111
Elevator.isOpen()30111
Elevator.move()133425
Elevator.nextFloor()153414
Elevator.open()91111
Elevator.run()34104712
FinishReminder.isUnfinished()30111
FinishReminder.setFinished()30111
InputHandler.InputHandler()70111
InputHandler.run()205344
Person.Person()40111
Person.call()439199
Person.in()40111
Person.out()40111
RequestFloorSet.getHighest()92123
RequestFloorSet.getLowest()92123
RequestQueue.RequestQueue()40111
RequestQueue.addRequest()71122
RequestQueue.getRequest()81222
RequestQueue.getRequestCount()30111
Scheduler.Scheduler()100111
Scheduler.getNextRequest()30111
Scheduler.run()359467
Scheduler.startElevator()30111
TestElevator.main()130111
ClassLCOMLOCNAACNOACOCavgOSavgWMC
Elevator11161392.095.1823
FinishReminder1912112
InputHandler1323027.54
Person159231.757.257
RequestFloorSet22002356
RequestQueue126231.52.756
Scheduler158521.757.57
TestElevator115011101
ProjectFILESLOCv(G)avgv(G)tot
project93812.3771

由于Person实现的是Callable接口,所以其运行函数为Person.call()。这一次所有的方法复杂度都相当低,这是很令人满意的,唯独电梯的run()方法一枝独秀,应该将其分割并转移至其他函数中,这点在第七次作业中有所改进。其余方面均属于可以接受的水平,表示在当了练习时长一个半月的OO练习生之后在设计上有了一些长进。

3.4 出错分析

强测无错误出现,性能上不错,还有3个满分,可喜可贺。

自己debug的过程好像也没什么记忆深刻的地方,因为同步都做的很到位,且该碰的雷区在第五次作业已经先碰过了,这次基本就是从自己的上一次作业copy paste下来的,所以就跳过吧。

4. 第七次作业

4.1 需求分析

第七次作业需要实现多电梯并行,每个电梯有不连续的楼层限制,且电梯有容量限制。

4.2 实现方案

此次作业的结构和先前没有什么区别,需要多考虑的问题有两个:如何将人分配给不同电梯以及如何满足容量限制。

对于将人分配给电梯的问题,由于我的设计中将每个人都单独作为线程,所以人在即将搭乘电梯时无法对三部电梯的运行情况做统一判断,所以人对电梯的选择只能依靠电梯所能到达的楼层。在这里,我选择将选择电梯的工作交给每个人在到达电梯厅的时刻自己判断(也就是在构造函数中进行判断)。这种情景好比自己去坐电梯时看到各个电梯到达楼层不同,需要首先根据自己的需求以及各个电梯的楼层限制推测出自己的换乘方案,再依据此方案分成一次或两次单电梯乘坐过程。在楼层取交集的问题上,采用实现了NavigableSetTreeSet不仅可以直接通过retainAll()取交集,还能根据楼层获得ceiling()floor(),美哉。

我的策略是当一个人能一次到达目的地就不采用换乘,这不仅避免了需要根据电梯当前运行情况进行优化的交互难度,还避免了更改调度模式的大量后效性问题。人需要依据三个电梯的可达楼层随机选择一个可直达的电梯直达目的地,或随机选择两部换乘电梯通过换乘到达目的地。此处的随机是通过遍历Collections.shuffle()随机排序后的电梯序列实现的。当需要换乘时,会从两部电梯的交集中选择一个最高且位于起始楼层/目的楼层之间的楼层进行换乘,若不存在这样的楼层,就从两部电梯的交集中选择一个距离起始地/目的地距离最小的楼层进行换乘。这样的目的是尽可能减小换乘开销,并增大可以合并换乘楼层的可能。

对于容量限制,由于将每个人作为一个线程,这样不同线程和有限资源的模型使用信号量来控制再合适不过了,而Java的同步包java.util.concurrent刚好提供了这样的信号量机制Semaphore。在人上电梯时,获取一个信号量;在人下电梯时,释放一个信号量,这样通过信号量的阻塞机制可以绝对避免产生超载问题。在第五次作业中我就已经对容量问题有了采用信号量的设想,但是在第七次作业实际应用时我发现对信号量的应用实际上和应用一个记录剩余容量的AtomicInteger并没有什么区别,因为人在可进电梯但被容量所限时所做的不是在电梯下一次出人后立即上电梯,而是需要返回等待状态。

对于调度策略,依然采用了上次的策略。有所不同的是,为了维护容量限制,需要在电梯满时前往一个符合调度策略的可以出人的楼层,因此不能再简单地维护一个记录楼层需求的Set,而是需要维护两个容器分别记录电梯外部请求和内部请求,并且由于开门后该楼层的需求不一定全部满足,因此不能简单地通过删除需求表明已经到达此楼层。这里我将原本的RequestFloorSet改写成了扩展ConcurrentHashMapRequestFloorMap,用于将楼层和请求数对应,该容器由每个Person在上下电梯时进行维护。

对于同步策略,此次仅仅新加了一个(并没有怎么派上用场的)信号量机制。其余部分沿用了先前的设计。有一点需要注意的是,在部分需要先判断信号量是否为0再进行操作的check-then-act操作上需要记得加锁,另外需要保证上锁顺序不发生颠倒以避免死锁。

对于结束策略,此次稍有不同。相比于之前电梯只需在输入结束后判断当前电梯是否满足当前电梯要求的情况,此次由于增加了换乘,在满足当前电梯需求后可能出现人需要该部电梯换乘的情况,因此电梯必须等待所有人都到达目的地后才能结束。此处利用Scheduler维护一个剩余人数变量,当Person到来时该变量+1,完成时该变量-1,仅有在输入结束且该变量为0时可以停止电梯线程。

本次作业的UML类图如下(为了方便作业间对比,我将本次作业新加的关系用橙色线表示,新加的注解用粗体字表示):

Project7ClassGraph

本次作业的UML时序图如下(为了方便作业间对比,我将本次作业新加的关系用橙色线表示,新加的注解用粗体字表示):

Project7SequenceGraph

4.3 度量分析

本次作业的代码度量如下:

MethodLOCCONTROLev(G)iv(G)v(G)
Elevator.Elevator()201122
Elevator.addRequest()71122
Elevator.close()50111
Elevator.delRequest()71122
Elevator.getCurrentFloor()30111
Elevator.getElevatorId()30111
Elevator.getIn()71122
Elevator.getOut()30111
Elevator.getReachable()30111
Elevator.getRemainingCapacity()30111
Elevator.getRequestLock()30111
Elevator.isOpen()30111
Elevator.move()1834710
Elevator.nextFloor()153414
Elevator.open()81111
Elevator.run()481241116
FinishReminder.isUnfinished()30111
FinishReminder.setFinished()30111
InputHandler.InputHandler()70111
InputHandler.run()205344
Person.Person()4397810
Person.call()132122
Person.findTransfer()356367
Person.getCurrentElevator()92313
Person.getCurrentRequest()92313
Person.in()60111
Person.out()60111
Person.takeElevator()531211212
RequestFloorMap.decrement()30111
RequestFloorMap.getHighest()92134
RequestFloorMap.getLowest()92134
RequestFloorMap.haveRequest()30111
RequestFloorMap.increment()30111
RequestFloorMap.noRequest()82313
RequestQueue.RequestQueue()40111
RequestQueue.addRequest()71122
RequestQueue.getRequest()81222
RequestQueue.getRequestCount()30111
Scheduler.Scheduler()302133
Scheduler.delRequest()30111
Scheduler.getNextRequest()30111
Scheduler.getRemainingCount()30111
Scheduler.getSharing()40111
Scheduler.run()3811489
Scheduler.startElevator()51122
TestElevator.main()120111
ClassLCOMLOCNAACNOACOCavgOSavgWMC
Elevator1175171425.2532
FinishReminder1912112
InputHandler1323027.54
Person1182673.7512.8830
RequestFloorMap6370622.8312
RequestQueue126231.52.756
Scheduler296752.146.5715
TestElevator11401191
ProjectFILESLOCv(G)avgv(G)tot
project96322.87132

本次作业中在度量上能看出一些设计问题。在方法复杂度上,电梯的run()方法复杂度更高了,应该移至其他方法处。几个主要的方法,包括电梯的运行,人的乘梯流程以及人对换乘方案的选取都出现了较高的复杂度,但这些无法避免,并且在可接受范围内。Person的平均方法规模很大,说明该类的方法应该做进一步拆分。电梯的属性数过大是由于其中包含了很多需要的常量,但是在反思代码时发现其中部分属性可以作为局部变量处理。RequestFloorMap类的内聚程度很差,一共只有6个方法,内聚性却是6,证明每两个方法间都没有什么交集,不过该类本来就是作为工具类扩展了其他容器,也算有情可原。

4.4 出错分析

强测无错误出现,对于这个不易做更进一步优化的结构来说性能上也还算可以接受。

这次作业也没有经过很长时间的debug,毕竟相较于之前多出的同步问题并不是很多,写代码时的错误大都出在调度策略上,例如电梯在满员时依然会去接新活儿导致不停鬼畜,这类问题不仅明显而且好改,所以也就一笔带过了。

5. 设计原则分析

Project 5Project 6Project 7
SRP-单一功能将人分为一个单独对象是符合SRP的设计模式同左同左
OCP-开闭原则留出了尽可能多的扩展性,以便后续使用由于调度策略不同修改了部分run()方法增加信号量之后对原有代码修改几乎仅有增加锁和增加判断
LSP-里式替换未使用继承存在容器继承,其实现符合LSP存在容器继承,其实现符合LSP
ISP-接口隔离未实现接口所实现接口仅有Callable,符合ISP所实现接口仅有Callable,符合ISP
DIP-依赖反转不存在对任何一个类的抽象,全部依赖实体,未实现DIP同左同左

二、多线程bug分析

对于多线程的错误,由于其不可复现性和毫秒级的错误差距,在我看来利用fuzzing方法通过大量随机测试发现问题的难度相当大,且能够发现的大都不是同步问题,而是对于单个线程的处理问题。因此,除了对于单个部分的单元测试外,这次我在构造了几组简单的强测全部通过后没有像上一单元一样着手去写测试脚本,而是将更多时间花在了对于代码各个部分的同步分析上,通过对各个对象在不同运行过程之间的同步逻辑进行反复对照,对于这样规模的多线程程序应该更加有效。

在构造测试样例时,可以考虑两个方向:会引起调度错误的样例以及会引起同步错误的样例。在本次测试中,在同一时间或者0秒输入的样例即为针对同步问题的样例,而对于相近时间涌入的大量在同一楼层等电梯的乘客即为针对调度问题的样例。此外,对于EOF的输入时间导致的关闭错误也算是同步错误的一种,同样可以构造相应样例。

对于调试,断点已经不足以满足要求,除非所调试的线程为能够阻塞一切的控制线程。能够看到很多人选择使用System.err进行错误输出,然而这有一点投机取巧的意味。其实,我们可以使用Java内置的日志系统Logger记录程序运行信息,不仅能够很方便的记录每条信息的输出时间,所在线程名称,所在类和所在方法,还能很方便的自定义输出位置,我想这应该是多线程debug的更好选择。

三、感想

为了此次多线程作业,我提前一周开始阅读了那本被几乎所有Java教程推荐的多线程教程《Java Concurrency in Practice》,掌握了不变性条件的内涵、多种同步工具的使用以及多线程程序的一些设计原则,到现在已经阅读完大半。不得不说,这本书里的内容使我这三次作业的完成过程可谓轻松愉快(虽然不能debug的确是有些让人懊恼,我一直觉得好的debug工具是让写代码变得有趣的最关键原因,其次的原因才是IDE的黑色主题色)。在电梯程序的设计中,不变性条件并没有过多的显示出来,反而几乎所有的锁都加在了请求队列上,这种基本的生产者-消费者模式贯穿了整个单元,加上Java线程安全容器的丰富,所以需要自行增加同步锁的地方比较有限。尽管如此,作为第一次设计的多线程程序,依然感觉“纸上得来终觉浅”。我在编写时的一点感觉是,多线程代码需要在写下每一个共享对象时都在脑子里过一遍这个对象在其他线程中可能会进行哪些操作,将其依照时序排列组合一番,确定自己正在写下的操作在任何一种情况下都不会出问题才能放心写下;而对于连续出现的共享对象,则需要更进一步考虑,确定这些语句的组合在其他操作插入的情况下不会出现问题才可以。happens-before原则以及check-then-act、read-modify-write模板会帮我们避免很多这样的错误。

对于设计原则,这次是在最后一次电梯作业才讲到,但是反观过去已经快要把所有原则违反一遍了(笑)。在其中,最主要的应该是开闭原则,而LSP、ISP和DIP这三个子类和父类、实现和接口之间的关系准则在我看来是帮助实现开闭原则的辅助模式。尤其是DIP,在google了一番之后颇有醍醐灌顶之感,解决了我之前对于开闭原则如何实现的疑惑。如果重新设计,我想Person线程对应的应该不是一部实体电梯,而是一个电梯接口或是电梯抽象类,至于这三次截然不同的电梯理应是实现了这个电梯接口的三种不同的电梯。人实际上不关注电梯是什么样的,人只关心这个电梯能不能把我送到目的地,而人的工作也只有按按钮而已,这背后的机制都是由策略不一的电梯选择的。为了达成开闭原则,需要实现依赖反转,而依赖反转又需要里式替换和接口隔离作为运行机制的保障,这一切又只有在实现了单一功能之后才能使得设计更加方便,由此看来,SOLID原则应该是一环扣一环的,彼此之间存在一定的逻辑关系。

最后,我想说一点经历了这几次作业之后对面向对象设计原则的看法。在设计完电梯程序后,我越来越觉得,OO相比于过去的实现算法而言更像是数学建模:将一个看似抽象的问题拆分成一个个对象,我们只需要对各个对象有什么,能做什么进行代码描述即可,区别仅仅在于这段描述行为的“代码描述”应该用怎样的算法或是怎样的策略进行填充;在应用了SOLID原则之后,甚至在建模时都不必要构建一个实体,因为一个对象有什么可能通过继承而来,而描述一个对象能做什么的最好方法实际上是构造一个interface。这样看来,实际上与其说每个类是一个只能用一次的类,倒不如说每个所设计出来的类就像是一个我们不断调用的“库”,而实现的时候也不应该站在需求最底层考虑,而是尽可能将每一部分进行逻辑上的拔高,需要功能时能用父类就用父类,能用接口就用接口,能预留扩展空间就预留扩展空间。也正是秉着这样的建模心态,我在这次的电梯程序中采用了更为贴近生活也更为自然的设计方式,我还经常和同学开玩笑说“我的程序是一个人去坐电梯,而大家的程序是人都是木头人,电梯开门以后里面会伸出触手把人硬拽进去”。希望经过之后的作业我能对OO模式有更深的体会。

转载于:https://www.cnblogs.com/Sheryc/p/10741930.html

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值