ioGame

无锁异步化、事件驱动架构设计的 java netty 网络编程框架; 轻量级,无需依赖任何第三方中间件或数据库就能支持集群、分布式; 适用于网络游戏服务器、物联网、内部系统及各种需要长连接的场景; 通过 ioGame 你可以很容易的搭建出一个集群无中心节点、集群自动化、分布式的网络服务器;fxgl netty protobuf unity3d godot cocos Creator websocket tcp socket;java netty 游戏服务器框架

AGPL-3.0 License

Stars
751
Committers
8

Bot releases are hidden (Show)

ioGame - ioGame 21.8,java 高性能游戏服务器框架;netty 高性能游戏服务器框架 Latest Release

Published by iohao 5 months ago

21.8

文档与日志


版本更新汇总

  • [light-game-room] #278 桌游类、房间类游戏的扩展模块,简化与规范化房间管理相关的、开始游戏流程相关的、玩法操作相关的相关扩展
  • [core] #290 新增广播文档构建器,简化生成广播对接文档
  • [示例集合整理] 将 SimpleExample(文档中所有功能点的示例)、SpringBootExample(综合示例)、ioGameWeb2Game(web 转游戏 - 示例理解篇)、fxglSimpleGame(移动同步 FXGL + netty)合并成一个示例项目。

[core]

#290 新增广播文档构建器,简化生成广播对接文档

下面是使用示例

public class MyLogicServer extends AbstractBrokerClientStartup {
    @Override
    public BarSkeleton createBarSkeleton() {
        // 业务框架构建器
        BarSkeletonBuilder builder = ...
        
        // 错误码、广播、推送对接文档生成
        extractedDco(builder);

        return builder.build();
    }
    
    private void extractedDco(BarSkeletonBuilder builder) {
        // 错误码
        Arrays.stream(GameCode.values()).forEach(builder::addMsgExceptionInfo);

        // UserCmd
        builder.addBroadcastDoc(BroadcastDoc.newBuilder(UserCmd.of(UserCmd.enterSquare))
                .setDataClass(SquarePlayer.class)
                .setDescription("新玩家加入房间,给房间内的其他玩家广播")
        ).addBroadcastDoc(BroadcastDoc.newBuilder(UserCmd.of(UserCmd.move))
                .setDataClass(SquarePlayerMove.class)
                .setDescription("其他玩家的移动")
        ).addBroadcastDoc(BroadcastDoc.newBuilder(UserCmd.of(UserCmd.offline))
                .setDataClass(LongValue.class)
                .setDescription("有玩家下线了。userId")
        );

        // room
        builder.addBroadcastDoc(BroadcastDoc.newBuilder(RoomCmd.of(RoomCmd.roomUpdateBroadcast))
                .setDataClass(FightRoomNotice.class)
                .setDescription("房间更新通知")
        ).addBroadcastDoc(BroadcastDoc.newBuilder(RoomCmd.of(RoomCmd.playerEnterRoomBroadcast))
                .setDataClass(FightPlayer.class)
                .setDescription("有新玩家加入房间")
        ).addBroadcastDoc(BroadcastDoc.newBuilder(RoomCmd.of(RoomCmd.enterRoom))
                .setDataClass(FightEnterRoom.class)
                .setDescription("玩家自己进入房间")
        ).addBroadcastDoc(BroadcastDoc.newBuilder(RoomCmd.of(RoomCmd.dissolveRoomBroadcast))
                .setDescription("解散房间")
        ).addBroadcastDoc(BroadcastDoc.newBuilder(RoomCmd.of(RoomCmd.quitRoom))
                .setDataClass(LongValue.class)
                .setDescription("有玩家退出房间了。userId")
        ).addBroadcastDoc(BroadcastDoc.newBuilder(RoomCmd.of(RoomCmd.ready))
                .setDataClass(PlayerReady.class)
                .setDescription("有玩家准备或取消准备了")
        ).addBroadcastDoc(BroadcastDoc.newBuilder(RoomCmd.of(RoomCmd.nextRoundBroadcast))
                .setDataClass(IntValue.class)
                .setDescription("对局开始,通知玩家开始选择。round 当前对局数")
        ).addBroadcastDoc(BroadcastDoc.newBuilder(RoomCmd.of(RoomCmd.operationBroadcast))
                .setDataClass(LongValue.class)
                .setDescription("通知其他玩家,有玩家做了选择。userId")
        ).addBroadcastDoc(BroadcastDoc.newBuilder(RoomCmd.of(RoomCmd.littleSettleBroadcast))
                .setDataClassList(FightRoundPlayerScore.class)
                .setDescription("广播玩家对局分数")
        ).addBroadcastDoc(BroadcastDoc.newBuilder(RoomCmd.of(RoomCmd.gameOverBroadcast))
                .setDescription("游戏结束")
        );
    }
}

其他扩展阅读

下面是生成后的对接文档预览

==================== 游戏文档格式说明 ====================
https://www.yuque.com/iohao/game/irth38#cJLdC

==================== FightHallAction 大厅(类似地图) ====================
路由: 1 - 1  --- 【登录】 --- 【FightHallAction:67】【loginVerify】
    方法参数: LoginVerify loginVerify 登录验证
    方法返回值: UserInfo 玩家信息
 
路由: 1 - 2  --- 【进入大厅】 --- 【FightHallAction:95】【enterSquare】
    方法参数: EnterSquare enterSquare 进入大厅
    方法返回值: ByteValueList<SquarePlayer> 所有玩家
    广播推送: SquarePlayer 新玩家加入房间,给房间内的其他玩家广播
 
路由: 1 - 4  --- 【玩家移动】 --- 【FightHallAction:131】【move】
    方法参数: SquarePlayerMove squarePlayerMove 玩家移动
    方法返回值: void 
    广播推送: SquarePlayerMove 其他玩家的移动
 
路由: 1 - 5  --- 【玩家下线】 --- 【FightHallAction:155】【offline】
    方法返回值: void 
    广播推送: LongValue 有玩家下线了。userId
 

==================== FightRoomAction  ====================
路由: 2 - 1  --- 【玩家创建新房间】 --- 【FightRoomAction:63】【createRoom】
    方法返回值: void 
 
路由: 2 - 2  --- 【玩家进入房间】 --- 【FightRoomAction:96】【enterRoom】
    方法参数: LongValue roomId      房间号
    方法返回值: void 房间信息
    广播推送: FightEnterRoom 玩家自己进入房间
 
路由: 2 - 3  --- 【玩家退出房间】 --- 【FightRoomAction:120】【quitRoom】
    方法返回值: void 
    广播推送: LongValue 有玩家退出房间了。userId
 
路由: 2 - 4  --- 【玩家准备】 --- 【FightRoomAction:146】【ready】
    方法参数: BoolValue ready       true 表示准备,false 则是取消准备
    方法返回值: void 
    广播推送: PlayerReady 有玩家准备或取消准备了
 
路由: 2 - 5  --- 【房间列表】 --- 【FightRoomAction:222】【listRoom】
    方法返回值: ByteValueList<FightRoomNotice> 房间列表
 
路由: 2 - 6  --- 【玩家在游戏中的操作】 --- 【FightRoomAction:191】【operation】
    方法参数: FightOperationCommand command     玩家操作数据
    方法返回值: void 
 
路由: 2 - 7  --- 【开始游戏】 --- 【FightRoomAction:162】【startGame】
    方法返回值: void 
 

==================== 其它广播推送 ====================
路由: 2 - 51  --- 广播推送: FightRoomNotice (房间更新通知)
路由: 2 - 50  --- 广播推送: FightPlayer (有新玩家加入房间)
路由: 2 - 52  --- 广播推送: IntValue (对局开始,通知玩家开始选择。round 当前对局数)
路由: 2 - 53  --- 广播推送: LongValue (通知其他玩家,有玩家做了选择。userId)
路由: 2 - 56  --- 广播推送: none (解散房间)
路由: 2 - 54  --- 广播推送: ByteValueList<FightRoundPlayerScore> (广播玩家对局分数)
路由: 2 - 55  --- 广播推送: none (游戏结束)
==================== 错误码 ====================
 -1008 : 绑定的游戏逻辑服不存在 
 -1007 : 强制玩家下线 
 -1006 : 数据不存在 
 -1005 : class 不存在 
 -1004 : 请先登录 
 -1003 : 心跳超时相关 
 -1002 : 路由错误 
 -1001 : 参数验错误 
 -1000 : 系统其它错误 
 1 : 玩家在房间里 
 3 : 房间不存在 
 4 : 非法操作 
 6 : 开始游戏需要的最小人数不足 
 7 : 请等待其他玩家准备 
 8 : 房间空间不足,人数已满 

[light-game-room]

room 模块相关文档 - room 桌游、房间类 (yuque.com)

#278 桌游类、房间类游戏的扩展模块,简化与规范化房间管理相关的、开始游戏流程相关的、玩法操作相关的相关扩展

light-game-room 房间,是 ioGame 提供的一个轻量小部件 - 可按需选择的模块。

light-game-room + 领域事件 + 内置 Kit = 轻松搞定桌游类游戏

该模块是桌游类、房间类游戏的解决方案。比较适合桌游类、房间类的游戏基础搭建,基于该模型可以做一些如,炉石传说、三国杀、斗地主、麻将 ...等类似的桌游。或者说只要是房间类的游戏,该模型都适用。比如,CS、泡泡堂、飞行棋、坦克大战 ...等。

如果你计划做一些桌游类的游戏,那么推荐你基于该模块做扩展。该模块遵循面向对象的设计原则,没有强耦合,可扩展性强。且帮助开发者屏蔽了很多重复性的工作,并可为项目中的功能模块结构、开发流程等进行清晰的组织定义,减少了后续的项目维护成本。

主要解决的问题与职责

桌游、房间类的游戏在功能职责上可以分为 3 大类,分别是

  1. 房间管理相关的
    1. 管理着所有的房间、查询房间列表、房间的添加、房间的删除、房间与玩家之间的关联、房间查找(通过 roomId 查找、通过 userId 查找)。
  2. 开始游戏流程相关的
    1. 通常桌游、房间类的游戏都有一些固定的流程,如创建房间、玩家进入房间、玩家退出房间、解散房间、玩家准备、开始游戏 ...等。
    2. 开始游戏时,需要做开始前的验证,如房间内的玩家是否符足够 ...等,当一切符合业务时,才是真正的开始游戏。
  3. 玩法操作相关的
    1. 游戏开始后,由于不同游戏之间的具体操作是不相同的。如坦克的射击,炉石的战前选牌、出牌,麻将的吃、碰、杠、过、胡,回合制游戏的普攻、防御、技能 ...等。
    2. 由于玩法操作的不同,所以我们的玩法操作需要是可扩展的,并用于处理具体的玩法操作。同时这种扩展方式更符合单一职责,使得我们后续的扩展与维护成本更低。

以上功能职责(房间管理相关、流程相关、玩法操作相关)属于相对通用的功能。如果每款游戏都重复的做这些工作,除了枯燥之外,还将浪费巨大的人力成本。

而当前模块则能很好的帮助开发者屏蔽这些重复性的工作,并可为项目中的功能模块结构、开发流程等进行清晰的组织定义,减少了后续的项目维护成本。更重要的是有相关文档,将来当你的团队有新进成员时,可以快速的上手。


room 实战简介

room 桌游、房间类实战(yuque.com)

文档中,我们基于该 room 模块做一个实战示例,该示例整体比较简单,多名玩家在房间里猜拳(石头、剪刀、布)得分。实战示例包括了前后端,前端使用 FXGL 引擎,这样开发者在学习时,只需 JDK 环境就可以了,而不需要安装更多的环境。启动游戏后玩家会将加入大厅(类似地图),多名玩家相互可见,并且玩家可以在大厅内移动。


[示例集合整理]

将 SimpleExample(文档中所有功能点的示例)、SpringBootExample(综合示例)、ioGameWeb2Game(web 转游戏 - 示例理解篇)、fxglSimpleGame(移动同步 FXGL + netty)合并成一个示例项目。

github gitee
ioGame 示例集合 ioGame 示例集合

21.7

文档与日志

版本更新汇总

  • [core] #112 protobuf 协议类添加检测,通过 action 构建时的监听器实现
  • [core] #272 业务框架 - 提供 action 构建时的监听回调
  • [core] #274 优化、提速 - 预生成 jprotobuf 协议类的代理,通过 action 构建时的监听器实现
  • [broker] fix #277#280 偶现 BrokerClientType 为空
  • [external] #271 游戏对外服 - 内置与可选 handler - log 相关的打印(触发异常、断开连接时)
  • [room] 简化命名: AbstractPlayer --> Player、AbstractRoom --> Room
  • 其他优化:预先生成游戏对外服统一协议的代理类及内置的协议碎片 (yuque.com)相关代理类,优化 action 参数解析

[external]

#271 游戏对外服 - 内置与可选 handler - log 相关的打印(触发异常、断开连接时)

其他参考 内置与可选的 Handler (yuque.com)

[core]

#272 业务框架 - 提供 action 构建时的监听回调

开发者可以利用 ActionParserListener 接口来观察 action 构建过程,或者做一些额外的扩展。

扩展示例参考

