6

适合新手:手把手教你用Go快速搭建高性能、可扩展的IM系统(有源码)

 4 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzUzMjM5ODk5Nw%3D%3D&%3Bmid=2247484754&%3Bidx=1&%3Bsn=e77cf6badeaca10160fdad43168a113c
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

“  本文原文共2万5千字,篇幅太长,为了体升阅读体验已做删减和优化, 如需阅读全文,请前往即时通讯网(52im.net)社区: http://www.52im.net/thread-2988-1-1.html  或点击下方的 阅读原文 ”!

0、引言

本文为开源工程:“github.com/GuoZhaoran/fastIM”的配套文章,原作者:“绘你一世倾城”,现为:猎豹移动php开发工程师,感谢原作者的技术分享。

友情提示: 本文适合有一定网络通信技术基础的IM新手阅读 如果你对网络编程,以及IM的一些理论知识知之甚少,请务必首先阅读: 新手入门一篇就够:从零开发移动端IM

》,按需补充相关知识。

配套源码:

本文写的虽然有点浅显但涉及内容不少,建议结合代码一起来读,文章配套的完整源码如下。

  • 主地址: https://github.com/GuoZhaoran/fastIM

  • 备地址: https://github.com/52im/fastIM

52im.net的另几篇同类代码你可能也喜欢:

  • 自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)

  • 拿起键盘就是干:跟我一起徒手开发一套分布式IM系统

  • 适合新手:从零开发一个IM服务端(基于Netty,有完整源码)

另外: 本文作者的另一篇文章,有兴趣也可以关注一下:《 12306抢票带来的启示:看我如何用Go实现百万QPS的秒杀系统(含源码) 》。

1、内容概述

本文的目的是帮助读者较为深入的理解socket协议,并快速搭建一个高可用、可拓展的IM系统。同时帮助读者了解IM系统后续可以做哪些优化和改进。

本文的内容概述:

  • 1)本文演示的IM系统包含基本的注册、登录、添加好友基础功能;

  • 2)提供单聊、群聊,并且支持发送文字、表情和图片,在搭建的系统上,读者可轻松的拓展语音、视频聊天、发红包等业务。

  • 2)为了帮助读者更清楚的理解IM系统的原理,第3节我会专门深入讲解一下websocket协议,websocket是长链接中比较常用的协议;

  • 3)然后第4节会讲解快速搭建IM系统的技巧和主要代码实现;

  • 4)在第5节笔者会对IM系统的架构升级和优化提出一些建议和思路;

  • 5)在最后章节做本文的回顾总结。

2、理解websocket协议

WebSocket的目标是在一个单独的持久连接上提供全双工、双向通信。在Javascript创建了Web Socket之后,会有一个HTTP请求发送到浏览器以发起连接。在取得服务器响应后,建立的连接会将HTTP升级从HTTP协议交换为WebSocket协议。

由于WebSocket使用自定义的协议,所以URL模式也略有不同。未加密的连接不再是http://,而是ws://;加密的连接也不是https://,而是wss://。在使用WebSocket URL时,必须带着这个模式,因为将来还有可能支持其他的模式。

使用自定义协议而非HTTP协议的好处是,能够在客户端和服务器之间发送非常少量的数据,而不必担心HTTP那样字节级的开销。由于传递的数据包很小,所以WebSocket非常适合移动应用。

本节内容太多,此处已删减约5000字 ,原文请见52im社区链接: http://www.52im.net/thread-2988-1-1.html 或点击下方的“ 阅读原文 ”!

3、开始动手

3.1 系统架构和代码文件目录结构

下图是一个比较完备的IM系统架构: 包含了C端( 客户端 )、接入层( 通过协议接入 )、S端( 服务端 )处理逻辑和分发消息、存储层用来持久化数据。

FnuMBjF.jpg!web  

