1.服务器框架总览
1.1.简介
jforgame,是一个用java编写的轻量级高性能手游服务端框架。项目提供各种支持快速二次开发的组件,以及对生产环境的服务进行管理的工具。同时,为了使用户能够快速上手,项目提供了若干常用业务功能作为演示。
Github地址:https://github.com/kingston-csj/jforgame
1.2.maven中央仓库的组件
组件 | 作用 |
jforgame-commons | 基础公共服务 |
jforgame-socket | Tcp socket通信,包括io网关模块,消息路由,会话管理,包含netty和mina版本 |
jforgame-spring-boot-starter-data | 以springboot的starter模式封装对配置数据的读取,支持csv,excel等文件格式。支持配置数据热更新,支持二级缓存 |
jforgame-codec | 用于socket通信的数据编解码 |
jforgame-hotswap | 支持游戏代码务热更新 |
借助这几个组件,即可部署一个高效高性能的游戏服务器框架
2.组件说明
2.1.socket网络框架
Jforgame的网络框架支持netty和mina版本,mina已经日薄西山了,直接选择netty吧。
对于网络游戏,一般都选择socket或者websocket作为网络通信。
下面简要比较下三者的区别:
- http: 无状态,基于请求-响应模式。服务器无法主动向客户端推送消息(逆向ajax技术比较鸡肋,不讨论),即使是非常小的游戏,除非主要逻辑都放到客户端,服务器只负责存档,否则不要用http。无论是开发还是拓展,难度都非常大。
- Socket:双向长链接,一般游戏使用TCP模式,udp虽然通信效率高,但需要业务底层处理丢包、乱序问题,得不偿失。
- WebSocket:双向长链接,http协议的升级版本。比起socket的主要优势,是wss使用https协议能够防止数据被监听和破;另外,就是搭建反向代理可以做到负载均衡(但游戏很难做到业务无状态,此优点在游戏服务器不存在)。劣势是:http协议很啰嗦,消息包比较大。
总结:尽量使用socket,一劳永逸!
2.1.1.私有协议栈与消息编辑码
私有协议栈
Socket是传输层协议,网络通信的载体是二进制流,二进制流本身没有边界,所以服务器与客户端需要协商定义一个封包的格式。
Jforgame2.0使用如下的私有协议栈:
// ----------------protocol pattern-------------------------
// header(12bytes) | body
// msgLength = 12+len(body) | body
// msgLength | index | cmd | body
包头:4个字节的消息长度,4个字节的包序号,4个字节的消息类型,总共12个字节
包体:每个cmd类型的消息的数据载体。类型不同,内容不同,长度也不同。
消息编解码
对于一个消息bean,可以有多种方式进行编解码,例如json,protobuf。
Jforgame-codec提供了两种默认实现,一种是根据javabean的结构,自动编解码;另外一种是使用百度的JProtobuf注解工具,可以不编写.proto文件使用Pb格式。
关于使用何种消息编解码,需要跟客户端协商。不到万不得已,不使用json格式,因为json的数据体积太大了。
2.1.2.客户端消息回调
消息包头有一个int的字段表示消息序号,客户端可以利用这个字段实现请求-响应的回调模式,消息序号必须是自增长的,由客户端维护,服务器不做检测。
服务器也可利用这个字段做消息重放检测,就是防止客户端通过抓包工具进行数据重放,只要服务器收到的消息序号小于等于已经收到的最大序号,即可判定客户端消息异常。
2.2.热更组件
Web项目通常使用Nginx反向代理来做服务维护,当程序需要更新或者修复重大bug的时候,则通过屏蔽节点的方式实现热修复。
游戏服务器不是http,没法使用这种策略。游戏服务器采用JDK的Instrumentation进行代码热修复(鼎鼎大名的Arthas就是使用该机制)。
Jforgame提供了代码热更的工具,项目只需引入jforgame-doctor工具,
并把下载的jforgame-hotswap-agent.jar 文件放到jar包同级目录的agent目录下,亦可通过jforgame.hotswap.JavaDoctor#setAgentPath()方法修改agent文件的存放目录。
热更步骤:
当程序出现bug,将修复bug之后的代码进行编译,并把编译后的class文件统一放到指定目录(通过程序启动参数的scriptDir参数进行设置)。由管理后台触发HotSwapManager#reloadClass()方法即可。示例如下:
2.3.配置数据读取
项目使用jforgame-spring-boot-starter-data组件来读取策划配置的数据,可选择csv或者excel格式,推荐使用csv文件,文件小很多。
2.3.1.基本使用
直接引入jforgame-spring-boot-starter-data依赖,在application.yml增加以下配置即可
jforgame:
data:
suffix: .xlsx
location: excel/
## 配置表实体扫描目录
tableScanPath: org.game.server.game.database.config
## 二级缓存Container扫描目录
containerScanPath: org.game.server.game.database.config
例如,对于抽奖表excel文件
对应的实体定义
2.3.2.二级缓存
Container默认只存储主键/索引与实体的映射关系,如果业务需要存储一些二级缓存,只需要定义的Container子类即可,类似
import jforgame.data.Container;
import org.apache.commons.lang3.Range;
import org.game.server.game.database.config.domain.LotteryData;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
public class LotteryContainer extends Container<Integer, LotteryData> {
private Map<Integer, Range<Integer>> probs = new HashMap<>();
/**
* 最大抽奖概率
*/
private int maxProb;
@Override
public void init() {
maxProb = data.values().stream().mapToInt(LotteryData::getProb).sum();
int left = 0;
// 几何概率
// 假设记录1命中概率为10,记录2命中概率为30,记录2命中概率为60,
// 则最大概率为三者之和100
// 记录1命中范围为[0,10),记录2命中范围为[10,40),记录3命中范围为[40,100),
// 抽奖时随机取100以内的随机值,假设随机值为55
// 则从右到左遍历,发现55在记录2的范围内,则命中
// 注意:这里不能从左到右,因为Range的底层设计是左右都是闭区间
// Range r = Range.between(0,100);
// Assert.isTrue(r.contains(100));
// Assert.isTrue(r.contains(0));
for (LotteryData item : data.values()) {
int right = left + item.getProb();
Range<Integer> range = Range.between(left, right);
probs.put(item.getId(), range);
left = right;
}
}
public int randItem() {
int randNum = new Random().nextInt(maxProb);
for (Map.Entry<Integer, Range<Integer>> entry : probs.entrySet()) {
Range<Integer> range = entry.getValue();
if (range.contains(randNum)) {
return entry.getKey();
}
}
// 理论上不会跑到这里来
return 0;
}
}
2.3.3.在线热更新配置
当程序启动期间,如果希望修改配置并让其生效,可以直接通过管理后台热更新配置,
参见:
@RestController
@RequestMapping("/admin")
@Slf4j
public class AdminFacade {
@PostMapping("/reload")
public SimpleReply reload(@RequestBody ReqReload req) {
log.info("管理后台请求热更配置:参数为{}", req.getFiles());
// 使用set去重
Set<String> tables = Arrays.stream(req.getFiles().split(SplitUtil.SEMICOLON)).collect(Collectors.toSet());
List<String> succ = new ArrayList<>();
List<String> failed = new ArrayList<>();
for (String table : tables) {
try {
GameContext.dataManager.reload(table);
succ.add(table);
} catch (Exception e) {
log.error("", e);
failed.add(table);
}
}
log.info("本次热更配置,成功为[{}],失败为[{}]", JsonUtil.object2String(succ), JsonUtil.object2String(failed));
if (failed.isEmpty()) {
return SimpleReply.success(String.format("表格[%s]热更新成功", JsonUtil.object2String(succ)));
}
return SimpleReply.failed(I18nConstants.COMMON_INTERNAL_ERROR, String.format("表格[%s]热更新失败", JsonUtil.object2String(failed)));
}
}
3.基本业务
3.1.线程模型
服务器业务线程模型是指在游戏服务器中,如何组织和管理线程来处理游戏业务逻辑,尽可能减少不必要的锁竞争。
使用springmvc的同学都知道,对于客户端的http请求(不管是否是同一个用户),服务器会根据请求,均匀地轮询到线业务线程池的不同线程。
同样的线程模型,到了游戏服务器就行不通了。打个比方,如果玩家有一个道具,客户端同时发出了两个使用道具的请求,如果不考虑线程安全的话,有可能这两个请求都会验证通过,导致数据异常。
如果仍然使用轮询策略,那么对于一个简单的使用道具,业务逻辑都不得不对背包(或者其他锁对象)进行加锁,对于玩家的任何请求都要考虑加锁,对于开发效率,以及服务器性能来说都是灾难性的。
线程模型的作用在于:如何避免无效锁竞争,让程序运行得如丝般润滑。
Jforgame默认使用的线程模型为:
根据客户端链接的建立顺序,设置一个自增长的序号,玩家在整个登录过程都维持同一个序号,消息网关接收到一个消息包之后,根据session的序号,投入到指定的线程执行。如此,同一个玩家的所有消息,都只会在同一条线程执行,巧妙避免了线程锁竞争。
具体可参考DispatchThreadModel类和MessageIoDispatcher类
另外,如果业务需要添加玩家任务,可使用如下方法:
ThreadSafeUtil#addPlayerTask
3.2.消息路由
SpringMvc使用@RestController和@RequestMapping注解来映射客户端请求。类似地,jforgame使用@MessageRoute来实现消息路由,@MessageRoute注解有一个参数model,代表当前模块的id,例如任务,背包分属于两个模块。
如上图,分别申明任务模块与抽奖模块,
使用@RequestHandler注解表明消息处理器
方法的第一个参数一定为IdSession, 第二个参数一定为消息类。
方法如果申明返回值,则底层自动推送给客户端,否则,需要业务代码手动推送。
接下来看下请求消息的类定义
@Data
@MessageMeta(cmd = QuestService.CMD_REQ_QUEST_REWARD)
public class ReqTakeReward implements Message {
private int id;
}
每个消息都需要实现Message接口,并使用@MessageMeta指定cmd类型
Cmd的类型为模块内部定义,模块与cmd共同构成消息的外包类型。
例如QuestFacade的模块号为7,ReqTakeReward的cmd为1,则客户端协议号计算公式为
Module * 100 + cmd, 即701。客户端701协议号对应ReqTakeReward消息。
这里对每个模块的目录结构做一个规范建议
例如任务模块
event包:存放事件定义
facade包:存放外观类定义,包括消息路由,事件监听等
message包:存放交互协议
model包:存放model层实体
service包:业务执行
其中,facade层与message层最好按照如此模板,因为底层在启服的时候,会扫描每一个facade层的上一层的所有交互协议。参考GameMessageFactory类
3.3.数据持久化
本框架使用mysql作为持久层,项目视情况生产环境可以直接使用云mysql数据库
本框架的orm采用了SpringJpa,不考虑Mybatics以及MybaticsPlus等库,理由如下:
- 游戏服务器只在数据缓存不存在时候才会从数据库进行全量查询操作;
- 第一次生成数据才会触发insert操作;
- 大部分都是全量更新操作;
- 很少有跨表关联查询;
- 几乎可以不使用删除操作。
也就是说,游戏服务器只需要非常简单的crud命令即可满足要求。
使用SpringJpa可以借助hinbernate的自动建表加字段功能。
对于玩家的所有数据,将所有功能到放在同一张表(PlayerEnt)。
例如下面的玩家定义
这里使用JPA的@Convert注解,可以自定义表字段与实例属性的映射关系,做到直接把一个javabean存到表字段,非常方便。
3.4.数据缓存
前面说过,玩家只在缓存查找不到才会到数据库进行查询。
用户数据在第一次查询到过期之前,都保存在缓存里。业务对数据的变动,只是修改内存数据。
本项目缓存使用SpringCache,缓存实现使用了cache,相关配置如下
初始容量为100,最大容量为1000,过期时间为1h,根据项目实际情况自行修改
这里使用了caffeine进程内缓存。
关于使用进程内缓存与进程外缓存的优缺点,这里做个简单权衡说明
进程内缓存(如caffeine):简单易用,但占用游戏进程自身内存
进程外缓存(如redis):不占用进程内存,跨进程数据传输涉及io编解码消耗以及跨机器网络IO。
总结:对于小游戏这种体量小的游戏,直接使用caffeine进程内缓存即可。
对于玩家缓存,代码如下:
需要注意的是,由于缓存底层是使用代理模式,在本类的方法调用不会触发@Cacheable和@CachePut相关逻辑,因此该类不宜提供过多的方法,保留最纯粹的缓存服务即可。
3.5.事件驱动
假设这样的业务需求:游戏服务器希望在玩家升级时触发多种效果。例如玩家升级后,各种属性都会提高,开启新的系统玩法,学习新的技能等等,业务代码可能是类似这样子
可以看出,玩家升级后,所有跟升级挂钩的业务都要集中在一起,依次被处理。这样写出来的代码耦合度非常高。一旦有新的业务加入,这里就要继续插代码。
为了达到解耦的效果,我们引入了事件驱动模型。
当玩家触发了升级这个动作,我们完全可以把“升级”这个动作包装成一个事件,任何对这个事件感兴趣的“观察者”就可以捕捉并执行相应的逻辑。
项目提供了EventBus工具类,该类有两个方法,一个是同步执行,一个是异步执行。
这里有个原则,跟玩家相关的事件使用同步执行,避免线程问题;否则,考虑使用异步执行。
事件驱动在游戏里使用非常广泛,典型地,如每日重置事件,任务触发等等。
3.6.消耗与奖励
游戏业务的通用流程大致可以分成三个阶段。
- 条件:根据条件判断(例如等级)
- 消耗:门票,例如消耗金币,钻石
- 奖励:例如:经验,金币,钻石等等。
当然,三个阶段不一定都是必需的。
以VIP商城购买为例:
条件:有充值获得vip资格
消耗:不同道具消耗不等数量的钻石
奖励:对应的道具
抽象出条件、消耗与奖励之后,即使策划修改配置,只要不增加类型,都可以无需程序改代码。不同功能模块,配置还可以通用,还可以由策划自行配置多个奖励组合等。非常方便。
详细代码可参考
ConditionUtil、ConsumeUtil、RewardUtil三个类文件
全套业务工程项目私聊获取