// 简单打印
public final class YourActionParserListener implements ActionParserListener {
    @Override
    public void onActionCommand(ActionParserContext context) {
        ActionCommand actionCommand = context.getActionCommand();
        log.info(actionCommand);
    }
}

void test() {
    BarSkeletonBuilder builder = ...;
    builder.addActionParserListener(new YourActionParserListener());
}

#112 protobuf 协议类添加检测,通过 action 构建时的监听器实现

如果当前使用的编解码器为 ProtoDataCodec 时,当 action 的参数或返回值的类没有添加 ProtobufClass 注解时(通常是忘记添加),给予一些警告提示。

// 该协议类没有添加 ProtobufClass 注解
class Bird {
    public String name;
}

@ActionController(1)
public class MyAction {
    @ActionMethod(1)
    public Bird testObject() {
        return new Bird();
    }
}

警告打印参考

======== 注意,协议类没有添加 ProtobufClass 注解 ========
class com.iohao.game.action.skeleton.core.action.Bird

#274 优化、提速 - 预生成 jprotobuf 协议类的代理,通过 action 构建时的监听器实现

如果当前使用的编解码器为 ProtoDataCodec 时,会在启动时就预先生成好 jprotobuf 协议类对应的代理类(用于 .proto 相关的 编码、解码),而不必等到用时在创建该代理类。从而达到整体优化提速的效果。

在此之前,在没做其他设置的情况下,首次访问 action 时,如果参数使用的 jprotobuf 协议类,那么在解码该参数时,会通过 ProtobufProxy.create 来创建对应的代理类(类似 .proto 相关的 编码、解码)。之后再访问时,才会从缓存中取到对应的代理类。

该优化默认开启,开发者可以不需要使用与配置跟 jprotobuf-precompile-plugin 插件相关的了。

已经预先生成的代理类有

[room]
简化命名: AbstractPlayer --> Player、AbstractRoom --> Room

其他优化

优化 action 参数解析

本次更新

属性监听特性

#264 新增属性值变更监听特性

文档 : 属性监听 (yuque.com)

属性可添加监听器,当某些属性值的发生变化时,触发监听器。

使用场景举例

比如玩家的血量低于一定值时,需要触发无敌状态;此时,我们就可以监听玩家的血量,并在该属性上添加一个对应的监听器来观察血量的变化,当达到预期值时就触发对应的业务。

类似的使用场景还有很多,这里就不过多的举例了。属性监听的特点在于属性变化后会触发监听器。

属性监听特点

  • 可为属性添加监听器,用于观察属性值的变化。
  • 属性可以添加多个监听器。
  • 属性的监听器可以移除。

框架已经内置了几个属性实现类,分别是:

  • IntegerProperty
  • LongProperty
  • StringProperty
  • BooleanProperty
  • ObjectProperty

for example - 添加监听器

BooleanProperty

当 BooleanProperty 对象的值发生改变时,触发监听器。

var property = new BooleanProperty();
// 添加一个监听器。
property.addListener((observable, oldValue, newValue) -> {
   log.info("oldValue:{}, newValue:{}", oldValue, newValue);
});

property.get(); // value is false
property.set(true); // 值变更时,将会触发监听器
property.get(); // value is true

IntegerProperty

当 IntegerProperty 对象的值发生改变时,触发监听器。

var property = new IntegerProperty();
// add listener monitor property object
property.addListener((observable, oldValue, newValue) -> {
   log.info("oldValue:{}, newValue:{}", oldValue, newValue);
});

property.get(); // value is 0
property.set(22); // When the value changes,listeners are triggered
property.get(); // value is 22

property.increment(); // value is 23. will trigger listeners

for example - 移除监听器

下面这个示例,我们将 property 初始值设置为 10,随后添加了一个监听器;当监听器观察到新值为 9 时,就从 observable 中移除自己(这个自己指的是监听器本身),而 observable 则是 IntegerProperty。

@Test
public void remove1() {
    IntegerProperty property = new IntegerProperty(10);
    // 添加一个监听器
    property.addListener(new PropertyChangeListener<>() {
        @Override
        public void changed(PropertyValueObservable<? extends Number> observable, Number oldValue, Number newValue) {
            log.info("1 - newValue : {}", newValue);

            if (newValue.intValue() == 9) {
                // 移除当前监听器
                observable.removeListener(this);
            }
        }
    });

    property.decrement(); // value 是 9,并触发监听器
    property.decrement(); // value 是 8,由于监听器已经移除,所以不会触发任何事件。
}

下面的示例中,我们定义了一个监听器类 OnePropertyChangeListener 并实现了 PropertyChangeListener 监听器接口。示例中,我们通过 OnePropertyChangeListener 对象的引用来移除监听器。

@Test
public void remove2() {
    // 监听器
    OnePropertyChangeListener onePropertyChangeListener = new OnePropertyChangeListener();
    
    // 属性
    IntegerProperty property = new IntegerProperty();
    // 添加监听器
    property.addListener(onePropertyChangeListener);

    property.increment(); // value == 1,并触发监听器
    property.removeListener(onePropertyChangeListener); // 移除监听器
    property.increment(); // value == 2,由于监听器已经移除,所以不会触发任何事件。
}

// 自定义的监听器
class OnePropertyChangeListener implements PropertyChangeListener<Number> {
    @Override
    public void changed(PropertyValueObservable<? extends Number> observable, Number oldValue, Number newValue) {
        log.info("oldValue:{}, newValue:{}, observable:{}", oldValue, newValue, observable);
    }
}

属性监听 - 小结

属性监听在使用上是简单的,如果你的业务中有关于属性变化后需要触发某些事件的,可以考虑引用该特性。框架为 int、long、boolean、Object、String 等基础类型提供了对应的属性监听。

属性监听特性支持添加多个监听器,支持移除监听器。

模拟客户端相关

模拟客户端新增与服务器断开连接的方法。

模拟客户端新增是否活跃的状态属性。

ClientUser clientUser = ...;
// 是否活跃,true 表示玩家活跃
clientUser.isActive();
// 关闭模拟客户端连接
clientUser.getClientUserChannel().closeChannel();

获取游戏对外服的数据与扩展相关

文档 获取游戏对外服的数据与扩展 (yuque.com)

RequestCollectExternalMessage 增加 userId 字段。

#265 模拟玩家请求时 - 从游戏对外服中获取在线玩家相关数据

新增 UserHeadMetadataExternalBizRegion,从用户(玩家)所在游戏对外服中获取用户自身的数据,如用户所绑定的游戏逻辑服、元信息 ...等

使用参考

@Slf4j
@RestController
@RequestMapping("other")
public class OtherController {
    static final AtomicLong msgId = GameManagerController.msgId;
    /** 为了方便测试,这里指定一个 userId 来模拟玩家 */
    static final long userId = GameManagerController.userId;

    @GetMapping("/notice")
    public String notice() {
        log.info("other notice");
        // 使用协议碎片特性 https://www.yuque.com/iohao/game/ieimzn
        StringValue data = StringValue.of("other GM web msg " + msgId.incrementAndGet());
        // 模拟请求 : 路由 - 业务数据
        RequestMessage requestMessage = BarMessageKit.createRequestMessage(ExchangeCmd.of(ExchangeCmd.notice), data);

        // 设置需要模拟的玩家
        HeadMetadata headMetadata = requestMessage.getHeadMetadata();
        headMetadata.setUserId(userId);

        // 从游戏对外服中获取一些用户(玩家的)自身的数据,如元信息、所绑定的游戏逻辑服 ...等
        Optional<HeadMetadata> headMetadataOptional = ExternalCommunicationKit.employHeadMetadata(requestMessage);

        if (headMetadataOptional.isPresent()) {
            // 发起模拟请求
            extractedRequestLogic(requestMessage);

            // 打印从游戏对外服获取的元信息
            byte[] attachmentData = headMetadata.getAttachmentData();
            ExchangeAttachment attachment = DataCodecKit.decode(attachmentData, ExchangeAttachment.class);
            return "other notice 玩家的元信息: %s - %s".formatted(attachment, msgId.get());
        } else {
            return "other notice 玩家 %s 不在线,无法获取玩家的元信息 - %s".formatted(userId, msgId.get());
        }
    }

    private void extractedRequestLogic(RequestMessage requestMessage) {
        // 向逻辑服发送请求,该模拟请求具备了玩家的元信息。
        BrokerClient brokerClient = MyKit.brokerClient;
        InvokeModuleContext invokeModuleContext = brokerClient.getInvokeModuleContext();
        invokeModuleContext.invokeModuleVoidMessage(requestMessage);
    }
}

任务工具相关

TaskListener 接口增加异常回调方法 void onException(Throwable e),用于接收异常信息;当 triggerUpdate 或 onUpdate 方法抛出异常时,将会传递到该回调方法中。

@Test
public void testException() throws InterruptedException {
    AtomicBoolean hasEx = new AtomicBoolean(false);
    TaskKit.runOnce(new OnceTaskListener() {
        @Override
        public void onUpdate() {
            // 模拟一个业务异常
            throw new RuntimeException("hello exception");
        }

        @Override
        public void onException(Throwable e) {
            hasEx.set(true);
            // 触发异常后,将来到这里
            log.error(e.getMessage(), e);
        }
    }, 10, TimeUnit.MILLISECONDS);

    TimeUnit.MILLISECONDS.sleep(200);
    Assert.assertTrue(hasEx.get()); // true
}

业务框架相关 - [common-core]

#266 新增 RangeBroadcast 范围内的广播功能,这个范围指的是,可指定某些用户进行广播。

在执行广播前,开发者可以自定义业务逻辑,如

  • 添加一些需要广播的用户
  • 删除一些不需要接收广播的用户
  • 可通过重写 logic、trick 方法来做一些额外扩展

for example
在某些业务场景下,能让语义更清晰

// example - 1
new RangeBroadcast(flowContext)
        // 需要广播的数据
        .setResponseMessage(responseMessage)
        // 添加需要接收广播的用户
        .addUserId(1)
        .addUserId(2)
        .addUserId(List.of(3L, 4L, 5L))
        // 排除一些用户,被排除的用户将不会接收到广播
        .removeUserId(1)
        // 执行广播
        .execute();

// example - 2
new RangeBroadcast(flowContext)
        // 需要广播的数据
        .setResponseMessage(cmdInfo, playerReady)
        // 添加需要接收广播的用户
        .addUserId(1)
        // 执行广播
        .execute();

[light-game-room] 房间模块

移除 AbstractRoom broadcast 系列方法,开发者可使用 RoomBroadcastFlowContext 接口实现旧的兼容。

移除 AbstractRoom createSend 方法,开发者可使用 ofRangeBroadcast 系列来代替。AbstractRoom 新增 RoomBroadcastEnhance,实现房间内的广播增强,该系列在语义上更清晰。

这里使用一个准备就绪的示例来演示,当有玩家准备时,需要通知房间内的其他玩家

final RoomService roomService = ...;

@ActionMethod(RoomCmd.ready)
public void ready(boolean ready, FlowContext flowContext) {
    long userId = flowContext.getUserId();
    // 得到玩家所在的房间
    AbstractRoom room = this.roomService.getRoomByUserId(userId);
    
    // 准备
    PlayerReady playerReady = new PlayerReady();
    playerReady.userId = userId;
    playerReady.ready = ready;
  
    // 通知房间内的所有玩家,有玩家准备或取消准备了
    room.ofRangeBroadcast(flowContext)
            // 响应数据(路由、业务数据)
            .setResponseMessage(flowContext.getCmdInfo(), playerReady)
            .execute();
}

// 准备或取消准备
@ProtobufClass
@FieldDefaults(level = AccessLevel.PUBLIC)
public class PlayerReady {
    /** 当前操作的玩家 userId */
    long userId;
    /** true 表示准备 */
    boolean ready;
}

AbstractRoom 增加 ifPlayerExist、ifPlayerNotExist 方法。

ifPlayerExist 方法

如果玩家在房间内,就执行给定的操作,否则不执行任何操作。

这里演示玩家加入房间的业务

RoomService roomService = ...;
AbstractRoom room = ...;
// 如果玩家不在房间内,就创建一个玩家,并让玩家加入房间
room.ifPlayerNotExist(userId, () -> {
    // 玩家加入房间
    FightPlayerEntity newPlayer = new FightPlayerEntity();
    newPlayer.setId(userId);
    
    this.roomService.addPlayer(room, newPlayer);
});

ifPlayerNotExist 方法

如果玩家不在房间内,就执行给定的操作,否则不执行任何操作。

这里结合 RangeBroadcast,演示当有新玩家加入房间时,通知房间内的其他玩家。

AbstractRoom room = ...;
// 有新玩家加入房间,通知其他玩家
room.ifPlayerExist(userId, (FightPlayerEntity playerEntity) -> {
    FightPlayer fightPlayer = FightMapstruct.ME.convert(playerEntity);
    room.ofRangeBroadcast(flowContext)
            .setResponseMessage(RoomCmd.of(RoomCmd.playerEnterRoomBroadcast), fightPlayer)
            // 排除不需要通知的玩家(当前 userId 是自己)
            .removeUserId(userId)
            .execute();
});

主要更新

  1. #255 关于 Proto 生成排除属性问题
/**
 * 动物
 */