简要介绍一下本次IM的技术实现情况:

  • 1)我们本节C端使用的是Webapp, 通过Go语言渲染Vue模版快速实现功能;

  • 2)接入层使用的是websocket协议,前边已经进行了深入的介绍;

  • 3)S端是我们实现的重点,其中鉴权、登录、关系管理、单聊和群聊的功能都已经实现,读者可以在这部分功能的基础上再拓展其他的功能,比如:视频语音聊天、发红包、朋友圈等业务模块;

  • 4)存储层我们做的比较简单,只是使用Mysql简单持久化存储了用户关系,然后聊天中的图片资源我们存储到了本地文件中。

虽然我们的IM系统实现的比较简化,但是读者可以在次基础上进行改进、完善、拓展,依然能够作出高可用的企业级产品。

我们的系统服务使用Go语言构建,代码结构比较简洁,但是性能比较优秀( 这是Java和其他语言所无法比拟的 ),单机支持几万人的在线聊天。

下边是代码文件的目录结构( 完整源码请自行下载 ):

app

│ ├── args

│ │ ├── contact.go

│ │ └── pagearg.go

│ ├── controller //控制器层,api入口

│ │ ├── chat.go

│ │ ├── contract.go

│ │ ├── upload.go

│ │ └── user.go

│ ├── main.go //程序入口

│ ├── model //数据定义与存储

│ │ ├── community.go

│ │ ├── contract.go

│ │ ├── init.go

│ │ └── user.go

│ ├── service //逻辑实现

│ │ ├── contract.go

│ │ └── user.go

│ ├── util //帮助函数

│ │ ├── md5.go

│ │ ├── parse.go

│ │ ├── resp.go

│ │ └── string.go

│ └── view //模版资源

│ │ ├── ...

asset //js、css文件

resource //上传资源,上传图片会放到这里

源码的具体说明如下:

  • 1)从入口函数main.go开始,我们定义了controller层,是客户端api的入口;

  • 2)service用来处理主要的用户逻辑,消息分发、用户管理都在这里实现;

  • 3)model层定义了一些数据表,主要是用户注册和用户好友关系、群组等信息,存储到mysql;

  • 4)util包下是一些帮助函数,比如加密、请求响应等;

  • 5)view下边存储了模版资源信息,上边所说的这些都在app文件夹下存储;

  • 6)外层还有asset用来存储css、js文件和聊天中会用到的表情图片等;

  • 7)resource下存储用户聊天中的图片或者视频等文件。

总体来讲,我们的代码目录机构还是比较简洁清晰的。

了解了我们要搭建的IM系统架构,我们再来看一下架构重点实现的功能吧。

3.2 10行代码万能模版渲染

Go语言提供了强大的html渲染能力,非常简单的构建web应用,下边是实现模版渲染的代码,它太简单了,以至于可以直接在main.go函数中实现。

代码如下:

func registerView() {

tpl, err := template.ParseGlob("./app/view/**/*")

if err != nil {

log.Fatal(err.Error())

}

for _, v := range tpl.Templates() {

tplName := v.Name()

http.HandleFunc(tplName, func(writer http.ResponseWriter, request *http.Request) {

tpl.ExecuteTemplate(writer, tplName, nil)

})

}

}

...

func main() {

......

http.Handle("/asset/", http.FileServer(http.Dir(".")))

http.Handle("/resource/", http.FileServer(http.Dir(".")))

registerView()

log.Fatal(http.ListenAndServe(":8081", nil))

}

Go实现静态资源服务器也很简单,只需要调用http.FileServer就可以了,这样html文件就可以很轻松的访问依赖的js、css和图标文件了。使用http/template包下的ParseGlob、ExecuteTemplate又可以很轻松的解析web页面,这些工作完全不依赖与nginx。

现在我们就完成了登录、注册、聊天C端界面的构建工作:

miYjUjM.jpg!web       

ZzauMbu.jpg!web  

3.3 注册、登录和鉴权

之前我们提到过,对于注册、登录和好友关系管理,我们需要有一张user表来存储用户信息。我们使用 https://github.com/go-xorm/xorm 来操作mysql。

首先看一下mysql表的设计。

app/model/user.go

package model

import "time"

const (

SexWomen = "W"

SexMan = "M"

SexUnknown = "U"

)

