golang寫的即時(shí)通訊服務(wù)器

簡要介紹

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

使用技術(shù):

數(shù)據(jù)庫:Mysql+Redis
組件:grpc+jsoniter+zap

安裝部署

1.首先安裝MySQL,Redis
2.創(chuàng)建數(shù)據(jù)庫gim座舍,執(zhí)行sql/create_table.sql沮翔,完成初始化表的創(chuàng)建(數(shù)據(jù)庫包含提供測試的一些初始數(shù)據(jù))
3.修改conf下配置文件,使之和你本地配置一致
4.分別切換到app的tcp_conn,ws_conn,logic目錄下曲秉,執(zhí)行g(shù)o run main.go,啟動TCP連接層服務(wù)器,WebSocket連接層服務(wù)器,邏輯層服務(wù)器

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

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

rpc接口簡介

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

項(xiàng)目目錄介紹

├─ app           # 服務(wù)啟動入口
│   ├── tcp_conn # TCP連接層啟動入口
|   ├── ws_conn  # WebSocket連接層啟動入口
│   └── logic    # 邏輯層啟動入口
├─ conf # 配置
├─ tcp_conn      # TCP連接層服務(wù)代碼
├─ ws_conn       # WebSocket連接層服務(wù)代碼
├─ ligic         # 邏輯層服務(wù)代碼
│   ├── cache    # 緩存矢洲,操作redis封裝
|   ├── dao      # 操作數(shù)據(jù)庫的封裝
│   └── db       # Redis以及MySQL實(shí)例
│   └── model    # 模型層
│   └── service  # 業(yè)務(wù)邏輯層
│   └── rpc      # 對外提供的rpc接口
├─ public        # 連接層和邏輯層公共代碼
├─ sql           # 數(shù)據(jù)庫建表語句
├─ test          # 測試腳本
├─ docs          # 項(xiàng)目文檔

TCP拆包粘包

遵循LV的協(xié)議格式璧眠,一個(gè)消息包分為兩部分,消息字節(jié)長度以及消息內(nèi)容读虏。
這里為了減少內(nèi)存分配,拆出來的包的內(nèi)存復(fù)用讀緩存區(qū)內(nèi)存袁滥。
拆包流程:
1.首先從系統(tǒng)緩存區(qū)讀取字節(jié)流到buffer
2.根據(jù)包頭的length字段盖桥,檢查報(bào)的value字段的長度是否大于等于length
3.如果大于,返回一個(gè)完整包(此包內(nèi)存復(fù)用)题翻,重復(fù)步驟2
4.如果小于揩徊,將buffer的有效字節(jié)前移,重復(fù)步驟1

服務(wù)簡介

1.tcp_conn
維持與客戶端的TCP長連接嵌赠,心跳塑荒,以及TCP拆包粘包,消息編解碼
1.ws_conn
維持與客戶端的WebSocket長連接姜挺,心跳齿税,消息編解碼
2.logic
消息轉(zhuǎn)發(fā)邏輯,設(shè)備信息炊豪,用戶信息凌箕,群組信息的操作

離線消息同步

用戶的消息維護(hù)一個(gè)自增的序列號拧篮,當(dāng)客戶端TCP連接斷開重新建立連接時(shí),首先要做TCP長連接的登錄牵舱,然后用客戶端本地已經(jīng)同步的最大的序列號做消息同步串绩,這樣就可以保證離線消息的不丟失。

單用戶多設(shè)備支持

當(dāng)用戶發(fā)送消息時(shí)芜壁,除了將消息發(fā)送目的用戶
在DB中礁凡,每個(gè)用戶只維護(hù)一個(gè)自己的消息列表,但是用戶的每個(gè)設(shè)備各自維護(hù)自己的同步序列號慧妄,設(shè)備使用自己的同步序列號在消息列表中做消息同步

消息轉(zhuǎn)發(fā)邏輯

單聊和普通群組采用寫擴(kuò)散把篓,超級大群使用讀擴(kuò)散。
讀擴(kuò)散和寫擴(kuò)散的選型腰涧。
首先解釋一下韧掩,什么是讀擴(kuò)散,什么是寫擴(kuò)散

讀擴(kuò)散

簡介:群組成員發(fā)送消息時(shí)窖铡,也是先建立一個(gè)會話疗锐,都將這個(gè)消息寫入這個(gè)會話中,同步離線消息時(shí)费彼,需要同步這個(gè)會話的未同步消息
優(yōu)點(diǎn):每個(gè)消息只需要寫入數(shù)據(jù)庫一次就行滑臊,減少數(shù)據(jù)庫訪問次數(shù),節(jié)省數(shù)據(jù)庫空間
缺點(diǎn):一個(gè)用戶有n個(gè)群組箍铲,客戶端每次同步消息時(shí)雇卷,要上傳n個(gè)序列號,服務(wù)器要對這n個(gè)群組分別做消息同步

寫擴(kuò)散

簡介:就是每個(gè)用戶維持一個(gè)消息列表颠猴,當(dāng)有其他用戶給這個(gè)用戶發(fā)送消息時(shí)关划,給這個(gè)用戶的消息列表插入一條消息即可
優(yōu)點(diǎn):每個(gè)用戶只需要維護(hù)一個(gè)序列號和消息列表
缺點(diǎn):一個(gè)群組有多少人,就要插入多少條消息翘瓮,當(dāng)群組成員很多時(shí)贮折,DB的壓力會增大

群組簡介

普通群組:

特點(diǎn):
消息存多份,群組中的每個(gè)成員保存一份
優(yōu)缺點(diǎn):
1.支持離線消息同步
2.群組成員越多资盅,DB壓力越大

