golang寫的IM服務器(tcp,websocket,grpc)

本文轉載:https://github.com/alberliu/gim

簡要介紹

gim是一個即時通訊服務器,代碼全部使用golang完成旗唁。主要功能
1.支持tcp,websocket接入
2.離線消息同步
3.多業(yè)務接入
4.單用戶多設備同時在線
5.單聊,群聊,以及超大群聊天場景
6.支持服務水平擴展

使用技術:

數據庫:Mysql+Redis
通訊框架:Grpc
長連接通訊協議:Protocol Buffers
日志框架:Zap

安裝部署

1.首先安裝MySQL勋桶,Redis
2.創(chuàng)建數據庫gim,執(zhí)行sql/create_table.sql,完成初始化表的創(chuàng)建(數據庫包含提供測試的一些初始數據)
3.修改config下配置文件例驹,使之和你本地配置一致
4.分別切換到cmd的tcp_conn,ws_conn,logic目錄下捐韩,執(zhí)行go run main.go,啟動TCP連接層服務器,WebSocket連接層服務器,邏輯層服務器

迅速跑通本地測試

1.在test目錄下,tcp_conn或者ws_conn目錄下鹃锈,執(zhí)行go run main,啟動測試腳本
2.根據提示,依次填入app_id,user_id,device_id,sync_sequence(中間空格空開)荤胁,進行長連接登錄;數據庫device表中已經初始化了一些設備信息屎债,用作測試
3.執(zhí)行api/logic/logic_client_ext_test.go下的TestLogicExtServer_SendMessage函數仅政,發(fā)送消息

業(yè)務服務器如何接入

1.首先生成私鑰和公鑰
2.在app表里根據你的私鑰添加一條app記錄
3.將app_id和公鑰保存到業(yè)務服務器
4.將用戶通過LogicClientExtServer.AddUser接口添加到IM服務器
5.通過LogicClientExtServer.RegisterDevice接口注冊設備,獲取設備id(device_id)
6.將app_id扔茅,user_id,device_id用公鑰通過公鑰加密已旧,生成token,相應庫的代碼在pkg/util/aes.go
7.接下來使用這個token,app就可以和IM服務器交互

rpc接口簡介

項目所有的proto協議在gim/public/proto/目錄下
1.tcp.proto
長連接通訊協議
2.logic_client.ext.proto
對客戶端(Android設備召娜,IOS設備)提供的rpc協議
3.logic_server.ext.proto
對業(yè)務服務器提供的rpc協議
4.logic.int.proto
對conn服務層提供的rpc協議
5.conn.int.proto
對logic服務層提供的rpc協議

項目目錄簡介

項目結構遵循 https://github.com/golang-standards/project-layout

api:          服務對外提供的grpc接口
cmd:          服務啟動入口
config:       服務配置
internal:     每個服務私有代碼
pkg:          服務共有代碼
sql:          項目sql文件
test:         長連接測試腳本

服務簡介

1.tcp_conn
維持與客戶端的TCP長連接运褪,心跳,以及TCP拆包粘包玖瘸,消息編解碼
2.ws_conn
維持與客戶端的WebSocket長連接秸讹,心跳,消息編解碼
3.logic
設備信息雅倒,用戶信息璃诀,群組信息管理,消息轉發(fā)邏輯

TCP拆包粘包

遵循LV的協議格式蔑匣,一個消息包分為兩部分劣欢,消息字節(jié)長度以及消息內容榜旦。
這里為了減少內存分配涣楷,拆出來的包的內存復用讀緩存區(qū)內存。
拆包流程:
1.首先從系統(tǒng)緩存區(qū)讀取字節(jié)流到buffer
2.根據包頭的length字段颊埃,檢查報的value字段的長度是否大于等于length
3.如果大于价脾,返回一個完整包(此包內存復用)牧抵,重復步驟2
4.如果小于,將buffer的有效字節(jié)前移侨把,重復步驟1

單用戶多設備支持犀变,離線消息同步