@ProtobufClass
@FieldDefaults(level = AccessLevel.PUBLIC)
public class Animal {
    /** id */
    int id;
    /** 动物类型 - 枚举测试 */
    AnimalType animalType;
  	/** 年龄 - 忽略的属性*/
    @Ignore
    String age;
}

生成后的 .proto

// 动物
message Animal {
  // id
  int32 id = 1;
  // 动物类型 - 枚举测试
  AnimalType animalType = 2;
}
  1. 增强 ClassScanner 类

  2. 优化模拟客户端

  3. #258 文档生成,兼容 gradle 编译路径

  4. enhance jprotobuf,临时解决打包后不能在 linux java21 环境运行的问题,see java21,springBoot3.2 打 jar 后使用异常 · Issue #211 · jhunters/jprotobuf (github.com)

  5. 生成 .proto 时,在最后打印文件路径

#253

CreateRoomInfo.createUserId int --> long

文档生成时,默认指定 StandardCharsets.UTF_8

javaProjectBuilder.setEncoding(StandardCharsets.UTF_8.name());

玩家下线时,使用自身所关联的线程处理。

SocketUserSessions removeUserSession

ExecutorRegion

1 优化默认创建策略

2 优化 ExecutorRegionKit,SimpleThreadExecutorRegion 默认使用全局单例,减少对象的创建。

ioGame - java 游戏服务器框架 netty ioGame 21.3

Published by iohao 7 months ago

#250

游戏对外服 - 自定义编解码 - WebSocketMicroBootstrapFlow

文档 游戏对外服-自定义编解码 (yuque.com)

重写 WebSocketMicroBootstrapFlow createExternalCodec 方法,用于创建开发者自定义的编解码,其他配置则使用 pipelineCodec 中的默认配置。

DefaultExternalServerBuilder builder = ...;

builder.setting().setMicroBootstrapFlow(new WebSocketMicroBootstrapFlow() {
    @Override
    protected MessageToMessageCodec<BinaryWebSocketFrame, BarMessage> createExternalCodec() {
        // 开发者自定义的编解码实现类。
        return new YourWsExternalCodec();
    }
});

以下展示的是 WebSocketMicroBootstrapFlow pipelineCodec 相关代码

public class WebSocketMicroBootstrapFlow extends SocketMicroBootstrapFlow {
    ... 省略部分代码
    @Override
    public void pipelineCodec(PipelineContext context) {
        // 添加 http 相关 handler
        this.httpHandler(context);

        // 建立连接前的验证 handler
        this.verifyHandler(context);

        // 添加 websocket 相关 handler
        this.websocketHandler(context);

        // websocket 编解码
        var externalCodec = this.createExternalCodec();
        context.addLast("codec", externalCodec);
    }

    @Override
    protected MessageToMessageCodec<BinaryWebSocketFrame, BarMessage> createExternalCodec() {
        // createExternalCodec 相当于一个钩子方法。
        return new WebSocketExternalCodec();
    }
};

#249
将集群启动顺序放到 Broker(游戏网关)之后。

集群增减和逻辑服 Connect 增减使用同一线程处理。

IoGameGlobalConfig brokerClusterLog 集群相关日志不开启。

ioGame 简单介绍

你是否想要开发一个高性能、稳定、易用、自带负载均衡、避免类爆炸设计、可跨进程跨机器通信、集群无中心节点、集群自动化、有状态多进程的分布式的网络编程服务器呢?如果是的话,这里向你推荐一个由 java 语言编写的网络编程框架 ioGame。下面将会从多个方面来对框架做一些简单的介绍。

ioGame 是一个轻量级的网络编程框架,适用于网络游戏服务器、物联网、内部系统及各种需要长连接的场景;

ioGame 有以下特点:

  • 无锁异步化、事件驱动的架构设计
  • 同时支持 TCP、WebSocket、UDP 多种连接方式,并且可扩展
  • 支持 protobuf、json 等不同的通信协议
  • 集群无中心节点、集群自动化、分布式的设计
  • 真轻量级,不依赖任何第三方中间件或数据库就能支持集群、分布式
  • 提供多种通讯方式,且逻辑服之间可以相互跨机器通信
  • 框架为开发者提供了同步、异步、异步回调的方法,用于逻辑服之间的相互访问
  • ioGame 是纯 javaSE 的,使得 ioGame 能与其他框架方便的进行集成、融合;如 spring ...等
  • 学习成本低,开发体验好
  • 支持多服单进程、多服多进程的启动和部署方式
  • 提供游戏文档生成的辅助功能
  • 包体小、启动快、内存占用少
  • 提供优雅的路由访问权限控制
  • 提供了灵活的线程扩展、设置
  • 具备智能的同进程亲和性
  • 具备全链路调用日志跟踪特性
  • 业务框架提供了插件机制,插件是可插拨、可扩展的
  • JSR380验证、断言 + 异常机制 = 更少的维护成本
  • action 支持自动装箱、拆箱基础类型,用于解决协议碎片的问题

ioGame 是一个专为网络编程设计的轻量级框架,它可以帮助你快速地搭建和运行自己的网络服务器。ioGame 适用于网络游戏服务器、物联网、内部系统及各种需要长连接的场景。如各种类型和规模的网络游戏,无论是 H5、手游还是 PC 游戏,无论是简单的聊天室,还是复杂的全球同服、回合制游戏、策略游戏、放置休闲游戏、即时战斗、MMORPG 等,ioGame 都可以满足你的需求。

ioGame 在打包、内存占用、启动速度等方面也是优秀的。打 jar 包后大约 15MB,应用通常会在 0.x 秒内完成启动,内存占用小。详细请看 快速从零编写服务器完整示例

在生态融合方面,ioGame 可以很方便的与 spring 集成(5 行代码);除了 spring 外,还能与任何其他的框架做融合,如:solon ... 等,从而使用其他框架的相关生态。

ioGame 在架构上解决了传统框架所产生的 N*N 问题与传统架构对比)。传统架构在扩展机器时,需要借助很多第三方中间件,如:Redis、MQ、ZooKeeper ...等,才能满足整体架构的运作。通常,只要引入了需要安装的中间件才能做到扩展的,那么你的架构或者说框架,基本上与轻量级无缘了。

在轻量级方面,ioGame 不依赖任何第三方中间件或数据库就能支持集群、分布式,只需要 java 环境就可以运行。这意味着在使用上简单了,在部署上也为企业减少了部署成本、维护难度。使用 ioGame 时,只需一个依赖即可获得整个框架,而无需安装其他服务,如: Nginx、Redis、MQ、Mysql、ZooKeeper、Protobuf 协议编译工具 ...等。

ioGame 具备全链路调用日志跟踪特性,这在分布式下非常的实用。该特性为每个请求分配一个唯一标识,并记录在日志中,通过唯一标识可以快速的在日志中过滤出指定请求的信息。ioGame 提供的全链路调用日志跟踪特性更是强大,支持跨机器、跨进程。简单的说,从玩家的请求进来到结束,无论该请求经过了多少个游戏逻辑服,都能精准记录。

在通讯方式方面,大部分框架只能支持推送(广播)这一类型的通讯方式;ioGame 则提供了多种类型的通讯方式,通过对各种通讯方式的组合使用,可以简单完成以往难以完成的工作,并且这些通讯方式都支持跨进程、跨机器通信,且具备全链路调用日志跟踪。这些通讯方式分别是

  1. 请求响应(单次请求处理)
  2. 广播(推送)
  3. 单个逻辑服间的相互通讯(可跨机器通信、可跨进程通信)
  4. 与同类型多个逻辑服相互通讯(可跨多个机器通信、可跨多个进程通信)
  5. 脉冲通讯(可跨多个机器通信、可跨多个进程通信)
  6. 分布式事件总线(类似 MQ、Redis 发布订阅机制;可跨多个机器通信、可跨多个进程通信)

在编码风格上,ioGame 为开发者提供了类 MVC 的编码风格(无入侵的 Java Bean ),这种设计方式很好的避免了类爆炸。同时,框架为开发者提供了同步、异步、异步回调的方法,用于逻辑服之间的相互访问;这使得开发者所编写的代码会非常的优雅,并且具备全链路调用日志跟踪。

从 ioGame21 开始,框架添加了虚拟线程的相关支持。各逻辑服之间通信阻塞部分使用虚拟线程,这样可以很好的避免阻塞业务线程,并大幅提高了框架的吞吐量。

在线程安全方面,框架为开发者解决了单个玩家的并发问题;即使玩家重新登录后,也会使用相同的线程来消费业务,并推荐使用领域事件来解决同一房间或业务内多个玩家的并发问题。框架在线程的扩展性上提供了友好的支持,开发者可以很容易的编写出无锁并发代码,这得益于 ioGame 独有的线程执行器设计与扩展。换句话说,你不会因为并发问题烦恼。

在无锁并发方面,ioGame 提供了优雅、独特的线程执行器设计。通过该特性,开发者能轻易的编写出无锁高并发的代码。

在连接方式方面,ioGame 允许开发者使用一套业务代码同时支持多种连接方式,无需进行任何修改。ioGame 已经支持了 TCP、WebSocket 和 UDP 连接方式,并且也支持在这几种连接方式之间进行灵活切换。连接方式是可扩展的,并且扩展操作也很简单,这意味着之后如果支持了 KCP,无论你当前项目使用的是 TCP、WebSocket 还是 UDP,都可以切换成 KCP;注意了,即使切换到 KCP 的连接方式,现有的业务代码也无需改变。

在通信协议方面,ioGame 让开发者用一套业务代码,就能轻松切换和扩展不同的通信协议,如 Protobuf、JSON 等。只需一行代码,就可以从 Protobuf 切换到 JSON,无需改变业务方法。

在增减协议方面,ioGame 可以让你在新增或减少协议时,无需重启游戏对外服与 Broker(游戏网关);这样既能避免玩家断线,又能避免因新增、减少协议而重启所有机器的痛点。

在协议碎片方面,action 支持自动装箱、拆箱基础类型特性,用于解决协议碎片的问题。同时该特性除了能使你的业务代码更加清晰以外,还能大幅提高开发者在该环节的生产力。

在集群方面,ioGame 的 Broker (游戏网关)采用无中心节点、自动化的集群设计,所有节点平等且自治,不存在单点故障。集群能够自动管理和弹性扩缩,节点加入或退出时,能够自动保证负载均衡和数据一致性,不影响服务可用性。

在分布式方面,ioGame 的逻辑服使用了分布式设计思想,将服务器分为游戏对外服游戏逻辑服等不同层次,并且每一层都有明确的职责和接口。这样可以提高代码可读性和可维护性,并且方便进行水平扩展

在学习成本方面,ioGame 的学习成本非常低,可以说是零学习成本,即使没有游戏编程经验,也能轻松上手。开发者只需掌握普通的 java 方法或 webMVC 相关知识,就能用框架开发业务。框架不要求开发者改变编码习惯,而是自身适应开发者的需求。

同进程亲和性方面,在同一进程内,不同 Netty 实例之间的通信,是通过内存进行传输的,不需要经过网络传输,数据传输速度极快。同进程亲和性指的是,优先访问同进程内的游戏逻辑服,当同进程内没有能处理请求的游戏逻辑服时,才会去其他进程或机器中查找能处理请求的游戏逻辑服;简单点说,框架对于请求的处理很智能,会优先将请求给同进程内的逻辑服消费。

在开发体验方面,ioGame 非常注重开发者的开发体验;框架提供了 JSR380 验证断言 + 异常机制业务代码定位,action 支持自动装箱、拆箱基础类型,用于解决协议碎片的问题 ...等。诸多丰富的功能,使得开发者的业务代码更加的清晰、简洁;

业务框架提供了插件机制,插件是可插拨、可扩展的。框架内置提供了 DebugInOutaction 调用统计业务线程监控插件各时间段调用统计插件...等插件;不同的插件提供了不同的关注点,比如我们可以使用调用、监控等插件相互配合,可以让我们在开发阶段就知道是否存在性能问题。合理利用好各个插件,可以让我们在开发阶段就能知道问题所在,提前发现问题,提前预防问题。

在分布式开发体验方面,通常在开发分布式应用时是需要启动多个进程的。这会让调试与排查问题变得非常困难,从而降低开发者的效率、增加工作量等,这也是很多框架都解决不了的问题,但 ioGame 做到了!ioGame 支持多服单进程的启动方式,这使得开发者在开发和调试分布式系统时更加简单。

与前端对接联调方面,ioGame 提供了游戏文档生成的辅助功能,可以做到代码即对接文档。简单地说,当业务代码编写完后,框架会自动生成最新的文档。如果没有游戏文档的生成,那么你将要抽出一些时间来编写、维护对接文档的工作,而且当团队人数多了之后,文档就会很乱、不同步、不是最新的、忘记更新等情况就会出现。

在部署方面,ioGame 支持多服单进程的方式部署,也支持多服多进程多机器的方式部署;在部署方式上可以随意的切换而不需要更改代码。日常中我们可以按照单体思维开发,到了生产可以选择使用多进程的方式部署。

在安全方面,所有的游戏逻辑服不需要开放端口,天然地避免了扫描攻击。由于不需要为每个逻辑服分配独立的端口,那么我们在使用诸如云服务器之类的服务时,就不需要担心端口开放权限的问题了。别小看这一个环节,通常这些小细节最浪费开发者的时间。由于我们不需要管理这些 IP:Port,这部分的工作量就自然地消失了

