【Pitaya-官方Demo解读笔记】1. chat demo

8 篇文章 0 订阅


demo 目录在 examples/demo/chat

运行项目

  1. 如准备工作所说,先开启 etcd 和 nats 服务

  2. 运行 go run main.go 把服务器跑起来

  3. 在浏览器中打开 http://localhost:3251/web/

    浏览器是一个前端,可以开启多个页面(客户端),服务器会广播客户端的加入和聊天消息
    在这里插入图片描述

功能

  1. 打开页面时,即加入了一个客户端,随机为该客户端分配了一个 guest 开头的 string id,该 id 可手动更改
  2. 新加入的客户端接收到当前已有的 members 列表,其他客户端接收到了新客户端加入的消息 “New user: 3”
  3. 客户端可以发送聊天消息,该消息被广播到当前所有客户端
    在这里插入图片描述

代码分析

main

首先看 main 函数主要逻辑:

  1. NewDefaultBuilder 创建了一个serverType 为 “chat” 的前端服务Builder
  2. 前端服务需要有至少一个 Acceptor,接受客户端连接,这里是添加了监听 3250 端口的 WebSocket Accpetor
  3. 创建一个内存 Group 服务,并创建了一个名为 “room” 的组,猜测是用来存放房间里的用户数据
  4. 创建并注册 Room 组件,用来处理客户端消息
  5. log.SetFlags 设置日志的相关信息
  6. 启动 http 前端服务,这个服务主要是用来响应浏览器请求,与监听了 3250 端口的服务器进行通信
  7. 启动 app,在前面有调用了 defer app.Shutdown(),这里封装了 dieChan,用于优雅关闭

main 函数里涉及到了很多结构,接下来依次看看具体做了什么

configApp

func configApp() *config.BuilderConfig {
	conf := config.NewDefaultBuilderConfig()
	conf.Pitaya.Buffer.Handler.LocalProcess = 15
	conf.Pitaya.Heartbeat.Interval = time.Duration(15 * time.Second)
	conf.Pitaya.Buffer.Agent.Messages = 32
	conf.Pitaya.Handler.Messages.Compression = false
	return conf
}

第一行调用 NewDefaultBuilderConfig,跟进函数看一下实现,这里是返回了 BuilderConfig 结构并初始化了一些默认值,其中使用了匿名结构体

func NewDefaultBuilderConfig() *BuilderConfig {
    return &BuilderConfig {
        Pitaya: *NewDefaultPitayaConfig(),
        ...
        // 匿名结构体
        DefaultPipelines: struct {
			StructValidation struct {
				Enabled bool `mapstructure:"enabled"`
			} `mapstructure:"structvalidation"`
		}{
			StructValidation: struct {
				Enabled bool `mapstructure:"enabled"`
			}{
				Enabled: false,
			},
		},
    }
}

顺便看一下 config.go 这个文件,基本上 pitaya 所有配置相关的代码都在这里
在这里插入图片描述

其中有大量使用到匿名结构体,匿名结构体一般用于这样的场景,全局只有一份,不需要创建这个结构体的多个变量。

再看 configApp 其他代码,就是进行了一些配置

Pitaya.Buffer.Handler.LocalProcess

接收并在本地处理的 Handler 服务的消息缓冲长度 = 15

最终赋值到HandlerService (handler.go -> NewHandlerService)

Pitaya.Heartbeat.Interval

客户端心跳超时时间

超出这个时间,就认定为客户端离线,关闭连接,查找引用,跟进 agent.go -> heartbeat

func (a *agentImpl) heartbeat() {
	ticker := time.NewTicker(a.heartbeatTimeout)

	defer func() {
		ticker.Stop()
		a.Close()
	}()

	for {
		select {
		case <-ticker.C:
			deadline := time.Now().Add(-2 * a.heartbeatTimeout).Unix()
			if atomic.LoadInt64(&a.lastAt) < deadline {
				logger.Log.Debugf("Session heartbeat timeout, LastTime=%d, Deadline=%d", atomic.LoadInt64(&a.lastAt), deadline)
				return
			}

			// chSend is never closed so we need this to don't block if agent is already closed
			select {
			case a.chSend <- pendingWrite{data: hbd}:
			case <-a.chDie:
				return
			case <-a.chStopHeartbeat:
				return
			}
		case <-a.chDie:
			return
		case <-a.chStopHeartbeat:
			return
		}
	}
}

当 tick 到达时,检查上次心跳时间 lastAt 是否超时,如果超时则从 for 循环中退出,退出 heartbeat 之前调用 defer,关闭客户端连接

Pitaya.Buffer.Agent.Messages

??? 在官方文档里,对这个配置的解释是 Buffer size for received client messages for each agent,表示这接收客户端消息的缓冲区大小。

但是在代码里,该配置项最终落实到了 agent.go -> agentImpl struct -> chSend

