本节代码:xyq10612/PitayaGame at chapter1.3-登录逻辑与服务器路由 (github.com)
前面两节我们完成了账号注册,接下来让我们实现登录逻辑。
我们再看一下 第一节 画的架构图:
考虑 3 个问题:
- ProxyServer 只是代理服务器,真正的登录业务逻辑(如:从数据库中加载玩家数据)是在 LobbyServer 中实现的,但是 LobbyServer 是多开的一个服务器组,代理服务器是如何知道该转发给哪个大厅服的呢?
- 假设玩家 PlayerA 在 LobbyServer2 中登录了,由于 IO 比较慢,服务器一般会尽量少的去访问 MongoDB,所以即使玩家下线了, PlayerA 的数据也不会立刻从服务器中移除,而是会缓存一段时间。当玩家再次登录上来的时候,代理服务器将登录请求再次转发到 LobbyServer2,此时服务器上是有该玩家的缓存数据的,就不需要再去 DB 拉玩家信息了。那么 ProxyServer 是如何知道玩家之前在哪个服务器服务器登录过、保留有缓存数据呢?
- 如果一个账号在短时间内多次登录肯定会出问题,如何实现登录锁机制、保证登录操作的原子性?
本节我们主要解决前两个问题,通过的 Session
和 Redis
来记录信息,实现 LobbyServer 的路由策略。
封装 Redis Module
既然要借助 Redis 来记录登录信息,那么我们先把操作封装一下,参考对 MongoDB 的封装,实现 DataBase
接口:
PS: 完整的代码实现在 common/modules/redis
目录
type RedisStorage struct {
modules.Base
*redis.Client
config RedisConfig
}
func NewRedisStorage(config RedisConfig) *RedisStorage {
return &RedisStorage{
config: config,
}
}
func (r *RedisStorage) Init() error {
r.Connect()
return nil
}
func (r *RedisStorage) Connect() {
uri := r.config.GetConnURI()
opts, err := redis.ParseURL(uri)
if err != nil {
panic(err)
}
r.Client = redis.NewClient(opts)
if err := r.TestPing(); err != nil {
panic(err)
}
}
func (r *RedisStorage) TestPing() error {
_, err := r.Client.Ping(context.TODO()).Result()
return err
}
func (r *RedisStorage) Close() {
_ = r.Client.Close()
}
同样的,在 common/helper/redisHelper.go
也提供一个获取 Redis 模块的方法:
var r *redis.RedisStorage
func GetRedis() *redis.RedisStorage {
if r == nil {
module, err := pitaya.DefaultApp.GetModule(constants.RedisModule)
if err != nil {
panic(err)
}
r = module.(*redis.RedisStorage)
}
return r
}
定义登录消息
message LoginRequest {
string account = 1;
string password = 2;
}
message LoginResponse {
ErrCode ret = 1;
string uid = 2;
}
现在我们定义登录的回复只返回了 UID,但是实际上还需要返回其他的信息,比如:玩家的昵称、头像、等级等等。具体细节我们之后再完善,现在先聚焦于登录流程的实现。
在 LobbyServer 处理登录消息
以下 uid 的生成是临时的,但是也保证了唯一性,后面我们会使用 MongoDB 的自增 ID 来生成 UID。
顺便实现一个伪造的登录后逻辑 MockLogic
,用于测试,后面我们在使用 pitaya-cli 测试的时候会用上。
// lobbyServer/service/accountService.go
func (s *AccountService) Login(ctx context.Context, req *proto.LoginRequest) (*proto.LoginResponse, error) {
logger := pitaya.GetDefaultLoggerFromCtx(ctx)
logger.Infof("login...%v", req)
uid := req.Account + s.app.GetServerID()[:6]
return &proto.LoginResponse{Ret: proto.ErrCode_OK, Uid: uid}, nil
}
func (s *AccountService) MockLogic(ctx context.Context) (*proto.CommonResponse, error) {
logger := pitaya.GetDefaultLoggerFromCtx(ctx)
logger.Infof("mock logic !")
return &proto.CommonResponse{Err: proto.ErrCode_OK}, nil
}
在 ProxyServer 处理登录消息
回到本节一开始提出的问题,当一个玩家登录的时候,我们可以将其登录信息记录到 Redis 中,这样当玩家再次登录的时候,我们就可以通过 Redis 来判断该玩家之前是否登录过,如果登录过,就可以直接将消息转发到之前登录的服务器上。如果之前登录的服务器已经下线了,那么就随机转发到一个大厅服上,并且记录下此次登录的服务器,以便下次登录的时候使用。
再进一步思考,客户端注册的时候,与 ProxyServer 的会话已经建立起来了,注册成功时,LobbyServer 可以缓存账号信息,与登录逻辑的优化思路一致,避免过多的 IO 操作,注册之后的其他消息,也应该转发到这个 LobbyServer 上,所以我们在注册成功后,将该 LobbyServer 的 ID 绑定到会话 session
上。当客户端接着发送登录消息时,可以直接从会话中获取对应的 LobbyServer ID。
将上述思路分解一下:
- 从 session 获取上次的 lobby, 找不到就从 redis 缓存获取上次登录的 lobby
- 没有上次登录的, 或者上次登录的 lobby 已经不存在了, 随机分配一个 lobby
- 转发登录到 lobby, 由 lobby 处理登录逻辑, 初始化玩家数据等
- 更新 redis 缓存, 记录登录的 lobby
- 绑定 UID 和 lobby ID 到 Session
注册成功后,将 lobbyID 绑定到 Session
修改 Register
的实现:
func (s *AccountService) Register(ctx context.Context, req *proto.RegisterRequest) (*proto.CommonResponse, error) {
logger := pitaya.GetDefaultLoggerFromCtx(ctx)
session := s.app.GetSessionFromCtx(ctx)
rsp := &proto.CommonResponse{Err: proto.ErrCode_ERR}
lobby := router.GetRandomLobby()
if lobby == nil {
logger.Errorf("cannot find random lobby!")
return rsp, nil
}
err := s.app.RPCTo(ctx, lobby.ID, "lobby.account.register", rsp, req)
if err != nil {
return nil, err
}
_ = session.Set(constants.SessionLobbyIdKey, lobby.ID)
rsp.Err = proto.ErrCode_OK
return rsp, nil
}
实现 Login,找到上次登录过的服务器
根据前面的思路分解,依次实现:
func (s *AccountService) Login(ctx context.Context, req *proto.LoginRequest) (*proto.LoginResponse, error) {
logger := pitaya.GetDefaultLoggerFromCtx(ctx)
session := s.app.GetSessionFromCtx(ctx)
rsp := &proto.LoginResponse{Ret: proto.ErrCode_ERR}
var lobbyId string
// 1. 从session获取上次的lobby, 找不到就从redis缓存获取上次登录的lobby
if session.HasKey(constants.SessionLobbyIdKey) {
lobbyId = session.Get(constants.SessionLobbyIdKey).(string)
logger.Infof("get lobby from session, account: %s", req.Account)
}
if lobbyId == "" {
loginModel, err := loginModel.Get(req.Account)
if err != nil {
return rsp, err
}
lobbyId = loginModel.LobbyId
logger.Infof("get lobby from cache, account: %s", req.Account)
}
// 2. 没有上次登录的, 或者上次登录的lobby已经不存在了, 随机分配一个lobby
if lobbyId == "" || !router.IsLobbyAlive(lobbyId) {
lobby := router.GetRandomLobby()
if lobby == nil {
logger.Errorf("cannot find random lobby!")
return rsp, nil
}
lobbyId = lobby.ID
logger.Infof("get lobby from random, account: %s", req.Account)
}
// 3. 转发登录到lobby, 由lobby处理登录逻辑, 初始化玩家数据等
err := s.app.RPCTo(ctx, lobbyId, "lobby.account.login", rsp, req)
if err != nil {
logger.Errorf("rpc to lobby err: %v", err.Error())
rsp.Ret = proto.ErrCode_ERR
return rsp, nil
}
logger = logger.WithField("userId", rsp.Uid).WithField("lobby", lobbyId)
// 4. 更新redis缓存, 记录登录的lobby
loginModel.Save(req.Account, lobbyId)
// 5. 绑定session-lobby, session-uid
session.Bind(ctx, rsp.Uid)
session.Set(constants.SessionLobbyIdKey, lobbyId)
logger.Infof("login success account: %s", req.Account)
return rsp, nil
}
所以在登录成功后,Session
上就有了玩家的 UID
和 连接 上的 lobbyServer。
防止客户端绕过正常登录,直接发送消息给 LobbyServer
在第二节末尾我们说过,客户端可以直接发送消息到 lobbyServer,比如通过 lobby.account.register
来注册。这里也是一样的,可以绕过代理服务器,绕过登录逻辑,直接发送 lobbyserver.account.mocklogic
给 lobbyServer,我们测试一下看看:
尴尬了不是,别说不用登录了,账号都不要,直接可以玩游戏了,这可不行。
先搞清楚一个问题,pitaya 的前端服务器(在我们的实战项目中就是 proxyServer )在收到一个请求时,会根据路由 URL 提取目标 ServerType 并转发。当你没有设置路由规则的时候,pitaya 会使用默认的路由规则,我们前面也提到过,默认路由规则就是返回遍历获取到的第一个服务器。
既然是这样,那我们就可以从路由规则上下功夫,定义自己的路由策略了。在上一步里,登录成功时会将玩家的 UID
和 lobbyServer ID
都绑定到了 Session
,也就是说,没有绑定过的就肯定没正常登录。
捋清楚了,写代码:
// proxyServer/router/router.go
func LobbyRouterFunc(
ctx context.Context,
route *route.Route,
payload []byte,
servers map[string]*cluster.Server,
) (*cluster.Server, error) {
// 转发到绑定的 lobby
session := pitaya.GetSessionFromCtx(ctx)
if session == nil || !session.HasKey(constants.SessionLobbyIdKey) {
return nil, errors.New("not find binding lobby in session")
}
if session.UID() == "" {
return nil, errors.New("please login")
}
lobbyId := session.Get(constants.SessionLobbyIdKey).(string)
s, ok := servers[lobbyId]
if !ok {
return nil, errors.New("bind lobby is not exist")
}
return s, nil
}
在 main
函数中,还得将路由方法注册进来:
app.AddRoute(constants.LobbyServer, router.LobbyRouterFunc)
测试
开启 1 个 proxy 和 2 个 lobby
1. 客户端不再能绕过 proxyServer 正常的注册登录逻辑
pitaya-cli
Pitaya REPL Client
>>> connect 127.0.0.1:40000
Using json client
connected!
>>> request lobby.account.mocklogic // 不能绕过登录
>>> sv->{"code":"PIT-500","msg":"not find binding lobby in session"}
>>> request lobby.account.register {"account":"test3", "password":"pwd123456"} // 不能绕过 proxy 去 lobby 注册
>>> sv->{"code":"PIT-500","msg":"not find binding lobby in session"}
>>> request proxy.account.register {"account":"test3", "password":"pwd123456"} // 正常注册逻辑
>>> sv->{}
>>> request lobby.account.login {"account":"test3", "password":"pwd123456"} // 不能绕过 proxy 去 lobby 登录
>>> sv->{"code":"PIT-500","msg":"please login"}
>>> request proxy.account.login {"account":"test3", "password":"pwd123456"} // 正常登录逻辑
>>> sv->{"uid":"test37f5fab"}
2. 客户端登录后,每次请求都落在了同一个 lobbyServer
发送 5 次 mocklogic 请求,都落在了同一个 lobbyServer 上
之前在 Login
成功后,proxyServer 将 UID
绑定到了 Session
,这个数据会在底层被同步到后端服务器 lobbyServer,所以 MockLogic
日志打印出了 userId=xxxxxx
。pitaya 框架层确实做了非常多有用的功能~!
3. 客户端断开连接后再次登录,会登录上之前的服务器,如果之前的服务器不存在,才会登录到其他还活着的服务器
这部分我就不测试了,开启两个 lobby,关闭其中一个,查看服务器日志就可以测试。
代理服务器会优先将请求分发给上次登录过的 lobby,如果之前的 lobby 不存在了,则会随机分配一个。
小结
本节我们基本实现了登录流程,完成了代理服务器对登录请求的正确路由,解决了客户端绕过代理服务器直接发送消息给 lobbyServer 的问题,但是我们还没有保证登录操作的原子性,对于高并发、高频率的登录请求,还是有可能出现问题的,这个我们会在下一节解决。
个人能力有限,如果你对本节,或者本系列,有任何疑问和建议,欢迎提出、欢迎指正,谢谢!