本节代码:xyq10612/PitayaGame at chapter1.3-登录逻辑与服务器路由 (github.com)
前面两节我们完成了账号注册,接下来让我们实现登录逻辑。
我们再看一下 第一节 画的架构图:
考虑 3 个问题:
本节我们主要解决前两个问题,通过的 Session
和 Redis
来记录信息,实现 LobbyServer 的路由策略。
既然要借助 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,但是实际上还需要返回其他的信息,比如:玩家的昵称、头像、等级等等。具体细节我们之后再完善,现在先聚焦于登录流程的实现。
以下 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 }
回到本节一开始提出的问题,当一个玩家登录的时候,我们可以将其登录信息记录到 Redis 中,这样当玩家再次登录的时候,我们就可以通过 Redis 来判断该玩家之前是否登录过,如果登录过,就可以直接将消息转发到之前登录的服务器上。如果之前登录的服务器已经下线了,那么就随机转发到一个大厅服上,并且记录下此次登录的服务器,以便下次登录的时候使用。
再进一步思考,客户端注册的时候,与 ProxyServer 的会话已经建立起来了,注册成功时,LobbyServer 可以缓存账号信息,与登录逻辑的优化思路一致,避免过多的 IO 操作,注册之后的其他消息,也应该转发到这个 LobbyServer 上,所以我们在注册成功后,将该 LobbyServer 的 ID 绑定到会话 session
上。当客户端接着发送登录消息时,可以直接从会话中获取对应的 LobbyServer ID。
将上述思路分解一下:
修改 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 }
根据前面的思路分解,依次实现:
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,比如通过 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
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"}
发送 5 次 mocklogic 请求,都落在了同一个 lobbyServer 上
之前在 Login
成功后,proxyServer 将 UID
绑定到了 Session
,这个数据会在底层被同步到后端服务器 lobbyServer,所以 MockLogic
日志打印出了 userId=xxxxxx
。pitaya 框架层确实做了非常多有用的功能~!
这部分我就不测试了,开启两个 lobby,关闭其中一个,查看服务器日志就可以测试。
代理服务器会优先将请求分发给上次登录过的 lobby,如果之前的 lobby 不存在了,则会随机分配一个。
本节我们基本实现了登录流程,完成了代理服务器对登录请求的正确路由,解决了客户端绕过代理服务器直接发送消息给 lobbyServer 的问题,但是我们还没有保证登录操作的原子性,对于高并发、高频率的登录请求,还是有可能出现问题的,这个我们会在下一节解决。
个人能力有限,如果你对本节,或者本系列,有任何疑问和建议,欢迎提出、欢迎指正,谢谢!