type User struct {

Id int64 `xorm:"pk autoincr bigint(64)" form:"id" json:"id"`

Mobile string `xorm:"varchar(20)" form:"mobile" json:"mobile"`

Passwd string `xorm:"varchar(40)" form:"passwd" json:"-"` // 用户密码 md5(passwd + salt)

Avatar string `xorm:"varchar(150)" form:"avatar" json:"avatar"`

Sex string `xorm:"varchar(2)" form:"sex" json:"sex"`

Nickname string `xorm:"varchar(20)" form:"nickname" json:"nickname"`

Salt string `xorm:"varchar(10)" form:"salt" json:"-"`

Online int `xorm:"int(10)" form:"online" json:"online"` //是否在线

Token string `xorm:"varchar(40)" form:"token" json:"token"` //用户鉴权

Memo string `xorm:"varchar(140)" form:"memo" json:"memo"`

Createat time.Time `xorm:"datetime" form:"createat" json:"createat"` //创建时间, 统计用户增量时使用

}

我们user表中存储了用户名、密码、头像、用户性别、手机号等一些重要的信息,比较重要的是我们也存储了token标示用户在用户登录之后,http协议升级为websocket协议进行鉴权,这个细节点我们前边提到过,下边会有代码演示。

接下来我们看一下model初始化要做的一些事情吧。

app/model/init.go

package model

import (

"errors"

"fmt"

_ "github.com/go-sql-driver/mysql"

"github.com/go-xorm/xorm"

"log"

)

var DbEngine *xorm.Engine

func init() {

driverName := "mysql"

dsnName := "root:root@(127.0.0.1:3306)/chat?charset=utf8"

err := errors.New("")

DbEngine, err = xorm.NewEngine(driverName, dsnName)

if err != nil && err.Error() != ""{

log.Fatal(err)

}

DbEngine.ShowSQL(true)

//设置数据库连接数

DbEngine.SetMaxOpenConns(10)

//自动创建数据库

DbEngine.Sync(new(User), new(Community), new(Contact))

fmt.Println("init database ok!")

}

我们创建一个DbEngine全局mysql连接对象,设置了一个大小为10的连接池。model包里的init函数在程序加载的时候会先执行,对Go语言熟悉的同学应该知道这一点。我们还设置了一些额外的参数用于调试程序,比如:设置打印运行中的sql,自动的同步数据表等,这些功能在生产环境中可以关闭。我们的model初始化工作就做完了,非常简陋,在实际的项目中,像数据库的用户名、密码、连接数和其他的配置信息,建议设置到配置文件中,然后读取,而不像本文硬编码的程序中。
注册是一个普通的api程序,对于Go语言来说,完成这件工作太简单了。
我们来看一下代码:

############################

//app/controller/user.go

############################

......

//用户注册

func UserRegister(writer http.ResponseWriter, request *http.Request) {

var user model.User

util.Bind(request, &user)

user, err := UserService.UserRegister(user.Mobile, user.Passwd, user.Nickname, user.Avatar, user.Sex)

if err != nil {

util.RespFail(writer, err.Error())

} else {

util.RespOk(writer, user, "")

}

}

......

############################

//app/service/user.go

############################

......

type UserService struct{}

//用户注册

func (s *UserService) UserRegister(mobile, plainPwd, nickname, avatar, sex string) (user model.User, err error) {

registerUser := model.User{}

_, err = model.DbEngine.Where("mobile=? ", mobile).Get(®isterUser)

if err != nil {

return registerUser, err

}

//如果用户已经注册,返回错误信息

if registerUser.Id > 0 {

return registerUser, errors.New("该手机号已注册")

}

registerUser.Mobile = mobile

registerUser.Avatar = avatar

registerUser.Nickname = nickname

registerUser.Sex = sex

registerUser.Salt = fmt.Sprintf("%06d", rand.Int31n(10000))

registerUser.Passwd = util.MakePasswd(plainPwd, registerUser.Salt)

registerUser.Createat = time.Now()

//插入用户信息

_, err = model.DbEngine.InsertOne(®isterUser)

return registerUser, err

}

......

############################

//main.go

############################

......

func main() {

http.HandleFunc("/user/register", controller.UserRegister)

}

