经测试此产品运营稳定
包含数十款房卡子游戏、俱乐部(五级权限)、比赛场
客户端采用Lua脚本开发 、后端Python
看过一些棋牌产品 很多产品基于此套棋牌框架开发而来 算市面上一个主流框架
但却没有发现一个关于此框架的文档说明 特此个人准备写几篇文档以方便新手和后来的开发维护人员借鉴 以节约队伍开发成本 尽快步入产品开发过程
Web提供的API服务是基于tornado框架实现
网关与业务之间使用了twisted异步通讯库 用于提升性能 socket长链接方式
框架在设计上支持负载均衡 除路由服务外 其他的服务器皆支持动态无限量扩展部署
Web服务 -- web_server.py
--- Http短链接形式 服务更加稳定 且在用户基数比较大的时候可以很有效的节省服务器资源 减轻服务器负担
--- 提供APIs(提供除子游戏 游戏层面的消息通讯外的所有接口 如比较高频的创建房间、加入房间)
---提供游戏系统APIs 方便运营对数据和游戏进行管控
游戏网关 -- gate.py --> base_server.py
--- 服务端除web服务外的 唯一向外开放的网络接口
--- 实现系统和用户(群)通讯 维护每个链接进来客户端的session 将seesion与用户uid对应起来
路由服务 -- router.py --> base_server.py
--- 接受来自web端的请求、游戏服务消息 将消息转发到相应的服务
游戏服务类 -- base_game.py --> base_server.py
--- 发送消息给路由服务 路由将消息转发到网关 网关将消息根据uid(s)发送给相应的客户端 客户端发送消息到服务器则路径相反
网络部分通讯流程关系图:
./web-cocos/web_server.py ---> 基于tornado实现的一个web服务处理器
#配置web服务器端口信息
define("port", default=8193, help="run on the given port", type=int)
class Application(tornado.web.Application):
def __init__(self):
#此处仅列出几个代表性的api
handlers = [
#苹果内购iap接口
("/iap", IAPHandler),
#游客登陆
("/guestLogin", GuestLoginHandler),
#微信登陆
("/wechatLogin",WeChatLoginHandler),
#创建房间
("/createRoom", CreateRoomHandler),
#获取钻石信息
("/getDiamondsChange", DiamondsHandler),
#查询房间信息
("/queryServerInfo", QueryRoomHandler),
#...
#...
]
#注册web消息处理接口
tornado.web.Application.__init__(self, handlers, **settings)
./server-cocos/gate.py ---> ./server-cocos/base/init.py
def _do_start(server_class, server_id, service_type, server_name, is_gate):
assert server_id
assert service_type
#如果是网关服务 则从数据库 'servers'表中读取服务器信息
if is_gate:
server_info = servers_model.get(server_id)
else:
#如果是子游戏服务 则从数据库‘server_rooms’表中读取服务器信息
server_info = servers_model.get_idle_room(server_id, service_type)
if not server_info:
text = f"\033[31mError\033[0m {server_name} - \033[31m{service_type}\033[0m - {server_id} isn't idle server!"
print(text)
main_logger.fatal(text)
sys.exit(0)
#启动服务
_run_server(server_class, server_id, service_type, server_name, server_info)
./server-cocos/hall/gateway.py 维护每个链接进来的客户端session 且提供账户验证服务、系统服务
#启动网关服务
def start_service(self):
#链接路由服务 将服务注册到路由表 且设置一个收到网络消息的回调函数
self._start_listen_channel(self.__on_receive_message)
player_agent = Factory()
player_agent.protocol = SessionClient
reactor.listenTCP(self.__port, player_agent)
self.logger.info('Starting listening on port %d', self.__port)
LoopingCall(5 * 60, self.__clean_timeout_sessions)
servers_model.update_status(self.server_id, 1)
#添加系统消息shutdown的处理函数 用于妥善停止网关服务
reactor.addSystemEventTrigger('before', 'shutdown', self.on_signal_stop)
reactor.run()
#装载用户验证服务、系统服务
def setup(self, server_id, service_type, service_name, server_info):
BaseServer.setup(self, server_id, service_type, service_name, server_info)
port = int(server_info.get('port'))
self.__port = port
#设置验证服务处理handler 用户登陆、注册
self.set_service(AuthService())
#设置系统服务处理handler 系统消息广播
self.set_service(SystemService())
#保存用户session 将session与uid关联起来
def __save_session(self, session: SessionClient):
if not session or not session.uid or not session.verified:
return
self.__sessions[session.uid] = session
# 接受到客户端发来的消息
def on_line_received(self, session, line):
if not line or len(line) < 2:
return
#将接受到客户端的消息进行json格式解码
ret = utils.json_decode(line)
if not ret or not ret.get('cmd'):
session.close()
return self.logger.info('receive data error: ' + utils.bytes_to_str(line))
self.distribute(session, ret.get('cmd'), ret.get('msg'))
#消息分发处理
def distribute(self, session, cmd, msg):
service_type, cmd = protocol_utils.unpack_command(cmd)
#将消息分发到相应已注册的handler
service = self.__services.get(service_type)
if not service:
return self.__check_run_client_commands(session, service_type, cmd, msg)
service.service(session, cmd, msg)
#向所有用户广播消息
def send_global_message(self, service_type, cmd, msg):
for s in self.__sessions.values():
self.__send_message_by_session(s, service_type, cmd, msg)
#向指定uid用户发送消息
def __send_message_by_uid(self, uid, service_type, cmd, msg):
session = self.__get_session_by_uid(uid)
if session:
session.send(protocol_utils.pack_client_message(service_type, cmd, msg))
./server-cocos/hall/router.py --- 每一个服务(包括网关、子游戏服务)都会注册到路由表 从web端接受到消息 然后根据服务类型分发到相应的服务处理
from protocol.route_protocol import PubFactory
# 启动路由服务
def start_service(self):
endpoints.serverFromString(reactor, "tcp:{0}".format(self.__port)).listen(PubFactory())
self.logger.info('Starting listening on port %d', self.__port)
#添加系统消息shutdown的处理函数 用于妥善停止路由服务
reactor.addSystemEventTrigger('before', 'shutdown', self.on_signal_stop)
reactor.run()
./protocol/router_protocol.py
#当有服务器链接到路由服务时 将服务注册到路由表clients
def connectionMade(self):
if self.ip not in self.factory.allow_ips:
self.sendLine(b"Access denny.")
self.close()
return
self.factory.clients.add(self)
#接收网络消息
def lineReceived(self, line):
head, body = protocol_utils.unpack_s2s_package(line)
if not head:
return
cid, from_sid, from_service, to_sid, to_service, with_ack, cmd = protocol_utils.unpack_s2s_head(head)
if 1 == with_ack: # 响应ACK消息
self.__response_ack(cid)
if cmd == commands_s2s.S2S_HEART_BEAT:
return self.__on_heart_beat(from_sid, from_service)
if cmd == commands_s2s.S2S_ACK:
return
if cmd == commands_s2s.S2S_SEND:
return self.__try_send_message(to_sid, to_service, line)
#当路由服务接受到消息时 遍历路由表clients 将消息分发给相应的服务器
def __try_send_message(self, to_sid, to_service, line):
for c in self.factory.clients:
if c == self:
continue
if to_sid > 0 and to_sid != c.sid:
continue
if to_service > 0 and to_service != c.service_type:
continue
c.sendLine(line)
./server-cocos/protocol/router_client.py --- 每一个链接到路由服务的 都属于一个router client
#注册链接断开和 消息接受处理的handler
def set_handlers(self, on_connection_lost, on_line_received):
self.__on_connection_lost = on_connection_lost
self.__on_line_received = on_line_received
#接受到路由转发来的消息
def lineReceived(self, line):
self.__last_data_arrive_time = utils.timestamp()
if callable(self.__on_line_received):
#__on_line_received 为一个回调函数 接收到消息后将消息转发到这里处理
self.__on_line_received(self, line)
else:
main_logger.warn("line receive with no handlers!", line)
#给路由服务发送消息
def send(self, obj):
# 发送数据包, obj要可以被json编码
# s = utils.json_encode(obj)
self.sendLine(utils.json_encode(obj).encode("utf8"))
#链接路由服务
def connect_route_server(on_conn_success, on_conn_fail):
host = config.get_item("router_ip") or "127.0.0.1"
port = config.get_item("router_port") or 20000
point = TCP4ClientEndpoint(reactor, host, port)
conn = point.connect(PubClientFactory())
conn.addCallbacks(on_conn_success, on_conn_fail)
./server-cocos/base/base_game.py --- 子游戏服务的基类
#游戏服务类继承于基本服务器BaseServer
class BaseGame(BaseServer):
def __init__(self):
BaseServer.__init__(self)
self.__defer = None
self.__in_stop = False
self.__stop_timer = None
self.__players = {} # 玩家数列列表
self.__judges = {} # 桌子对象列表
#所有子游戏通用 添加处理几个游戏层面的消息处理handler
self._add_handlers({
const.ACK: self.update_ack_time,
const.PLAYER_TABLE_REMOVE: self.remove_player_table,
const.PLAYER_UNREADY: self.unready_player,
})
#启动游戏服务
def start_service(self):
#更新数据库中此游戏服务的状态
servers_model.set_room_start(self.server_id, self.service_type, os.getpid(), utils.read_version())
#链接路由服务 将服务注册到路由表 且设置一个收到网络消息的回调函数
self._start_listen_channel(self.on_receive_message)
try:
#添加系统消息shutdown的处理函数 用于妥善停止游戏服务
reactor.addSystemEventTrigger('before', 'shutdown', self.on_signal_stop)
reactor.run()
except KeyboardInterrupt:
self.close_server()
#发送网络数据给玩家
def send_body_to_player(self, uid, cmd, body, service_type=None, to_all_service=False):
service_type = service_type or self.service_type
#根据协议打包数据
message = protocol_utils.pack_to_player_body(cmd, uid, body)
#如果未指定目的服务器 则发送数据到网关
to_service = 0 if to_all_service else const.SERVICE_GATE
#此函数通过保存的路由器句柄发送数据到路由
return self._s2s_raw_publish(0, self.sid, service_type, 0, to_service, 1,
commands_s2s.S2S_SEND, message)
#注册处理网络消息handlers
self._add_handlers({
const.ACK: self.update_ack_time,
const.PLAYER_TABLE_REMOVE: self.remove_player_table,
const.PLAYER_UNREADY: self.unready_player,
})
#接受到网络数据 根据注册的handlers进行分发处理
def on_receive_message(self, from_sid, from_service, to_sid, to_service, body):
#解包网络数据
cmd, uid, msg = protocol_utils.unpack_to_player_body(body)
if not cmd:
return
if cmd == commands_system.SOCKET_CHANGE:
offline = msg.get("offline")
return self.on_player_connection_change(uid, offline)
if from_service == const.SERVICE_SYSTEM:
return self.__run_system_commands(cmd, uid, msg)
return self.service(cmd, uid, msg)
扩展性
在有需要的情况下也可以部署多个网关服务器
游戏业务负载均衡
1.当一个新桌子被启用时 用redis记录对应服务器id和桌子数量
2.在创建新桌子的时候 更具redis中的记录 选取一个最低负载的游戏服务器提供服务
./server-cocos/base/base_judge.py
#添加玩家到桌子信息中 更新桌子数量信息
def add_player_in_table_info(self, info):
# 玩家第一次加入到一个游戏桌子的时候
if not self.__first_join:
self.__first_join = True
# 在游戏服务中更新桌子数量信息
self.__service.modify_table_count(self.club_id, self.sub_floor_id)
find_player = False
for i in self.__table_info['players']:
if not i:
continue
if i['uid'] == info['uid']:
find_player = True
break
# 添加玩家到桌子信息中
if not find_player:
self.__table_info['players'].append(info)
./server-cocos/base/base_game.py
#更新桌子数量信息
def modify_table_count(self, club_id, sub_floor):
#将redis中字符串key存储的数字值增加一
share_connect().incr(f"table:{self.service_type}:{self.server_id}")
if sub_floor == -1:
return
database.incr_club_sub_floor_count(club_id, sub_floor)
self.club_join_create_room(club_id, sub_floor)
./web-cocos/models/game_room_model.py -- 游戏房间相关模型
#从数据库中查询所有在运行的对应类型的服务器
def _get_running_sid_by_game_type(conn, game_type):
sql = f"SELECT sid FROM `server_rooms` WHERE game_type={game_type} and status=1"
return conn.query(sql)
#从redis中取出服务器id
def _get_server_sid_from_redis(redis_conn, game_type, servers):
rooms = {}
for i in servers:
#在redis中取出字符串Key对应的数字值
count = redis_conn.get(f"table:{game_type}:{i['sid']}")
count = 0 if not count else count.decode('utf-8')
rooms[i['sid']] = int(count)
max_desk = 1000000
default_sid = 1
#获得一个负载最低的服务器 并返回
for key in rooms:
if rooms[key] <= max_desk:
default_sid = key
max_desk = rooms[key]
return default_sid
#在创建游戏房间时 选取负载最低的服务器
def get_best_server_sid(conn, redis_conn, game_type):
#得到对应服务的所有服务器
server = _get_running_sid_by_game_type(conn, game_type)
if not server:
return 0
if len(server) == 1:
return server[0]['sid']
return _get_server_sid_from_redis(redis_conn, game_type, server)
./web-cocos/controllers/room_handler.py --- 游戏房间控制器
#创建房间消息 处理的handler
class CreateRoomHandler(BaseHandler):
#此处可能导致解散一个房间后 创建相同游戏房间 房间号和之前相同的问题
#先尝试从redis中取房间ID 如果为0则 生成随机房间ID
tid = base_redis.spop_table_id() or utils.get_random_num(6)
#校验用户是否具备足够开房钻石
idle_table_diamond = tables_model.get_total_idle_diamonds_by_uid(self.share_db(), user['uid'])
if user.get('diamond', 0) + user.get('yuan_bao') < diamonds + idle_table_diamond:
return self.write_json(error.DIAMONDS_CLUB_NOT_ENOUGH)
#通过redis链接获取当前最低负载房间服务器id
room_sid = game_room_model.get_best_server_sid(self.share_db(), redis_conn(), game_type)
if not room_sid:
return self.write_json(error.DATA_BROKEN)
# 房间创建成功 将房间服务器id等开房数据插入到 'tables'表中
count = tables_model.insert(self.share_db(), room_sid, game_type, int(tid), self.uid, is_agent, total_round,
diamonds, rule_type, club_id, rules, consume_type=consume_type)
#房间创建成功 则返回房间消息给创建者
return self.write_json(error.OK, {'roomID': int(tid), "gameType": game_type,
"ruleDetails": rules, "isAgent": is_agent})