每個用戶都會維護一個自增的序列號,當用戶A給用戶B發(fā)送消息是秋柄,首先會獲取A的最大序列號获枝,設置為這條消息的seq,持久化到用戶A的消息列表骇笔,
再通過長連接下發(fā)到用戶A賬號登錄的所有設備映琳,再獲取用戶B的最大序列號机隙,設置為這條消息的seq,持久化到用戶B的消息列表萨西,再通過長連接下發(fā)
到用戶B賬號登錄的所有設備。
假如用戶的某個設備不在線旭旭,在設備長連接登錄時谎脯,用本地收到消息的最大序列號,到服務器做消息同步持寄,這樣就可以保證離線消息不丟失源梭。

度擴散和寫擴散

首先解釋一下,什么是讀擴散稍味,什么是寫擴散

讀擴散

簡介:群組成員發(fā)送消息時废麻,先建立一個會話,都將這個消息寫入這個會話中模庐,同步離線消息時烛愧,需要同步這個會話的未同步消息
優(yōu)點:每個消息只需要寫入數據庫一次就行,減少數據庫訪問次數掂碱,節(jié)省數據庫空間
缺點:一個用戶有n個群組怜姿,客戶端每次同步消息時,要上傳n個序列號疼燥,服務器要對這n個群組分別做消息同步

寫擴散

簡介:在群組中沧卢,每個用戶維持一個自己的消息列表,當群組中有人發(fā)送消息時醉者,給群組的每個用戶的消息列表插入一條消息即可
優(yōu)點:每個用戶只需要維護一個序列號和消息列表
缺點:一個群組有多少人但狭,就要插入多少條消息,當群組成員很多時撬即,DB的壓力會增大

消息轉發(fā)邏輯選型以及特點

普通群組:

采用寫擴散立磁,群組成員信息持久化到數據庫保存。支持消息離線同步搞莺。

超大群組:

采用讀擴散息罗,群組成員信息保存到redis,不支持離線消息同步。

核心流程時序圖

長連接登錄

eaf3a08af9c64bbd.png

離線消息同步

ef9c9452e65be3ced63573164fec7ed5.png

心跳

6ea6acf2cd4b956e.png

消息單發(fā)

c1.d1和c1.d2分別表示c1用戶的兩個設備d1和d2,c2.d3和c2.d4同理


e000fda2f18e86f3.png

小群消息群發(fā)

c1,c2.c3表示一個群組中的三個用戶


749fc468746055a8ecf3fba913b66885.png

大群消息群發(fā)

e3f92bdbb3eef199d185c28292307497.png

錯誤處理,鏈路追蹤,日志打印

系統(tǒng)中的錯誤一般可以歸類為兩種才沧,一種是業(yè)務定義的錯誤迈喉,一種就是未知的錯誤,在業(yè)務正式上線的時候温圆,業(yè)務定義的錯誤的屬于正常業(yè)務邏輯挨摸,不需要打印出來,
但是未知的錯誤岁歉,我們就需要打印出來得运,我們不僅要知道是什么錯誤膝蜈,還要知道錯誤的調用堆棧,所以這里我對GRPC的錯誤進行了一些封裝熔掺,使之包含調用堆棧饱搏。

func WrapError(err error) error {
    if err == nil {
        return nil
    }

    s := &spb.Status{
        Code:    int32(codes.Unknown),
        Message: err.Error(),
        Details: []*any.Any{
            {
                TypeUrl: TypeUrlStack,
                Value:   util.Str2bytes(stack()),
            },
        },
    }
    return status.FromProto(s).Err()
}
// Stack 獲取堆棧信息
func stack() string {
    var pc = make([]uintptr, 20)
    n := runtime.Callers(3, pc)

    var build strings.Builder
    for i := 0; i < n; i++ {
        f := runtime.FuncForPC(pc[i] - 1)
        file, line := f.FileLine(pc[i] - 1)
        n := strings.Index(file, name)
        if n != -1 {
            s := fmt.Sprintf(" %s:%d \n", file[n:], line)
            build.WriteString(s)
        }
    }
    return build.String()
}

