游戏服务器开发指南(六):条件判断永远放在状态变更前

文章介绍了在游戏服务器开发中,编写健壮接口的重要原则——条件判断应置于状态变更之前,以确保状态变更的合法性和原子性。通过示例代码展示了如何避免部分状态变更导致的问题,并讨论了代码复用与事务处理的选择,强调了符合玩家直觉和易于日志查询的设计方法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

大家好!我是长三月,一位在游戏行业工作多年的老程序员,专注于分享服务器开发相关的文章。

从这篇开始,我们进入到通用程序设计的主题。这个主题主要探讨如何编写高效、健壮、易读的业务代码。游戏的开发本质上还是以内容制作为导向的,业务代码的开发时间要远远大于架构设计和框架搭建的时间。而游戏服务器的业务又与互联网或者其他领域不同,有着自己的特点。这个主题会汇集多条从游戏服务器业务开发中总结的经验法则,每篇从一个小点切入,看似基础但实则重要。

本文是通用程序设计主题下的第一篇。关于编写健壮的服务器接口,有条很重要的原则是:条件判断永远放在状态变更前

这条原则意思是为接口编写处理逻辑时,先罗列所有的条件判断,如果所有的判断通过,那么才进行状态变更(如更新DB或者修改内存状态),否则返回接口失败的信息给前端。例如下面从商店购买商品的代码:

public Msg buy(int playerId, int commodityId, int num) {
	if (!isCommodityIdValid(commodityId)) {	// 判断商品id是否存在
		return newFailMsg("错误的商品id");
	}
	if (!isCommodityEnough(commodityId)) {	// 判断商店中商品数量是否足够
		return newFailMsg("这种商品剩余数量已不足");
	}
	int costMoney = getCommodityCost(commodityId, num);
	if (!isMoneyEnough(playerId, costMoney)) {	// 判断玩家金币是否足够
		return newFailMsg("您的金币不足");
	}
	consumeMoney(playerId, costMoney);	// 消耗金币 
	deliverCommodity(playerId, commodity, num);	// 发送商品给玩家
	return newSuccMsg(playerId, commodityId, num);
}

这样做的原因很好理解:只有在所有的判断条件都通过后,状态变更(消耗金币和发货)才是合法的。而且消耗金币和发货这两个操作是相互关联的,适合放在一个原子操作中,要么同时成功,要么同时失败。试想,如果把对商品剩余数量的判断移到consumeMoney之后,那么可能会发生消耗了金币商品却没有到账的情况,这肯定是无法接受的。因此,将条件判断放在状态变更前,是为了保证状态变更的合法性和原子性

另一种保证状态变更原子性的方法是使用数据库事务,但这里不推荐。原因是目前大部分游戏服务器为了实时性考虑,都倾向于把数据放在内存中,数据库事务无法针对内存中的数据变更做出事务性的保证。

有时候,为了追求代码复用,代码编写者可能会违反这条原则。这并非一定是因为疏忽,也可能是编写者有意为之,因为觉得状态的部分变更也是可以接受的。例如,原来有一个抽奖1次的程序:

public Msg drawLottery(int playerId) {
	int costMoney = getLotteryCost();
	if (!isMoneyEnough(playerId, costMoney)) {	// 判断玩家金币是否足够
		return newFailMsg("您的金币不足");
	}
	consumeMoney(playerId, costMoney);	// 消耗金币 
	Reward reward = doDrawLottery(playerId);	// 抽1次奖,奖励发送给玩家
	return newSuccMsg(playerId, reward);
}

后来需要新增10连抽的功能,为了代码的复用性,通过调用上面的代码10次来实现:

public Msg drawLottery10(int playerId) {
	Msg combinedMsg = newCombinedMsg();	// 组合消息,用于将单次抽奖结果拼装到一起
	for (int i = 1; i <= 10; i++) {
		Msg msg = drawLottery(playerId);
		if (msg.isFail()) {	// 如果本次抽奖失败,那么直接返回之前的抽奖结果
			return combinedMsg;
		} else {
			combinedMsg.add(msg);
		}
	}
	return combinedMsg;
}

代码编写者的思路是:通过不断重复单次抽奖,直到玩家的金币不够就会自然停止。但是,这种代码编写方式是不值得推荐的,因为会有以下缺点:

  1. 与玩家游戏直觉不符。玩家通常倾向于认为抽奖要么成功,要么失败,10连抽如果结果不足10个第一反应是游戏出了bug。
  2. 容易造成前端处理的混乱。上面代码会造成一种介于成功和失败之间的“部分成功”,前端对这种特殊情况如果处理不当,可能造成抽奖结果显示错误的问题。
  3. 为日志查询带来麻烦。假定方法doDrawLottery中包括对抽奖结果的日志记录,那么10连抽时会被分开记录成0-10条,彼此之间没有直接关联,如果需要区分10连抽还是单抽则会比较困难。

建议的改进写法是修改原有的drawLottery方法,将其改造成抽1次和抽10次都可以调用的通用方法,这样不仅保证了代码的复用性,还不会带来原写法的上述缺点。具体代码如下:

public Msg drawLottery(int playerId, int num) {
	if (num != 1 && num != 10) {
		return newFailMsg("非法的抽奖次数");
	}
	int costMoney = getLotteryCost() * num;
	if (!isMoneyEnough(playerId, costMoney)) {	// 判断玩家金币是否足够
		return newFailMsg("您的金币不足");
	}
	consumeMoney(playerId, costMoney);	// 消耗金币 
	List<Reward> rewards = doDrawLottery(playerId, num);	// 按指定次数抽奖,奖励发送给玩家
	return newSuccMsg(playerId, rewards);
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值