cover_image

原原世界任务系统的设计与实现

刘鹏程 映客技术
2023年05月25日 07:09

前言

大部分的app都有任务系统,这是一种很好的激励用户的方式。今天以原原世界app为例,和大家分享一个基础的任务系统是如何设计与实现的,希望在大家遇到相似业务场景时能够有所帮助。

功能划分

先来看一下任务系统的页面:

图片

任务系统主要包含四个功能:

    1、任务配置:动态配置任务的文案、奖励、是否启用等属性。

    2、任务展示:任务的状态、排序以及分类。

    3、任务进度:当用户执行某个动作时,比如组队、送礼,我们需要更新任务的进度。

    4、领取奖励:当任务完成后,就可以自动下发或手动领取奖励。

图片

根据功能可以看出,这是一个很基础的任务系统,系统设计上的重点主要在于任务配置和进度更新。而进度的更新则需要调用或被其它程序调用,我们先来看一下调用关系。

调用关系

假设我们要开发“用户第一次组队”的新手任务,当游戏服务执行完组队逻辑后,就需要通知任务服务更新进度,最简单的方式自然是直接调用接口。

但如果还有其它任务,比如“第一次充值”,就会导致任务服务被多个服务依赖,而这些服务其实并不应该关心任务逻辑。

图片


同理,如果还有其它服务需要在用户组队后得到通知,就会导致游戏服务依赖多个不相干的服务。

图片


大家应该都能想到,此时可以利用kafka进行解耦,这里简单介绍一下。

kafka是一个高吞吐的分布式消息队列,主要用来进行应用解耦、流量削峰、异步处理。

消息队列的基本模型包含三个角色,生产者向消息队列发送消息,消费者从消息队列接收消息。

图片


在引入kafka后,调用模型就变成了:

图片

游戏、金融等服务在执行完主逻辑后,会异步投递消息给kafka。关心这些消息的其它服务只需要自行订阅对应的主题,即可在产生消息时被通知。



事件处理层

调用关系优化起来很简单,但服务内部的事件处理就麻烦一些了。

假设我们已经实现了用户第一次组队的任务,现在要开发用户组队20分钟的任务,那么我们需要在消费用户组队kafka消息的逻辑里新增这个任务的逻辑:

图片

在上面的例子里,任务逻辑直接写在了消费逻辑里,新增任务需要修改消费逻辑的代码。

这其实是不符合开闭原则的,添加一个新功能时,应该扩展代码(模块、类、方法),而非修改已有代码。


如果仅仅是修改消费逻辑还可以接受,但如果还有其它的事件来源,比如分享任务,当用户分享原原世界到朋友圈后,客户端会调用接口上报,我们需要在这个接口里调用任务逻辑。

任务直接被各个模块依赖,这显然是不利于维护的。

图片


我们分析一下可以发现,其实任务只关心事件本身,并不关心事件来源。所以我们可以引入中间层,对事件来源层和任务层进行解耦。事件处理层需要做两件事:

    1、为任务层屏蔽事件来源,任务只需要接收事件并处理。

    2、将任务与事件关联起来,让任务能够订阅关心的事件,并在产生事件时通知相关任务。

图片

发布/订阅模式

那么事件处理层如何实现呢?

我们之前利用了kafka解除消息发送者和接收者的耦合,这里遇到的问题其实是类似的。

我们可以借鉴kafka的发布/订阅模型,可能已经有同学联想到了发布/订阅模式,也叫观察者模式,这里简单介绍一下。


发布/订阅模式能够解除发布者和订阅者的耦合,适用于需要在某个对象变更时通知其它对象的场景。

发布者并不需要关心有哪些订阅者、这些订阅者是什么身份,在新增或移除订阅者时不需要修改发布者的代码。


举个例子,假设我们开了一家商店,到货时需要通知所有找我们预约过的顾客。

图片

这其实是很麻烦的,顾客得打电话找我们预约,合作商可能是发邮件预约,我们得关注各种消息渠道,一个个去通知这些用户。

那我们可以做一个公众号,用户自己关注公众号预约就行,到货时我们发一个推送就可以通知这些用户。

至于预约的用户是顾客、合作商还是其他人,我们并不需要关心。

图片


这是发布/订阅模式的UML图:

图片

发布/订阅模式具体实现:

图片

图片

然后我们需要定义一个事件处理器EventHandler,并为每一个事件创建一个Publisher,关注这个事件的任务需要实现Subscriber,并进行订阅:

图片

事件处理层的上层,也就是事件来源层层在产生事件时调用该方法即可通知关注该时间的任务:

图片

Handle方法其实就是封装了Publisher.Notify方法:

图片


引入事件处理层后,我们只需要开发完任务逻辑,然后增加一行代码订阅对应事件:

图片

任务和事件的关系全部聚拢到了一个函数里,十分清晰。


顺带一提,我们编写消费kafka的代码时,会为每个事件都编写一个处理函数。

其实很多时候这些函数逻辑都是一样的,接收消息、处理、提交消息,只是消息类型不同。

此时我们可以利用反射减少重复编码:

图片

现在新增一个事件只需要1行代码,而之前需要为每个事件都编写一个函数(约15行)。

目前减少了约70%的代码,随着以后事件的增多,这个比例会更大。


任务配置

接下来我们来看任务配置,原理就是将任务的属性与逻辑分离。

我们需要将任务的文案、奖励、跳转动作等属性保存到数据库里。调整属性时,只需要修改数据库,而不需要修改代码、上线。

任务的逻辑则需要写在代码里(一些复杂的任务系统设计成可以通过表达式配置),通过id关联。

图片

其中,跳转动作是由客户端来执行,这需要我们约定好动作与客户端函数的映射关系。如果某个版本新增了一个动作,老版本app的动作按钮就无法响应。因此,我们还需要设定任务的最小版本号,客户端版本小于该版本号则不展示。

任务分类

再说一下任务的分类。

根据需求,很容易想到我们要对任务进行分类。那我们就按需求上的签到任务、新手任务、每日任务来进行分类就行了吗?

答案是否定的。这只是展示层的分类,并不能体现出这些任务在实体层次上的特性。


我们先分析一下这几种任务:

    1、新手任务:只能完成一次

    2、每日任务:每天都能完成一次

    3、签到任务:每天都能完成一次,连续完成会有不同的奖励,间断后会重置


所以我们可以对上面的分类进行抽象,在实体层上更贴切的分类应该是普通任务、周期任务、连续任务,对应的数据结构是:

图片

这样做有什么好处呢?举个例子,需求中提到了之后要做成长任务,和新手任务一样,也是只能完成一次,唯一的区别是奖励会自动发放。

如果我们对任务进行了抽象,实体层就可以直接复用普通任务的逻辑。

图片


最后来看一个任务示例:

图片

总结

1、通过kafka解除服务间的耦合

2、通过事件处理层解除任务与事件来源的耦合

3、通过分离任务属性与逻辑实现动态配置

4、通过分层分类对任务类型进行复用



继续滑动看下一个
映客技术
向上滑动看下一个