大家好!我是长三月,一位在游戏行业工作多年的老程序员,专注于分享服务器开发相关的文章。
从这篇开始,我们进入到通用程序设计的主题。这个主题主要探讨如何编写高效、健壮、易读的业务代码。游戏的开发本质上还是以内容制作为导向的,业务代码的开发时间要远远大于架构设计和框架搭建的时间。而游戏服务器的业务又与互联网或者其他领域不同,有着自己的特点。这个主题会汇集多条从游戏服务器业务开发中总结的经验法则,每篇从一个小点切入,看似基础但实则重要。
本文是通用程序设计主题下的第一篇。关于编写健壮的服务器接口,有条很重要的原则是:条件判断永远放在状态变更前。
这条原则意思是为接口编写处理逻辑时,先罗列所有的条件判断,如果所有的判断通过,那么才进行状态变更(如更新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;
}
代码编写者的思路是:通过不断重复单次抽奖,直到玩家的金币不够就会自然停止。但是,这种代码编写方式是不值得推荐的,因为会有以下缺点:
- 与玩家游戏直觉不符。玩家通常倾向于认为抽奖要么成功,要么失败,10连抽如果结果不足10个第一反应是游戏出了bug。
- 容易造成前端处理的混乱。上面代码会造成一种介于成功和失败之间的“部分成功”,前端对这种特殊情况如果处理不当,可能造成抽奖结果显示错误的问题。
- 为日志查询带来麻烦。假定方法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);
}