文章目录
demo 目录在 examples/demo/chat
运行项目
-
如准备工作所说,先开启 etcd 和 nats 服务
-
运行
go run main.go
把服务器跑起来 -
在浏览器中打开 http://localhost:3251/web/
浏览器是一个前端,可以开启多个页面(客户端),服务器会广播客户端的加入和聊天消息
功能
- 打开页面时,即加入了一个客户端,随机为该客户端分配了一个 guest 开头的 string id,该 id 可手动更改
- 新加入的客户端接收到当前已有的 members 列表,其他客户端接收到了新客户端加入的消息 “New user: 3”
- 客户端可以发送聊天消息,该消息被广播到当前所有客户端
代码分析
main
首先看 main 函数主要逻辑:
NewDefaultBuilder
创建了一个serverType 为 “chat” 的前端服务Builder- 前端服务需要有至少一个 Acceptor,接受客户端连接,这里是添加了监听 3250 端口的 WebSocket Accpetor
- 创建一个内存 Group 服务,并创建了一个名为 “room” 的组,猜测是用来存放房间里的用户数据
- 创建并注册 Room 组件,用来处理客户端消息
log.SetFlags
设置日志的相关信息- 启动 http 前端服务,这个服务主要是用来响应浏览器请求,与监听了 3250 端口的服务器进行通信
- 启动 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
结构保存在 HandlerService
的 handlerPool
中,这个处理池其实就是一个路由字典,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 就可以了解到了)