這樣,不僅可以拿到錯誤的堆棧置逻,錯誤的堆棧也可以跨RPC傳輸推沸,但是,但是這樣你只能拿到當前服務的堆棧券坞,卻不能拿到調用方的堆棧鬓催,就比如說,A服務調用
B服務恨锚,當B服務發(fā)生錯誤時宇驾,在A服務通過日志打印錯誤的時候,我們只打印了B服務的調用堆棧猴伶,怎樣可以把A服務的堆棧打印出來课舍。我們在A服務調用的地方也獲取
一次堆棧。

func WrapRPCError(err error) error {
    if err == nil {
        return nil
    }
    e, _ := status.FromError(err)
    s := &spb.Status{
        Code:    int32(e.Code()),
        Message: e.Message(),
        Details: []*any.Any{
            {
                TypeUrl: TypeUrlStack,
                Value:   util.Str2bytes(GetErrorStack(e) + " --grpc-- \n" + stack()),
            },
        },
    }
    return status.FromProto(s).Err()
}

func interceptor(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
    err := invoker(ctx, method, req, reply, cc, opts...)
    return gerrors.WrapRPCError(err)
}

var LogicIntClient   pb.LogicIntClient

func InitLogicIntClient(addr string) {
    conn, err := grpc.DialContext(context.TODO(), addr, grpc.WithInsecure(), grpc.WithUnaryInterceptor(interceptor))
    if err != nil {
        logger.Sugar.Error(err)
        panic(err)
    }

    LogicIntClient = pb.NewLogicIntClient(conn)
}

像這樣蜗顽,就可以獲取完整一次調用堆棧布卡。
錯誤打印也沒有必要在函數返回錯誤的時候,每次都去打印雇盖。因為錯誤已經包含了堆棧信息

// 錯誤的方式
if err != nil {
    logger.Sugar.Error(err)
    return err
}

// 正確的方式
if err != nil {
    return err
}

然后忿等,我們在上層統(tǒng)一打印就可以

func startServer {
    extListen, err := net.Listen("tcp", conf.LogicConf.ClientRPCExtListenAddr)
    if err != nil {
        panic(err)
    }
    extServer := grpc.NewServer(grpc.UnaryInterceptor(LogicClientExtInterceptor))
    pb.RegisterLogicClientExtServer(extServer, &LogicClientExtServer{})
    err = extServer.Serve(extListen)
    if err != nil {
        panic(err)
    }
}

func LogicClientExtInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
    defer func() {
        logPanic("logic_client_ext_interceptor", ctx, req, info, &err)
    }()

    resp, err = handler(ctx, req)
    logger.Logger.Debug("logic_client_ext_interceptor", zap.Any("info", info), zap.Any("ctx", ctx), zap.Any("req", req),
        zap.Any("resp", resp), zap.Error(err))

    s, _ := status.FromError(err)
    if s.Code() != 0 && s.Code() < 1000 {
        md, _ := metadata.FromIncomingContext(ctx)
        logger.Logger.Error("logic_client_ext_interceptor", zap.String("method", info.FullMethod), zap.Any("md", md), zap.Any("req", req),
            zap.Any("resp", resp), zap.Error(err), zap.String("stack", gerrors.GetErrorStack(s)))
    }
    return
}

這樣做的前提就是,在業(yè)務代碼中透傳context,golang不像其他語言崔挖,可以在線程本地保存變量贸街,像Java的ThreadLocal,所以只能通過函數參數的形式進行傳遞,gim中狸相,service層函數的第一個參數
都是context薛匪,但是dao層和cache層就不需要了,不然脓鹃,顯得代碼臃腫逸尖。
最后可以在客戶端的每次請求添加一個隨機的request_id,這樣客戶端到服務的每次請求都可以串起來了瘸右。