在模拟客户端测试方面,ioGame 提供了压测&模拟客户端请求模块。此模块是用于模拟客户端,简化模拟工作量,只需要编写对应请求与回调。除了可以模拟简单的请求外,通常还可以做一些复杂的请求编排,并支持复杂业务的压测。与单元测试不同的是,该模块可以模拟真实的网络环境,并且在模拟测试的过程中与服务器的交互是可持续的、可互动的,同时也是支持自动化的

架构灵活性方面,ioGame 的架构由三部分组成:1.游戏对外服、2.Broker(游戏网关)、3.游戏逻辑服;三者既可相互独立,又可相互融合。这意味着使用 ioGame 可以适应任何类型的游戏,因为只需通过调整部署方式,就可以满足不同类型的游戏需求。在 ioGame 中进行这些调整工作非常简单,而且不会对现有代码产生不良影响。

开发者基于 ioGame 编写的项目模块,通常是条理清晰的,得益于框架对路由的合理设计,同时也为路由提供了优雅的访问权限控制。当我们整理好这些模块后,对于其他开发者接管项目或后续的维护中,会是一个不错的帮助(模块的整理与建议)。或许现阶段你感受不到这块的威力,随着你深入地使用实践就能体会到这么设计的诸多好处与优势。

开发者基于 ioGame 编写的项目,通常是语法简洁的、高性能的、低延迟的。框架最低要求使用 JDK21,这样即可以让项目享受到分代 ZGC 带来的改进,还能享受语法上的简洁。分代 ZGC 远低于其亚毫秒级暂停时间的目标,可以在不影响游戏速度的情况下,清理掉多余的内存;这样就不会出现卡顿或者崩溃的问题了,相当于在项目中变相的引入了一位 JVM 调优大师。

综上所述,ioGame 是一个非常适合网络游戏开发的框架。可以让你轻松地创建高性能、低延迟、易扩展的游戏服务器,并且节省时间和资源。如果你想要快速地开发出令人惊艳的网络游戏,请不要犹豫,立即选择 ioGame 吧!框架屏蔽了很多复杂且重复性的工作,并可为项目中的功能模块结构、开发流程等进行清晰的组织定义,减少了后续的项目维护成本。

框架在开发、部署、压测&模拟测试 ...等,各个阶段都提供了很好的支持。相信你已经对 ioGame 有了一个初步的了解,虽然还有很多丰富的功能与特性没有介绍到,但你可以通过后续的实践过程中来深入了解。感谢你的阅读,并期待你使用 ioGame 来打造自己的游戏服务器。

ioGame - ioGame21 网络编程框架发布,史诗级增强

Published by iohao 8 months ago

框架版本更新日志 (yuque.com)

相关示例仓库已同步更新,升级过程可参考示例仓库。

ioGame21 首发计划

功能支持 完成 描述 issu
游戏对外服开放自定义协议 功能增强 #213
游戏对外服缓存 功能增强、性能提升 #76
FlowContext 增加通信能力,提供同步、异步、异步回调的便捷使用 功能增强 #235
虚拟线程支持; 各逻辑服之间通信阻塞部分,改为使用虚拟线程, 避免阻塞业务线程 功能增强、性能提升
默认不使用 bolt 线程池,减少上下文切换。 ioGame17:netty --> bolt 线程池 --> ioGame 线程池。 ioGame21: 1. netty --> ioGame 线程池。 2. 部分业务将直接在 netty 线程中消费业务。 性能提升
全链路调用日志跟踪;日志增强 traceId 功能增强 #230
移除文档自动生成,改为由开发者调用触发。 整理
移除过期代码 整理 #237
分布式事件总线 可以代替 redis pub sub 、 MQ ,并且具备全链路调用日志跟踪,这点是中间件产品做不到的。 功能增强 #228
日志库使用新版本 slf4j 2.0
Fury 支持。 Fury 是一个基于JIT动态编译和零拷贝的高性能多语言序列化框架 观望中 在计划内,不一定会支持 因在发布 ioGame21 时,Fury 还未发布稳定版本,所以这里暂不支持。
心跳响应前的回调 功能增强 #234
FlowContext 增加更新、获取元信息的便捷使用 功能增强 #236

ioGame21 首发内容简介

在 ioGame21 中,该版本做了数百项优化及史诗级增强。

  • 文档方面
  • 线程管理域方面的开放与统一、减少线程池上下文切换
  • FlowContext 增强
  • 新增通讯方式 - 分布式事件总线
  • 游戏对外服方面增强
  • 全链路调用日志跟踪
  • 各逻辑服之间通信阻塞部分,改为使用虚拟线程, 避免阻塞业务线程,从而使得框架的吞吐量得到了巨大的提升。

游戏对外服相关

#76 游戏对外服缓存

更多的介绍,请阅读游戏对外服缓存文档。

游戏对外服缓存,可以将一些热点的业务数据缓存在游戏对外服中,玩家每次访问相关路由时,会直接从游戏对外服的内存中取数据。这样可以避免反复请求游戏逻辑服,从而达到性能的超级提升;

private static void extractedExternalCache() {
    // 框架内置的缓存实现类
    DefaultExternalCmdCache externalCmdCache = new DefaultExternalCmdCache();
    // 添加到配置中
    ExternalGlobalConfig.externalCmdCache = externalCmdCache;
    // 配置缓存 3-1
    externalCmdCache.addCmd(3, 1);
}

#213 游戏对外服开放自定义协议

更多的介绍,请阅读对外服的协议说明文档。

开发者可自定义游戏对外服协议,用于代替框架默认的 ExternalMessage 公共对外协议。

#234 心跳响应前的回调

更多的介绍,请阅读心跳设置与心跳钩子文档。

在部分场景下,在响应心跳前可添加当前时间,使得客户端与服务器时间同步。

@Slf4j
public class DemoIdleHook implements SocketIdleHook {
    ... ... 省略部分代码
    volatile byte[] timeBytes;

    public DemoIdleHook() {
        updateTime();
        // 每秒更新当前时间
        TaskKit.runInterval(this::updateTime, 1, TimeUnit.SECONDS);
    }

    private void updateTime() {
        LongValue data = LongValue.of(TimeKit.currentTimeMillis());
        // 避免重复序列化,这里提前序列化好时间数据
        timeBytes = DataCodecKit.encode(data);
    }

    @Override
    public void pongBefore(BarMessage idleMessage) {
        // 把当前时间戳给到心跳接收端
        idleMessage.setData(timeBytes);
    }
}

FlowContext

#235 FlowContext 增加通信能力,提供同步、异步、异步回调的便捷使用

更多的介绍,请阅读 FlowContext 文档。

// 跨服请求 - 同步、异步回调演示
void invokeModuleMessage() {
    // 路由、请求参数
    ResponseMessage responseMessage = flowContext.invokeModuleMessage(cmdInfo, yourData);
    RoomNumMsg roomNumMsg = responseMessage.getData(RoomNumMsg.class);
    log.info("同步调用 : {}", roomNumMsg.roomCount);

    // --- 此回调写法,具备全链路调用日志跟踪 ---
    // 路由、请求参数、回调
    flowContext.invokeModuleMessageAsync(cmdInfo, yourData, responseMessage -> {
        RoomNumMsg roomNumMsg = responseMessage.getData(RoomNumMsg.class);
        log.info("异步回调 : {}", roomNumMsg.roomCount);
    });
}

// 广播
public void broadcast(FlowContext flowContext) {
    // 全服广播 - 路由、业务数据
    flowContext.broadcast(cmdInfo, yourData);

    // 广播消息给单个用户 - 路由、业务数据、userId
    long userId = 100;
    flowContext.broadcast(cmdInfo, yourData, userId);

    // 广播消息给指定用户列表 - 路由、业务数据、userIdList
    List<Long> userIdList = new ArrayList<>();
    userIdList.add(100L);
    userIdList.add(200L);
    flowContext.broadcast(cmdInfo, yourData, userIdList);

    // 给自己发送消息 - 路由、业务数据
    flowContext.broadcastMe(cmdInfo, yourData);

    // 给自己发送消息 - 业务数据
    // 路由则使用当前 action 的路由。
    flowContext.broadcastMe(yourData);
}

#236 FlowContext 增加更新、获取元信息的便捷使用

更多的介绍,请阅读 FlowContext 文档。

void test(MyFlowContext flowContext) {
    // 获取元信息
    MyAttachment attachment = flowContext.getAttachment();
    attachment.nickname = "渔民小镇";

    // [同步]更新 - 将元信息同步到玩家所在的游戏对外服中
    flowContext.updateAttachment();

    // [异步无阻塞]更新 - 将元信息同步到玩家所在的游戏对外服中
	flowContext.updateAttachmentAsync();
}

public class MyFlowContext extends FlowContext {
    MyAttachment attachment;

    @Override
    @SuppressWarnings("unchecked")
    public MyAttachment getAttachment() {
        if (Objects.isNull(attachment)) {
            this.attachment = this.getAttachment(MyAttachment.class);
        }

        return this.attachment;
    }
}

线程相关

更多的介绍,请阅读 ioGame 线程相关文档。

虚拟线程支持,各逻辑服之间通信阻塞部分使用虚拟线程来处理,避免阻塞业务线程。

默认不使用 bolt 线程池,减少上下文切换。ioGame21 业务消费的线程相关内容如下:

  1. netty --> ioGame 线程池。
  2. 部分业务将直接在 netty 线程中消费业务。

在 ioGame21 中,框架内置了 3 个线程执行器管理域,分别是

  1. UserThreadExecutorRegion ,用户线程执行器管理域。
  2. UserVirtualThreadExecutorRegion ,用户虚拟线程执行器管理域。
  3. SimpleThreadExecutorRegion ,简单的线程执行器管理域。

从工具类中得到与用户(玩家)所关联的线程执行器

@Test
public void userThreadExecutor() {
    long userId = 1;

    ThreadExecutor userThreadExecutor = ExecutorRegionKit.getUserThreadExecutor(userId);

    userThreadExecutor.execute(() -> {
        // print 1
        log.info("userThreadExecutor : 1");
    });

    userThreadExecutor.execute(() -> {
        // print 2
        log.info("userThreadExecutor : 2");
    });
}

@Test
public void getUserVirtualThreadExecutor() {
    long userId = 1;

    ThreadExecutor userVirtualThreadExecutor = ExecutorRegionKit.getUserVirtualThreadExecutor(userId);

    userVirtualThreadExecutor.execute(() -> {
        // print 1
        log.info("userVirtualThreadExecutor : 1");
    });

    userVirtualThreadExecutor.execute(() -> {
        // print 2
        log.info("userVirtualThreadExecutor : 2");
    });
}

@Test
public void getSimpleThreadExecutor() {
    long userId = 1;

    ThreadExecutor simpleThreadExecutor = ExecutorRegionKit.getSimpleThreadExecutor(userId);

    simpleThreadExecutor.execute(() -> {
        // print 1
        log.info("simpleThreadExecutor : 1");
    });

    simpleThreadExecutor.execute(() -> {
        // print 2
        log.info("simpleThreadExecutor : 2");
    });
}

从 FlowContext 中得到与用户(玩家)所关联的线程执行器

void executor() {
    // 该方法具备全链路调用日志跟踪
    flowContext.execute(() -> {
        log.info("用户线程执行器");
    });

    // 正常提交任务到用户线程执行器中
    // getExecutor() 用户线程执行器
    flowContext.getExecutor().execute(() -> {
        log.info("用户线程执行器");
    });
}

void executeVirtual() {
    // 该方法具备全链路调用日志跟踪
    flowContext.executeVirtual(() -> {
        log.info("用户虚拟线程执行器");
    });

    // 正常提交任务到用户虚拟线程执行器中
    // getVirtualExecutor() 用户虚拟线程执行器
    flowContext.getVirtualExecutor().execute(() -> {
        log.info("用户虚拟线程执行器");
    });

    // 示例演示 - 更新元信息(可以使用虚拟线程执行完成一些耗时的操作)
    flowContext.executeVirtual(() -> {
        log.info("用户虚拟线程执行器");
        
        // 更新元信息
        flowContext.updateAttachment();
        
        // ... ... 其他业务逻辑
    });
}

日志相关

日志库使用新版本 slf4j 2.x

#230 支持全链路调用日志跟踪;

更多的介绍,请阅读全链路调用日志跟踪文档。

开启 traceId 特性

该配置需要在游戏对外服中设置,因为游戏对外服是玩家请求的入口。

// true 表示开启 traceId 特性
IoGameGlobalConfig.openTraceId = true;

全链路调用日志跟踪插件 TraceIdInOut 添加到业务框架中,表示该游戏逻辑服需要支持全链路调用日志跟踪。如果游戏逻辑服没有添加该插件的,表示不需要记录日志跟踪。

BarSkeletonBuilder builder = ...;
// traceId
TraceIdInOut traceIdInOut = new TraceIdInOut();
builder.addInOut(traceIdInOut);

分布式事件总线

#228 分布式事件总线是新增的通讯方式,可以代替 redis pub sub 、 MQ ...等中间件产品;分布式事件总线具备全链路调用日志跟踪,这点是中间件产品所做不到的。