首先,我们使用util.Bind(request, &user)将用户参数绑定到user对象上,使用的是util包中的Bind函数,具体实现细节读者可以自行研究,主要模仿了Gin框架的参数绑定,可以拿来即用,非常方便。然后我们根据用户手机号搜索数据库中是否已经存在,如果不存在就插入到数据库中,返回注册成功信息,逻辑非常简单。
登录逻辑更简单:

############################

//app/controller/user.go

############################

...

//用户登录

func UserLogin(writer http.ResponseWriter, request *http.Request) {

request.ParseForm()

mobile := request.PostForm.Get("mobile")

plainpwd := request.PostForm.Get("passwd")

//校验参数

if len(mobile) == 0 || len(plainpwd) == 0 {

util.RespFail(writer, "用户名或密码不正确")

}

loginUser, err := UserService.Login(mobile, plainpwd)

if err != nil {

util.RespFail(writer, err.Error())

} else {

util.RespOk(writer, loginUser, "")

}

}

...

############################

//app/service/user.go

############################

...

func (s *UserService) Login(mobile, plainpwd string) (user model.User, err error) {

//数据库操作

loginUser := model.User{}

model.DbEngine.Where("mobile = ?", mobile).Get(&loginUser)

if loginUser.Id == 0 {

return loginUser, errors.New("用户不存在")

}

//判断密码是否正确

if !util.ValidatePasswd(plainpwd, loginUser.Salt, loginUser.Passwd) {

return loginUser, errors.New("密码不正确")

}

//刷新用户登录的token值

token := util.GenRandomStr(32)

loginUser.Token = token

model.DbEngine.ID(loginUser.Id).Cols("token").Update(&loginUser)

//返回新用户信息

return loginUser, nil

}

...

############################

//main.go

############################

......

func main() {

http.HandleFunc("/user/login", controller.UserLogin)

}

实现了登录逻辑,接下来我们就到了用户首页,这里列出了用户列表,点击即可进入聊天页面。用户也可以点击下边的tab栏查看自己所在的群组,可以由此进入群组聊天页面。
具体这些工作还需要读者自己开发用户列表、添加好友、创建群组、添加群组等功能,这些都是一些普通的api开发工作,我们的代码程序中也实现了,读者可以拿去修改使用,这里就不再演示了。
我们再重点看一下用户鉴权这一块吧,用户鉴权是指用户点击聊天进入聊天界面时,客户端会发送一个GET请求给服务端,请求建立一条websocket长连接,服务端收到建立连接的请求之后,会对客户端请求进行校验,以确实是否建立长连接,然后将这条长连接的句柄添加到map当中(因为服务端不仅仅对一个客户端服务,可能存在千千万万个长连接)维护起来。
我们下边来看具体代码实现:

############################

//app/controller/chat.go

############################

......

//本核心在于形成userid和Node的映射关系

type Node struct {

Conn *websocket.Conn

//并行转串行,

DataQueue chan []byte

GroupSets set.Interface

}

......

//userid和Node映射关系表

var clientMap map[int64]*Node = make(map[int64]*Node, 0)

//读写锁

var rwlocker sync.RWMutex

//实现聊天的功能

func Chat(writer http.ResponseWriter, request *http.Request) {

query := request.URL.Query()

id := query.Get("id")

token := query.Get("token")

userId, _ := strconv.ParseInt(id, 10, 64)

//校验token是否合法

islegal := checkToken(userId, token)

conn, err := (&websocket.Upgrader{

CheckOrigin: func(r *http.Request) bool {

return islegal

},

}).Upgrade(writer, request, nil)

if err != nil {

log.Println(err.Error())

return

}

//获得websocket链接conn

node := &Node{

Conn: conn,

DataQueue: make(chan []byte, 50),

GroupSets: set.New(set.ThreadSafe),

}

//获取用户全部群Id

comIds := concatService.SearchComunityIds(userId)

for _, v := range comIds {

node.GroupSets.Add(v)

}

rwlocker.Lock()

clientMap[userId] = node

rwlocker.Unlock()

//开启协程处理发送逻辑

go sendproc(node)

//开启协程完成接收逻辑

go recvproc(node)

sendMsg(userId, []byte("welcome!"))

}

