无锁异步化、事件驱动架构设计的 java netty 网络编程框架; 轻量级,无需依赖任何第三方中间件或数据库就能支持集群、分布式; 适用于网络游戏服务器、物联网、内部系统及各种需要长连接的场景; 通过 ioGame 你可以很容易的搭建出一个集群无中心节点、集群自动化、分布式的网络服务器;fxgl netty protobuf unity3d godot cocos Creator websocket tcp socket;java netty 游戏服务器框架
AGPL-3.0 License
Bot releases are hidden (Show)
Published by iohao 5 months ago
21.8
文档与日志
版本更新汇总
[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 大类,分别是
以上功能职责(房间管理相关、流程相关、玩法操作相关)属于相对通用的功能。如果每款游戏都重复的做这些工作,除了枯燥之外,还将浪费巨大的人力成本。
而当前模块则能很好的帮助开发者屏蔽这些重复性的工作,并可为项目中的功能模块结构、开发流程等进行清晰的组织定义,减少了后续的项目维护成本。更重要的是有相关文档,将来当你的团队有新进成员时,可以快速的上手。
room 实战简介
文档中,我们基于该 room 模块做一个实战示例,该示例整体比较简单,多名玩家在房间里猜拳(石头、剪刀、布)得分。实战示例包括了前后端,前端使用 FXGL 引擎,这样开发者在学习时,只需 JDK 环境就可以了,而不需要安装更多的环境。启动游戏后玩家会将加入大厅(类似地图),多名玩家相互可见,并且玩家可以在大厅内移动。
[示例集合整理]
将 SimpleExample(文档中所有功能点的示例)、SpringBootExample(综合示例)、ioGameWeb2Game(web 转游戏 - 示例理解篇)、fxglSimpleGame(移动同步 FXGL + netty)合并成一个示例项目。
github | gitee |
---|---|
ioGame 示例集合 | ioGame 示例集合 |
Published by iohao 5 months ago
21.7
文档与日志
版本更新汇总
[external]
#271 游戏对外服 - 内置与可选 handler - log 相关的打印(触发异常、断开连接时)
[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 参数解析
Published by iohao 6 months ago
#264 新增属性值变更监听特性
文档 : 属性监听 (yuque.com)
属性可添加监听器,当某些属性值的发生变化时,触发监听器。
使用场景举例
比如玩家的血量低于一定值时,需要触发无敌状态;此时,我们就可以监听玩家的血量,并在该属性上添加一个对应的监听器来观察血量的变化,当达到预期值时就触发对应的业务。
类似的使用场景还有很多,这里就不过多的举例了。属性监听的特点在于属性变化后会触发监听器。
属性监听特点
框架已经内置了几个属性实现类,分别是:
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
下面这个示例,我们将 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();
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
}
#266 新增 RangeBroadcast 范围内的广播功能,这个范围指的是,可指定某些用户进行广播。
在执行广播前,开发者可以自定义业务逻辑,如
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();
移除 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();
});
Published by iohao 6 months ago
/**
* 动物
*/
@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;
}
增强 ClassScanner 类
优化模拟客户端
#258 文档生成,兼容 gradle 编译路径
enhance jprotobuf,临时解决打包后不能在 linux java21 环境运行的问题,see java21,springBoot3.2 打 jar 后使用异常 · Issue #211 · jhunters/jprotobuf (github.com)
生成 .proto 时,在最后打印文件路径
Published by iohao 7 months ago
CreateRoomInfo.createUserId int --> long
文档生成时,默认指定 StandardCharsets.UTF_8
javaProjectBuilder.setEncoding(StandardCharsets.UTF_8.name());
玩家下线时,使用自身所关联的线程处理。
SocketUserSessions removeUserSession
ExecutorRegion
1 优化默认创建策略
2 优化 ExecutorRegionKit,SimpleThreadExecutorRegion 默认使用全局单例,减少对象的创建。
Published by iohao 7 months ago
游戏对外服 - 自定义编解码 - WebSocketMicroBootstrapFlow
重写 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 集群相关日志不开启。
你是否想要开发一个高性能、稳定、易用、自带负载均衡、避免类爆炸设计、可跨进程跨机器通信、集群无中心节点、集群自动化、有状态多进程的分布式的网络编程服务器呢?如果是的话,这里向你推荐一个由 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 则提供了多种类型的通讯方式,通过对各种通讯方式的组合使用,可以简单完成以往难以完成的工作,并且这些通讯方式都支持跨进程、跨机器通信,且具备全链路调用日志跟踪。这些通讯方式分别是
在编码风格上,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 支持自动装箱、拆箱基础类型,用于解决协议碎片的问题 ...等。诸多丰富的功能,使得开发者的业务代码更加的清晰、简洁;
业务框架提供了插件机制,插件是可插拨、可扩展的。框架内置提供了 DebugInOut、action 调用统计、业务线程监控插件、各时间段调用统计插件...等插件;不同的插件提供了不同的关注点,比如我们可以使用调用、监控等插件相互配合,可以让我们在开发阶段就知道是否存在性能问题。合理利用好各个插件,可以让我们在开发阶段就能知道问题所在,提前发现问题,提前预防问题。
在分布式开发体验方面,通常在开发分布式应用时是需要启动多个进程的。这会让调试与排查问题变得非常困难,从而降低开发者的效率、增加工作量等,这也是很多框架都解决不了的问题,但 ioGame 做到了!ioGame 支持多服单进程的启动方式,这使得开发者在开发和调试分布式系统时更加简单。
与前端对接联调方面,ioGame 提供了游戏文档生成的辅助功能,可以做到代码即对接文档。简单地说,当业务代码编写完后,框架会自动生成最新的文档。如果没有游戏文档的生成,那么你将要抽出一些时间来编写、维护对接文档的工作,而且当团队人数多了之后,文档就会很乱、不同步、不是最新的、忘记更新等情况就会出现。
在部署方面,ioGame 支持多服单进程的方式部署,也支持多服多进程多机器的方式部署;在部署方式上可以随意的切换而不需要更改代码。日常中我们可以按照单体思维开发,到了生产可以选择使用多进程的方式部署。
在安全方面,所有的游戏逻辑服不需要开放端口,天然地避免了扫描攻击。由于不需要为每个逻辑服分配独立的端口,那么我们在使用诸如云服务器之类的服务时,就不需要担心端口开放权限的问题了。别小看这一个环节,通常这些小细节最浪费开发者的时间。由于我们不需要管理这些 IP:Port,这部分的工作量就自然地消失了。
在模拟客户端测试方面,ioGame 提供了压测&模拟客户端请求模块。此模块是用于模拟客户端,简化模拟工作量,只需要编写对应请求与回调。除了可以模拟简单的请求外,通常还可以做一些复杂的请求编排,并支持复杂业务的压测。与单元测试不同的是,该模块可以模拟真实的网络环境,并且在模拟测试的过程中与服务器的交互是可持续的、可互动的,同时也是支持自动化的。
在架构灵活性方面,ioGame 的架构由三部分组成:1.游戏对外服、2.Broker(游戏网关)、3.游戏逻辑服;三者既可相互独立,又可相互融合。这意味着使用 ioGame 可以适应任何类型的游戏,因为只需通过调整部署方式,就可以满足不同类型的游戏需求。在 ioGame 中进行这些调整工作非常简单,而且不会对现有代码产生不良影响。
开发者基于 ioGame 编写的项目模块,通常是条理清晰的,得益于框架对路由的合理设计,同时也为路由提供了优雅的访问权限控制。当我们整理好这些模块后,对于其他开发者接管项目或后续的维护中,会是一个不错的帮助(模块的整理与建议)。或许现阶段你感受不到这块的威力,随着你深入地使用实践就能体会到这么设计的诸多好处与优势。
开发者基于 ioGame 编写的项目,通常是语法简洁的、高性能的、低延迟的。框架最低要求使用 JDK21,这样即可以让项目享受到分代 ZGC 带来的改进,还能享受语法上的简洁。分代 ZGC 远低于其亚毫秒级暂停时间的目标,可以在不影响游戏速度的情况下,清理掉多余的内存;这样就不会出现卡顿或者崩溃的问题了,相当于在项目中变相的引入了一位 JVM 调优大师。
综上所述,ioGame 是一个非常适合网络游戏开发的框架。可以让你轻松地创建高性能、低延迟、易扩展的游戏服务器,并且节省时间和资源。如果你想要快速地开发出令人惊艳的网络游戏,请不要犹豫,立即选择 ioGame 吧!框架屏蔽了很多复杂且重复性的工作,并可为项目中的功能模块结构、开发流程等进行清晰的组织定义,减少了后续的项目维护成本。
框架在开发、部署、压测&模拟测试 ...等,各个阶段都提供了很好的支持。相信你已经对 ioGame 有了一个初步的了解,虽然还有很多丰富的功能与特性没有介绍到,但你可以通过后续的实践过程中来深入了解。感谢你的阅读,并期待你使用 ioGame 来打造自己的游戏服务器。
Published by iohao 8 months ago
相关示例仓库已同步更新,升级过程可参考示例仓库。
功能支持 | 完成 | 描述 | 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 中,该版本做了数百项优化及史诗级增强。
#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);
}
}
#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 业务消费的线程相关内容如下:
在 ioGame21 中,框架内置了 3 个线程执行器管理域,分别是
从工具类中得到与用户(玩家)所关联的线程执行器
@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 分布式事件总线,特点
下面两个订阅者是分别在不同的进程中的,当事件发布后,这两个订阅者都能接收到 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 中,该版本做了数百项优化及史诗级增强。
这里介绍 ioGame17 迁移到 ioGame21。
升级建议,先将 ioGame17 升级到 17 系列中的最后一个版本(17.1.61)。查看是否使用了已标记为过期的方法,先将过期方法改为正常的(标记为过期的方法上有注释及推荐使用的代替方法)。
整体破坏性变更内容较少,基本只涉及到游戏对外服,原因(#213)是游戏对外服开放了自定义协议。如果开发者没有在游戏对外服做任何扩展的(如,编解码、心跳、其他 netty handler ...等 ),那么升级将是丝滑的。
#213 游戏对外服开放了自定义协议,所以相关的 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) {
}
}
#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;
}
}
示例库中,提供了 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();
}
Published by iohao 10 months ago
#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.
Published by iohao 11 months ago
#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 和 SimpleThreadExecutorRegion 提供了单例实现,即使在同一进程中启动了多个逻辑服,也不会创建过多线程执行器,而是使用同一个。
Published by iohao 11 months ago
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>
Published by iohao 12 months ago
优化 FlowContext createRequestMessage
可能在 springboot 集成 light-domain-event 时,启动报 java.lang.ClassNotFoundException
关于改造现有或老客户端项目到 ioGame 遇到的问题
HeadMetadata 增加 customData 属性;用于自定义数据,专为开发者预留的一个字段,开发者可以利用该字段来传递自定义数据。该字段由开发者自己定义,框架不会对数据做任何处理,也不会做任何检查,开发者可以利用该字段来传递任何数据,包括自定义对象。
因发展需要,模拟客户端使用新 api ,与 SDK 风格做统一。从 17.1.58 版本开始,推荐开发者使用新版本的 api。已经将老的 api 做了过期标记,将在 ioGame21 版本中完全移除这些过期的 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);
});
变更说明
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
Published by iohao about 1 year ago
[#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>
Published by iohao about 1 year ago
[#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();
}
}
Published by iohao about 1 year ago
[#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);
}
Published by iohao about 1 year ago
// 断言为 true, 就抛出异常,可自定义消息
void assertTrueThrows(boolean v1, String msg)
// 断言值 value 不能为 null, 否则就抛出异常,可自定义消息
void assertNonNull(Object value, String msg)
请使用新游戏对外服
模拟命令域 InputCommandRegion 新增 loginSuccessCallback 成功回调方法,当模拟玩家登录成功后会调用此方法
public class MapInputCommandRegion extends AbstractInputCommandRegion {
... ... 省略部分代码
@Override
public void loginSuccessCallback() {
// 进入地图,根据地图 id
EnterMapReq enterMapReq = new EnterMapReq();
enterMapReq.mapId = 1;
ofRequestCommand(MapCmd.enterMap).request(enterMapReq);
}
}
一般在登录模拟请求的回调中主动的调用,开发者可在登录成功后,调用此方法。使其触发所有的 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);
}
}
新增重复上一次命令的支持
假设在模拟客户端控制台查中,有如下命令
每次输入 5-1,会给人物增加 10 点经验值
如果我们想多次增加经验值,每次在控制台 5-1 太麻烦了;这里有个小技巧,在控制台输入 【+】,可以重复上次输入的命令。
Published by iohao about 1 year ago
文档生成增强,增加 action 参数注释说明.
文档生成增强,返回值注释说明.
fix 在 pom 中引入本地 jar 时,文档解析的错误。
Published by iohao over 1 year ago
[160] 轻量小部件 - 压测&模拟客户端请求模块
文档:https://www.yuque.com/iohao/game/tc83ud
介绍
此模块是用于模拟客户端,简化模拟工作量,只需要编写对应请求与回调。
使用该模块后,当我们与前端同学联调某个功能时,不需要跟前端哥们说:在点一下、在点一下、在点一下了。这种“在点一下”的交流联调方式将成为过去式。
除了可以模拟简单的请求外,通常还可以做一些复杂的请求编排,并支持复杂业务的压测。模拟测试的过程是可互动的,但也支持测试自动化。
与单元测试不同的是,该模块可以模拟真实的网络环境,并且在模拟测试的过程中与服务器交互是可持续的、可互动的。
可互动模式是用于调试测试某些功能。在互动的过程中,开发者可以在控制台中指定执行某个模拟请求命令,并且支持在控制台中输入一些动态的请求参数,从而让我们轻松的测试不同的业务逻辑走向。
关于可互动部分,需要将后续文档阅读完后才能知道其具体意思。
特点
整体文档有点多,但在实际的使用上是比较简单的。
图左边是我们提供的 action,图右边则是我们编写的模拟请求。
控制台中是可交互的部分,可以查看提供了哪些模拟的客户端请求。
通过在控制台中输入 【cmd-subCmd】 来触发对应的请求。
触发请求后,当服务器有响应数据时,会进入到对应模拟请求的回调中。
比如需要做一个简单的:私聊系统、聊天频道、好友系统时,这个模拟客户端就派上用场了。
模拟客户端模块还提供了一个 ClientUser(玩家)对象,一个模拟客户端对应一个 ClientUser。ClientUser 是客户端的用户(玩家)对象,开发者可以通过动态属性 options 来扩展业务,比如可以在动态属性中保存货币、战力值、血条 ...等。也可以通过继承的方式来扩展。
文档生成增强,增加 action 参数注释说明、返回值注释说明。
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(游戏网关)、游戏逻辑服,当有请求需要处理时:
图中有这么几个部分,分别是:
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】来处理。
开发者可以利用同进程亲和性的特点,按照上图中的部署方式,可以让各服务器之间通过进程来做微隔离。此外,又能提供一些游戏逻辑服来处理公共业务;如一些跨服活动、跨服战...等,各种跨服业务。
使用该部署方式,可做参考的游戏类型如下:
同进程亲和性是优先访问同进程内的游戏逻辑服,并不是说不能访问其他进程的游戏逻辑服。
特点
ioGame 的架构由三部分组成:1.游戏对外服、2.Broker(游戏网关)、3.游戏逻辑服;三者既可相互独立,又可相互融合。所以,使用 ioGame 几乎可以满足任意的部署方式,可以根据你的需求来适应不同类型的游戏,并且在 ioGame 中做这些工作是简单的。
这里为什么要特意介绍一下三者之间的组合关系呢?目的是想告诉开发者,ioGame 的架构是灵活的。同进程亲和性在以下组合会生效。
为了更简单的说明三者之间的灵活性,现在把三者用字母代替,A.游戏对外服、B.Broker(游戏网关)、C.游戏逻辑服;我们可以得出如下组合
ABC :三者在一个进程中,他们之间使用内存通信;(无需传输)
AB + C :【游戏对外服和游戏网关】在一个进程中,他们之间使用内存通信;(传输一次)
A + BC :【游戏网关和游戏逻辑服】在一个进程中,他们之间使用内存通信;(传输一次)
通过上面的组合,我们可以看出,ioGame 架构是支持类似传统架构那样只做一次跳转,甚至可以做到零跳转,这完全取决于你们的部署方式。
上面三种组合方式,都具备同进程亲和性。同进程亲和性生效的要点只有一个,至少保证有两部分在一个进程内。
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;
Published by iohao over 1 year ago
[#115] 游戏对外服增加路由是否存在检测
参考:https://www.yuque.com/iohao/game/ea6geg#EeWiH
新游戏对外服中增加路由存在检测。当路由不存在时,可以起到抵挡的作用,而不必经过其他服务器。
[#114] 支持玩家与多个游戏逻辑服的动态绑定
文档:动态绑定游戏逻辑服
动态绑定游戏逻辑服,指的是玩家与游戏逻辑服绑定后,之后的请求都由该游戏逻辑服来处理。
玩家动态绑定逻辑服节点后,之后的请求都由这个绑定的游戏逻辑服来处理,可以实现类似 LOL、王者荣耀匹配后动态分配房间的效果。
支持玩家与多个游戏逻辑服的动态绑定。
使用场景
跨服活动、跨服战斗等。
动态绑定游戏逻辑服可以解决玩家增量的问题,我们都知道一台机器所能承载的运算是有上限的;当上限达到时,就需要增加新机器来分摊请求量;如果你开发的游戏是有状态的,那么你如何解决请求分配的问题呢?在比如让你做一个类似 LOL、王者荣耀的匹配,将匹配好的玩家分配到一个房间中,之后这些玩家的请求都能在同一个游戏逻辑服上处理,这种业务你该如何实现呢?
使用框架提供的动态绑定逻辑服节点可以轻松解决此类问题,而且还可以根据业务规则,计算出当前空闲最多的游戏逻辑服,并将此游戏逻辑服与玩家做绑定,从而做到均衡的利用机器资源,来防止请求倾斜的问题。
[#113] 新版本游戏对外服
文档:新游戏对外服使用
迁移指南 :迁移到新版游戏对外服
【新版游戏对外服】用于取代【旧的游戏对外服】,如果条件允许,请尽可能做迁移,整体工作量很少。旧的游戏对外服将不在做功能上的新增,如果存在 bug 将会继续修复,维护期会持续到下个大版本前。
从架构简图中,我们知道了整体架构由三部分组成 1.游戏对外服、2.游戏网关、3.游戏逻辑服。本篇将介绍游戏对外服这部分,及功能扩展等相关的。
游戏对外服的职责
扩展场景
游戏对外服主要负责与用户(玩家)的连接。假设一台硬件支持最多建立 5000 个用户连接,当用户量达到 7000 人时,我们可以增加一个游戏对外服来进行流量控制和减压。
由于游戏对外服的扩展性和灵活性,可以支持同时在线玩家从几千人到数千万人不等。这是因为,通过增加游戏对外服的数量,可以有效地进行连接的负载均衡和流量控制,使得系统能够更好地承受高并发的压力。
连接方式的切换、支持、扩展
ioGame 已提供了 TCP、WebSocket、UDP 连接方式的支持,并提供了灵活的方式来实现连接方式的切换。可以将 TCP、WebSocket、UDP 连接方式与业务代码进行无缝衔接。开发者可以用一套业务代码,无需任何改动,同时支持多种通信协议。
如果想要切换到不同的连接方式,只需要更改相应的枚举即可,非常简单。在不使用 ioGame 时,将连接方式从 TCP 改为 WebSocket 或 UDP 等,需要进行大量的调整和改动。然而,在 ioGame 中,实现这些转换是非常简单的。此外,不仅可以轻松切换各种连接方式,而且可以同时支持多种连接方式,并使它们在同一应用程序中共存。
连接方式是可扩展的,而且扩展也简单,这意味着之后如果支持了 KCP,那么将已有项目的连接方式,如 TCP、WebSocket、UDP 切换成 KCP 也是简单的。
需要再次强调的是,连接方式的切换对业务代码没有任何影响,无需做出任何改动即可实现连接方式的更改。
新游戏对外服 - UML
游戏对外服由两部分构成
我们只需要关注 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>