func getCtx() context.Context {
    token, _ := util.GetToken(1, 2, 3, time.Now().Add(1*time.Hour).Unix(), util.PublicKey)
    return metadata.NewOutgoingContext(context.TODO(), metadata.Pairs(
        "app_id", "1",
        "user_id", "2",
        "device_id", "3",
        "token", token,
        "request_id", strconv.FormatInt(time.Now().UnixNano(), 10)))
}

github

https://github.com/alberliu/gim

?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末娇跟,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子太颤,更是在濱河造成了極大的恐慌苞俘,老刑警劉巖,帶你破解...
    沈念sama閱讀 219,039評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件龄章,死亡現場離奇詭異吃谣,居然都是意外死亡乞封,警方通過查閱死者的電腦和手機,發(fā)現死者居然都...
    沈念sama閱讀 93,426評論 3 395
  • 文/潘曉璐 我一進店門岗憋,熙熙樓的掌柜王于貴愁眉苦臉地迎上來肃晚,“玉大人,你說我怎么就攤上這事仔戈∠菥荆” “怎么了?”我有些...
    開封第一講書人閱讀 165,417評論 0 356
  • 文/不壞的土叔 我叫張陵杂穷,是天一觀的道長。 經常有香客問我卦绣,道長耐量,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,868評論 1 295
  • 正文 為了忘掉前任滤港,我火速辦了婚禮廊蜒,結果婚禮上,老公的妹妹穿的比我還像新娘溅漾。我一直安慰自己山叮,他們只是感情好,可當我...
    茶點故事閱讀 67,892評論 6 392
  • 文/花漫 我一把揭開白布添履。 她就那樣靜靜地躺著屁倔,像睡著了一般。 火紅的嫁衣襯著肌膚如雪暮胧。 梳的紋絲不亂的頭發(fā)上锐借,一...
    開封第一講書人閱讀 51,692評論 1 305
  • 那天,我揣著相機與錄音往衷,去河邊找鬼钞翔。 笑死,一個胖子當著我的面吹牛席舍,可吹牛的內容都是我干的布轿。 我是一名探鬼主播,決...
    沈念sama閱讀 40,416評論 3 419
  • 文/蒼蘭香墨 我猛地睜開眼来颤,長吁一口氣:“原來是場噩夢啊……” “哼汰扭!你這毒婦竟也來了?” 一聲冷哼從身側響起脚曾,我...
    開封第一講書人閱讀 39,326評論 0 276
  • 序言:老撾萬榮一對情侶失蹤东且,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后本讥,有當地人在樹林里發(fā)現了一具尸體珊泳,經...
    沈念sama閱讀 45,782評論 1 316
  • 正文 獨居荒郊野嶺守林人離奇死亡鲁冯,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,957評論 3 337
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現自己被綠了色查。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片薯演。...
    茶點故事閱讀 40,102評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖秧了,靈堂內的尸體忽然破棺而出跨扮,到底是詐尸還是另有隱情,我是刑警寧澤验毡,帶...
    沈念sama閱讀 35,790評論 5 346
  • 正文 年R本政府宣布衡创,位于F島的核電站,受9級特大地震影響晶通,放射性物質發(fā)生泄漏璃氢。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,442評論 3 331
  • 文/蒙蒙 一狮辽、第九天 我趴在偏房一處隱蔽的房頂上張望一也。 院中可真熱鬧,春花似錦喉脖、人聲如沸椰苟。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,996評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽舆蝴。三九已至,卻和暖如春菱皆,著一層夾襖步出監(jiān)牢的瞬間须误,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,113評論 1 272
  • 我被黑心中介騙來泰國打工仇轻, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留京痢,地道東北人。 一個月前我還...
    沈念sama閱讀 48,332評論 3 373
  • 正文 我出身青樓篷店,卻偏偏與公主長得像祭椰,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子疲陕,可洞房花燭夜當晚...
    茶點故事閱讀 45,044評論 2 355

推薦閱讀更多精彩內容