前言
虽然已经学习过计算机网络的理论课程,也做过相关的搭网实验,但是感觉缺乏更进一步的实践,导致很多理论知识都流于表面,并且正在随着时间流逝而快速被遗忘。最近几天刚好有空,打算抽出时间来学习如何从0开始做一个简易的IM系统,借此夯实网络通信的基础。
关键词
go ;websocket
参考代码来源
git clone https://github.com/klintcheng/chatdemo.git
这是一位大佬在Github上开源的demo,他还有另外一个微服务版、支持企业级高并发的项目Kim,掘金小册 分布式IM原理与实战: 从0到1打造即时通讯云 就是作者为Kim写的。这本小册我正在看,感觉写得很不错,理论知识讲得很深入、扎实,整体逻辑清晰、层层递进。(非打广告,只是我学习别人的项目并且记录心得,理应注明一下出处,尊重他人的知识产权和劳动成果)
理论知识
websocket
什么是websocket?
websocket可以理解为是http的增强版,所以它使用的端口也是80。
它可以在用户的浏览器和服务器之间打开交互式通信会话。使用此 API,可以向服务器发送消息并接收事件驱动的响应,而无需通过轮询服务器的方式以获得响应。
为什么是websocket?
IM通信的一个关键在于长连接
,早期,在浏览器(IE6)还不兼容websocket的时候,一般采用如下两种方式实现长连接:
- 集成flash或其插件(现已废弃)
- 使用http的长轮询技术
后者有哪些缺点呢?
- 长轮询技术涉及多次连接的建立和释放,对服务性能有影响。
- 协议不兼容,http是超文本传输协议,但是长连接需要的是私有二进制协议。
websocket就像是让更专业的人做更专业的事。所以随着浏览器对协议的兼容,使用websocket势在必行。
下图是websocket连接模型的生命周期:
具体实现
下面这段服务端的代码就是依据上面的生命周期流程写的:
// Start server
func (s *Server) Start() error {
mux := http.NewServeMux()
log := logrus.WithFields(logrus.Fields{
"module": "Server",
"listen": s.address,
"id": s.id,
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
conn, _, _, err := ws.UpgradeHTTP(r, w)
if err != nil {
conn.Close()
return
}
// 读取userId
user := r.URL.Query().Get("user")
if user == "" {
conn.Close()
return
}
// 添加到会话管理中
old, ok := s.addUser(user, conn)
if ok {
// 断开旧的连接
old.Close()
log.Infof("close old connection %v", old.RemoteAddr())
}
log.Infof("user %s in from %v", user, conn.RemoteAddr())
go func(user string, conn net.Conn) {
err := s.readloop(user, conn)
if err != nil {
log.Warn("readloop - ", err)
}
conn.Close()
// 删除用户
s.delUser(user)
log.Infof("connection of %s closed", user)
}(user, conn)
})
log.Infoln("started")
return http.ListenAndServe(s.address, mux)
}
代码的其他部分也很好地体现了模拟对象的性质:
先来看websocket对象,一个websocket对象包含了如下内容:
type ServerOptions struct {
writewait time.Duration //写超时时间
readwait time.Duration //读超时时间
}
type Server struct {
once sync.Once //在需要被执行的部分只执行一次
options ServerOptions //读超时、写超时时限
id string
address string
sync.Mutex //互斥区,用于并发控制锁定资源
users map[string]net.Conn //用于模拟会话列表
}
所谓的同账号互踢,就是用相同账号的新连接替换掉旧链接:
func (s *Server) addUser(user string, conn net.Conn) (net.Conn, bool) {
s.Lock()
defer s.Unlock()
old, ok := s.users[user]
s.users[user] = conn
return old, ok
}
这里穿插一个关于Go的知识点:
关于其中defer的用法,最直接的说法是延迟调用,最直观的理解就是在该函数返回前会执行defer对应的那条语句的意思(可以理解为现在还不想执行,比如释放锁,释放资源,但是之后一定要做,又怕之后忘记了,所以先写上,然后加个defer,它到时候自然会执行)
删除账号类似,在这里就是把map中的对应数据清除:
func (s *Server) delUser(user string) {
s.Lock()
defer s.Unlock()
delete(s.users, user)
}
这里的关闭连接就是通过for循环遍历map里面的所有net.Conn并调用Close()接口关闭:
func (s *Server) Shutdown() {
s.once.Do(func() {
s.Lock()
defer s.Unlock()
for _, conn := range s.users {
conn.Close()
}
})
}
在demo中,通过遍历所有用户并打印文本消息的方式模拟广播:
for u, conn := range s.users {
if u == user { // 不发给自己
continue
}
logrus.Infof("send to %s : %s", u, broadcast)
err := s.writeText(conn, broadcast)
if err != nil {
logrus.Errorf("write to %s failed, error: %v", user, err)
}
}
这类操作都要涉及到并发编程的问题,所以要加锁保证操作过程的独立性。
readloop,顾名思义:循环地读取帧数据,直到连接关闭或者发生错误,才返回对应的内容。大体框架是这样的:
func (s *Server) readloop(user string, conn net.Conn) error {
for {
frame, err := ws.ReadFrame(conn)
if err != nil {
return err
}
if frame.Header.OpCode == xxx {
...
continue
}
if frame.Header.OpCode == ws.OpClose {
return errors.New("remote side close the conn")
}
...
// 接收文本帧内容
if frame.Header.OpCode == ws.OpText {
xxx
} else if frame.Header.OpCode == ws.OpBinary {
xxx
}
}
}
TCP协议中规定,当双方超过一定时间均未发送数据,则默认为断开连接(两军问题的可能解,没有回应也是一种回应)。所以源代码中函数开头有如下一行:
_ = conn.SetReadDeadline(time.Now().Add(s.options.readwait))
它设定了一个连接过期时限。
demo中只对后台进行了模拟,按照代码作者的说法,可以自行找一个可以提供前端可视化输入的网站来验证效果,比如
coolaf (截止笔者发文时该网站仍能访问)
运行
按运行Go项目的方法编译运行即可。
注意:在界面中输入的url的协议不是http/https,而是这里对应的ws。
至此,对demo的学习就结束了。笔者也对websocket协议的应用以及网络通信的基本流程有了粗略的了解。