......

//校验token是否合法

func checkToken(userId int64, token string) bool {

user := UserService.Find(userId)

return user.Token == token

}

......

############################

//main.go

############################

......

func main() {

http.HandleFunc("/chat", controller.Chat)

}

......

进入聊天室,客户端发起/chat的GET请求,服务端首先创建了一个Node结构体,用来存储和客户端建立起来的websocket长连接句柄,每一个句柄都有一个管道DataQueue,用来收发信息,GroupSets是客户端对应的群组信息,后边我们会提到。

type Node struct {

Conn *websocket.Conn

//并行转串行,

DataQueue chan []byte

GroupSets set.Interface

}

服务端创建了一个map,将客户端用户id和其Node关联起来:

//userid和Node映射关系表

var clientMap map[int64]*Node = make(map[int64]*Node, 0)

接下来是主要的用户逻辑了,服务端接收到客户端的参数之后,首先校验token是否合法,由此确定是否要升级http协议到websocket协议,建立长连接,这一步称为鉴权。
代码如下:

//校验token是否合法

islegal := checkToken(userId, token)

conn, err := (&websocket.Upgrader{

CheckOrigin: func(r *http.Request) bool {

return islegal

},

}).Upgrade(writer, request, nil)

鉴权成功以后,服务端初始化一个Node,搜索该客户端用户所在的群组id,填充到群组的GroupSets属性中。然后将Node节点添加到ClientMap中维护起来,我们对ClientMap的操作一定要加锁,因为Go语言在并发情况下,对map的操作并不保证原子安全。
代码如下:

//获得websocket链接conn

node := &Node{

Conn: conn,

DataQueue: make(chan []byte, 50),

GroupSets: set.New(set.ThreadSafe),

}

//获取用户全部群Id

comIds := concatService.SearchComunityIds(userId)

for _, v := range comIds {

node.GroupSets.Add(v)

}

rwlocker.Lock()

clientMap[userId] = node

rwlocker.Unlock()

服务端和客户端建立了长链接之后,会开启两个协程专门来处理客户端消息的收发工作,对于Go语言来说,维护协程的代价是很低的,所以说我们的单机程序可以很轻松的支持成千上完的用户聊天,这还是在没有优化的情况下。
代码如下:

......

//开启协程处理发送逻辑

go sendproc(node)

//开启协程完成接收逻辑

go recvproc(node)

sendMsg(userId, []byte("welcome!"))

......

至此,我们的鉴权工作也已经完成了,客户端和服务端的连接已经建立好了,接下来我们就来实现具体的聊天功能吧。

3.4 实现单聊和群聊

实现聊天的过程中,消息体的设计至关重要,消息体设计的合理,功能拓展起来就非常的方便,后期维护、优化起来也比较简单。

我们先来看一下,我们消息体的设计:

############################

//app/controller/chat.go

############################

type Message struct {

Id int64 `json:"id,omitempty" form:"id"` //消息ID

Userid int64 `json:"userid,omitempty" form:"userid"` //谁发的

Cmd int `json:"cmd,omitempty" form:"cmd"` //群聊还是私聊

Dstid int64 `json:"dstid,omitempty" form:"dstid"` //对端用户ID/群ID

Media int `json:"media,omitempty" form:"media"` //消息按照什么样式展示

Content string `json:"content,omitempty" form:"content"` //消息的内容

Pic string `json:"pic,omitempty" form:"pic"` //预览图片

Url string `json:"url,omitempty" form:"url"` //服务的URL

Memo string `json:"memo,omitempty" form:"memo"` //简单描述

Amount int `json:"amount,omitempty" form:"amount"` //其他和数字相关的

}

每一条消息都有一个唯一的id,将来我们可以对消息持久化存储,但是我们系统中并没有做这件工作,读者可根据需要自行完成。然后是userid,发起消息的用户,对应的是dstid,要将消息发送给谁。
还有一个参数非常重要,就是cmd,它表示是群聊还是私聊,群聊和私聊的代码处理逻辑有所区别。
我们为此专门定义了一些cmd常量:

