H5游戏服务端(三)
网络游戏和单机游戏差异很大, 除了游戏逻辑代码之外还需要关注服务器状态, 最明显就是要登录授权在线|维护网络请求心跳等情况.
这里先从登录授权开始, 这里需要第三方SDK的配合, 具体流程:
- HTTP 请求第三方SDK验证授权: SDK服务端发起 OAuth 授权获取 access_token 验证之类信息
- 第三方授权后在SDK服务器生成账号: SDK服务端拿到 access_token 在数据库生成账号在 Redis 登记凭证, 最后 UID+Token 返回客户端
- 客户端接收授权码后连接游戏服务端: 客户端拿到 UID+Token 连接提交给游戏服务端, 游戏服务端验证 Redis 授权是否一致
- 游戏服务端创建玩家服务器数据实体: 读取策划配置的玩家初始化状态写入到游戏数据库之中, 这里和Web端数据库读写有些差异
- 游戏服务端装载数据实体检测心跳: 把数据实体挂载服务内存当中, 并且检测网络心跳, 这里心跳也算是思考点确定服务端还是客户端发起心跳
- 游戏客户端监听登录完成事件等待切换场景: 之前只做过 Web 端可能会被影响到认为消息请求后必定响应, 从而等待 Websocket 响应
这里有些关键点会针对说明下, 因为这里面不同人处理方式都不一样, 比如 Redis 和数据库之类并不是必选而是需要自己考虑.
问题1: 为什么游戏服务端不合并SDK服务端功能?
在现代商业化游戏之中, 应用下载平台 和 游戏AD推广 也是关键组成部分, 比如最明显 bilibili 联运平台登录时候会调度登录进入游戏;
在这过程之中第三方通讯会通知返回告诉你已经授权, 游戏服务端集成SDK功能需要提供HTTP服务, 这时候对端口发起 DDOS 会影响游戏服务端;
同时游戏广告服务需要追踪转化, 也就是需要知道玩家在点击广告到最终自己SDK服务器创建账号整体转化流程, 才能按需给广告商切换玩家流量池付费.
游戏服务端尽可能单一, 集成 Web 功能相当于引入未可知问题, 加大被网络攻击风险并拖慢进程效率, 并且SDK服务端需要频繁重启调试追加功能
问题2: 为什么要引入 Redis? Redis是必须的吗?
Redis 并不是必须的, 主要是为了通知第三方登录凭证已经授权, 你可以用 MySQL 的数据库都行, 甚至不需要数据库直接在进程挂载HashMap监听都行;
这里主要是路径依赖, 像是网络游戏需要对接后台邮件推送, 所以挂载定时游戏端检查 Redis 是否有邮件消息从而推送指定玩家上;
之前公司采用过 MongoDB 管理, 具体效果也是差不多的.
我个人设计游戏服务端不喜欢挂载HTTP服务, 更多喜欢监控 Redis|Mongo 消息队列获取其他任务调用执行, 包括后台邮件推送|强制下线黑名单等
问题2: 游戏服务端需要管理数据库吗?
网络游戏服务端不仅管理数据库的, 还必须要做好游戏分服设计, 哪怕是单个服务器要是需要区分服务器1; 因为不同第三方SDK都视玩家为自己的私域流量, 账号玩家信息不能互通交流; 而且因为不同平台注册奖励不同推送力度有所不同, 所以不能统一计算; 最明显的就是平台A生成游戏奖励码10个用来直播推广, 如果不做分服设计会出现平台B的用户拿了平台A奖励码在游戏内部使用.
另外为了加快数据实体写入到数据库方式, 后续公司普遍采用内存型数据做数据落地方向,
我比较常见看到的是游戏服务端采用 MongoDB|Redis 保存游戏数据.
必须注意: 数据库走的是网络IO的! 如果走同步写入数据库, 那请求响应时间 = 服务端连接数据库 + 数据库查询 + 业务处理 + 数据保存
问题3: 数据实体如何写入数据库?
如果接触到 skynet 服务端框架, 就会知道实体落地到数据库设计推荐采用 备份者 方式;
也就是读取的数据时候将实体挂载在内存上, 然后玩家提交操作的时候标识数据需要更新, 通过多线程更新数据写入到数据(异步写入).
这种方式巧妙利用多线程将内存实体异步保存到数据库且不会影响到业务逻辑, 读取挂载进程内存上的实体数据效率也不错.
如果想提高自己最好阅读 skynet 源码, 并且查看 lua 服务器相关逻辑样例
问题4: 心跳需要客户端还是服务端发起?
无论任何服务端编程语言都默认带有 idle 状态超时自动断开的机制, 所以也衍生心跳包保持请求活跃放置被断开的情况;
有的比较随意直接服务端定时推送个特殊消息包, 也有是客户端自己去实现定时请求特殊消息包.
这里推荐采用服务端维持心跳, 请注意 Actor 模式是没有拒绝消息的权力, 只要请求格式正确那么数据必定会推送到 Actor 消息队列;
正常如果由客户端定时发起的话确实没问题, 但是非正常客户端抓取心跳请求开始不断从客户端攻击呢?
结果就是客户端不断推送心跳把 Actor 消息队列撑爆直接让整个服务瘫痪, 所以最好心跳由服务端来接管处理.
尽可能暴露出比较少的东西给客户端处理, 服务端掌控可以更快定位问题。
问题5: 请求一定有响应吗?
这里是最需要给 Web 开发者提醒的, 因为 HTTP 协议的无状态特性导致许多 Web 以为请求服务端会顺序响应回来;
从而会出现以下代码:
// 以下是伪造代码
socket = TCP.new(); // 生成SOCKET
// 请求登录
socket.write(LOGIN); // 写入数据
message = socket.read(); // 这里服务端返回数据
// 再获取玩家信息
socket.write(PLAYER); // 获取服务端玩家信息
player = socket.read(); // 这里一定是获取玩家信息了吧, 怎么是其他数据?
这里就是以 Web 方式编写游戏服务端代码, 但是服务端并不是那样的, 实际上要不要响应/怎么响应都是由服务端决定;
你所要做到的是把这种思维赶快丢弃, 重新去理解服务端网络数据推送机制和方式.
登录认证
这里按照第一章编写的 Actor 框架, 追加授权登录模块:
@EnableActor(owner = AuthorizedLogic.class)
public class AuthorizedLogic extends ActorConfigurer {
/**
* 日志
*/
final Logger logger = LoggerFactory.getLogger(AuthorizedLogic.class);
/**
* 心跳超时
*/
final Long timeout = 15L;
/**
* 测试登录密钥
*/
private String secret;
/**
* 初始化
*
* @throws Exception Error
*/
@Override
public void init() throws Exception {
super.init();
// 注意:
// 这里本来是应该第三方认证授权后客户端提交上来 uid + secret
// 但是开发阶段并没有接入第三方授权机制, 所以先采用随机生成字符串做固定授权
secret = DigestUtils.md5DigestAsHex(UUID.randomUUID().toString().getBytes(StandardCharsets.UTF_8));
logger.warn("login secret = {}", secret);
}
/**
* 退出进程
*
* @throws Exception Error
*/
@Override
public void destroy() throws Exception {
super.destroy();
logger.warn("server quit");
}
/**
* 登录
* Example: { "value":100,"args":{ "secret": "0451d36d412830afb521fc3a3f828350", "uid": 1}}
*/
@ActorMapping(value = 100)
public void login(WebsocketApplication runtime, WebSocketSession session, JsonNode args) throws Exception {
// todo: 认证登录授权
}
}
生成 AuthorizedLogic 负责对外开放的授权 Actor 认证; 开发初版必定是没有第三方SDK接入的,
所以采用服务启动自动生成 secret 来做登录认证, 这样也能方便项目初期立项开发.
这里可以看到我底层封装的 @ActorMapping 注解, 这个注解就是封装对外暴露入口, 具体查看实现:
/**
* 声明 Actor 方法入口
*/
@Inherited
@Documented
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ActorMapping {
/**
* 对外暴露的入口协议ID
*
* @return int
*/
int value() default -1;
/**
* 对外支持的访问的状态, 默认为空, 支持多种状态 { 1,2,3 } 访问
*
* @return int[]
*/
int[] state() default {};
}
value 代表对外暴露的入口协议ID, 全局该协议ID唯一所以消息覆盖掉 Actor 实例入口;
state 则是允许被访问的状态, 这里需要理解 状态机(state machine) 概念, 简单来说访问最简单有开放|私有状态.
对于状态概念建议了解学习状态机和 HTTP 无状态相关, 状态管理基本上游戏客户端|服务端都要了解的概念
网络游戏抽象出来客户端链接必须要维护线上状态, 根据状态标识编写对应的逻辑代码:
0 - 无状态: 会话访问的唯一状态, 只能访问到开放最广泛的public级别1 - 已授权: 当第三方登录授权之后允许访问的逻辑代码, 这时候就是正式游戏游玩逻辑2 - 战斗中: 这种最多在MMO之类副本战斗情况, 防止在副本残血非法购买外部道具来回血( 拦截协议重新封包 )- ……
基本上客户端 Websocket 链接对应维护自身状态, 在生成 WebSocket 就建立个线程安全的数据哈希表:
/**
* 挂载 Websocket 服务
*/
@Order
@Component
public class WebsocketApplication extends TextWebSocketHandler {
/// 其他代码略
/**
* 玩家目前的状态管理对象 - 采用线程安全
*/
final Map<WebSocketSession, Integer> online = new ConcurrentHashMap<>();
/**
* 玩家在线 UID 标识 - 采用线程安全
*/
final Map<WebSocketSession, Long> users = new ConcurrentHashMap<>();
/**
* 切换玩家会话状态
*
* @param session 会话
* @param state 状态
*/
public void setState(WebSocketSession session, Integer state) {
if (online.containsKey(session)) {
online.put(session, state);
}
}
/**
* 设置玩家ID
*
* @param session 会话
* @param uid Long
*/
public void setUid(WebSocketSession session, Long uid) {
if (users.containsValue(uid)) {
for (Map.Entry<WebSocketSession, Long> user : users.entrySet()) {
if (user.getValue().equals(uid)) {
users.remove(user.getKey());
break;
}
}
}
users.put(session, uid);
}
/**
* Established
*
* @param session handler
* @throws Exception Error
*/
@Override
public void afterConnectionEstablished(@NonNull WebSocketSession session) throws Exception {
logger.debug("Established = {}", session);
online.put(session, 0);// 访问的时候初始化状态, 默认0代表无状态
}
/**
* Closed
*
* @param session handler
* @param status close state
* @throws Exception Error
*/
@Override
public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull CloseStatus status) throws Exception {
logger.debug("Close = {},{}", session, status);
online.remove(session); // 会话退出的时候, 直接清空哈希表对应状态
}
}
这里采用 Websocket 来做运行时管理, 并用了线程安全的哈希表容器处理, 跨 Actor 切换状态也不怕线程冲突,
最后授权逻辑代码:
/**
* 授权 Actor 实例
*/
@EnableActor(owner = AuthorizedLogic.class)
public class AuthorizedLogic extends ActorConfigurer {
/// 其他略
/**
* 心跳超时, 单位:秒
*/
private static final Long HEARTBEAT_TIMEOUT = 15L;
/**
* 授权登录接口, 这里先简单处理业务逻辑
* 可以看到这里注解 state 不声明采用默认 {}, 这代表默认该 Actor 协议是对外开放
* Example: { "value":100,"args":{ "secret": "0451d36d412830afb521fc3a3f828350", "uid": 1}}
*/
@ActorMapping(value = 100)
public void login(WebsocketApplication runtime, WebSocketSession session, JsonNode args) throws Exception {
// 没有传递JSON参数最好直接关闭掉链接防止掉无意义的嗅探
if (args == null) {
session.close();
return;
}
// 获取JSON内部参数 - UID: Long
JsonNode uidNode = args.get("uid");
if (uidNode == null || !uidNode.isNumber()) {
session.close();
return;
}
// 获取JSON内部参数 - secret: String
JsonNode secretNode = args.get("secret");
if (secretNode == null || !secretNode.isTextual()) {
session.close();
return;
}
// 判断用户提交授权码是否一致
// 这里可以考虑返回错误消息之后才中断链接, 但是后续可以自己处理优化
// TODO: 如果上线第三方SDK的时候需要在这里验证 Redis 内部授权码, 匹配完成才能切换成登录状态
if (!secret.equals(secretNode.asText())) {
session.close();
return;
}
// 切换 Websocket 到设置在线UID和在线状态
Long uid = uidNode.asLong();
runtime.setUid(session, uid);
runtime.setState(session, 1);// 切换已登录
// 获取事件容器对象, 用来管理事件
ActorEventContainer container = runtime.getContainer();
// todo: 推送到其他任务已经在线, 推送到其他 Actor 玩家已经上线, 告诉他们需要处理上线逻辑
// 验证完成, 服务端推送心跳包用来维持链接活跃
container.schedule(() -> {
try {
heartbeat(runtime, session);
} catch (IOException e) {
throw new RuntimeException(e);
}
}, HEARTBEAT_TIMEOUT, TimeUnit.SECONDS);
}
/**
* 心跳包方法, 用于玩家保持在线
*
* @param runtime 运行时
* @param session 会话
* @throws IOException Error
*/
public void heartbeat(WebsocketApplication runtime, WebSocketSession session) throws IOException {
// 如果会话关闭的话
if (!session.isOpen()) {
return;
}
// 这里推送给外部会话队列
// 这里响应代码可以自己处理, 后续优化可能需要定义全局静态码表
runtime.push(session, 100, new HashMap<>() {{
put("heartbeat", System.currentTimeMillis());
}});
// 延迟下一次递归进入该方法
runtime.getContainer().schedule(() -> {
try {
heartbeat(runtime, session);
} catch (IOException e) {
throw new RuntimeException(e);
}
}, HEARTBEAT_TIMEOUT, TimeUnit.SECONDS);
}
/**
* 这里编写授权样例, 可以看到 state 设定成只能被 {1,2,3} 其中之一访问
* 如果没有先跑 100 登录授权接口, 那么这个接口是无法被访问到的
*/
@ActorMapping(value = 111, state = {1, 2, 3})
public void tick(WebsocketApplication runtime, WebSocketSession session, JsonNode args) throws IOException {
logger.info("已被授权, 可以被访问到了");
runtime.push(session, 111, new HashMap<>() {{
put("message", "hello.world");
}});
}
}
那么授权功能已经初步处理好了, 先推送测试数据确认业务代码是否正常运行:
// 首先推送 state = {1,2,3} 才允许访问的协议, 可以确认到默认不会被调用掉
{
"value": 111,
"args": {}
}
// 之后模拟授权登录, 请求到登录接口对象, 这里还有每 15s 会推送过来的心跳包
{
"value": 100,
"args": {
"secret": "0451d36d412830afb521fc3a3f828350",
"uid": 1
}
}
// 再次访问 state = {1,2,3} 的接口, 查看服务端代码返回和IDE的打印是否符合
{
"value": 111,
"args": {}
}
现在看起来授权登录流程可以跑通了, 但是其实目前还有业务逻辑BUG, 可以暂时停下来思考有什么BUG.
这里停下来思考上面代码, 并且手动断点测试下有什么问题, 选用 Java 做开发游戏服务端的好处就是为了完美 IDEA 断点调试.
揭示答案: 那就是顶号的下线处理, 在上面代码只判断玩家已经登录并绑定 uid;
那么如果玩家用打开两个浏览器同时登录同个 uid, 那么按照逻辑直接只能有一个被登录成功.
这里需要再次修改之前 Websocket 的代码逻辑, 追加下线逻辑的代码:
/**
* 挂载 Websocket 服务
*/
@Order
@Component
public class WebsocketApplication extends TextWebSocketHandler {
/// 其他代码, 略
/**
* 玩家登录 UID 标识 - 采用线程安全
*/
final Map<WebSocketSession, Long> users = new ConcurrentHashMap<>();
/**
* 通过UID获取目前在线的会话
*
* @param uid 在线ID
* @return WebSocketSession
*/
public WebSocketSession getSession(Long uid) {
if (users.containsValue(uid)) {
// 反查出UID绑定会话句柄
for (Map.Entry<WebSocketSession, Long> user : users.entrySet()) {
if (user.getValue().equals(uid)) {
return user.getKey();
}
}
}
return null;
}
/**
* 用于推送的网络数据帧 - 这里后续会移出先处理成这样
*
* @param session 句柄
* @param message 数据
*/
public record MessageFrame(WebSocketSession session, TextMessage message) {
}
/**
* 构造方法
*
* @param container Actor events
*/
public WebsocketApplication(ActorEventContainer container) {
// 其他代码略
// 启动时候线程池追加定时服务
// 当数据队列为空的时候可以休眠下, 当 push 功能可以考虑继续唤醒定时任务
this.container.scheduleAtFixedRate(() -> {
// 获取消息队列数据
MessageFrame frame = messages.poll();
if (frame != null && frame.session.isOpen()) {
// 这里就是新的代码逻辑, 如果消息返回 null 代表需要关闭链接
try {
// 推送数据 OR 关闭会话
if (frame.message == null) {
frame.session.close();
} else {
frame.session.sendMessage(frame.message);
}
} catch (IOException e) {
logger.warn(e.getMessage());
throw new RuntimeException(e);
}
}
}, 0, 1000L, TimeUnit.MILLISECONDS);
}
/**
* 等待关闭, 推送消息队列关闭, 保证按序消息推送完关闭
*
* @param session 会话
*/
public void quit(WebSocketSession session) {
messages.add(new MessageFrame(session, null));
}
}
这里就是声明 Websocket 运行追加工具, 最后就是授权的 Actor 功能修改:
/**
* 授权 Actor 实例
*/
@EnableActor(owner = AuthorizedLogic.class)
public class AuthorizedLogic extends ActorConfigurer {
/// 其他代码, 略
/**
* 登录
* Example: { "value":100,"args":{ "secret": "0451d36d412830afb521fc3a3f828350", "uid": 1}}
*/
@ActorMapping(value = 100)
public void login(WebsocketApplication runtime, WebSocketSession session, JsonNode args) throws Exception {
// 其他代码, 略
// 切换 Websocket 到设置在线UID和在线状态
long uid = uidNode.asLong();
// 这时候就要判断玩家是否在线, 如果在线就推送强制下线并关闭之前链接
WebSocketSession owner = runtime.getSession(uid);
if (owner != null) {
// 找到会话返回消息并且强制踢下线
runtime.push(owner, 110, new HashMap<>(0));
runtime.quit(owner);
}
// 重新写入在线状态
runtime.setUid(session, uid);
runtime.setState(session, 1);// 切换已登录
// 其他代码, 略
}
}
现在测试下两个不同的 Websocket 链接同时请求登录, 会不会出现互相之间顶号下线的情况, 如果出现就代表逻辑能够跑通了;
那么现在比较粗浅的游戏服务端框架已经搭建起来了, 之后就是不断填充游戏所需的功能要素了.
实体挂载与保存
这里之前出现大量的 实体/数据实体 等概念, 关于实体可以简单理解为挂载在进程内存的类实例对象,
而数据实体可以当作 玩家|地图场景 等这些具体类实例对象, 这里简单编写个玩家实体:
/**
* 玩家实体数据
*/
public class PlayerModel implements Serializable {
/**
* 玩家第三方登录uid
*/
private Long uid;
/**
* 玩家昵称
*/
private String nickname;
/**
* 玩家游戏内金币
*/
private Integer gold;
/**
* 场景ID
*/
private Integer scene;
}
和常规 Web 设计不一样, 你能够看到 Web 方面读写数据库都是采用同步方式, 用的时候查询数据库数据, 之后改改数值重新写入到数据库这种流程;
但是这在游戏服务端是千万不能做的事, 同步读写操作本身对于多进程任务开销极大会导致占用大量线程等待数据库响应数据操作完成.
最明显的是世界BOSS这种设计, 大量玩家需要针对同一目标消耗道具|次数进行攻击扣除BOSS血量, 如果用户量过大那么同步落地造成大量数据库访问瓶颈.
所以基于这种情况, 就需要考虑直接将数据库读取时候加载到进程的 HashMap 当中, 之后所有读取数据都是只读写该 HashMap
对象而不去访问数据库内容.
这里设计个玩家模块负责玩家账号生成和数值扣减的 Actor 对象:
/**
* 玩家信息 Actor
*/
@EnableActor(owner = PlayerLogic.class)
public class PlayerLogic extends ActorConfigurer {
/**
* 日志句柄
*/
final Logger logger = LoggerFactory.getLogger(PlayerLogic.class);
/**
* 这里其实不是最终设计, 只是为了先临时挂载在进程内存当中测试业务逻辑
*/
final Map<Long, PlayerModel> players = new HashMap<>();
/**
* 初始化方法
* 这里其实应该加载测试配表, 提供给默认创建玩家信息数据
*
* @throws Exception Error
*/
@Override
public void init() throws Exception {
super.init();
// todo: 启动时加载策划配表
}
/**
* 退出方法
* 将挂载更新过的实体写入到数据库内部完成落地
*
* @throws Exception Error
*/
@Override
public void destroy() throws Exception {
super.destroy();
// todo: 退出时需要把之前变动数据写入数据库
}
/**
* 服务端内部调用的指令, 确认玩家是否存在, 不存在就读表配置生成实体
* 注意: 这里的 state 可以自己定义, 这里设为 3 用来提供给进程互相调用
*/
@ActorMapping(value = 300, state = {3})
public void check(Long uid) {
// todo: 数据库先检索出样例加载到内存当中, 如果数据库不存在副本则需要帮助先在数据库落地实体
// 这里模拟账号写入
if (!players.containsKey(uid)) {
PlayerModel player = new PlayerModel();
player.setUid(uid);
player.setNickname(String.format("玩家 - %d", uid));
player.setGold(1000);// 创建账号获取资源, 这里其实应该读取策划配表
player.setScene(0);// 默认账号应该切换客户端场景ID, 这里其实也是需要策划配表确认
players.put(uid, player);
logger.info("已经挂载玩家实体: {}", player);
}
}
/**
* 暴露给已经登录玩家接口
* 用来登录之后加载时候获取玩家信息保存本地
*/
@ActorMapping(value = 301, state = {1})
public void info(WebsocketApplication runtime, WebSocketSession session, JsonNode args) {
Long uid = runtime.getUid(session);
// todo: 提供给客户端玩家所有道具|数值|红点等信息用于加载渲染
}
}
这里先简单在内存当中生成个玩家, 并且发放玩家注册的 1000 金币初始资源, 之后就是在登录接口验证创建初始化账号:
/**
* 授权 Actor 实例
*/
@EnableActor(owner = AuthorizedLogic.class)
public class AuthorizedLogic extends ActorConfigurer {
// 其他代码略
/**
* 登录
* Example: { "value":100,"args":{ "secret": "0451d36d412830afb521fc3a3f828350", "uid": 1}}
*/
@ActorMapping(value = 100)
public void login(WebsocketApplication runtime, WebSocketSession session, JsonNode args) throws Exception {
// 其他代码, 略
// 切换 Websocket 到设置在线UID和在线状态
Long uid = uidNode.asLong();
// 其他略
// 获取全局 Actor 容器
ActorEventContainer container = runtime.getContainer();
// 通知玩家模块挂载实体数据到内存, 这就是挂载玩家信息到进程内存
ActorConfigurer playerConfigurer = container.get(300);
if (playerConfigurer != null) {
playerConfigurer.invoke(300, 3, uid);
}
// 其他代码, 略
}
}
这里实际上不应该这样挂载玩家实体, 但是目前来说仅仅作为初步服务端先这样, 后续再起另外篇章写数据库落地处理.
之后重新登录能够看到你创建玩家实体跟随挂载在进程内存中, 约等于创建自己的 扮演者(Actor) 在服务器当中;
后续就是设计自己的玩法, 从而构建出自己的在线网络游戏.