显然,这是一个发送到客户端的发送缓冲区大小(文档实在不靠谱,还是要看源码)

Pitaya.Handler.Messages.Compression

消息压缩

MessageEncoder 根据这个来判断是否要调用 zlib 压缩,如果进行了压缩,则打个 gzipMask tag 到消息头部,这样 Decode 的时候可以取出这个标记,判断是否为压缩包

NewDefaultBuilder

创建一个默认的 Builder,pitaya app 就是用这个 Builder 构建出来的

除了一些必须要设置的,大部分都直接提供了默认配置函数,方便直接调用

大概了解一下必须传入的参数:

  • isFrontend:创建一个前端还是后端服务,如果是前端服务,需要提供 Acceptor
  • serverType: 标识该服务器的一个 key,服务器之间、客户端与服务器之间的通信,都需要这个值来路由
  • serverMode: 单机模式,还是集群模式,一般都是使用集群即 Cluster
  • serverMetadata: 业务层传递到框架层的数据,目前是使用于 grpc,本例中没有使用,暂且不表
  • builderConfig: 各种配置

Acceptor

AddAcceptor 限制了必须是前端服务才可以添加 acceptor

Builder 的 accpetors 是一个切片,一个 Pitaya app 是支持多个 accpetor, 只要实现了该接口的都可以 Add 到切片里,可以是 TCP、WebSocket 等。

Group

组是一个接近业务层的封装概念,摘录一下官方文档对 Group 的描述:

Groups are structures which store information about target users and allows sending broadcast messages to all users in the group and also multicast messages to a subset of the users according to some criteria.

组是一种结构,它存储有关目标用户的信息,并允许向组中的所有用户发送广播消息,还允许根据某些条件向用户的子集发送多播消息。

They are useful for creating game rooms for example, you just put all the players from a game room into the same group and then you’ll be able to broadcast the room’s state to all of them.

它们对于创建游戏房间非常有用,例如,你只需要将房间中的所有玩家放入同一个组中,然后就可以向他们广播房间的状态。

本例中实例化了一个 MemoryGroupService,其内部维护了一个全局 group 字典,且是线程安全的。可以使用 GroupCreate 创建一个组。

创建组还可以使用 GroupCreateTTL,该函数多了一个过期时间,超出 ttlTime 没有刷新 LastRefresh 时,groupTTLCleanup 会从 group 字典中删除该组。这个过期清理函数是在 NewMemoryGroupService 时调用的,可以学习一下这里 sync.Once) 的用法:

// memory_group_service.go
var (
	memoryGroupsMu sync.RWMutex
	memoryGroups   map[string]*MemoryGroup
	memoryOnce     sync.Once
)

func NewMemoryGroupService(config config.MemoryGroupConfig) *MemoryGroupService {
	memoryOnce.Do(func() {
		memoryGroups = make(map[string]*MemoryGroup)
		go groupTTLCleanup(config.TickDuration)
	})
	return &MemoryGroupService{}
}

摘录官方文档的描述:

Do calls the function f if and only if Do is being called for the first time for this instance of Once

if once.Do(f) is called multiple times, only the first call will invoke f, even if f has a different value in each invocation. A new instance of Once is required for each function to execute.

不翻译了,简单说就是同一个 Once 实例的 Do 方法所接收的 func 只会执行一次,无论 Do 调用了几次,无论每次调用时传入的是否为同一个 func。

通过这个API,就可以保证 group 服务只会被初始化一次,与包 init 比,sync.Once 更好控制调用时机。

Room 组件

Register

NewRoom 创建了一个 Room 组件的实例,然后 Register 到 pitaya app 的 handlerComp 切片里。切片里保存的是本地的消息处理组件,远程的消息处理组件保存在了 remoteComp 里,是通过 RegisterRemote 注册的。

app 在 startupComponents 遍历初始化所有组件,然后注册到了 handlerService 里,这个注册函数值得看一下:

// handler.go
// Register registers components
func (h *HandlerService) Register(comp component.Component, opts []component.Option) error {
	s := component.NewService(comp, opts)

	if _, ok := h.services[s.Name]; ok {
		return fmt.Errorf("handler: service already defined: %s", s.Name)
	}

	if err := s.ExtractHandler(); err != nil {
		return err
	}

	// register all handlers
	h.services[s.Name] = s
	for name, handler := range s.Handlers {
		h.handlerPool.Register(s.Name, name, handler)
	}
	return nil
}

对于传入的每个组件,都会创建对应的 Service 并加入到 HandlerService 来管理,还会调用 Service 的 ExtractHandler 来提取组件所包含的消息处理方法,代码如下:

// service.go