超大群組:

特點(diǎn):
消息只保存一份
優(yōu)缺點(diǎn):
1.DB壓力不會隨著群組成員的人數(shù)的增加而增加
2.不支持離線消息同步

核心流程時(shí)序圖

長連接登錄

eaf3a08af9c64bbd.png

離線消息同步

ef9c9452e65be3ced63573164fec7ed5.png

心跳

6ea6acf2cd4b956e.png

消息單發(fā)

e000fda2f18e86f3.png

小群消息群發(fā)

749fc468746055a8ecf3fba913b66885.png

大群消息群發(fā)

e3f92bdbb3eef199d185c28292307497.png

錯(cuò)誤處理,鏈路追蹤,日志打印

系統(tǒng)中的錯(cuò)誤一般可以歸類為兩種调榄,一種是業(yè)務(wù)定義的錯(cuò)誤,一種就是未知的錯(cuò)誤呵扛,在業(yè)務(wù)正式上線的時(shí)候每庆,業(yè)務(wù)定義的錯(cuò)誤的屬于正常業(yè)務(wù)邏輯,不需要打印出來今穿,但是未知的錯(cuò)誤缤灵,我們就需要打印出來,我們不僅要知道是什么錯(cuò)誤,還要知道錯(cuò)誤的調(diào)用堆棧凤价,所以這里我對GRPC的錯(cuò)誤進(jìn)行了一些封裝鸽斟,使之包含調(diào)用堆棧。

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()
}

這樣利诺,不僅可以拿到錯(cuò)誤的堆棧富蓄,錯(cuò)誤的堆棧也可以跨RPC傳輸,但是慢逾,但是這樣你只能拿到當(dāng)前服務(wù)的堆棧立倍,卻不能拿到調(diào)用方的堆棧,就比如說侣滩,A服務(wù)調(diào)用B服務(wù)口注,當(dāng)B服務(wù)發(fā)生錯(cuò)誤時(shí),在A服務(wù)通過日志打印錯(cuò)誤的時(shí)候君珠,我們只打印了B服務(wù)的調(diào)用堆棧寝志,怎樣可以把A服務(wù)的堆棧打印出來。我們在A服務(wù)調(diào)用的地方也獲取一次堆棧策添。

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)
}

像這樣材部,就可以獲取完整一次調(diào)用堆棧。
錯(cuò)誤打印也沒有必要在函數(shù)返回錯(cuò)誤的時(shí)候唯竹,每次都去打印乐导。因?yàn)殄e(cuò)誤已經(jīng)包含了堆棧信息

// 錯(cuò)誤的方式
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è)務(wù)代碼中透傳context,golang不像其他語言物臂,可以在線程本地保存變量,像Java的ThreadLocal,所以只能通過函數(shù)參數(shù)的形式進(jìn)行傳遞产上,gim中棵磷,service層函數(shù)的第一個(gè)參數(shù)都是context,但是dao層和cache層就不需要了蒂秘,不然泽本,顯得代碼臃腫。

github

https://github.com/alberliu/gim

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末姻僧,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子蒲牧,更是在濱河造成了極大的恐慌撇贺,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,372評論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件冰抢,死亡現(xiàn)場離奇詭異松嘶,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)挎扰,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,368評論 3 392
  • 文/潘曉璐 我一進(jìn)店門翠订,熙熙樓的掌柜王于貴愁眉苦臉地迎上來巢音,“玉大人,你說我怎么就攤上這事尽超」俸常” “怎么了?”我有些...
    開封第一講書人閱讀 162,415評論 0 353
  • 文/不壞的土叔 我叫張陵似谁,是天一觀的道長傲绣。 經(jīng)常有香客問我,道長巩踏,這世上最難降的妖魔是什么秃诵? 我笑而不...
    開封第一講書人閱讀 58,157評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮塞琼,結(jié)果婚禮上菠净,老公的妹妹穿的比我還像新娘。我一直安慰自己彪杉,他們只是感情好毅往,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,171評論 6 388
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著在讶,像睡著了一般煞抬。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上构哺,一...
    開封第一講書人閱讀 51,125評論 1 297
  • 那天革答,我揣著相機(jī)與錄音,去河邊找鬼曙强。 笑死残拐,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的碟嘴。 我是一名探鬼主播溪食,決...
    沈念sama閱讀 40,028評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼娜扇!你這毒婦竟也來了错沃?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,887評論 0 274
  • 序言:老撾萬榮一對情侶失蹤雀瓢,失蹤者是張志新(化名)和其女友劉穎枢析,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體刃麸,經(jīng)...
    沈念sama閱讀 45,310評論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡醒叁,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,533評論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片把沼。...
    茶點(diǎn)故事閱讀 39,690評論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡啊易,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出饮睬,到底是詐尸還是另有隱情租谈,我是刑警寧澤,帶...
    沈念sama閱讀 35,411評論 5 343
  • 正文 年R本政府宣布续捂,位于F島的核電站垦垂,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏牙瓢。R本人自食惡果不足惜劫拗,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,004評論 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望矾克。 院中可真熱鬧页慷,春花似錦、人聲如沸胁附。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽控妻。三九已至州袒,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間弓候,已是汗流浹背郎哭。 一陣腳步聲響...
    開封第一講書人閱讀 32,812評論 1 268
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留菇存,地道東北人夸研。 一個(gè)月前我還...
    沈念sama閱讀 47,693評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像依鸥,于是被迫代替她去往敵國和親亥至。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,577評論 2 353