//定义命令行格式

const (

CmdSingleMsg = 10

CmdRoomMsg = 11

CmdHeart = 0

)

  • media是媒体类型,我们都知道微信支持语音、视频和各种其他的文件传输,我们设置了该参数之后,读者也可以自行拓展这些功能;

  • content是消息文本,是聊天中最常用的一种形式;

  • pic和url是为图片和其他链接资源所设置的;

  • memo是简介;

  • amount是和数字相关的信息,比如说发红包业务有可能使用到该字段。

消息体的设计就是这样,基于此消息体,我们来看一下,服务端如何收发消息,实现单聊和群聊吧。还是从上一节说起,我们为每一个客户端长链接开启了两个协程,用于收发消息,聊天的逻辑就在这两个协程当中实现。

代码如下:

############################

//app/controller/chat.go

############################

......

//发送逻辑

func sendproc(node *Node) {

for {

select {

case data := <-node.DataQueue:

err := node.Conn.WriteMessage(websocket.TextMessage, data)

if err != nil {

log.Println(err.Error())

return

}

}

}

}

//接收逻辑

func recvproc(node *Node) {

for {

_, data, err := node.Conn.ReadMessage()

if err != nil {

log.Println(err.Error())

return

}

dispatch(data)

//todo对data进一步处理

fmt.Printf("recv<=%s", data)

}

}

......

//后端调度逻辑处理

func dispatch(data []byte) {

msg := Message{}

err := json.Unmarshal(data, &msg)

if err != nil {

log.Println(err.Error())

return

}

switch msg.Cmd {

case CmdSingleMsg:

sendMsg(msg.Dstid, data)

case CmdRoomMsg:

for _, v := range clientMap {

if v.GroupSets.Has(msg.Dstid) {

v.DataQueue <- data

}

}

case CmdHeart:

//检测客户端的心跳

}

}

//发送消息,发送到消息的管道

func sendMsg(userId int64, msg []byte) {

rwlocker.RLock()

node, ok := clientMap[userId]

rwlocker.RUnlock()

if ok {

node.DataQueue <- msg

}

}

......

服务端向客户端发送消息逻辑比较简单,就是将客户端发送过来的消息,直接添加到目标用户Node的channel中去就好了。
通过websocket的WriteMessage就可以实现此功能:

func sendproc(node *Node) {

for {

select {

case data := <-node.DataQueue:

err := node.Conn.WriteMessage(websocket.TextMessage, data)

if err != nil {

log.Println(err.Error())

return

}

}

}

}

收发逻辑是这样的,服务端通过websocket的ReadMessage方法接收到用户信息,然后通过dispatch方法进行调度:

func recvproc(node *Node) {

for {

_, data, err := node.Conn.ReadMessage()

if err != nil {

log.Println(err.Error())

return

}

dispatch(data)

//todo对data进一步处理

fmt.Printf("recv<=%s", data)

}

}

dispatch方法所做的工作有两件:

  • 1)解析消息体到Message中;

  • 2)根据消息类型,将消息体添加到不同用户或者用户组的channel当中。

Go语言中的channel是协程间通信的强大工具, dispatch只要将消息添加到channel当中,发送协程就会获取到信息发送给客户端,这样就实现了聊天功能。

单聊和群聊的区别只是服务端将消息发送给群组还是个人,如果发送给群组,程序会遍历整个clientMap, 看看哪个用户在这个群组当中,然后将消息发送。

其实更好的实践是我们再维护一个群组和用户关系的Map,这样在发送群组消息的时候,取得用户信息就比遍历整个clientMap代价要小很多了。

func dispatch(data []byte) {

msg := Message{}

err := json.Unmarshal(data, &msg)

if err != nil {

log.Println(err.Error())

return

}

switch msg.Cmd {

case CmdSingleMsg:

sendMsg(msg.Dstid, data)

case CmdRoomMsg:

for _, v := range clientMap {

if v.GroupSets.Has(msg.Dstid) {

v.DataQueue <- data

}

}

case CmdHeart:

//检测客户端的心跳

}

}

