赞
踩
书籍链接:Java游戏服务器架构实战
作者提供的源码链接:kebukeYi / book-code
这里对书籍中比较重要的知识点(精华部分)进行摘录(总结)
游戏服务器开发时不能仅考虑接收客户端的信息并返回正确信息即可,需要考虑多线程处理消息的情况,否则会出现数据不一致的问题。比如给同一个用户请求的消息加锁或者把请求消息分配到固定的消息队列中。
良好的架构设计,需要预知项目哪些功能是公共的、是可以在架构中实现的,这样可以减少重复代码,提前为不同的业务开发提供服务。
单体游戏服务器架构的缺点是当注册人数或同时在线人数达到一定限度时,就需要开新的服务器,这样会导致旧服务器的用户越来越少,因此需要重新进行合服操作(用户数据合并到一个数据库中,不同用户连接同一个服务器)
分布式微服务器架构的优点是不同模块可以并行访问,如果某个功能坏了,对其他功能影响小,可以修复后快速重启进程,但缺点是对架构的设计难度较大,增加了运维难度,增加了不同模块通信功能的开发量。
一个域名可以配置多个ip
地址,客户端在访问时服务器时可以通过DNS
服务器中的负载均衡算法,返回一个可用的ip
地址,参考一个域名对应多个IP地址。
实际上,大型网站总是部分使用DNS域名解析,利用域名解析作为第一级负载均衡手段,即域名解析得到的一组服务器并不是实际提供服务的物理服务器,而是同样提供负载均衡服务器的内部服务器,这组内部负载均衡服务器再进行负载均衡,再请求发到真实的服务器上,最终完成请求。
Nginx
实现负载均衡,后期还可以将游戏服务中心拆解为多个微服务。MongoDB
(使用JPA的findById
来访问)。findById有synchronized
修饰防止缓存击穿,通过给缓存设置默认值防止缓存穿透,每次更新缓存时会重新设置过期时间防止缓存雪崩;3)将id字符串放入常量池,使用synchronized
代码块避免id被用户重复注册;4)全局捕获异常处理,减少每个controller写try/catch
,便于代码维护,还能将异常统一记录到日志中;5)redis使用setnx
来创建用户<id,name>,登录成功后通过JWT
实现token;consul
是服务发现和配置的工具,是一个分布式高可用的系统(http
访问)。网关服务从consul
查询所有的业务服务的注册信息,根据服务名称实现客户端的请求转发Spring cloud gateway
的核心工作原理是使用全局过滤的组件GlobalFilter
作用于所有请求路由,而GatewayFilter
只作用于某个请求的路由。ribbon
配合consul
注册中心使用。此外为了解决同一个用户(id相同)的请求被负载到不同的游戏服务注册中心中,导致用户多次注册的问题,这里需要保证在负载时将同一个用户id
打到相同服务器上,可以通过对id求 hashcode再取余 来解决DDoS
攻击,把流量分散在不同网关上可以防止流量过多而导致服务崩溃ip
),这些信息通过consul
服务注册中心获取(游戏网关会将信息注册到consul
上)Java
中利用discoveryClient
,通过服务名从服务注册中心获取服务列表信息。在游戏网关的负载均衡上,使用hash求余法来让同一个用户请求同一个游戏网关,避免并发操作导致的数据不一致netty
提供了更加方便进行socket
的长连接。Netty
是一个优秀的异步网络通信框架,已经对网络消息的编码和解码提供了一个完整的解决方案,开发人员只需要根据需求实现自定义的业务即可。
MessageToByteEncoder
是Netty
提供的一个编码抽象类,只需要继承这个类,实现encode
方法即可。在encode
方法中,把消息对象的数据依次写入ByteBuf
, 完成消息应用层的序列化,Netty
的底层会在向网络发送数据时,从ByteBuf
中读取序列化之后的Bytes
数组,再发送到Socket中。Netty
会把这个Bytes
数组封装为ByteBuf
返回给上层应用。解码要做的就是把ByteBuf
中的Bytes
数组数据转化为程序中使用的可读数据对象Netty
在进行I/O处理时采用的是Reactor模型,主要包括3个模块:多路复用器(Acceptor
),事件分发器(Dispatcher
)和事件处理器(Handler
)。其中Acceptor
负责与客户端建立连接,Dispatcher
是收到消息后,将消息分发到相应的连接链路中,handler
是一个责任链,用于处理消息读写等相应业务。Netty
中MessageToByteEncoder
类中encode()
,可以实现消息对象的序列化,但是在实际业务开发中,开发人员并不关心网络层的序列化和反序列化,因此需要对消息进行再次封装实现消息对象的自动序列化和反序列化。
xxxMsgRequest
,响应消息对象为xxxMsgResponse
,这里可以通过定义消息抽象类AbstractGameMessage
(抽象xxxMsgRequest
和xxxMsgResponse
的公共功能),接着新增一个ResponseHandler
,利用反射将网络数据包转化为数据对象,并将该Handler
添加到initChannel
的pipeline
中)JSON
或者protocol buffers
来实现序列化和反序列化。token
中包括第三方唯一id,用户Id,角色id,区id和加密公钥。RSA
)在明文较长时计算量大,因此这里使用对称加密对数据进行加密,使用非对称加密对对称加密的密钥进行加密。keep-alive
机制,没有及时性,因为它默认两小时才检测一次。
Netty
中提供一个用于检测连接空闲的IdleStateHandler
,包括readerIdleSeconds
,writerIdleTimeSeconds
和allIdleTimeSecond
s三个参数ID
都是递增的。游戏服务器网关在连接认证之后,会记录最近一次收到的消息的序列ID
,如果收到的新消息的序列ID
小于等于上次收到的序列ID
,表示此消息已处理过,丢弃此次请求的消息。重新连接之后,会重置这个序列ID
。spring Cloud gateway
的网关组件已完成了对http
请求的消息过滤,负载均衡和转发,可以与业务服务器进行交互。但是这种http
连接是短连接,在游戏服务器中,要求游戏网关和游戏服务器之间建立长连接,即在服务启动时建立连接,在转发时直接发送消息即可,可以减少建立连接时的等待时间,而且为了让网络的延迟越小,消息的序列化和反序列化的速度要快,消息体要尽量小。
如果游戏网关和不同的游戏业务服务器直接建立长连接,这个连接网不利于游戏网关和业务服务的动态伸缩和服务的扩展。
为了实现游戏网关和游戏业务服务之间的解耦,让游戏网关对游戏业务服务无感知,可以在两者之间增加一个消息中间件(观察者模式/发布订阅模式),比如游戏网关在收到用户的消息之后,会将消息(xxxMsgRequest
)发布到消息总线服务的一个固定的Topic1
上面;对于游戏业务服务器则会监听Topic1
中的消息并处理消息,处理完消息之后会将响应消息(xxxMsgResponse
)发布到Topic2
中;游戏网关会监听Topic2
中业务服务器发布的消息,并返回给客户端。
比如游戏业务服务包括战斗服务,副本服务和数据服务等,其中战斗服务包括两个服务器,副本服务也包括两个服务器,它们与游戏网关之间的交互是通过消息总线中间件来交互的
Spring Cloud Bus
为了解决微服务中各个节点之间的消息同步,一个服务节点可以向其他节点广播消息,其他节点可以根据消息改变自身状态。它底层的网络通信依赖与消息中间件,目前支持Kafka
和RabbitMQ
。Spring Cloud Bus
封装和简化了对底层消息中间件的调用。
Kafka
以Topic
为基本单位来存储信息,在创建Topic时,可以指定Topic的分区。当发送消息时,可以通过消息的key
决定存储在哪个分区上,实现负载均衡;消费者消费消息时也是以Topic为基本单位的(即指定一个消费组groupId),在同一个groupId下可以有n个消费者,理论上消费者的数目(n)等于这个Topic的分区数量,这样就能保证一个消费者对应一个Topic分区,实现并发处理
游戏网关和客户端的在进行网络通信时离不开消息(客户端的ip
地址,请求消息)的序列化和反序列化,游戏网关和游戏业务服务之间利用消息中间件(消息中间件里存的是二进制信息)进行网络通信时也如此,只不过包括的数据字段不一样。因此需要增加一个GameMessageInnerDecoder
,它的两个主要功能就是序列化发送消息并送到消息总线中,和反序列化从消息总线中接收消息。
游戏网关在接收客户端消息时需要实现负载均衡:首先先利用服务注册中心中的服务ID(serviceId
)获取服务器ID列表(服务在启动时会向服务注册中心注册当前的服务ID和服务器ID);接着将客户端的消息存放在消息中间件的Topic中。
由于一个游戏服务器网关和游戏服务器之间是1对多的关系,如果它们使用同一个Topic(一个服务)则会造成每个游戏服务器都收到游戏服务器网关的转发信息(非目标服务器需要额外判断转发消息到达的服务器ID与当前的服务器ID是否相同,不同则会丢弃该消息)
为了减少网络资源的浪费,这里可以为每个服务器分配一个独立的Topic
,每个Topic
包括服务器的ID
,因此在游戏网关中先对客户消息进行处理,接着增加一个DispatchGameMessageHandler
类,用于转发客户消息到具体的业务服务器中,这里会通过playerServiceInstance
的selectServerById
来获取一个可达的服务器,并缓存这个服务器的信息。
游戏网关将客户端消息转发到游戏服务器的流程(就是观察者模式的实例化):
business_message_topic_服务器ID
,将消息序列化,并发送到消息总线服务的business_message_topic_服务器ID中;business_topic_服务器ID
信息,用于接收游戏服务器网关发布的消息。游戏网关接收游戏服务器的响应信息的流程
gateway_message_topic_网关服务器ID
的消息总线服务上。gateway_message_topic_网关服务器ID
,并接收业务服务器发布的消息。注意:在通信过程中,一定要把Topic
对应上,它是整个消息服务通信的纽带。如果
发布消息的Topic
和监听消息的Topic
对应不上,是完成不了消息服务通信的。
游戏业务处理框架中对线程数量的管理需要考虑任务的类型:I/O密集型,计算密集型还是两者都有;如果有多种类型的任务则需要考虑使用多个线程池:
分配两个独立的线程池可以使得业务处理不受数据库操作的影响
在对线程的使用上,一定要严格按照不同的任务类型,使用对应的线程池。在游戏服务开发中,要严格规定开发人员不可随意创建新的线程。如果有特殊情况,需要特殊说明,并做好其使用性的评估,防止创建线程的地方过多,最后不可控制。
在Netty
中,最常用的几个线程模型核心类有EventExecutorGroup
、DefaultEventExecutorGroup
、EventExecutor
、DefaultEventExecutor
、NioEventLoopGroup
、NioEventLoop
。
EventExecutorGroup
(线程池组)和EventExecutor
。在Netty中,EventExecutorGroup相当于一个线程池,EventExecutor
相当于一个java中方的Executor.SingleThreadExecutor
线程池。在游戏开发时,不同类型的任务会被分到不同的线程池组中去执行。从异步处理的问题出发:
1)如果要想获得线程的返回结果(数据库查询结果),可以利用线程创建Future
对象,但是这种方法在使用future.get()
等待结果时,主线程会一直等待,导致线程利用率降低。
2)采用回调函数(Consumer
函数式接口),在子线程执行后调用回调函数完成值的更新,但是这样也会存在问题:调用回调函数的线程和创建回调函数的线程不是同一个线程,如果控制不当容易出现并发操作同一个对象的问题。
创建线程Netty的线程池组可以处理以上两个问题:重写了JDK的Future
,添加了Listener
方法,并新添加了Promise
类(继承自Netty的Future
)。Promise
中可以设置返回的结果,然后会调用Listener
方法。Promise
是和EventExecutor
一起使用的,在创建Promise
的时候,会在构造方法中添加一个EventExecutor
参数,这样监听方法就会在EventExecutor
线程中执行,代码如下所示。
这里重写的Future
接口中包含方法addListener
,在使用时需要传入Listener
的实例,而在创建Listener
实例时需要重写operationComplete
方法。这样让executor
线程即完成创建promise
对象并返回查询结果,又完成listener对象对future
对象的监听(future.get()
),这样主线程就不会阻塞。
在游戏服务器中,大多数用户是操作自己的数据,也有功能是一个用户的操作影响另一个用户的操作,对于前者只需要对信息按顺序进行处理即可,不存在并发问题;对于后者会出现并发现象。
Netty
中关于消息处理的设计具有顺序性,异步性和可扩展性。当客户端与服务器建立连接时,会创建一个Channel连接对象,在这个Channel中,客户端的消息是按顺序处理的(因为一个channel
默认在同一个EventExecutor
中处理)
Netty
处理消息有几个核心类:Channel
、ChannelPipeline
(默认实现类是DefaultChannelPipeline)、ChannellnboundHandler
、ChannelOutboundHandler
、ChannelHandlerContext
它们构成了Netty消息处理的整个框架。
Channel
是一个连接对象,而ChannelPipeline
中会管理一个由多个ChannelOutboundHandler
和ChannelInboundHandler
ChannellnboundHandler
负责处理接收到的消息,ChannelOutboundHandler
负责管理服务器发送出去的消息;Channel
接收到客户端的消息,会将消息通过ChannelPipeline
发送到 ChannelHandler
的链表中。Netty
的整个消息处理系统类似于一个责任链,这个责任链由ChannelHandler
组成。例如客户端与服务器创建连接,即初始化Channel
时,可以向ChannelPipeline
的Handler链表中添加Handler
来完成消息的顺序处理过程。
Handler
链表的好处是提供了灵活的扩展性,想添加一个事件的处理,只需要添加一 个Handler
即可。例如在项目后期需要添加一些监控统计,比如流量统计、消息吞吐量等,只需要添加相应功能的Handler
实现类即可。但是要注意Handler
处理的顺序性,进入的消息是从链表头部向链表尾部流动,发出的消息是从链表尾部向链表头部流动。
由于用户大部分时候是处理自己的数据,因此为每个用户分配一个channel
对象来专门处理信息。这里需要一个线程安全的集合,来管理playerId
和channel
实例的映射。
玩家与服务器进行连接时会先注册channel
对象,如果channel
还未成功注册,玩家发送了其他新的消息,这时会将这些消息放在waitTaskList
队列上排队,待channel
注册成功之后会一次性执行队列上的任务;如果channel
注册成功了,新的消息并不会继续在waitTaskList
队列上排队,而是在EventExecutor
上排队。
现在考虑不同游戏用户之间的数据交互,比如一个用户需要查看另一个用户的数据,例如排行榜的显示,或者一个用户给另一个用户赠送体力等。
举个例子:一个游戏用户给另一个游戏用户赠送体力值时,如果直接操作对方的数据,就有可能出现这样的情况。假如用户A、B都在线,用户A查询到用户B的数据,给用户B
添加体力过程会分成3步。
1)获取用户B当前剩余的体力值。
2)在剩余的体力值上添加赠送的体力值。
3)将当前最新的体力值set回用户B的Player对象中。
很明显这3步不是原子操作,那么如果在向B的Player中set当前体力值之前,这
个时候正好用户B自己操作通关了某个副本,扣除了10点体力值,就会导致A给B的Player添加体力值的时候就包括了这10点体力值,相当于B的体力值没有任何损失。而实际上赠送的体力和扣除的体力是不能抵消的。
在解决不同游戏用户数据交互时,一般有两种方法:
在架构设计上解决用户数据之间的直接交互:比如在竞技场中,一个游戏用户要挑战竞技场排行榜上的另一用户,需要提前预览这个对手的一些信息,比如防守阵容、战斗力、各个英雄的职业等。这就需要在当前用户的查询请求中,向服务端查询另一个用户的竞技场信息。为了防止出现上面所说的多线程并发操作的异常,可以使用Promise/Future
的机制。
因为在上面的架构设计中,一个playerld
对应一个GameChannel
,所以当需要查询一个用户的数据时,需要向这个用户的GameChannel
中发送一个事件。而这个事件被处理之后,它的返回结果由Promise
的监听接口带回。
GameChannel
设计的时候,规定了对一个用户的数据操作都是以事件驱动的。比如处理客户端的请求,会把请求封装为一个任务事件,放到GameChannel
中处理;Executor
中按顺序执行所有的事件,所以这整个过程是线程安全的(这样多个用户查询当前用户信息时,只需要向当前用户发送Event
事件即可,它的返回结果由创建Promise
以及监听接口的线程带回,该线程由各自用户创建)GetPlayerInfoEvent
发送到playerId
对应的GameChannel
中,在playerId对应的GameBusinessMessageDispatchHandler
中需要通过if_else
来判断其他用户发送的Event类型,做出相应的处理,userEventTriggered
方法如下:上面代码存在的问题是不利于功能的扩展,即每增加一个事件需要修改userEventTriggered
的判断条件,这里可以采用注解来让GameChannel
事件自动分发处理。
UserEvent
注解;DispatchGameMessageService
方法,在项目启动的时候会自动扫描@GameMessageHandler
类实例,接着扫描该类实例下所有被@UserEvent
标注的方法,并把类实例和方法缓存下来。在GameBusinessMessageDispatchHandler
收到事件的处理时,只需要调用
dispatchUserEventService.callMethod(utx,evt,promise);
即可完成其他玩家发送过来要处理的事件,并返回结果给其他玩家设置的监听接口。
游戏用户数据需要异步加载:原因是如果游戏服务在等待网络I/O向数据库请求数据时发生了阻塞,会导致游戏服务的吞吐量下降,因此需要考虑将加载数据的线程池和业务处理的线程池区分开,让业务处理线程不阻塞,提高系统吞吐量和响应时间。
MongoDB
数据库。GameChannel
注册的时候加载用户的数据到内存中,用户在游戏服务中心服务登录的时候,会先将用户的Player
数据缓存在Redis
中,进入游戏的时候,从Redis
中读取用户数据,加载到内存(SRAM
)中,这里的Redis
是作为二级缓存(DRAM
)的。Redis
是内存型数据库,从Redis
中读取数据要比数据库快很多,但是这种方式在请求Redis
的时候,还是会有网络I/O的操作,阻塞当前线程的业务处理,需要将其修改为异步加载,将Redis
的请求放到另外线程进行处理。异步加载游戏数据的实现(使用Netty
):之前创建的PlayerDao
是同步加载数据库数据的,这里创建AsyncPlayerDao
,将数据库的调用过程放到特定的线程池中,并通过事件驱动的方式交给GameChannel
的handler
进行处理。当查询Player
事件执行完之后,通过Promise
回调方法会将结果缓存在内存中。
上面是通过异步将数据加载到缓存(redis
)中,接下来介绍游戏数据持久化到数据库(MongoDB
/redis
)
游戏数据持久化方式有2种:
数据库持久化操作也需要采用异步的方式,即将持久化的操作封装为任务事件,放到指定的数据持久化线程中执行。由于要持久化的是Player
对象的集合,这就会导致业务线程和持久化线程访问Players
时存在对象共享的问题(并发操作会导致数据不一致),解决方法如下3种:
players
序列化为json,再交给数据持久化线程处理,序列化虽耗时,但不用关注集合并发问题;ConcurrentHashMap
,LinkedBlockingQueue
等在移除GameChannel
,或者关闭游戏服务器进程的时候,需要强制将缓存中的数据持久化到数据库中
当GameChannel
注册成功之后,需要开启两个定时器:设置 Redis
持久化定时器和 MongoDB
持久化定时器 ,由于Redis持久化速度较快,故时间间隔可以短些
将 Player
对象中的数据和行为进行分离 :原因是Player
对象(主要是各种业务线程来操作)中包含了数据以及对应的get
,set
方法,如果把Player
对象的行为,比如等级升级,英雄技能学习,宠物领取等行为都放在Player
类中,会十分的臃肿,而且这些方法不能以get
,set
开头,因为这些行为不需要被序列化成json
。
manager
类来单独管理Player
对象的行为,比如HeroManager
管理英雄技能的学习,levelManager
管理玩家的等级等。XXXManager
类中,可以在对象创建时,将数据对象通过构造方法传进来,之后就可以在这个XXXManager
类中直接对这个数据对象进行操作了。但是在XXXManager
类的管理中,一个XXXManager
类不要操作另外一个XXXManager
管理的数据,比如在技能升级时,扣道具的操作要放在道具的Manager中,而不要在技能Manager
中直接操作背包数据进行修改。这样可以保证数据操作的唯一性。分布式架构永远绕不过的问题就是不同进程间的数据通信,即远程过程调用(Remote Procedure Call, RPC)现在被统一称为内部RPC。现在有很多开源的RPC库,比如gRPC、Dubbo、Hessian、Thrift等。毋庸置疑,这些都是非常优秀的RPC框架,但是它们大部分都用于Web服务,且为短连接,满足不了游戏对高性能的需要。
有些架构师在项目初期就全面实行微服务化,也有架构师认为架构是根据需求变化的,应该根据项目的需求来选择合适的架构,架构也是随着项目变化而变化的,不能贪图一次性的完美。总之,架构应该以满足目前需求为先,并具有一定的前瞻性。
游戏服务需不需要微服务化:
EventExecutorGroup
,所以这个服务最好是单独拆分出来。使用总线服务自定义RPC组件:传统的RPC
组件(gRPC
,Thift
等)存在的问题是,在一个服务与其他服务进行通信时,需要知道该服务的ip和端口,对于新的服务的创建和销毁无法感知(业务部署通常是多实例的),服务之间的耦合性高,而且不同服务既是客户端又是服务端,维护起来比较麻烦。因此这里利用消息总线服务,封装了适合游戏服务的RPC系统,开发人员只需知道服务的ID,不用知道服务器的ip和端口。
负载均衡如何实现:由于每个服务是多个实例的,因此目标服务实例的ID就不是固定了,需要通过一个公共的服务来同步服务实例的存活信息。
PlayerServicelnstance
方法中的selectServerld
方法即可。它会统一管理目标服务实例ID的选择。选择的目标服务实例ID会缓存在本地内存中,如果没有一个清理策略的话,会存储得越来越多,最终导致内存泄漏。这里的建议是,可以在playerld
对应的GameChannel
结束的时候,发送一个清理服务实例ID缓存事件,来清理服务实例ID的缓存数据。这里补充一下为什么不使用其他的RPC组件而是使用消息中间件作为自定义的RPC通信,主要原因是
这里区分客户端发送的消息和RPC的消息,定义了一个枚举类型
在创建竞技场服务项目时,可以在application.yml
上配置竞技场服务要监听的topic
,以及数据处理之后要发布到的topic
。
RPC
消息的发送和接收过程(使用Netty
线程池实现消息的异步处理,消息处理过程包括编码/解码,加密/解密,连接认证,心跳检测等):
RPC
的请求都是由客户端的请求操作触发的(可以放在之前定义的GatewayMessageContext
类中,不需要再引入新的类就可以直接调用RPC
的发送方法)。RPC
消息的发送就和客户端返回消息一样,需要经过一系列的Handler
,最终到达GameChannelPipeline
链表的头部Handler
。因此在AbstractGameChannelHandlerContext
添加writeRPCMessage
方法;RPC
请求发送之后,竞技场服务需要监听和发送RPC消息一样的Topic
,来接收RPC
消息,而且要把接收到的RPC请求纳入GameChannel
进行处理;GameChannelPipeline
的一系列Handler
的处理之后,最后消息到达GameChannelPipline
的HeadContext
类中,在这里调用GameChannel
的channel.unsafeSendRpcMessage(gameMessage,callback);
方法。在这里面会判断是否为RPC
响应消息,如果是响应消息的话,会调用GameRpcService
中发送RPC
响应消息的方法到消息总线中。RPC
请求超时检测:
RPC
消息时,会调用addCallBack()
回调方法,此时会在缓存中建立RPC
消息序列的ID和回调接口之间的映射,如果该RPC
请求消息有了响应,则从缓存集合中删除掉该消息序列ID和value。ID-value
还在,说明该ID的 RPC
消息序列还没有返回响应结果。事件系统主要由事件源(事件产生)、事件内容(发布事件)、事件管理器、事件监听接口组成。
为什么要使用事件系统:
不同模块之间存在数据交互,如果某个模块直接调用其他模块会导致随着系统规模扩大复杂度相应增大,由于模块A与其他模块是1对多的关系,引入事件系统后会向所有模块广播一个事件数据,模块通过监听接口监听该消息是否是自己所需要的,这样可以减少模块与模块之间的直接调用,使代码更容易维护。
事件系统的实现并不复杂(类似于观察者模式),一般由3个部分组成,即事件内容对象、事件分发管理器、事件监听接口。功能模块可以继承实现事件监听接口,然后将监听接口的实现类实例注册到事件分发管理器上面。当一个事件产生的时候,调用事件分发管理器,把事件发送到对应的监听接口中。因为事件系统是一个公共组件,所以把它放在my-game-common
项目中。
PlayerUpdateListener
)时都需要继承事件监听接口(IGameEventListener
),创建新的事件(PlayerUpdateEventGame
)时需要继承事件内容(EventGame
)接口。会导致监听类非常多,事件类非常多,使用起来相对麻烦一些。这样不仅浪费开发时间,而且也使代码变得臃肿,因此这种方式适合事件不是太多的服务。@GameEventService
和@GameEventListener
,其中@GameEventService
用于标记在类上面,它继承了Spring中@Service
的特性,标记了这个注解的类可以作为Bean被Spring容器管理。@GameEventListener
标记在类的方法中,表明这个方法处理某个事件@GameEventListener(PlayerUpgradeLevelEvent.class)
。每次当sendGameEvent
之后,会通过String key = gameEventMessage.getClass().getName(); List<IGameEventListener> listeners = this.eventListenerMap.get(key);
,即通过对应的事件内容类,找到对应的Listener
列表,并逐一进行listerner.update()
。(是不是很像观察者模式)个人的学习建议:
第一阶段:一边阅读该书,一边理解作者提供的代码
1)阅读完后问自己如下几个问题(系统架构角度)
Netty
),业务服务器监听gatway_topic_id,用于接收客户端传来的序列化后的消息,而游戏网关则监听business_message_topic,用于接收业务服务器处理后的消息。实现的细节主要关注如下这几部分(代码层面)
consul
实现服务注册和发现Spring Cloud Bus
来访问Kafka
,实现游戏网关和业务服务之间的客户端消息通信、以及业务服务和业务服务之间的内部RPC通信Netty
消息处理过程Netty
如何异步加载数据,并利用事件监听器回调返回处理后的数据第二阶段:代码部分反复巩固和学习,在理清各个模块之间究竟是怎么分工和交互的,项目架构为什么要这么设计,这样设计是基于怎样的需求,能解决什么样的问题,还是基于某种设计理念之后,吃透作者提供的代码。
谈一谈我对架构的理解:
- 1)架构师一定要改变思维,不能只停留在单体服务上,要思考如何保证服务运行时的高可用性,因此服务一定是多实例的,是集群部署的(
redis
,mysql
,rabbitMQ
等),要尝试提出分布式高可用服务的解决方案;- 2)在满足需求的情况下,架构师更多要考虑系统的性能(吞吐量,
CPU
,内存等使用率,时延等),可以简单理解: 程序员要生孩子(实现项目的功能性需求),架构师要陪伴孩子成长(随着项目需求的变化,对项目的非功能性需求提出解决方案)。- 3)架构师平时要更关注于服务的可用性,模块的可重用性,后期是否易维护等抽象的问题。
- 4)架构师既要有编程能力,又要有解决项目问题的经验,也要有理论知识(软设、系分、架构考就完了)
- 5)架构是根据需求变化的,应该根据项目的需求来选择合适的架构,架构也是随着项目变化而变化的,不能贪图一次性的完美。总之,架构应该以满足目前需求为先,并具有一定的前瞻性。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。