文档 - 分布式事件总线

ioGame 分布式事件总线,特点

  • 使用方式与 Guava EventBus 类似
  • 具备全链路调用日志跟踪。(这点是中间件产品做不到的)
  • 支持跨多个机器、多个进程通信
  • 支持与多种不同类型的多个逻辑服通信
  • 纯 javaSE,不依赖其他服务,耦合性低。(不需要安装任何中间件)
  • 事件源和事件监听器之间通过事件进行通信,从而实现了模块之间的解耦
  • 当没有任何远程订阅者时,将不会触发网络请求。(这点是中间件产品做不到的)

下面两个订阅者是分别在不同的进程中的,当事件发布后,这两个订阅者都能接收到 UserLoginEventMessage 消息。

@ActionController(UserCmd.cmd)
public class UserAction {
	... 省略部分代码
    @ActionMethod(UserCmd.fireEvent)
    public String fireEventUser(FlowContext flowContext) {
        long userId = flowContext.getUserId();

        log.info("fire : {} ", userId);
        
        // 事件源
        var userLoginEventMessage = new UserLoginEventMessage(userId);
        // 发布事件
        flowContext.fire(userLoginEventMessage);

        return "fireEventUser";
    }
}

// 该订阅者在 【UserLogicStartup 逻辑服】进程中,与 UserAction 同在一个进程
@EventBusSubscriber
public class UserEventBusSubscriber {
    @EventSubscribe(ExecutorSelector.userExecutor)
    public void userLogin(UserLoginEventMessage message) {
        log.info("event - 玩家[{}]登录,记录登录时间", message.getUserId());
    }
}

// 该订阅者在 【EmailLogicStartup 逻辑服】进程中。
@EventBusSubscriber
public class EmailEventBusSubscriber {
    @EventSubscribe
    public void mail(UserLoginEventMessage message) {
        long userId = message.getUserId();
        log.info("event - 玩家[{}]登录,发放 email 奖励", userId);
    }
}

小结

在 ioGame21 中,该版本做了数百项优化及史诗级增强。

  • 在线文档方面
  • 线程管理域方面的开放与统一、减少线程池上下文切换
  • FlowContext 增强
  • 新增通讯方式 - 分布式事件总线
  • 游戏对外服方面增强
  • 全链路调用日志跟踪

ioGame17 迁移到 ioGame21

这里介绍 ioGame17 迁移到 ioGame21。

文档:17 迁移到 ioGame21

升级建议

升级建议,先将 ioGame17 升级到 17 系列中的最后一个版本(17.1.61)。查看是否使用了已标记为过期的方法,先将过期方法改为正常的(标记为过期的方法上有注释及推荐使用的代替方法)。

整体破坏性变更内容较少,基本只涉及到游戏对外服,原因(#213)是游戏对外服开放了自定义协议。如果开发者没有在游戏对外服做任何扩展的(如,编解码、心跳、其他 netty handler ...等 ),那么升级将是丝滑的。

游戏对外服 - 变更涉及

#213 游戏对外服开放了自定义协议,所以相关的 netty handler 需要做变更。如果开发者的项目中没有对游戏对外服做扩展的,可以忽略该小节的内容。

1. netty - handler 部分

如果开发者在游戏对外服中扩展了 netty handler 的,需要将参数由 ExternalMessage 改为 BarMessage(内部协议)。

参考示例

更详细的参考框架内置 handler 相关源码。

注意点:netty Handler 参数使用的是 BarMessage。因为在 ioGame21 中,默认的编解码器会将 BarMesage 转为 ExternalMessage。

public class WebSocketExternalCodec extends MessageToMessageCodec<BinaryWebSocketFrame, BarMessage> {
    ... ...省略部分代码
    @Override
    protected void encode(ChannelHandlerContext ctx, BarMessage message, List<Object> out) {
        ExternalMessage externalMessage = ExternalCodecKit.convertExternalMessage(message);
        ... ...省略部分代码
    }

    @Override
    protected void decode(ChannelHandlerContext ctx, BinaryWebSocketFrame binary, List<Object> out) {
		... ...省略部分代码
        ExternalMessage externalMessage = DataCodecKit.decode(bytes, ExternalMessage.class);

        BarMessage message = ExternalCodecKit.convertRequestMessage(externalMessage);

        //【游戏对外服】接收【游戏客户端】的消息
        out.add(message);
    }
}

public final class CmdCheckHandler extends SimpleChannelInboundHandler<BarMessage>
        implements CmdRegionsAware {
    ... ...省略部分代码

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, BarMessage message) {
    }
}
2. 心跳钩子部分

#234 新增心跳响应前的回调方法 pongBefore,开发者可以按需使用;比如,可以在心跳响应前添加上当前时间。

注意点:给客户端主动发送消息时,需要发送 BarMesage,不能使用 ExternalMessage。因为在 ioGame21 中,默认的编解码器会将 BarMesage 转为 ExternalMessage。

参考代码如下

public final class YourSocketIdleHook implements SocketIdleHook {

    @Override
    public void pongBefore(BarMessage idleMessage) {
        // 把当前时间戳给到心跳接收端
        LongValue data = LongValue.of(TimeKit.currentTimeMillis());
        idleMessage.setData(data);
    }

    @Override
    public boolean callback(UserSession userSession, IdleStateEvent event) {
        IdleState state = event.state();
        if (state == IdleState.READER_IDLE) {
            /* 读超时 */
            log.debug("READER_IDLE 读超时");
        } else if (state == IdleState.WRITER_IDLE) {
            /* 写超时 */
            log.debug("WRITER_IDLE 写超时");
        } else if (state == IdleState.ALL_IDLE) {
            /* 总超时 */
            log.debug("ALL_IDLE 总超时");
        }

        BarMessage message = ExternalCodecKit.createErrorIdleMessage(ActionErrorEnum.idleErrorCode);
        // 错误消息
        message.setValidatorMsg(ActionErrorEnum.idleErrorCode.getMsg() + " : " + state.name());

        // 通知客户端,触发了心跳事件
        userSession.writeAndFlush(message);

        // 返回 true 表示通知框架将当前的用户(玩家)连接关闭
        return true;
    }
}

日志库使用新版本 slf4j 2.x。

示例库中,提供了 logback 1.4.14 的使用参考。

<!-- https://mvnrepository.com/artifact/ch.qos.logback/logback-classic -->
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.4.14</version>
</dependency>

注意事项,ioGame 框架只做了 logback 的兼容。如果你使用了其他的日志库,可参考 LoggerSpaceFactory4LogbackBuilder ,使用类路径覆盖的方式来兼容(因为 bolt 目前 slf4j 默认实现只有 1.x 相关的)。

所以,较为方便的升级方法是统一使用 logback 日志库。否则,需要你自己做其他日志库的兼容实现扩展。

业务框架插件包名变更

flow interal 包名纠正为 internal,这部分只涉及到业务框架插件相关的;将旧的包名删除后,重新引入新的包名即可。

对接文档生成

在 ioGame21 中,我们移除了在启动时对接文档的自动生成。如有需要的,开发者可主动调用对接文档的生成,使用参考:

public static void main(String[] args) {
    ... 省略部分代码

    new NettyRunOne()
            ... ...
            .startup();

    // 生成对接文档
    BarSkeletonDoc.me().buildDoc();
}

主要更新

#223 一天内 action 各小时的调用统计插件

打印预览

以下打印的是一天内(24小时)的业务消费量,计划以每小时为一个时间阶段,小时内可划分钟阶段。

2023-11-29 action 调用次数 共 [100] 	0:00 8 ; - [15~30分钟 3 ] - [30~45分钟 2 ] - [45~59分钟 3 ]
	1:00 9 ; - [0~15分钟 1 ] - [15~30分钟 4 ] - [30~45分钟 1 ] - [45~59分钟 3 ]
	2:00 4 ; - [0~15分钟 1 ] - [15~30分钟 2 ] - [45~59分钟 1 ]
	3:00 2 ; - [0~15分钟 1 ] - [15~30分钟 1 ]
	4:00 1 ; - [0~15分钟 1 ]
	5:00 4 ; - [0~15分钟 1 ] - [15~30分钟 1 ] - [30~45分钟 1 ] - [45~59分钟 1 ]
	6:00 5 ; - [0~15分钟 1 ] - [15~30分钟 1 ] - [30~45分钟 1 ] - [45~59分钟 2 ]
	7:00 4 ; - [15~30分钟 2 ] - [30~45分钟 1 ] - [45~59分钟 1 ]
	8:00 4 ; - [0~15分钟 1 ] - [30~45分钟 3 ]
	9:00 4 ; - [15~30分钟 2 ] - [30~45分钟 2 ]
	10:00 5 ; - [15~30分钟 2 ] - [30~45分钟 1 ] - [45~59分钟 2 ]
	11:00 3 ; - [15~30分钟 2 ] - [45~59分钟 1 ]
	12:00 4 ; - [0~15分钟 2 ] - [30~45分钟 2 ]
	13:00 1 ; - [30~45分钟 1 ]
	14:00 5 ; - [0~15分钟 1 ] - [45~59分钟 4 ]
	15:00 6 ; - [0~15分钟 1 ] - [15~30分钟 2 ] - [45~59分钟 3 ]
	16:00 4 ; - [0~15分钟 1 ] - [15~30分钟 1 ] - [30~45分钟 1 ] - [45~59分钟 1 ]
	17:00 7 ; - [0~15分钟 1 ] - [15~30分钟 3 ] - [30~45分钟 3 ]
	18:00 2 ; - [0~15分钟 1 ] - [15~30分钟 1 ]
	19:00 7 ; - [0~15分钟 1 ] - [15~30分钟 3 ] - [30~45分钟 3 ]
	20:00 5 ; - [15~30分钟 3 ] - [30~45分钟 2 ]
	21:00 3 ; - [15~30分钟 2 ] - [30~45分钟 1 ]
	22:00 1 ; - [45~59分钟 1 ]
	23:00 2 ; - [15~30分钟 1 ] - [45~59分钟 1 ]

使用示例

BarSkeletonBuilder builder = ...;
// 各时间段 action 调用统计插件,将插件添加到业务框架中
var timeRangeInOut = new TimeRangeInOut();
builder.addInOut(timeRangeInOut);

默认的打印预览

默认配置下的打印如下,没有分钟阶段的调用次数统计,只有每小时的阶段的调用次数统计。

2023-11-29 action 调用次数 共 [10000] 	0:00 431 ;
	1:00 416 ;
	2:00 421 ;
	3:00 414 ;
	4:00 441 ;
	5:00 423 ;
	6:00 407 ;
	7:00 395 ;
	8:00 410 ;
	9:00 413 ;
	10:00 378 ;
	11:00 411 ;
	12:00 380 ;
	13:00 413 ;
	14:00 417 ;
	15:00 416 ;
	16:00 400 ;
	17:00 430 ;
	18:00 471 ;
	19:00 440 ;
	20:00 405 ;
	21:00 430 ;
	22:00 414 ;
	23:00 424 ;

其他

fix endPoint removeBinding.

主要更新

#227 增加调度、定时器相关便捷工具,使用 HashedWheelTimer 来模拟 ScheduledExecutorService 调度

public void test() {
    // 每分钟调用一次
    TaskKit.runIntervalMinute(() -> log.info("tick 1 Minute"), 1);
    // 每 2 分钟调用一次
    TaskKit.runIntervalMinute(() -> log.info("tick 2 Minute"), 2);

    // 每 2 秒调用一次
    TaskKit.runInterval(() -> log.info("tick 2 Seconds"), 2, TimeUnit.SECONDS);
    // 每 30 分钟调用一次
    TaskKit.runInterval(() -> log.info("tick 30 Minute"), 30, TimeUnit.MINUTES);

    //【示例 - 移除任务】每秒调用一次,当 hp 为 0 时就移除当前 TimerListener
    TaskKit.runInterval(new IntervalTaskListener() {
        int hp = 2;

        @Override
        public void onUpdate() {
            hp--;
            log.info("剩余 hp:2-{}", hp);
        }

        @Override
        public boolean isActive() {
            // 当返回 false 则表示不活跃,会从监听列表中移除当前 TimerListener
            return hp != 0;
        }
    }, 1, TimeUnit.SECONDS);

    //【示例 - 跳过执行】每秒调用一次,当 triggerUpdate 返回值为 true,即符合条件时才执行 onUpdate 方法
    TaskKit.runInterval(new IntervalTaskListener() {
        int hp;

        @Override
        public void onUpdate() {
            log.info("current hp:{}", hp);
        }

        @Override
        public boolean triggerUpdate() {
            hp++;
            // 当返回值为 true 时,会执行 onUpdate 方法
            return hp % 2 == 0;
        }
    }, 1, TimeUnit.SECONDS);

    //【示例 - 指定线程执行器】每秒调用一次
    // 如果有耗时的任务,比如涉及一些 io 操作的,建议指定执行器来执行当前回调(onUpdate 方法),以避免阻塞其他任务。
    ExecutorService executorService = TaskKit.getCacheExecutor();

    TaskKit.runInterval(new IntervalTaskListener() {
        @Override
        public void onUpdate() {
            log.info("执行耗时的 IO 任务,开始");

            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            log.info("执行耗时的 IO 任务,结束");
        }

        @Override
        public Executor getExecutor() {
            // 指定执行器来执行当前回调(onUpdate 方法),以避免阻塞其他任务。
            return executorService;
        }
    }, 1, TimeUnit.SECONDS);

    TimeUnit.SECONDS.sleep(20);
}