......

func sendMsg(userId int64, msg []byte) {

rwlocker.RLock()

node, ok := clientMap[userId]

rwlocker.RUnlock()

if ok {

node.DataQueue <- msg

}

}

可以看到,通过channel,我们实现用户聊天功能还是非常方便的,代码可读性很强,构建的程序也很健壮。

下边是笔者本地聊天的示意图:

jayy6rN.jpg!web   

jAF3Q3e.jpg!web  

3.5 发送表情和图片

下边我们再来看一下聊天中经常使用到的发送表情和图片功能是如何实现的吧。

其实表情也是小图片,只是和聊天中图片不同的是,表情图片比较小,可以缓存在客户端,或者直接存放到客户端代码的代码文件中(不过现在微信聊天中有的表情包都是通过网络传输的)。

下边是一个聊天中返回的图标文本数据:

{

"dstid":1,

"cmd":10,

"userid":2,

"media":4,

"url":"/asset/plugins/doutu//emoj/2.gif"

}

客户端拿到url后,就加载本地的小图标。
聊天中用户发送图片也是一样的原理,不过聊天中用户的图片需要先上传到服务器,然后服务端返回url,客户端再进行加载,我们的IM系统也支持此功能。
我们看一下图片上传的程序:

############################

//app/controller/upload.go

############################

func init() {

os.MkdirAll("./resource", os.ModePerm)

}

func FileUpload(writer http.ResponseWriter, request *http.Request) {

UploadLocal(writer, request)

}

//将文件存储在本地/im_resource目录下

func UploadLocal(writer http.ResponseWriter, request *http.Request) {

//获得上传源文件

srcFile, head, err := request.FormFile("file")

if err != nil {

util.RespFail(writer, err.Error())

}

//创建一个新的文件

suffix := ".png"

srcFilename := head.Filename

splitMsg := strings.Split(srcFilename, ".")

if len(splitMsg) > 1 {

suffix = "." + splitMsg[len(splitMsg)-1]

}

filetype := request.FormValue("filetype")

if len(filetype) > 0 {

suffix = filetype

}

filename := fmt.Sprintf("%d%s%s", time.Now().Unix(), util.GenRandomStr(32), suffix)

//创建文件

filepath := "./resource/" + filename

dstfile, err := os.Create(filepath)

if err != nil {

util.RespFail(writer, err.Error())

return

}

//将源文件拷贝到新文件

_, err = io.Copy(dstfile, srcFile)

if err != nil {

util.RespFail(writer, err.Error())

return

}

util.RespOk(writer, filepath, "")

}

......

############################

//main.go

############################

func main() {

http.HandleFunc("/attach/upload", controller.FileUpload)

}

我们将文件存放到本地的一个磁盘文件夹下,然后发送给客户端路径,客户端通过路径加载相关的图片信息。
关于发送图片,我们虽然实现功能,但是做的太简单了,我们在接下来的章节详细的和大家探讨一下系统优化相关的方案。怎样让我们的系统在生产环境中用的更好。

4、程序优化和系统架构升级方案

我们上边实现了一个功能健全的IM系统,要将该系统应用在企业的生产环境中,需要对代码和系统架构做优化,才能实现真正的高可用。

本节主要从代码优化和架构升级上谈一些个人观点,能力有限不可能面面俱到,希望读者也在回复中给出更多好的建议。

4.1 代码优化

关于框架: 我们的代码没有使用框架,函数和api都写的比较简陋,虽然进行了简单的结构化,但是很多逻辑并没有解耦,所以建议大家业界比较成熟的框架对代码进行重构, Gin 就是一个不错的选择。

关于Map: 系统程序中使用clientMap来存储客户端长链接信息,Go语言中对于大Map的读写要加锁,有一定的性能限制,在用户量特别大的情况下,读者可以对clientMap做拆分,根据用户id做hash或者采用其他的策略,也可以将这些长链接句柄存放到redis中。