// ExtractHandler extract the set of methods from the
// receiver value which satisfy the following conditions:
// - exported method of exported type
// - one or two arguments
// - the first argument is context.Context
// - the second argument (if it exists) is []byte or a pointer
// - zero or two outputs
// - the first output is [] or a pointer
// - the second output is an error
func (s *Service) ExtractHandler() error {
	typeName := reflect.Indirect(s.Receiver).Type().Name()
	if typeName == "" {
		return errors.New("no service name for type " + s.Type.String())
	}
	if !isExported(typeName) {
		return errors.New("type " + typeName + " is not exported")
	}

	// Install the methods
	s.Handlers = suitableHandlerMethods(s.Type, s.Options.nameFunc)

	if len(s.Handlers) == 0 {
		str := ""
		// To help the user, see if a pointer receiver would work.
		method := suitableHandlerMethods(reflect.PtrTo(s.Type), s.Options.nameFunc)
		if len(method) != 0 {
			str = "type " + s.Name + " has no exported methods of handler type (hint: pass a pointer to value of that type)"
		} else {
			str = "type " + s.Name + " has no exported methods of handler type"
		}
		return errors.New(str)
	}

	for i := range s.Handlers {
		s.Handlers[i].Receiver = s.Receiver
	}

	return nil
}

这里比较底层,就不详细解读了,主要逻辑在于其中的 suitableHandlerMethods -> isHandlerMethod,通过反射得到组件的导出方法的签名,如果满足消息处理函数的签名规则,则认为是消息处理方法,这些handler方法及相关信息被打包成 Handler 结构保存在 HandlerServicehandlerPool 中,这个处理池其实就是一个路由字典,key为路由名,当acceptor收到消息时,就是根据这个路由名字去匹配处理方法的。

内嵌结构体

Room 组件内嵌结构体 component.Base ,间接实现了 Component 接口

type Room struct {
    component.Base
    timer *timer.Timer
    app   pitaya.Pitaya
}
// Base implements a default component for Component.
type Base struct{}
// Init was called to initialize the component.
func (c *Base) Init() {
// AfterInit was called after the component is initialized.
func (c *Base) AfterInit() {}
// BeforeShutdown was called before the component to shutdown.
func (c *Base) BeforeShutdown() {
// Shutdown was called to shutdown the component.
func (c *Base) Shutdown() {}

可以看到 Base 结构体并没有实际的逻辑,只是实现了接口的空方法,这也是内嵌结构体的用法之一,使得结构体不必实现所有的接口方法,只需要重写自己关心的即可。

AfterInit

创建一个定时器,每分钟打印 group 里的成员数量

这个没啥好说的

Join handler

看方法签名就知道是一个消息处理方法了,且有返回消息

在前端 index.html 的最后能看到消息发送,服务器端就是由这个 Join 方法来处理的

starx.request("room.join", {}, join);

先看第一行 GetSessionFromCtx,当底层将消息传递上来的时候,会将很多数据,比如说玩家会话相关信息,存放在 context 上下文里。handler 有几种签名形式,但是所有的消息处理方法,第一个参数都是这个上下文,可以通过 GetSessionFromCtx 获得对应的会话(会话里封装了很多网络连接相关的数据)。

// GetSessionFromCtx retrieves a session from a given context
func (app *App) GetSessionFromCtx(ctx context.Context) session.Session {
	sessionVal := ctx.Value(constants.SessionCtxKey)
	if sessionVal == nil {
		logger.Log.Debug("ctx doesn't contain a session, are you calling GetSessionFromCtx from inside a remote?")
		return nil
	}
	return sessionVal.(session.Session)
}

接下来就是从会话中取出 ID 绑定到他的 UID 上,ID 是一个底层概念,在 NewSession 时,即会话被创建时,就由底层服务自增生成的一个唯一id,每次创建会话都不会重复。而 UID 是一个业务层的概念,即用户唯一标识:UserID,不会因为会话关闭、重新创建而改变。这个 demo 应该是为了方便,就没有在业务层特别规定一个用户ID,简单以底层的会话ID来表示了。后面我们有机会,再详细了解一下这个UID的用法。

然后是调用 GroupMembers 获得当前组内的所有用户,Push 推送到客户端(即浏览器),就对应了我们一开始了解到的 功能2:

新加入的客户端接收到当前已有的 members 列表

同时,这个新加入的客户端会被广播到组内其他用户,自己也会加入到组内被管理,在此,我们也了解了 Group 的简单用法。

Message handler

无返回消息的处理方法,逻辑没啥好说的

总结

这是一个很简单的demo,主逻辑就是开启一个前端服务,接收客户端上行消息并处理,但是其中涉及了很多框架层的概念,比如:Config、Builder、Acceptor、Group、Component、Handler 等。现在只是简单了解一下,顺带看了底层的实现,浅尝辄止。接下来通过其他的 demo 再慢慢学习更多的框架知识和用法,自己动手改造或者实现一个前后端服务器。(前端服务、后端服务在这个例子里还没有体现,接下来的 cluster 就可以了解到了)

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值