#225 新增插件 - 业务线程监控

详细文档 https://www.yuque.com/iohao/game/zoqabk4gez3bckis

BarSkeletonBuilder builder = ...;
// 业务线程监控插件,将插件添加到业务框架中
var threadMonitorInOut = new ThreadMonitorInOut();
builder.addInOut(threadMonitorInOut);


打印预览

业务线程[RequestMessage-8-1] 共执行了 1 次业务平均耗时 1 ms, 剩余 91 个任务未执行
业务线程[RequestMessage-8-2] 共执行了 1 次业务平均耗时 1 ms, 剩余 0 个任务未执行
业务线程[RequestMessage-8-3] 共执行了 1 次业务平均耗时 1 ms, 剩余 36 个任务未执行
业务线程[RequestMessage-8-4] 共执行了 1 次业务平均耗时 1 ms, 剩余 0 个任务未执行
业务线程[RequestMessage-8-5] 共执行了 1 次业务平均耗时 1 ms, 剩余 88 个任务未执行
业务线程[RequestMessage-8-6] 共执行了 1 次业务平均耗时 1 ms, 剩余 0 个任务未执行
业务线程[RequestMessage-8-7] 共执行了 7 次业务平均耗时 1 ms, 剩余 56 个任务未执行
业务线程[RequestMessage-8-8] 共执行了 1 次业务平均耗时 1 ms, 剩余 0 个任务未执行

其他更新

废废弃 InternalKit,使用 TaskKit 代替

HeadMetadata 新增 cloneAll 方法

增加 ThreadExecutorRegion 线程执行器管理域接口;

  • 新增 UserThreadExecutorRegion 实现,执行器具体数量是不大于 Runtime.getRuntime().availableProcessors() 的 2 次幂。
  • 新增 SimpleThreadExecutorRegion 实现,执行器的数量与 Runtime.getRuntime().availableProcessors() 相同。

默认为 UserThreadExecutorRegion 和 SimpleThreadExecutorRegion 提供了单例实现,即使在同一进程中启动了多个逻辑服,也不会创建过多线程执行器,而是使用同一个。

TImeKit 新加时间更新策略,开发者可设置时间更新策略。

RandomKit 功能增强,随机得到数组中的一个元素。

移除 SocketUserSessionHandler exceptionCaught 的日志打印。