关于图片上传: 上边提到图片上传的过程,有很多可以优化的地方,首先是图片压缩( 微信也是这样做的 )。图片资源的压缩不仅可以加快传输速度,还可以减少服务端存储的空间。另外对于图片资源来说,实际上服务端只需要存储一份数据就够了,读者可以在图片上传的时候做hash校验,如果资源文件已经存在了,就不需要再次上传了,而是直接将url返回给客户端( 各大网盘厂商的秒传功能就是这样实现的 )。

代码还有很多优化的地方,比如:

  • 1)我们可以将鉴权做的更好,使用wss://代替ws://;

  • 2)在一些安全领域,可以对消息体进行加密,在高并发领域,可以对消息体进行压缩;

  • 3)对Mysql连接池再做优化,将消息持久化存储到mongo,避免对数据库频繁的写入,将单条写入改为多条一块写入;

  • 4)为了使程序耗费更少的CPU,降低对消息体进行Json编码的次数,一次编码,多次使用......

4.2 系统架构升级

我们的系统太过于简单,所在在架构升级上,有太多的工作可以做,笔者在这里只提几点比较重要的。

1)应用/资源服务分离:

我们所说的资源指的是图片、视频等文件,可以选择成熟厂商的Cos,或者自己搭建文件服务器也是可以的,如果资源量比较大,用户比较广,cdn是不错的选择。

2)突破系统连接数,搭建分布式环境:

对于服务器的选择,一般会选择linux,linux下一切皆文件,长链接也是一样。单机的系统连接数是有限制的,一般来说能达到10万就很不错了,所以在用户量增长到一定程序,需要搭建分布式。分布式的搭建就要优化程序,因为长链接句柄分散到不同的机器,实现消息广播和分发是首先要解决的问题,笔者这里不深入阐述了,一来是没有足够的经验,二来是解决方案有太多的细节需要探讨。搭建分布式环境所面临的问题还有:怎样更好的弹性扩容、应对突发事件等。

3)业务功能分离:

我们上边将用户注册、添加好友等功能和聊天功能放到了一起,真实的业务场景中可以将它们做分离,将用户注册、添加好友、创建群组放到一台服务器上,将聊天功能放到另外的服务器上。业务的分离不仅使功能逻辑更加清晰,还能更有效的利用服务器资源。

4)减少数据库I/O,合理利用缓存:

我们的系统没有将消息持久化,用户信息持久化到mysql中去。在业务当中,如果要对消息做持久化储存,就要考虑数据库I/O的优化,简单讲:合并数据库的写次数、优化数据库的读操作、合理的利用缓存。

上边是就是笔者想到的一些代码优化和架构升级的方案。

5、本文结语

不知道大家有没有发现,使用Go搭建一个IM系统比使用其他语言要简单很多,而且具备更好的拓展性和性能( 并没有吹嘘Go的意思 )。

在当今这个时代,5G将要普及,流量不再昂贵,IM系统已经广泛渗入到了用户日常生活中。对于程序员来说,搭建一个IM系统不再是困难的事情。

如果读者根据本文的思路,理解Websocket,Copy代码,运行程序,应该用不了半天的时间就能上手这样一个IM系统。

IM系统是一个时代,从QQ、微信到现在的人工智能,都广泛应用了即时通信,围绕即时通信,又可以做更多产品布局。

笔者写本文的目的就是想要帮助更多人了解IM,帮助一些开发者快速的搭建一个应用,燃起大家学习网络编程知识的兴趣,希望的读者能有所收获,能将IM系统应用到更多的产品布局中。

6、相关文章


因无法直接引用外链,本文引用的技术文章链接如下:

[1]《新手入门一篇就够:从零开发移动端IM》:

http://www.52im.net/thread-464-1-1.html

[2]《自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)》:

http://www.52im.net/thread-2671-1-1.html

[3]《拿起键盘就是干:跟我一起徒手开发一套分布式IM系统》:

http://www.52im.net/thread-2775-1-1.html

[4]《适合新手:从零开发一个IM服务端(基于Netty,有完整源码)》:

http://www.52im.net/thread-2768-1-1.html

[5]《12306抢票带来的启示:看我如何用Go实现百万QPS的秒杀系统(含源码)》:

http://www.52im.net/thread-2771-1-1.html


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK