前言
前前后后花了五天的时间,用九篇文章学习完了 Zinx 框架的官方教程并在本地实现了 Zinx-v1.0。Zinx 的官方教程详见:https://www.yuque.com/aceld/npyr8s/se5lgv。
附上我的九篇用于学习记录的文章整理:
- 【Zinx】Day1:初识 Zinx 框架
- 【Zinx】Day2-Part1:Zinx框架基础路由模块
- 【Zinx】Day2-Part2:使用 Viper 实现 Zinx 的全局配置
- 【Zinx】Day3:Zinx 的消息封装
- 【Zinx】Day4:Zinx 的多路由模式
- 【Zinx】Day5-Part1:实现 Zinx 的读写分离模型
- 【Zinx】Day5-Part2:Zinx 的消息队列及多任务机制
- 【Zinx】Day5-Part3:Zinx 的连接管理
- 【Zinx】Day5-Part4:Zinx 的连接属性设置
项目总结
现在我们从 Server 和 Client 的视角出发,对完全体的 Zinx-v1.0 进行回顾和总结。Zinx 项目的目录结构如下:
Zinx Server
首先看一下我们用于启动 Server 端的 main.go
,对其进行拆解:
package main
import (
"fmt"
"zinx/settings"
"zinx/ziface"
"zinx/znet"
)
// ping test 自定义路由
type PingRouter struct {
znet.BaseRouter
}
// Ping Handle
func (this *PingRouter) Handle(request ziface.IRequest) {
fmt.Println("Call PingRouter Handle")
//先读取客户端的数据,再回写ping...ping...ping
fmt.Println("recv from client : msgId=", request.GetMsgID(), ", data=", string(request.GetData()))
err := request.GetConnection().SendBuffMsg(0, []byte("ping...ping...ping"))
if err != nil {
fmt.Println(err)
}
}
type HelloZinxRouter struct {
znet.BaseRouter
}
// HelloZinxRouter Handle
func (this *HelloZinxRouter) Handle(request ziface.IRequest) {
fmt.Println("Call HelloZinxRouter Handle")
//先读取客户端的数据,再回写ping...ping...ping
fmt.Println("recv from client : msgId=", request.GetMsgID(), ", data=", string(request.GetData()))
err := request.GetConnection().SendBuffMsg(1, []byte("Hello Zinx Router V0.10"))
if err != nil {
fmt.Println(err)
}
}
// 创建连接的时候执行
func DoConnectionBegin(conn ziface.IConnection) {
fmt.Println("DoConnecionBegin is Called ... ")
//=============设置两个链接属性,在连接创建之后===========
fmt.Println("Set conn Name, Home done!")
conn.SetProperty("Name", "yggp")
conn.SetProperty("Home", "Learning Zinx")
//===================================================
err := conn.SendMsg(2, []byte("DoConnection BEGIN..."))
if err != nil {
fmt.Println(err)
}
}
// 连接断开的时候执行
func DoConnectionLost(conn ziface.IConnection) {
//============在连接销毁之前,查询conn的Name,Home属性=====
if name, err := conn.GetProperty("Name"); err == nil {
fmt.Println("Conn Property Name = ", name)
}
if home, err := conn.GetProperty("Home"); err == nil {
fmt.Println("Conn Property Home = ", home)
}
//===================================================
fmt.Println("DoConnectionLost is Called ... ")
}
func main() {
//创建一个server句柄
err := settings.Init()
if err != nil {
panic(err)
}
s := znet.NewServer()
//注册链接hook回调函数
s.SetOnConnStart(DoConnectionBegin)
s.SetOnConnStop(DoConnectionLost)
//配置路由
s.AddRouter(0, &PingRouter{})
s.AddRouter(1, &HelloZinxRouter{})
//开启服务
s.Serve()
}
我们从main()
函数出发,看一下上线一个 Zinx 服务器都需要做哪些工作。
在 main 函数的函数体当中,我们主要完成了以下几项工作:
- 参数初始化;
- 创建一个 Server 对象;
- 注册回调函数;
- 配置路由;
- 开启服务;
参数初始化
首先,main 的第一个语句调用了settings.Init()
,用于根据 yaml 配置文件初始化一个全局的配置结构对象。配置结构对象的定义放在了 settings 文件夹下的 settings.go
文件当中,其定义为:
type ZinxConfig struct {
Name string `mapstructure:"name"`
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Version string `mapstructure:"version"`
MaxPacketSize uint32 `mapstructure:"max_packet_size"`
MaxConn int `mapstructure:"max_conn"`
WorkerPoolSize uint32 `mapstructure:"worker_pool_size"`
MaxWorkerTaskLen uint32 `mapstructure:"max_worker_task_len"`
MaxMsgChanLen uint32 `mapstructure:"max_msg_chan_len"`
}
在 settings 当中,我们定义了一个全局变量,用于保存并随时调用配置参数,在Init()
文件当中我们对这个变量进行初始化:
var Conf = new(ZinxConfig)
func Init() (err error) {
viper.SetConfigFile("../conf/config.yaml")
viper.AddConfigPath(".") // 指定查找配置文件的路径(这里使用相对路径)
err = viper.ReadInConfig() // 读取配置信息
if err != nil {
// 读取配置信息失败
fmt.Printf("viper.ReadInConfig() failed, err:%v\n", err)
return
}
// 把读取到的配置信息反序列化到 Conf 变量中
if err = viper.Unmarshal(Conf); err != nil {
fmt.Printf("viper.Unmarshal failed, err:%v\n", err)
}
viper.WatchConfig()
viper.OnConfigChange(func(in fsnotify.Event) {
fmt.Println("配置文件修改了...")
if err = viper.Unmarshal(Conf); err != nil {
fmt.Printf("viper.Unmarshal failed, err:%v\n", err)
}
})
return
}
配置参数的管理我是使用 viper 实现的,而不是按照教程当中使用 json 和 Unmarshal 来实现。
在 config.yaml
文件当中,参数的配置如下:
name: "zinx server"
host: "127.0.0.1"
port: 7777
max_conn: 3
version: "v1.0"
max_packet_ize: 4096
worker_pool_size: 10
max_worker_task_len: 1024
max_msg_chan_len: 10
将这些需要我们手动配置的参数通过全局变量的形式进行管理要优于硬编码,最直观的一个好处就是当我们修改某个参数的时候,不需要去每一个使用到这个参数的文件当中寻找并修改,而是直接修改 yaml 文件即可。
创建一个 Server 对象
进行参数初始化之后,现在我们要创建一个 Server 对象,在 main 函数中调用 Server 的工厂函数:
s := znet.NewServer()
Server 类型本身的定义如下:
type Server struct {
Name string // Name 为服务器的名称
IPVersion string // IPVersion: IPv4 or other
IP string // IP: 服务器绑定的 IP 地址
Port int // Port: 服务器绑定的端口
msgHandler ziface.IMsgHandle // 将 Router 替换为 MsgHandler, 绑定 MsgId 与对应的处理方法
ConnMgr ziface.IConnManager // 当前 Server 的连接管理器
onConnStart func(conn ziface.IConnection) // Server 在连接创建时的 Hook 函数
onConnStop func(conn ziface.IConnection) // Server 在连接删除时的 Hook 函数
}
Server 最基本的字段包括 Name、IPVersion、IP、Port,它们是与 Server 本身属性相关的成员。msgHandler 是 Server 的多路由管理器,它的作用是注册 Router,并根据 msgID 调用相应的业务。ConnMgr 是连接管理,用于控制连接的添加、删除以及清空,基于 ConnMgr 可以做到服务器最大连接数的控制。onConnStart 和 onConnStop 是两个函数类型的成员(在 Golang 当中,函数是一等公民,可以像对待变量一样对待函数,因此func(conn ziface.IConnection)
是一个输入为 IConnection 接口类型的函数类型),用于注册在连接创建后及连接断开前的回调函数。
NewServer()
在 znet 下的 server.go
当中定义:
// NewServer 将创建一个服务器的 Handler
func NewServer() ziface.IServer {
s := &Server{
Name: settings.Conf.Name,
IPVersion: "tcp4",
IP: settings.Conf.Host,
Port: settings.Conf.Port,
msgHandler: NewMsgHandle(),
ConnMgr: NewConnManager(),
}
return s
}
具体来说,NewServer 函数将会返回一个 ziface.IServer
接口类型,s := &Server{... ... ...}
将会返回一个 Server 结构的指针,它将作为返回值,让调用者拿到服务器的句柄。
我们可以看一下 Server 类型实现的接口 IServer,了解一下 Server 都具有哪些方法:
// 定义服务器接口
type IServer interface {
Start() // Start 启动服务器方法
Stop() // Stop 停止服务器方法
Serve() // Serve 开启服务器方法
AddRouter(msgId uint32, router IRouter) // 路由功能: 给当前服务注册一个路由业务方法
GetConnMgr() IConnManager // 得到连接管理器
SetOnConnStart(func(IConnection)) // 设置该 Server 在连接创建时的 hook 函数
SetOnConnStop(func(IConnection)) // 设置该 Server 在连接断开时的 hook 函数
CallOnConnStart(conn IConnection) // 调用连接 onConnStart Hook 函数
CallOnConnStop(conn IConnection) // 调用连接 onConnStop Hook 函数
}
回顾我们的 main 函数,我们用到了 SetOnConnStart
/SetOnConnStop
/AddRouter
/Serve
四种方法。前两个方法注册了 Server 在连接建立后以及断开前的回调函数,而 AddRouter
实现了路由(即具体的业务函数)的注册功能,Serve
用于开启服务。
注册回调函数
现在我们来回顾一下回调函数的注册,实际上这两个方法的实现非常的简单:
func (s *Server) SetOnConnStart(hookFunc func(ziface.IConnection)) {
s.onConnStart = hookFunc
}
func (s *Server) SetOnConnStop(hookFunc func(ziface.IConnection)) {
s.onConnStop = hookFunc
}
main.go
当中用到的回调函数是
// 创建连接的时候执行
func DoConnectionBegin(conn ziface.IConnection) {
fmt.Println("DoConnecionBegin is Called ... ")
//=============设置两个链接属性,在连接创建之后===========
fmt.Println("Set conn Name, Home done!")
conn.SetProperty("Name", "yggp")
conn.SetProperty("Home", "Learning Zinx")
//===================================================
err := conn.SendMsg(2, []byte("DoConnection BEGIN..."))
if err != nil {
fmt.Println(err)
}
}
// 连接断开的时候执行
func DoConnectionLost(conn ziface.IConnection) {
//============在连接销毁之前,查询conn的Name,Home属性=====
if name, err := conn.GetProperty("Name"); err == nil {
fmt.Println("Conn Property Name = ", name)
}
if home, err := conn.GetProperty("Home"); err == nil {
fmt.Println("Conn Property Home = ", home)
}
//===================================================
fmt.Println("DoConnectionLost is Called ... ")
}
可以看到,函数的类型就是以ziface.IConnection
为输入的函数,即func(ziface.IConnection)
,回调函数的注册就是将函数对象赋值给 Server 内部的成员,使得 Server 可以在恰当的时机调用这个函数。
配置路由
可以通过 Server 的 AddRouter
方法来添加路由,我们来看一下 AddRouter
方法的实现:
func (s *Server) AddRouter(msgId uint32, router ziface.IRouter) {
s.msgHandler.AddRouter(msgId, router)
fmt.Println("Add Router succ! msgId = ", msgId)
}
形参是 uint32
类型的 msgId
,以及 IRouter
类型的 router
,前者代表消息的 ID,后者是具体的业务处理路由。
在该方法中,真正的业务路由注册其实是通过 msgHandler 的 AddRouter 方法完成的,所以我们进一步看一下 msgHandler 类型的定义及其方法:
type MsgHandle struct {
Apis map[uint32]ziface.IRouter // map 存放每个 MsgId 对应的处理方法
WorkerPoolSize uint32 // 业务工作 worker 池的数量
TaskQueue []chan ziface.IRequest // worker 负责取任务的消息队列
}
Server 当中的成员 msgHandler 是一个 IMsgHandle 类型,而 IMsgHandle 的具体实现就是 MsgHandle 结构,它包含三个成员,分别是:
Apis
,它是一个map[uint32]ziface.IRouter
,即一个 key 为uint32
,value 为IRouter
的哈希表,恰好与AddRouter
方法的形参对应。它用于完成从 msgID 到具体的业务处理逻辑的映射;WorkerPoolSize
,是一个uint32
类型的变量,用于表示 worker 工作池的大小;TaskQueue
是一个 slice,存放的元素类型是chan ziface.IRequest
,即传输 IRequest 类型变量的通道。
根据 msgHandle 的结构定义,其工厂函数如下:
func NewMsgHandle() *MsgHandle {
return &MsgHandle{
Apis: make(map[uint32]ziface.IRouter),
WorkerPoolSize: settings.Conf.WorkerPoolSize,
TaskQueue: make([]chan ziface.IRequest, settings.Conf.WorkerPoolSize),
}
}
注意事项:这里需要注意的一点就是,TaskQueue 的初始化是对 slice 的初始化,settings.Conf.WorkerPoolSize
指定的是 TaskQueue 这个 slice 的 Len,而指定好 slice 的长度之后,由于我们没有对 slice 当中的元素进行初始化,因此它们默认赋零值。TaskQueue 这个 slice 当中的元素类型是 chan ziface.IRequest
,因此赋 channel 的零值 nil。nil channel 是无法使用的,因此在实现 StartWorkerPool()
方法的时候,需要重新为 TaskQueue 当中的每一个 channel 赋值。既然是 TaskQueue,因此使用带缓冲区的 channel 才有意义,具体的实现见下文。
MsgHandle 需要实现的方法如下:
type IMsgHandle interface {
DoMsgHandler(request IRequest) // 立即以非阻塞的方式处理消息
AddRouter(msgId uint32, router IRouter) // 为消息添加具体的处理逻辑
StartWorkerPool() // 启动 worker 工作池
SendMsgToTaskQueue(request IRequest) // 将消息交给 TaskQueue, 由 worker 进行处理
}
- DoMsgHandler 方法将立即以非阻塞的方式调用业务函数,根据具体的 request,进行业务处理;
- AddRouter 是进行路由注册的方法;
- StartWorkerPool 是启动 worker 工作池的方法;
- SendMsgToTaskQueue 是将消息发送到 TaskQueue 当中的方法。
我们来逐一拆解这四个方法在 msgHandle 当中的具体实现。
首先是 DoMsgHandler 这个方法:
// 立即以非阻塞的方式处理消息
func (mh *MsgHandle) DoMsgHandler(request ziface.IRequest) {
handler, ok := mh.Apis[request.GetMsgID()]
if !ok {
fmt.Println("api msgId = ", request.GetMsgID(), " is not FOUND!")
return
}
// 执行 Router 的 Handler
handler.PreHandle(request)
handler.Handle(request)
handler.PostHandle(request)
}
它要做的事情其实很简单,就是根据 request(需要注意的是,request 同样也是一个封装好的结构体,其中包含 msgID 和具体的数据)的 msgID 来确定这个 message 需要使用哪个业务函数来进行处理。具体来说,根据 Apis
可以索引到 msgID 对应的 Router,执行注册 Router 时实现的 PreHandle/Handle/PostHandle 即可。
既然提到了 Router 及其接口 IRouter,这里回顾一下 Router 的实现,它非常的简单。IRouter 接口的定义如下:
type IRouter interface {
PreHandle(request IRequest) // 处理 conn 业务之前的钩子方法
Handle(request IRequest) // 处理 conn 业务的方法
PostHandle(request IRequest) // 处理 conn 业务之后的钩子方法
}
而可以被视为基类的 BaseRouter 如下:
// 实现 router 时, 先嵌入这个基类, 然后根据需要对这个基类的方法进行重写
type BaseRouter struct{}
/*
此处 BaseRouter 的方法都为空的原因是
有一些 Router 不需要 PreHandle 和 PostHandle
所以 Router 全部嵌入 BaseRouter 的好处是, 不需要实现 PreHandle 和 PostHandle 也可以实例化
*/
func (br *BaseRouter) PreHandle(req ziface.IRequest) {}
func (br *BaseRouter) Handle(req ziface.IRequest) {}
func (br *BaseRouter) PostHandle(req ziface.IRequest) {}
在我们注册 Router 的时候,实际上是要自己定义一个实现了 IRouter 接口的结构,这个结构最好直接嵌入 BaseRouter,这样假如我们只想要实现 Handle 方法,而不需要 PreHandle 和 PostHandle,那么在 msgHandle 的 DoMsgHandler 方法中,会先调用 BaseRouter 的 PreHandle,再调用自定义的 Handle,最后调用 BaseRouter 的 PostHandle,避免了重复的方法定义。
现在我们回顾了 IRouter 的定义,以及 DoMsgHandler 的方法实现。现在我们继续回顾 MsgHandle 的其他方法。
MsgHandle 的第二个方法是 AddRouter,即:将 Server 实现的针对某个 msgID 的 Router 加入到 MsgHandle 的 Apis 当中,其实现如下:
// 为某条消息添加具体的处理逻辑
func (mh *MsgHandle) AddRouter(msgId uint32, router ziface.IRouter) {
// 判断当前 msg 绑定的 API 处理方法是否已经存在
if _, ok := mh.Apis[msgId]; ok {
panic("repeated api, msgId = " + strconv.Itoa(int(msgId)))
}
// 添加 msg 与 api 的绑定关系
mh.Apis[msgId] = router
fmt.Println("Add api msgId = ", msgId)
}
实际上就是向 hash map 当中添加一条记录。
MsgHandle 的第三个方法是 StartWorkerPool,它要做的就是开启 worker 工作池:
// StartWorkerPool 启动 worker 工作池
func (mh *MsgHandle) StartWorkerPool() {
// 遍历需要启动的 worker, 依次启动
for i := 0; i < int(mh.WorkerPoolSize); i++ {
// 一个 worker 被启动时, 给当前的 worker 对应的任务队列开辟空间
mh.TaskQueue[i] = make(chan ziface.IRequest, settings.Conf.MaxWorkerTaskLen)
// 启动当前 worker, 阻塞地等待对应的任务队列是否有消息传来
go mh.StartOneWorker(i, mh.TaskQueue[i])
}
}
还记得我们刚才提到的吗?在 MsgHandle 的工厂函数中,我们只初始化了 TaskQueue 这个 slice,还需要对其中具体的 channel 进行初始化,初始化的行为就体现在 StartWorkerPool 这个方法中。在使用 make 方法初始化 channel 之后,启动一个 goroutine,来启动相应的 worker。要注意的是,StartOneWorker 并非 IMsgHandle 接口的方法,其实现如下:
// StartOneWorker 启动一个 Worker 的工作流程
func (mh *MsgHandle) StartOneWorker(workerID int, taskQueue chan ziface.IRequest) {
fmt.Println("Worker ID = ", workerID, " is started.")
for {
select {
case request := <-taskQueue:
mh.DoMsgHandler(request)
}
}
}
可以看到,这个 goroutine 通过 for loop 以及 select 阻塞地等待来自 channel 的 request,一旦接收到 request,就调用 DoMsgHandler 对 request 进行处理。
MsgHandle 要实现的最后一个方法是 SendMsgToTaskQueue,它的作用是将 request 根据某种规则分配到某个 TaskQueue:
// SendMsgToTaskQueue 将消息交给 TaskQueue, 由 worker 进行处理
func (mh *MsgHandle) SendMsgToTaskQueue(request ziface.IRequest) {
// 根据 ConnID 来分配当前的连接应该由哪个 worker 负责处理
// 使用轮询分配法则
// 得到需要处理此条连接地 workerID
workerID := request.GetConnection().GetConnID() % mh.WorkerPoolSize
fmt.Println("Add ConnID = ", request.GetConnection().GetConnID(), " request msgID = ", request.GetMsgID(), "to workerID = ", workerID)
// 将请求消息发送给任务队列
mh.TaskQueue[workerID] <- request
}
我们的工作池容量可能为 10,这是一个可以自定义的全局参数,当 request 到来时,我们应该将这个 request 分配给某个 worker,Zinx 的官方教程当中采用取模的方法来分配 worker。求模运算得到 workerID 之后,将 request 发送给 TaskQueue 的 channel 即可。
至此,我们完整地回顾了 Zinx 框架多路由模式以及消息队列和多任务机制的实现,并回顾了服务注册的基类 BaseRouter。
开启服务
文章的篇幅比较长,因此我们重新回顾一下 server 的 main.go
当中 main 函数体的实现:
func main() {
//创建一个server句柄
err := settings.Init()
if err != nil {
panic(err)
}
s := znet.NewServer()
//注册链接hook回调函数
s.SetOnConnStart(DoConnectionBegin)
s.SetOnConnStop(DoConnectionLost)
//配置路由
s.AddRouter(0, &PingRouter{})
s.AddRouter(1, &HelloZinxRouter{})
//开启服务
s.Serve()
}
现在我们来到了最后一步,即:开启服务。我们通过 Server 的 Serve 方法开启服务。Serve 的具体实现如下:
func (s *Server) Serve() {
s.Start()
// 在 Server 开始监听之后, 此处还可以做一些其它事情
// 阻塞, 否则 main goroutine 退出, listenner 也将会随之退出
for {
time.Sleep(10 * time.Second)
}
}
Serve 方法进一步调用 Start 方法启动服务器,在此之后使用一个 for loop 阻塞 main 函数,如果不阻塞的话,main 函数将会结束,main goroutine 的退出将会导致其下辖子 goroutine 的结束,服务被关闭,因此在这里要进行阻塞。
Start 方法的实现如下:
// Start 开启 Server 的网络服务
func (s *Server) Start() {
fmt.Printf("[START] Server listenner at IP: %s, Port %d, is starting\n", s.IP, s.Port)
fmt.Printf("[Zinx] Version: %s, MaxConn: %d, MaxPacketSize: %d\n",
settings.Conf.Version,
settings.Conf.MaxConn,
settings.Conf.MaxPacketSize)
// 开启一个 goroutine 去做服务端的 Listener 业务
go func() {
// 0. 启动 worker 工作池机制
s.msgHandler.StartWorkerPool()
// 1. 获取一个 TCP 的 Addr
addr, err := net.ResolveTCPAddr(s.IPVersion, fmt.Sprintf("%s:%d", s.IP, s.Port))
if err != nil {
fmt.Println("resolve tcp addr err: ", err)
return
}
// 2. 监听服务器地址
listener, err := net.ListenTCP(s.IPVersion, addr)
if err != nil {
fmt.Println("listen", s.IPVersion, "err", err)
return
}
// 监听成功
fmt.Println("start Zinx server ", s.Name, " succ, now listenning...")
// TODO: server.go 应该有一个自动生成 ID 的方法, 比如 snowflake
var cid uint32
cid = 0
// 3. 启动 Server 网络连接服务
for {
// 3.1 阻塞等待客户端建立连接请求
conn, err := listener.AcceptTCP()
if err != nil {
fmt.Println("Accept err", err)
continue
}
// 3.2 Server.Start() 设置服务器最大连接控制, 如果超过最大连接, 则关闭此新的连接
if s.ConnMgr.Len() >= settings.Conf.MaxConn {
// 是否可以制定一个类似于 LRUCache 的连接规则 ?
conn.Close()
continue
}
// 3.3 处理该连接请求的业务方法, 此时应该有 handler 和 conn 是绑定的
dealConn := NewConnection(s, conn, cid, s.msgHandler)
cid++
go dealConn.Start()
}
}()
}
显然 Start 才是 Server 启动的关键。在 Start 的开始部分,先打印一些服务器的属性信息,之后开启一个 goroutine,用于监听来自 client 的连接请求。
具体来说,在这个 goroutine 当中,会首先启动 worker 工作池机制:
s.msgHandler.StartWorkerPool()
还记得嘛?StartWorkerPool()
方法会会对 TaskQueue 当中的 chan ziface.IRequest
进行初始化,并开启 worker 的 goroutine 阻塞地等待消息的到来(而消息将通过 TaskQueue 进行分配)。
之后,通过以下语句段来开启 Server 的监听服务:
// 1. 获取一个 TCP 的 Addr
addr, err := net.ResolveTCPAddr(s.IPVersion, fmt.Sprintf("%s:%d", s.IP, s.Port))
if err != nil {
fmt.Println("resolve tcp addr err: ", err)
return
}
// 2. 监听服务器地址
listener, err := net.ListenTCP(s.IPVersion, addr)
if err != nil {
fmt.Println("listen", s.IPVersion, "err", err)
return
}
上面这个语句块基本都是在调用 net 的库函数,因此不再赘述,我们需要知道的是上面这个语句块使服务器可以在指定的 IP 和端口对服务进行监听。
listener 启动后,我们使用一个 for loop,来阻塞地等待来自客户端的连接请求。当连接到来时,我们需要首先通过连接管理器 ConnManager 来判断当前连接的个数是否已经达到了服务器的连接数量上限,如果达到了上限,那么 Zinx 官方教程当中的要求是直接拒绝连接,而我个人认为此处或许可以加入一个 LRU 算法进行优化。
由于此处用到了 ConnManager,因此我们回顾一下 ConnManager 的实现。首先 ConnManager 实现了 IConnManager 接口,接口的定义如下:
type IConnManager interface {
Add(conn IConnection) // 添加连接
Remove(conn IConnection) // 删除连接
Get(connID uint32) (IConnection, error) // 利用 ConnID 获取连接
Len() int // 获取当前连接数量
ClearConn() // 删除并停止所有连接
}
因此 ConnManager 需要实现以上方法:
type ConnManager struct {
connections map[uint32]ziface.IConnection // 管理连接的信息
connLock sync.RWMutex // 读写连接的读写锁
}
func NewConnManager() *ConnManager {
return &ConnManager{
connections: make(map[uint32]ziface.IConnection),
}
}
// Add 添加连接
func (connMgr *ConnManager) Add(conn ziface.IConnection) {
// 保护共享资源 Map, 加写锁
connMgr.connLock.Lock()
defer connMgr.connLock.Unlock()
// 将 conn 连接添加到 ConnManager
connMgr.connections[conn.GetConnID()] = conn
fmt.Println("connection add to ConnManager successfully: conn num = ", connMgr.Len())
}
// Remove 删除连接
func (connMgr *ConnManager) Remove(conn ziface.IConnection) {
// 保护共享资源 map, 加写锁
connMgr.connLock.Lock()
defer connMgr.connLock.Unlock()
// 删除连接信息
delete(connMgr.connections, conn.GetConnID())
fmt.Println("connection Remove connID = ", conn.GetConnID(), " successfully: conn num = ", connMgr.Len())
}
// Get 利用 ConnID 获取连接
func (connMgr *ConnManager) Get(connID uint32) (ziface.IConnection, error) {
// 保护共享资源 Map, 加读锁
connMgr.connLock.RLock()
defer connMgr.connLock.RUnlock()
if conn, ok := connMgr.connections[connID]; ok {
return conn, nil
} else {
return nil, errors.New("connection not found")
}
}
// Len 获取当前连接个数
func (connMgr *ConnManager) Len() int {
return len(connMgr.connections)
}
// ClearConn 停止并清除当前所有连接
func (connMgr *ConnManager) ClearConn() {
// 保护共享资源 Map, 加写锁
connMgr.connLock.Lock()
defer connMgr.connLock.Unlock()
// 停止并删除全部的连接信息
for connID, conn := range connMgr.connections {
// 停止
conn.Stop()
// 删除
delete(connMgr.connections, connID)
}
fmt.Println("Clear All Connections successfully: conn num = ", connMgr.Len())
}
总得来说 ConnManager 的定义以及方法实现非常的直观且好理解,此处不再赘述,可以翻看我之前的学习文章。
现在我们终于来到了最关键的一步,即 Connection 的建立。Connection 用于管理 Server 和 Client 之间的连接,可以将它立即为一个隶属于 Server tunnel,它是 Server 和 Client 之间的桥梁,可以从 Client 读数据(前提是 Client 有数据发送给 Server),也可以向 Client 写数据。Server 的 Start 方法中 Connection 的建立以及启动如下:
... ... ...
// 3.3 处理该连接请求的业务方法, 此时应该有 handler 和 conn 是绑定的
dealConn := NewConnection(s, conn, cid, s.msgHandler)
cid++
go dealConn.Start()
这里的 cid
是一个与 Connection 相关的属性,用于标记连接的 ConnID,它被初始化为 0,但其实我们可以用一个更好的 ID 生成方法对其进行优化,比如 snowflake 算法。
NewConnection 是一个工厂函数,它建立了 Connection 对象。我们先来回顾一下 IConnection 接口以及 Connection 结构的定义。IConnection 接口的定义如下:
type IConnection interface {
Start() // 启动连接
Stop() // 停止连接
GetConnID() uint32 // 获取远程客户端地址信息
GetTCPConnection() *net.TCPConn // 从当前连接获取原始的 socket TCPConn
RemoteAddr() net.Addr // 获取远程客户端地址信息
SendMsg(msgId uint32, data []byte) error // 直接将 Message 数据发给远程的 TCP 客户端
SendBuffMsg(msgId uint32, data []byte) error // 添加带缓冲的发送消息接口
SetProperty(key string, value interface{}) // 设置连接属性
GetProperty(key string) (interface{}, error) // 获取连接属性
RemoveProperty(key string) // 移除连接属性
}
因此 Connection 需要实现上述所有的方法,我们先来看 Connection 结构的定义以及工厂函数 NewConnection 的实现:
type Connection struct {
TCPServer ziface.IServer // 标记当前 Conn 属于哪个 Server
Conn *net.TCPConn // 当前连接的 socket TCP 套接字
ConnID uint32 // 当前连接的 ID, 也可称为 SessionID, 全局唯一
isClosed bool // 当前连接的开启/关闭状态
Msghandler ziface.IMsgHandle // 将 Router 替换为消息管理模块
ExitBuffChan chan bool // 告知该连接一经退出/停止的 channel
msgChan chan []byte // 无缓冲 channel, 用于读/写两个 goroutine 之间的消息通信
msgBuffChan chan []byte // 定义 msgBuffChan
property map[string]interface{} // 连接属性
propertyLock sync.RWMutex // 保护连接属性修改的锁
}
// NewConnection 创建新的连接
func NewConnection(server ziface.IServer, conn *net.TCPConn, connID uint32, msgHandler ziface.IMsgHandle) *Connection {
c := &Connection{
TCPServer: server,
Conn: conn,
ConnID: connID,
isClosed: false,
Msghandler: msgHandler,
ExitBuffChan: make(chan bool, 1),
msgChan: make(chan []byte), // msgChan 初始化
msgBuffChan: make(chan []byte, settings.Conf.MaxMsgChanLen),
property: make(map[string]interface{}),
}
// 将新创建的 Conn 添加到连接管理器中
c.TCPServer.GetConnMgr().Add(c)
return c
}
在 Server 的 Start 方法中创建 Connection 之后,随即新建了一个 goroutine 并调用 Connection 的 Start 方法,因此我们从 Connection 的 Start 方法入手。Connection 的 Start 实现如下:
// Start 实现 IConnection 中的方法, 它启动连接并让当前连接开始工作
func (c *Connection) Start() {
// 开启处理该连接读取到客户端数据之后的业务请求
go c.StartWriter()
go c.StartReader()
c.TCPServer.CallOnConnStart(c)
for {
select {
case <-c.ExitBuffChan:
// 得到退出消息则不再阻塞
return
}
}
}
Connection 的 Start 方法首先开启 Reader 和 Writer 两个 goroutine,然后调用 Server 注册的回调函数,这个回调函数的行为将会在连接建立之后执行。之后,在 Start 当中将会通过 for loop 和 select 阻塞地等待来自 ExitBuffChan 的退出消息。
开启 Reader 和 Writer 两个 goroutine 实现了对 Connection 的读写分离,StartReader 的实现如下:
// StartReader 开启处理 conn 读数据的 goroutine
func (c *Connection) StartReader() {
fmt.Println("Reader Goroutine is running")
defer fmt.Println(c.RemoteAddr().String(), " conn reader exit !")
defer c.Stop()
for {
// 创建封包拆包的对象
dp := NewDataPack()
// 读取客户端的 msg head
headData := make([]byte, dp.GetHeadLen()) // 注意 GetHeadLen() 返回常量 8, 因为包的头部长度固定
if _, err := io.ReadFull(c.GetTCPConnection(), headData); err != nil {
fmt.Println("read msg head error", err)
c.ExitBuffChan <- true
return
}
// 拆包, 得到 msgid 和 datalen, 并放在 msg 中
msg, err := dp.Unpack(headData)
if err != nil {
fmt.Println("unpack error", err)
c.ExitBuffChan <- true
return
}
// 根据 dataLen 读取 data, 放在 msg.Data 中
var data []byte
if msg.GetDataLen() > 0 {
data = make([]byte, msg.GetDataLen())
if _, err := io.ReadFull(c.GetTCPConnection(), data); err != nil {
fmt.Println("read msg data error", err)
c.ExitBuffChan <- true
return
}
}
msg.SetData(data)
// 得到当前客户端请求的 Request 数据
req := Request{
conn: c,
msg: msg,
}
if settings.Conf.WorkerPoolSize > 0 {
// 已经启动工作池机制, 将消息交给 Worker 处理
c.Msghandler.SendMsgToTaskQueue(&req)
} else {
// 从绑定好的消息和对应的处理方法中执行 Handle 方法
go c.Msghandler.DoMsgHandler(&req)
}
}
}
要明确的是,StartReader 处于一个 goroutine 当中,它首先打印一些信息,并设置好 defer,之后开启 for loop 持续地监听来自客户端发送的消息。
在 for loop 当中,会首先创建一个用于封包和拆包的对象DataPack
,通过NewDataPack()
这个工厂函数来完成。既然提到了 Zinx 的封包和拆包,那么我们在此处回顾一下 Zinx 所使用的 DataPack。
为什么要在 Zinx 当中集成用于封包和拆包的 DataPack?原因是要解决服务器数据传输的粘包问题。由于当前 Zinx 是基于 TCP 连接进行消息的收发的,难免会出现两个数据包同时到达的情况(原因可能是 TCP 协议数据传输优化导致的,比如 Server 的缓冲区较大,Client 积攒多个包之后一并发送)。由于 Server 收到的数据是字节流,可以抽象为[]byte
,一个单纯的[]byte
对象对用户或者说 Server 而言是模糊的,我们不知道这个[]byte
当中包含了什么样的数据,可能是单个包的数据,也可能是多个包混合的数据。因此为了得到可读性较高的数据,我们应当对每个数据包进行封装,通过一个 package head 来清晰地标记这个数据包的范围,从而解决粘包问题。原因是如果我们知道了一个数据包的数据范围,每次处理单个 package 的数据的时候,只读取这个范围内的数据即可,不会越界读取。
DataPack 的接口为 IDataPack,其定义如下:
type IDataPack interface {
GetHeadLen() uint32 // 获取包头长度方法
Pack(msg IMessage) ([]byte, error) // 封包方法
Unpack([]byte) (IMessage, error) // 拆包方法
}
DataPack 的结构定义及工厂函数的实现如下:
// DataPack 为用于封包和拆包的类, 暂时不需要成员
type DataPack struct{}
var _ ziface.IDataPack = (*DataPack)(nil)
// NewDataPack 封包拆包实例的初始化方法
func NewDataPack() *DataPack {
return &DataPack{}
}
之后让 DataPack 实现 IDataPack 的方法:
// GetHeadLen 获取包头长度
func (dp *DataPack) GetHeadLen() uint32 {
// Id uint32(4 bytes) + DataLen uint32(4 bytes)
return 8
}
// Pack 为封包方法
func (dp *DataPack) Pack(msg ziface.IMessage) ([]byte, error) {
// 创建一个存放 byte 字节的缓冲
dataBuff := bytes.NewBuffer([]byte{})
// 写 dataLen
if err := binary.Write(dataBuff, binary.LittleEndian, msg.GetDataLen()); err != nil {
return nil, err
}
// 写 msgID
if err := binary.Write(dataBuff, binary.LittleEndian, msg.GetMsgId()); err != nil {
return nil, err
}
// 写 data 数据
if err := binary.Write(dataBuff, binary.LittleEndian, msg.GetData()); err != nil {
return nil, err
}
return dataBuff.Bytes(), nil
}
func (dp *DataPack) Unpack(binaryData []byte) (ziface.IMessage, error) {
// 创建一个用于输入二进制数据的 ioReader
dataBuff := bytes.NewReader(binaryData)
// 只解压 head 信息, 得到 dataLen 和 msgID
msg := &Message{}
// 读 dataLen
if err := binary.Read(dataBuff, binary.LittleEndian, &msg.DataLen); err != nil {
return nil, err
}
// 读 msgID
if err := binary.Read(dataBuff, binary.LittleEndian, &msg.Id); err != nil {
return nil, err
}
// 判断 dataLen 的长度是否超过了我们允许的最大包长度
if settings.Conf.MaxPacketSize > 0 && msg.DataLen > settings.Conf.MaxPacketSize {
return nil, errors.New("Too large msg data received")
}
// 此处只需要把 head 的数据拆包出来即可, 再通过 head 的长度, 从 conn 读取一次数据
return msg, nil
}
注意事项:这里需要注意的点是 Unpack,Unpack 方法只从[]byte
当中解析 package head,而 package head 的长度是固定的,只有 8 个字节,因此 Unpack 实际上只读取 []byte
的前 8 个字节,从中解析出数据字段的长度和 msgID。得到了数据字段的长度,我们可以直接通过 io.ReadFull
从 TCP 连接中读取相应长度的数据。
回顾完 DataPack 的实现之后,我们回到 Connection 的 StartReader,其拆解包的过程如下:
// ... ... ...
// 读取客户端的 msg head
headData := make([]byte, dp.GetHeadLen()) // 注意 GetHeadLen() 返回常量 8, 因为包的头部长度固定
if _, err := io.ReadFull(c.GetTCPConnection(), headData); err != nil {
fmt.Println("read msg head error", err)
c.ExitBuffChan <- true
return
}
// 拆包, 得到 msgid 和 datalen, 并放在 msg 中
msg, err := dp.Unpack(headData)
if err != nil {
fmt.Println("unpack error", err)
c.ExitBuffChan <- true
return
}
// 根据 dataLen 读取 data, 放在 msg.Data 中
var data []byte
if msg.GetDataLen() > 0 {
data = make([]byte, msg.GetDataLen())
if _, err := io.ReadFull(c.GetTCPConnection(), data); err != nil {
fmt.Println("read msg data error", err)
c.ExitBuffChan <- true
return
}
}
msg.SetData(data)
// ... ... ...
得到 msg 之后,可以构建 Request,Request 是对 Client 请求的封装,其实现如下:
type Request struct {
conn ziface.IConnection // 已经和客户端建立好的连接
msg ziface.IMessage // 客户端请求的数据
}
var _ ziface.IRequest = (*Request)(nil)
// GetConnection 获取请求连接信息
func (r *Request) GetConnection() ziface.IConnection {
return r.conn
}
// GetData 获取请求消息的数据
func (r *Request) GetData() []byte {
return r.msg.GetData()
}
// GetMsgID 获取请求的消息ID
func (r *Request) GetMsgID() uint32 {
return r.msg.GetMsgId()
}
实际上 Request 实现了 IRequest 接口,此处不再赘述。
Request 封装了当前的连接和 Client 发送过来的数据,数据又包含 ID、dataLen 和 data。还记得我们之前注册路由的时候,AddRouter 方法的第一个形参就是 msgID 嘛?实际上此处 msg 的 ID 和 msgID 是一一对应的,根据 msgID,来选择要处理这个 Request 的路由,这就是 Zinx-v1.0 多路由模式的工作规则。
得到 Request 之后,根据是否开启了消息队列及 worker pool 机制,将 request 发送给相应的 Router 进行处理即可:
if settings.Conf.WorkerPoolSize > 0 {
// 已经启动工作池机制, 将消息交给 Worker 处理
c.Msghandler.SendMsgToTaskQueue(&req)
} else {
// 从绑定好的消息和对应的处理方法中执行 Handle 方法
go c.Msghandler.DoMsgHandler(&req)
}
至此我们便完成了 StartReader 的回顾。
另一个模块是 Writer,StartWriter 的实现如下:
func (c *Connection) StartWriter() {
fmt.Println("[Writer Goroutine is running]")
defer fmt.Println(c.RemoteAddr().String(), "[conn Writer exit!]")
for {
select {
case data := <-c.msgChan:
if _, err := c.Conn.Write(data); err != nil {
fmt.Println("Send Data error:", err, " Conn Writer exit~")
return
}
case data, ok := <-c.msgBuffChan:
if ok {
if _, err := c.Conn.Write(data); err != nil {
fmt.Println("Send Buff Data error:", err, " Conn Writer exit")
return
}
} else {
fmt.Println("msgBuffChan is Closed")
return
}
case <-c.ExitBuffChan:
// conn 关闭
return
}
}
}
在 Writer 当中我们开启了一个 for loop,并结合 select 机制,从 msgChan 或 msgBuffChan 接收消息,一旦有消息发送到 msgChan 或 msgBuffChan,我们便将消息回写给 Connection 另一端的 Client。
我们需要知道的是,Writer 也运行在一个 goroutine 当中,想要向 Client 发送消息,实际上需要通过 Connection 的 msgChan 和 msgBuffChan,我们需要把消息发送给这两个 channel,才能把消息发送给 Client。
实现向 msgChan 和 msgBuffChan 发送消息的方法其实是 SendMsg 和 SendBuffMsg:
func (c *Connection) SendMsg(msgId uint32, data []byte) error {
if c.isClosed == true {
return errors.New("Connection closed when send msg")
}
// 将 data 封包
dp := NewDataPack()
msg, err := dp.Pack(NewMsgPackage(msgId, data))
if err != nil {
fmt.Println("Pack error msg id = ", msgId)
return errors.New("Pack error msg ")
}
// 将 data 发送
c.msgChan <- msg
return nil
}
func (c *Connection) SendBuffMsg(msgId uint32, data []byte) error {
if c.isClosed == true {
return errors.New("Connection closed when send buff msg")
}
// 将 data 封包并发送
dp := NewDataPack()
msg, err := dp.Pack(NewMsgPackage(msgId, data))
if err != nil {
fmt.Println("Pack error msg id = ", msgId)
return errors.New("Pack error msg")
}
c.msgBuffChan <- msg
return nil
}
我们需要明确的是,SendMsg 和 SendBuffMsg 的调用者应该是 Router。为什么是 Router 呢?因为 Router 处理具体的业务,它的三个 Handle 方法的输入是 Request,Request 当中包含着连接信息和封装的 message。Router 通过 Handle 处理好业务之后,可以通过 Connection 调用 Send 方法,将消息发送给 Channel,从而通过 Writer 进一步将消息发送给 Client。
一个可能的 Router 的定义如下:
type HelloZinxRouter struct {
znet.BaseRouter
}
// HelloZinxRouter Handle
func (this *HelloZinxRouter) Handle(request ziface.IRequest) {
fmt.Println("Call HelloZinxRouter Handle")
fmt.Println("recv from client : msgId=", request.GetMsgID(), ", data=", string(request.GetData()))
err := request.GetConnection().SendBuffMsg(1, []byte("Hello Zinx Router V0.10"))
if err != nil {
fmt.Println(err)
}
}
可以看到,在 Handle 当中,我们通过request.GetConnection().SendBuffMsg(1, []byte("Hello Zinx Router V0.10"))
完成消息的发送。
最后,Connection 好包含一些获取类成员的成员函数(可以这样理解,因为这些函数的行为就是获取 Connection 结构字段的值,但是需要明确的是 Golang 其实没有类这个概念,但是从设计模式的角度来说可以理解成通过成员函数获取成员字段):
// GetTCPConnection 从当前连接获取原始的 socket TCPConn
func (c *Connection) GetTCPConnection() *net.TCPConn {
return c.Conn
}
// GetConnID 获取当前连接的 ID
func (c *Connection) GetConnID() uint32 {
return c.ConnID
}
// RemoteAddr 获取远程客户端的地址信息
func (c *Connection) RemoteAddr() net.Addr {
return c.Conn.RemoteAddr()
}
Connection 还有一个 Stop 方法,它在 StartReader 中被通过 defer 调用,其实现如下:
// Stop 停止连接, 结束当前连接状态
func (c *Connection) Stop() {
fmt.Println("Conn Stop()... ConnID = ", c.ConnID)
// 1. 如果当前连接已经关闭
if c.isClosed == true {
return
}
c.isClosed = true
// Connection Stop() 如果用户注册了该连接的关闭回调业务, 那么应该在此刻显式调用
c.TCPServer.CallOnConnStop(c)
// 关闭 socket 连接
c.Conn.Close()
// 通知从缓冲队列读数据的业务, 该链接已经关闭
c.ExitBuffChan <- true
// 将连接从管理器中删除
c.TCPServer.GetConnMgr().Remove(c)
// 关闭该链接全部管道
close(c.ExitBuffChan)
close(c.msgBuffChan)
}
其中包含了连接断开前回调函数的调用、从连接管理器 ConnManager 删除连接、关闭通道等行为,此处不再赘述。
至此,我们从 Server 的角度出发,对 Zinx-v1.0 进行了完整的回顾。现在我们简单地从 Client 的角度出发,看一下客户端连接服务器并发送数据的行为。
Client
一个可能的 Client 如下:
package main
import (
"fmt"
"io"
"net"
"time"
"zinx/znet"
)
/*
模拟客户端
*/
func main() {
fmt.Println("Client Test ... start")
//3秒之后发起测试请求,给服务端开启服务的机会
time.Sleep(3 * time.Second)
conn, err := net.Dial("tcp", "127.0.0.1:7777")
if err != nil {
fmt.Println("client start err, exit!")
return
}
for {
//发封包message消息
dp := znet.NewDataPack()
msg, _ := dp.Pack(znet.NewMsgPackage(1, []byte("Zinx V1.0 Client1 Test Message")))
_, err := conn.Write(msg)
if err != nil {
fmt.Println("write error err ", err)
return
}
//先读出流中的head部分
headData := make([]byte, dp.GetHeadLen())
_, err = io.ReadFull(conn, headData) //ReadFull 会把msg填充满为止
if err != nil {
fmt.Println("read head error")
break
}
//将headData字节流 拆包到msg中
msgHead, err := dp.Unpack(headData)
if err != nil {
fmt.Println("server unpack err:", err)
return
}
if msgHead.GetDataLen() > 0 {
//msg 是有data数据的,需要再次读取data数据
msg := msgHead.(*znet.Message)
msg.Data = make([]byte, msg.GetDataLen())
//根据dataLen从io中读取字节流
_, err := io.ReadFull(conn, msg.Data)
if err != nil {
fmt.Println("server unpack data err:", err)
return
}
fmt.Println("==> Recv Msg: ID=", msg.Id, ", len=", msg.DataLen, ", data=", string(msg.Data))
}
time.Sleep(1 * time.Second)
}
}
可以看到,这个 Client 在 for loop 当中持续地向 Server 发送消息。作为连接到 Zinx Server 的 Client,它需要遵守 Zinx 的消息封包规则,因此 Client 也使用 DataPack 进行封包。同时,Server 发送给 Client 的消息也需要 Client 自行拆包。
注意事项:读取 package head 的 io.ReadFull
在 io.Reader
(即第一个传入的实参)是网络连接时,会阻塞读取直到将 []byte
填满,因此如果 Server 没有回传给 Client 数据,Client 会阻塞等待。对于 Server 的 Reader,由于 Reader 也使用 io.ReadFull
读取 package head,因此阻塞读取的逻辑相同。
未来展望
至此,我们花了大概两个多小时的时间完整地回顾并总结了 Zinx-v1.0 项目,可以说 Zinx-v1.0 我现在已经完全吃透了。由于 Zinx 是一个开源的轻量级并发服务器,时至今日,github 上的 Zinx repo 已经非常完善了,其在 Zinx-v1.0 的基础上做了很大的升级。后续如果时间充裕的话,我会持续对当前的 Zinx-v1.0 进行更新改进,欢迎持续关注。