[#221] 新增 action 调用统计插件 StatisticActionInOut

action 调用统计插件 StatisticActionInOut - 文档

StatActionInOut 是 action 调用统计插件,可以用来统计各 action 调用时的相关数据,如 action 的执行次数、总耗时、平均耗时、最大耗时、触发异常次数...等相关数据

开发者可以通过这些数据来分析出项目中的热点方法、耗时方法,从而做到精准优化

action 调用统计插件的打印预览

StatAction{cmd[1 - 1], 执行[50], 异常[0], 平均耗时[1833], 最大耗时[2945], 总耗时[91691] 
	500 ~ 1000 ms 的请求共 [7] 	1000 ~ 1500 ms 的请求共 [11] 	1500 ~ 2000 ms 的请求共 [9] 	> 2000 ms 的请求共 [23] 
StatAction{cmd[1 - 2], 执行[50], 异常[0], 平均耗时[1782], 最大耗时[2976], 总耗时[89133] 
	500 ~ 1000 ms 的请求共 [10] 	1000 ~ 1500 ms 的请求共 [7] 	1500 ~ 2000 ms 的请求共 [12] 	> 2000 ms 的请求共 [21] 

action 调用统计插件的使用

BarSkeletonBuilder builder = ...;
// action 调用统计插件,将插件添加到业务框架中
var statActionInOut = new StatActionInOut();
builder.addInOut(statActionInOut);

// 设置 StatAction 统计记录更新后的监听处理
// 这个监听不是必需的,这里只是简单的演示一下有变动时就做个打印。与 debugInOut 类似。
statActionInOut.setListener((statAction, time, flowContext) -> {
    // 简单打印统计记录值 StatAction
    System.out.println(statAction);
});

FlowContext 增强

增加 inOutStartTime 和 getInOutTime 方法,用于记录插件的开始执行时间和结束时所消耗的时间。当你扩展了多个插件时,插件中又有时间记录方面需求的,可以使用下面的方式。

// 使用示例
public final class YourInOut implements ActionMethodInOut {
   
    @Override
    public void fuckIn(FlowContext flowContext) {
        // 记录当前时间(重复调用只记录首次时间)
        flowContext.inOutStartTime();
    }

    @Override
    public void fuckOut(FlowContext flowContext) {
        // 消耗时间 = System.currentTimeMillis - inOutStartTime
        long time = flowContext.getInOutTime();
    }
}

其他更新

<netty.version>4.1.101.Final</netty.version>

优化 FlowContext createRequestMessage

#194

可能在 springboot 集成 light-domain-event 时,启动报 java.lang.ClassNotFoundException

#198

关于改造现有或老客户端项目到 ioGame 遇到的问题

HeadMetadata 增加 customData 属性;用于自定义数据,专为开发者预留的一个字段,开发者可以利用该字段来传递自定义数据。该字段由开发者自己定义,框架不会对数据做任何处理,也不会做任何检查,开发者可以利用该字段来传递任何数据,包括自定义对象。


模拟客户端

因发展需要,模拟客户端使用新 api ,与 SDK 风格做统一。从 17.1.58 版本开始,推荐开发者使用新版本的 api。已经将老的 api 做了过期标记,将在 ioGame21 版本中完全移除这些过期的 api。整体变化不大,内容如下

请求 api

变更说明

新版本反序列化消息放在 result 时解析。

setDescription 使用 setTitle 代替。

setInputRequestData 使用 setRequestData 代替。

对于 List 类型的使用更友好

请求 - list 响应

########## 请求 - 旧版本 ##########
// 创建一个模拟命令 - 【125-3】读取某个玩家的私有消息
ofCommandUserId(ChatCmd.readPrivateMessage).callback(ByteValueList.class, result -> {
    List<ChatMessage> list = result.toList(ChatMessage.class);
    if (CollKit.isEmpty(list)) {
        return;
    }

    log.info("玩家【{}】读取私聊消息数量 : {}", userId, list.size());
    System.out.println("------------------------------");
    list.stream().map(ClientChatKit::toString).forEach(System.out::println);
    System.out.println("------------------------------");

}).setDescription("读取某个玩家的私有消息");

########## 请求 - 新版本 ##########
// 创建一个模拟命令 - 【125-3】读取某个玩家的私有消息
ofCommandUserId(ChatCmd.readPrivateMessage)
        .setTitle("读取某个玩家的私有消息")
        .callback(result -> {
            List<ChatMessage> list = result.listValue(ChatMessage.class);
            if (CollKit.isEmpty(list)) {
                return;
            }

            log.info("玩家【{}】读取私聊消息数量 : {}", userId, list.size());
            System.out.println("------------------------------");
            list.stream().map(ClientChatKit::toString).forEach(System.out::println);
            System.out.println("------------------------------");
        });
########## 请求 - 旧版本 ##########
// 创建一个模拟命令 - 【125-2】未读消息的发送者列表
ofCommand(ChatCmd.listUnreadUserId).callback(LongValueList.class, result -> {
    LongValueList longValueList = result.getValue();
    log.info("未读消息的发送者列表 : {}", longValueList.values);
}).setDescription("未读消息的发送者列表");

########## 请求 - 新版本 ##########
// 创建一个模拟命令 - 【125-2】未读消息的发送者列表
ofCommand(ChatCmd.listUnreadUserId)
        .setTitle("未读消息的发送者列表")
        .callback(result -> {
            List<Long> values = result.listLong();
            log.info("未读消息的发送者列表 : {}", values);
        });

请求

########## 请求 - 旧版本 ##########
// 创建一个模拟命令 - 【125-1】玩家与玩家的私聊
ofCommand(ChatCmd.c_2_c)
        // 动态请求内容 - 私聊,聊天内容
        .setInputRequestData(() -> {
            ... 省略部分

            return chatSendMessage;
        })
        // 命令描述
        .setDescription("玩家与玩家的私聊");

########## 请求 - 新版本 ##########
// 创建一个模拟命令 - 【125-1】玩家与玩家的私聊
ofCommand(ChatCmd.c_2_c)
        .setTitle("玩家与玩家的私聊")
        // 动态请求内容 - 私聊,聊天内容
        .setRequestData(() -> {
            ... 省略部分

            return chatSendMessage;
        });

title、请求参数、响应

########## 请求 - 旧版本 ##########
HelloReq helloReq = new HelloReq();
helloReq.name = "abc12";

ofCommand(DemoCmd.here).callback(HelloReq.class, result -> {
    HelloReq value = result.getValue();
    log.info("value : {}", value);
}).setDescription("here").setRequestData(helloReq);

########## 请求 - 新版本 ##########
ofCommand(DemoCmd.here)
// 标题
.setTitle("here")
// 请求参数
.setRequestData(() -> {
    HelloReq helloReq = new HelloReq();
    helloReq.name = "abc12";
    return helloReq;
})
// 响应
.callback(result -> {
    HelloReq value = result.getValue(HelloReq.class);
    log.info("value : {}", value);
});

########## 请求 - 新版本 - 简写 ##########
ofCommand(DemoCmd.here).setTitle("here").setRequestData(() -> {
    HelloReq helloReq = new HelloReq();
    helloReq.name = "abc12";
    return helloReq;
}).callback(result -> {
    HelloReq value = result.getValue(HelloReq.class);
    log.info("value : {}", value);
});

广播 api

变更说明

listenBroadcast 使用 ofListen 代替。

新版本反序列化消息放在 result.getValue 时解析。

########## 广播 - 旧版本 ##########
// 广播监听回调 - 监听【125-11】玩家私聊消息通知
listenBroadcast(ChatNotifyMessage.class, result -> {
    ChatNotifyMessage chatNotifyMessage = result.getValue();
    // 聊天消息发送方的 userId
    long senderId = chatNotifyMessage.senderId;
    log.info("玩家[{}]给我的私聊通知", senderId);
}, ChatCmd.notifyPrivate, "玩家私聊消息通知");

########## 广播 - 新版本 ##########
// 广播监听回调 - 监听【125-11】玩家私聊消息通知
ofListen(result -> {
    ChatNotifyMessage chatNotifyMessage = result.getValue(ChatNotifyMessage.class);
    // 聊天消息发送方的 userId
    long senderId = chatNotifyMessage.senderId;
    log.info("玩家[{}]给我的私聊通知", senderId);
}, ChatCmd.notifyPrivate, "玩家私聊消息通知");

其他更新

<netty.version>4.1.100.Final</netty.version>
<lombok.version>1.18.30</lombok.version>

Full Changelog: https://github.com/game-town/ioGame/compare/17.1.55...17.1.58

主要更新

[#186] 增强 ProtoDataCodec
当 data 为null 时,使用空数组来解析序列化;可以确保 action 参数不会为 null,使得开发者拥有更好的编码体验。

将 ActionCommandTryHandler 逻辑合并到 ActionCommandHandler

DevConfig.me 标记为过期,并将 DevConfig 改为静态类;

把 MethodParsers.me 方法标记为过期,并将 MethodParsers 改为静态类;

<netty.version>4.1.97.Final</netty.version>

<lombok.version>1.18.28</lombok.version>

[#174] fix action 交给容器管理时,实例化两次的问题

获取游戏对外服的数据与扩展,获取ResponseCollectExternalMessage 新增 optionalAnySuccess 方法,方便得到成功的 optional

    public String getUserIp() {

        ResponseCollectExternalMessage message = ...

        return message
                .optionalAnySuccess()
                // 得到返回值
                .map(ResponseCollectExternalItemMessage::getData)
                // 将为 String
                .map(Objects::toString)
                // 如果没获取到给个空串,调用方就不需要做 null 判断了。
                .orElse("");
    }

压测&模拟客户端请求模块,新增模块名标识

public class BagInputCommandRegion extends AbstractInputCommandRegion {
    @Override
    public void initInputCommand() {
        this.inputCommandCreate.cmd = BagCmd.cmd;
        this.inputCommandCreate.cmdName = "背包模块";
    }
}

新游戏对外服新增 HttpRealIpHandler,用于获取玩家真实 ip 支持

游戏对外服 webSocket 使用 nginx 代理,也能获取真实的玩家 ip

public class MyExternalServer {
    ... ...省略部分代码
    public ExternalServer createExternalServer(int externalPort) {
        ... ...省略部分代码
        // 游戏对外服 - 构建器
        DefaultExternalServerBuilder builder = ...

        builder.setting().setMicroBootstrapFlow(new WebSocketMicroBootstrapFlow() {
            @Override
            protected void httpHandler(PipelineContext context) {
                super.httpHandler(context);
                /*
                 * HttpRealIpHandler 是框架内置的一个 handler。
                 * 添加上后,即使是通过 nginx 转发,也可以得到玩家真实的 ip
                 */
                context.addLast("HttpRealIpHandler", new HttpRealIpHandler());
            }
        });

        // 构建游戏对外服 https://www.yuque.com/iohao/game/ea6geg
        return builder.build();
    }
}

[#172] 新增 webSocket token 鉴权、校验支持

有时,我们需要在 WebSocket 建立连接前做 token 相关鉴权、校验的业务。ioGame 支持此类业务的扩展,我们可以在游戏对外服部分做相关扩展;

简单的说,如果校验没通过,我们就不建立 ws 连接了,在 http 阶段就结束所有流程,可以有效的减少恶意长连接。

相关文档与使用示例 https://www.yuque.com/iohao/game/tb1126szmgfu6u55

日志相关调整
移除 light-log 模块,统一使用 lombok slf4j 相关注解

压测&模拟客户端增强

新增 SplitParam,方便模拟测试时,解析控制台输入参数的获取

    private void useRequest() {
        InputRequestData inputRequestData = () -> {
            ScannerKit.log(() -> log.info("输入需要使用的背包物品,格式 [背包物品id-数量]"));
            String inputType = ScannerKit.nextLine("1-1");

            SplitParam param = new SplitParam(inputType);
            // 得到下标 0 的值
            String id = param.getString(0);
            // 得到下标 1 的值,如果值不存在,则使用默认的 1 代替
            int quantity = param.getInt(1, 1);

            ... ... 省略部分代码
        };

        ofCommand(BagCmd.use).callback(BoolValue.class, result -> {
            var value = result.getValue();
            log.info("value : {}", value);
        }).setDescription("使用背包物品").setInputRequestData(inputRequestData);
    }

1 DebugInout 在自定义 FlowContext 时的打印优化。

2 cmdInfo 新增 of 系列方法,用于代替 getCmdInfo 系列方法

3 异常机制接口 MsgExceptionInfo,新增方法

// 断言为 true, 就抛出异常,可自定义消息

void assertTrueThrows(boolean v1, String msg)

// 断言值 value 不能为 null, 否则就抛出异常,可自定义消息

void assertNonNull(Object value, String msg)

4 将旧版的游戏对外服标记为过时的

请使用新游戏对外服

5 模拟客户端新增成功回调触发

模拟命令域 InputCommandRegion 新增 loginSuccessCallback 成功回调方法,当模拟玩家登录成功后会调用此方法

public class MapInputCommandRegion extends AbstractInputCommandRegion {
    ... ... 省略部分代码

    @Override
    public void loginSuccessCallback() {
        // 进入地图,根据地图 id
        EnterMapReq enterMapReq = new EnterMapReq();
        enterMapReq.mapId = 1;
        ofRequestCommand(MapCmd.enterMap).request(enterMapReq);
    }
}

6 ClientUser 新增 callbackInputCommandRegion 方法

一般在登录模拟请求的回调中主动的调用,开发者可在登录成功后,调用此方法。使其触发所有的 InputCommandRegion.loginSuccessCallback 方法;

@Slf4j
public class LoginInputCommandRegion extends AbstractInputCommandRegion {
    ... ... 省略部分代码
    @Override
    public void initInputCommand() {
        InputRequestData inputRequestData = () -> {
            LoginVerify loginVerify = new LoginVerify();
            loginVerify.jwt = clientUser.getJwt();
            return loginVerify;
        };
        
        ofCommand(LoginCmd.loginVerify).callback(UserInfo.class, result -> {
            UserInfo userInfo = result.getValue();
            log.info("登录成功 : {}", userInfo);
            clientUser.setUserId(userInfo.id);
            clientUser.setNickname(userInfo.nickname);
            // ------------ 关键代码 ------------
            clientUser.callbackInputCommandRegion();
        }).setDescription("登录").setInputRequestData(inputRequestData);
    }
}

7 压测&模拟客户端请求

新增重复上一次命令的支持

演示

假设在模拟客户端控制台查中,有如下命令

  • 5-1 : 添加玩家经验值

image

增加经验值

每次输入 5-1,会给人物增加 10 点经验值
image

如果我们想多次增加经验值,每次在控制台 5-1 太麻烦了;这里有个小技巧,在控制台输入 【+】,可以重复上次输入的命令。

image

ioGame - 分步式网络编程框架 ioGame 17.1.48 文档生成增强

Published by iohao about 1 year ago

主要更新

文档生成增强,增加 action 参数注释说明.
文档生成增强,返回值注释说明.
fix 在 pom 中引入本地 jar 时,文档解析的错误。

主要更新

[160] 轻量小部件 - 压测&模拟客户端请求模块

文档:https://www.yuque.com/iohao/game/tc83ud

介绍

此模块是用于模拟客户端,简化模拟工作量,只需要编写对应请求与回调。

使用该模块后,当我们与前端同学联调某个功能时,不需要跟前端哥们说:在点一下、在点一下、在点一下了。这种“在点一下”的交流联调方式将成为过去式。

除了可以模拟简单的请求外,通常还可以做一些复杂的请求编排,并支持复杂业务的压测。模拟测试的过程是可互动的,但也支持测试自动化。

与单元测试不同的是,该模块可以模拟真实的网络环境,并且在模拟测试的过程中与服务器交互是可持续的、可互动的

可互动模式是用于调试测试某些功能。在互动的过程中,开发者可以在控制台中指定执行某个模拟请求命令,并且支持在控制台中输入一些动态的请求参数,从而让我们轻松的测试不同的业务逻辑走向。

关于可互动部分,需要将后续文档阅读完后才能知道其具体意思。

特点

  • 使用简单
  • 压测支持
  • 可以模拟客户端请求
  • 可以模拟真实的网络环境
  • 可以编排复杂的业务请求
  • 同样的模拟测试用例,支持在多种连接方式下工作(tcp、udp、websocket)
  • 可持续的与服务器交互,模拟测试的过程是可互动的,但也支持测试自动化

入门级演示

整体文档有点多,但在实际的使用上是比较简单的。

图一

图左边是我们提供的 action,图右边则是我们编写的模拟请求。

0cd9b0d4aa682c3dddc2a372e76ac0f7

图二

控制台中是可交互的部分,可以查看提供了哪些模拟的客户端请求。

通过在控制台中输入 【cmd-subCmd】 来触发对应的请求。

51148d07dde132224a04780b51e2209c

图三

触发请求后,当服务器有响应数据时,会进入到对应模拟请求的回调中。

6674c6a4ab6f7a72bb8d5594ad4e0e00

小结

比如需要做一个简单的:私聊系统、聊天频道、好友系统时,这个模拟客户端就派上用场了。

模拟客户端模块还提供了一个 ClientUser(玩家)对象,一个模拟客户端对应一个 ClientUser。ClientUser 是客户端的用户(玩家)对象,开发者可以通过动态属性 options 来扩展业务,比如可以在动态属性中保存货币、战力值、血条 ...等。也可以通过继承的方式来扩展。

其他更新

文档生成增强,增加 action 参数注释说明、返回值注释说明。

ioGame - ioGame 网络编程框架 17.1.45 同进程亲和性

Published by iohao over 1 year ago

主要更新

[#159] 同进程同时支持多种连接方式方式的技巧

public class MyApplication {
    ... ... 省略部分代码
    static int externalCorePort = 10100;

    public static void main(String[] args) {
        // 游戏对外服列表
        List<ExternalServer> externalServerList = listExternalServer();
        new NettyRunOne()
                .setExternalServerList(externalServerList)
                .startup();
    }

    static List<ExternalServer> listExternalServer() {
        return List.of(
                // 连接方式;WEBSOCKET
                createExternalServer(ExternalJoinEnum.WEBSOCKET)
                // 连接方式;TCP
                , createExternalServer(ExternalJoinEnum.TCP)
                // 连接方式;UDP
                , createExternalServer(ExternalJoinEnum.UDP)
        );
    }

    static ExternalServer createExternalServer(ExternalJoinEnum join) {
        int port = externalCorePort;
        port = join.cocPort(port);
        DefaultExternalServerBuilder builder = DefaultExternalServer
                .newBuilder(port)
                // 连接方式
                .externalJoinEnum(join)
                // 与 Broker (游戏网关)的连接地址
                .brokerAddress(new BrokerAddress("127.0.0.1", IoGameGlobalConfig.brokerPort));

        return builder.build();
    }
}

[#157] fix 默认心跳钩子问题

[#122] 同进程亲和性

相关文档:https://www.yuque.com/iohao/game/unp26u

简介

同进程内不同 Netty 实例通信时,是通过内存进行传输的,不需要经过网络传输,数据传输速度极快。也就是说,如果我们将游戏对外服、Broker(游戏网关)、游戏逻辑服部署在同一个进程中(也就是单体应用),那么各服务器之间是在内存中通信的。甚至可以简单的理解为在同一 JVM 中的 a 方法调用了 b 方法,b 方法调用了 c 方法。

同进程亲和性是 ioGame 的特性之一,可以让同一进程内的 Netty 实例拥有相互访问优先权。说人话就是,如果你在同一进程内启动了游戏对外服、Broker(游戏网关)、游戏逻辑服,当有请求需要处理时:

  • 即使你启动了多个 Broker(游戏网关),游戏对外服会优先将请求交给同进程内的 Broker(游戏网关)来处理。
  • 即使你启动了多个相同的游戏逻辑服,Broker(游戏网关)会优先将请求交给同进程的游戏逻辑服来处理。
  • 同样的,游戏逻辑服处理完请求后,会优先将响应交给同进程内的 Broker(游戏网关)。

部署简图

img

图中有这么几个部分,分别是:

  • 玩家,这些玩家可能连接到【1111】或【2222】进程中。
  • 【进程 3333】启动了多个公共的游戏逻辑服。
  • 【进程 1111】启动了游戏对外服、Broker(游戏网关)、游戏逻辑服[A-1、B-1]。
  • 【进程 2222】启动了游戏对外服、Broker(游戏网关)、游戏逻辑服[A-2、B-2]。

TIP:同一进程内,不同 Netty 实例之间的通信,是通过内存进行传输的,不需要经过网络传输,数据传输速度极快。

同进程亲和性指的是,优先访问同进程内的游戏逻辑服,当同进程内没有能处理请求的游戏逻辑服时,才会去其他进程或机器中查找能处理请求的游戏逻辑服;

处理流程

下面用一个场景来说明,游戏逻辑服对应的 action 数据如下:

游戏逻辑服-A 提供了路由:1-1。

游戏逻辑服-C 提供了路由:2-1。

从图中我们知道了,相同类型的【游戏逻辑服-A】启动了两台,分别在【进程 1111】和【进程 2222】中。

现在我们有一个玩家与【进程 1111】的游戏对外服建立了连接,并发起 1-1 请求;请求会被【游戏逻辑服-A-1】来处理,因为【游戏逻辑服-A-1】与玩家所在的【游戏对外服】是同一个进程内的,所以该请求会优先被【游戏逻辑服-A-1】消费;

通过同进程亲和性我们可以看出,虽然启动了两台相同类型的【游戏逻辑服-A】,但玩家的请求只会由【进程 1111】内的【游戏逻辑服-A-1】来消费,因为玩家连接的是【进程 1111】的游戏对外服。

当玩家发起 2-1 请求时,在【进程 1111】内找不到对应的游戏逻辑服来处理这个请求时,框架会将请求交给【游戏逻辑服-C-1】来处理。

使用场景

开发者可以利用同进程亲和性的特点,按照上图中的部署方式,可以让各服务器之间通过进程来做微隔离。此外,又能提供一些游戏逻辑服来处理公共业务;如一些跨服活动、跨服战...等,各种跨服业务

使用该部署方式,可做参考的游戏类型如下:

  • 滚服类型的游戏
  • RTS(Real Time Strategy)实时战略游戏,例如星际争霸、红色警戒。
  • MOBA(Multiplayer Online Battle Arena)多人在线竞技场游戏,例如英雄联盟、DOTA2。
  • ARPG(Action Role-playing Game)动作角色扮演游戏,例如暗黑破坏神、无尽之剑。
  • FPS(First-person Shooter)一人称射击游戏,例如使命召唤、绝地求生。
  • TPS(Third-person Shooter)第三人称射击游戏,例如疾速前进、战地。
  • 待补充...

小结

同进程亲和性是优先访问同进程内的游戏逻辑服,并不是说不能访问其他进程的游戏逻辑服。

特点

  • 同进程亲和性换句话说就是,除了可以优先让同进程内的逻辑服相互访问外,还能访问其他进程的逻辑服。
  • 各逻辑服在同进程内的通信,是通过内存进行传输的,不需要经过网络传输,数据传输速度极快。
  • 合理利用同进程亲和性,可以让各服务器之间通过进程来做微隔离。

最后

ioGame 的架构由三部分组成:1.游戏对外服、2.Broker(游戏网关)、3.游戏逻辑服;三者既可相互独立,又可相互融合。所以,使用 ioGame 几乎可以满足任意的部署方式,可以根据你的需求来适应不同类型的游戏,并且在 ioGame 中做这些工作是简单的。

这里为什么要特意介绍一下三者之间的组合关系呢?目的是想告诉开发者,ioGame 的架构是灵活的。同进程亲和性在以下组合会生效。

为了更简单的说明三者之间的灵活性,现在把三者用字母代替,A.游戏对外服、B.Broker(游戏网关)、C.游戏逻辑服;我们可以得出如下组合

ABC :三者在一个进程中,他们之间使用内存通信;(无需传输

AB + C :【游戏对外服和游戏网关】在一个进程中,他们之间使用内存通信;(传输一次

A + BC :【游戏网关和游戏逻辑服】在一个进程中,他们之间使用内存通信;(传输一次

通过上面的组合,我们可以看出,ioGame 架构是支持类似传统架构那样只做一次跳转,甚至可以做到零跳转,这完全取决于你们的部署方式。

上面三种组合方式,都具备同进程亲和性。同进程亲和性生效的要点只有一个,至少保证有两部分在一个进程内。

ioGame - Netty 网络游戏服务器框架 ioGame 17.1.44

Published by iohao over 1 year ago

主要更新

[#138] 提供协议碎片的工具类,方便协议碎片在广播时的使用

代码中演示了协议碎片相关的使用,通过工具类,可以让一些基础类型在使用上更简便。
对应的包装类中,都提供了静态 of 方法;

框架支持的包装类可到 框架支持的自动装箱、拆箱基础类型 查询。

... ... 省略部分代码
private static void test() {
  // 给客户端广播一个 int 值 : 1
  var bizData = WrapperKit.of(1);

  // 广播上下文
  CmdInfo cmdInfo = CmdInfo.getCmdInfo(DemoBroadcastCmd.cmd, DemoBroadcastCmd.broadcastMsg);
  BroadcastContext broadcastContext = BrokerClientHelper.getBroadcastContext();
  broadcastContext.broadcast(cmdInfo, bizData);

  // 给客户端广播一个 bool 值 : true
  var bizDataBoolean = WrapperKit.of(true);
  broadcastContext.broadcast(cmdInfo, bizDataBoolean);

  // 对象列表演示
  DemoBroadcastMessage broadcastMessage = new DemoBroadcastMessage();
  broadcastMessage.msg = "broadcast hello," + counter.longValue();
  List<DemoBroadcastMessage> list = new ArrayList<>();
  list.add(broadcastMessage);
  var bizDataList = WrapperKit.ofListByteValue(list);
  broadcastContext.broadcast(cmdInfo, bizDataList);

  // int 列表
  var bizDataIntList = IntValueList.of(List.of(1, 3, 5, 7));
  broadcastContext.broadcast(cmdInfo, bizDataIntList);

  ... ... 省略部分代码
  其他类同,不全部介绍了。
}

[#133] 向指定对外服上的用户广播数据

示例参考代码

... ...省略部分代码
private static void extracted(String externalId) {
    var bizData = new DemoBroadcastMessage();
    broadcastMessage.msg = "broadcast hello!" ;

    // 广播消息的路由
    CmdInfo cmdInfo = ...;
    ResponseMessage responseMessage = BarMessageKit.createResponseMessage(cmdInfo, bizData);
    
    // 指定游戏对外服广播
    HeadMetadata headMetadata = responseMessage.getHeadMetadata();
    int sourceClientId = MurmurHash3.hash32(externalId);
    headMetadata.setSourceClientId(sourceClientId);

    // 广播上下文
    BroadcastContext broadcastContext = BrokerClientHelper.getBroadcastContext();
    broadcastContext.broadcast(responseMessage);
}

容错设置

IoGameGlobalConfig.brokerSniperToggleAK47 = boolean;

Broker(游戏网关)转发消息容错配置
      游戏逻辑服与游戏对外服通信时,如果没有明确指定要通信游戏对外服,游戏网关则会将消息转发到所有的游戏对外服上。
      如果指定了游戏对外服的,游戏网关则会将消息转发到该游戏对外服上,而不会将消息转发到所有的对外服上。
 
      当为 true 时,开启容错机制
          表示开发者在发送消息时,如果指定了游戏对外服的,
          但【游戏网关】中没有找到所指定的【游戏对外服】,则会将消息转发到所有的游戏对外服上,
          这么做的目的是,即使开发者填错了指定的游戏对外服,也能保证消息可以送达到游戏对外服。
 
      当为 false 时,关闭容错机制
          表示在【游戏网关】中找不到指定的【游戏对外服】时,则不管了。
 
      支持的通讯方式场景
          广播、推送 
          获取游戏对外服的数据与扩展 
  
另一种叙述版本
      作用:
          在游戏逻辑服发送广播时,支持指定游戏对外服来广播;
          如果你能事先知道所要广播的游戏对外服,那么在广播时通过指定游戏对外服,可以避免一些无效的转发。
 
          为了更好的理解的这个配置的作用,这里将作一些比喻:
          1. 将广播时指定的游戏对外服,看作是目标
          2. 将发送广播的游戏逻辑服,看作是命令
          3. 而 Broker(游戏网关)职责是对消息做转发,可看成是一名射击员;射击员手上有两把枪,分别是狙击枪和 AK47。
 
          狙击枪的作用是单点目标,而 AK47 的作用则是扫射多个目标(就是所有的游戏对外服)。
 
      场景一:
          当设置为 true 时,表示射击员可以将手中的狙击切换为 AK47,什么意思呢?
          意思就是如果在【游戏网关】中没有找到所指定的【游戏对外服】,则将广播数据发送给【所有的游戏对外服】。(换 AK 来扫射)
          这么做的目的是,即使开发者填错了指定的游戏对外服,也能保证消息可以送达到游戏对外服。
 
      场景二:
          当设置为 false 时,表示找不到指定的【游戏对外服】时,则不管了。

[#131] 获取指定对外服上数据的接口

参考使用示例,通过 RequestCollectExternalMessage 请求对象,可以指定游戏对外服id;

@UtilityClass
public class ExternalCommunicationKit {
    /**
     * 设置元信息到游戏对外服
     * 
<pre>
     *     之后所有 action 的 FlowContext 中会携带上这个元信息对象,
     *     不建议在元信息保存过多的信息,因为会每次传递。
     * </pre>
*
     * @param attachment  元信息
     * @param flowContext flowContext
     */
    public void setAttachment(Attachment attachment, FlowContext flowContext) {
        // 不做 null 判断,只做个 userId 的检测
        long userId = attachment.getUserId();

        if (userId <= 0) {
            throw new RuntimeException("userId <= 0");
        }

        // 得到游戏对外服 id
        RequestMessage request = flowContext.getRequest();
        HeadMetadata headMetadata = request.getHeadMetadata();
        int sourceClientId = headMetadata.getSourceClientId();

        var requestCollectExternalMessage = new RequestCollectExternalMessage()
                // 根据业务码,调用游戏对外服与业务码对应的业务实现类 (AttachmentDataExternalBizRegion)
                .setBizCode(ExternalBizCodeCont.attachment)
                // 元信息
                .setData(attachment)
                // 指定游戏对外服
                .setSourceClientId(sourceClientId);

        BrokerClientHelper
                // 【游戏逻辑服】与【游戏对外服】通讯上下文
                .getInvokeExternalModuleContext()
                .invokeExternalModuleCollectMessage(requestCollectExternalMessage);
    }
}

容错设置

IoGameGlobalConfig.brokerSniperToggleAK47 = boolean;

ioGame - 网络游戏服务器框架 ioGame 17.1.43 新版游戏对外服

Published by iohao over 1 year ago

[#115] 游戏对外服增加路由是否存在检测

参考:https://www.yuque.com/iohao/game/ea6geg#EeWiH

新游戏对外服中增加路由存在检测。当路由不存在时,可以起到抵挡的作用,而不必经过其他服务器。

[#114] 支持玩家与多个游戏逻辑服的动态绑定

文档:动态绑定游戏逻辑服

动态绑定游戏逻辑服,指的是玩家与游戏逻辑服绑定后,之后的请求都由该游戏逻辑服来处理。

玩家动态绑定逻辑服节点后,之后的请求都由这个绑定的游戏逻辑服来处理,可以实现类似 LOL、王者荣耀匹配后动态分配房间的效果。

支持玩家与多个游戏逻辑服的动态绑定。

使用场景

跨服活动、跨服战斗等。

动态绑定游戏逻辑服可以解决玩家增量的问题,我们都知道一台机器所能承载的运算是有上限的;当上限达到时,就需要增加新机器来分摊请求量;如果你开发的游戏是有状态的,那么你如何解决请求分配的问题呢?在比如让你做一个类似 LOL、王者荣耀的匹配,将匹配好的玩家分配到一个房间中,之后这些玩家的请求都能在同一个游戏逻辑服上处理,这种业务你该如何实现呢?

使用框架提供的动态绑定逻辑服节点可以轻松解决此类问题,而且还可以根据业务规则,计算出当前空闲最多的游戏逻辑服,并将此游戏逻辑服与玩家做绑定,从而做到均衡的利用机器资源,来防止请求倾斜的问题。

[#113] 新版本游戏对外服

文档:新游戏对外服使用

迁移指南 :迁移到新版游戏对外服

【新版游戏对外服】用于取代【旧的游戏对外服】,如果条件允许,请尽可能做迁移,整体工作量很少。旧的游戏对外服将不在做功能上的新增,如果存在 bug 将会继续修复,维护期会持续到下个大版本前。

从架构简图中,我们知道了整体架构由三部分组成 1.游戏对外服、2.游戏网关、3.游戏逻辑服。本篇将介绍游戏对外服这部分,及功能扩展等相关的。

游戏对外服的职责

  1. 保持与用户(玩家)长的连接
  2. 帮助开发者屏蔽通信细节、与连接方式的细节
  3. 连接方式支持:WebSocket、TCP、UDP
  4. 将用户(玩家)请求转发到游戏网关
  5. 可动态增减扩展机器
  6. 功能扩展,如:路由存在检测、路由权限、UserSession 管理、心跳,及后续要提供但还未提供的熔断、限流、降载、用户流量统计等功能。

扩展场景

游戏对外服主要负责与用户(玩家)的连接。假设一台硬件支持最多建立 5000 个用户连接,当用户量达到 7000 人时,我们可以增加一个游戏对外服来进行流量控制和减压。

由于游戏对外服的扩展性和灵活性,可以支持同时在线玩家从几千人到数千万人不等。这是因为,通过增加游戏对外服的数量,可以有效地进行连接的负载均衡和流量控制,使得系统能够更好地承受高并发的压力。

连接方式的切换、支持、扩展

ioGame 已提供了 TCP、WebSocket、UDP 连接方式的支持,并提供了灵活的方式来实现连接方式的切换。可以将 TCP、WebSocket、UDP 连接方式与业务代码进行无缝衔接。开发者可以用一套业务代码,无需任何改动,同时支持多种通信协议。

如果想要切换到不同的连接方式,只需要更改相应的枚举即可,非常简单。在不使用 ioGame 时,将连接方式从 TCP 改为 WebSocket 或 UDP 等,需要进行大量的调整和改动。然而,在 ioGame 中,实现这些转换是非常简单的。此外,不仅可以轻松切换各种连接方式,而且可以同时支持多种连接方式,并使它们在同一应用程序中共存。

连接方式是可扩展的,而且扩展也简单,这意味着之后如果支持了 KCP,那么将已有项目的连接方式,如 TCP、WebSocket、UDP 切换成 KCP 也是简单的。

需要再次强调的是,连接方式的切换对业务代码没有任何影响,无需做出任何改动即可实现连接方式的更改。

新游戏对外服 - UML

image

游戏对外服由两部分构成

  1. ExternalCore:与真实玩家连接的 ExternalCore 服务器
  2. ExternalBrokerClientStartup:负责内部通信,与 Broker(游戏网关)通信

我们只需要关注 ExternalCore 这部分,新版游戏对外服总体来说只有四个核心接口,如果你只打算做功能扩展,只需要关注 MicroBootstrapFlow 接口就好了。

接口名 描述
ExternalServer 由 ExternalCore 和 ExternalBrokerClientStartup 组成的一个整体。职责:是启动 ExternalCore 和 ExternalBrokerClientStartup 。
ExternalCore 与真实玩家连接的服务器,也是通信框架屏蔽接口。ExternalCore 帮助开发者屏蔽各通信框架的细节,如 Netty、mina、smart-socket 等通信框。ioGame 默认提供了基于 Netty 的实现。职责:与真实玩家连接的服务器
MicroBootstrap 与真实玩家连接的服务器,服务器的创建由 MicroBootstrap 完成,实际上 ExternalCore 是一个类似代理类的角色。MicroBootstrap 帮助开发者屏蔽连接方式的细节,如 TCP、WebSocket、UDP 等。 职责:与真实玩家连接的【真实】服务器
MicroBootstrapFlow 与真实玩家连接【真实】服务器的启动流程,专为 MicroBootstrap 服务。开发者可通过此接口对服务器做编排,编排分为:构建时、新建连接时两种。框架提供了 TCP、WebSocket、UDP 的实现;开发者可以选择性的重写流程方法,来定制符合自身项目的业务。职责:业务编排,也是开发者在扩展时接触最多的一个接口。

其他更新

<scalecube-cluster.version>2.6.15</scalecube-cluster.version>

<netty.version>4.1.93.Final</netty.version>

Package Rankings
Top 15.78% on Repo1.maven.org
Related Projects