從零打造一套移動IM系統(一) 玩轉二進位制協議及protobuf

chaocai發表於2018-04-10

本文預設您已具備以下知識:

  • iOS開發的基礎知識,以及swift語法
  • node.js的基礎語法
  • TCP基礎及IM相關基礎知識

通過本文您將能收穫

  • 在iOS上用底層socket,伺服器建立tcp連線並通訊
  • 如何設計一個二進位制通訊協議
  • swift當中如何操作二進位制網路資料流,會涉及一些unsafe型別及C指標的操作
  • node.js中如何操作網路資料流
  • protobuf 3.0在客戶端及伺服器的實際運用,以及在兩個平臺中的編譯、序列化和反序列化
  • 心跳保活機制

1 基於socket的TCP通訊

1.1 iOS端實現

ios端採用開源庫CocoaAsyncSocket,進行TCP通訊。

private let delegateQueue = DispatchQueue.global()
private lazy var socket :GCDAsyncSocket = {
    let socket = GCDAsyncSocket(delegate: self, delegateQueue: delegateQueue)
    return socket
}()
複製程式碼

建立連線

socket.delegate   = self
try socket.connect(toHost: host, onPort: port)
socket.readData(withTimeout:-1, tag: 0)
複製程式碼

傳送資料

self.socket.write(data, withTimeout:5 * 60, tag: 0)
複製程式碼

連線成功監聽

func socket(_ sock: GCDAsyncSocket, didConnectToHost host: String, port: UInt16) {
    print("socket \(sock) didConnectToHost \(host) port \(port)")
}
複製程式碼

連線失敗監聽

func socketDidDisconnect(_ sock: GCDAsyncSocket, withError err: Error?) {
    print("socketDidDisconnect \(sock) withError \(err)")
}
複製程式碼

傳送資料

self.socket.write(data, withTimeout:5 * 60, tag: 0)
複製程式碼

接收資料

func socket(_ sock: GCDAsyncSocket, didRead data: Data, withTag tag: Int) {
	let msgArr = SocketDataBuilder.shared().parse(data: data)
	for (seq,socketData) in msgArr {
	    switch (socketData){
	    case .request(let comom):
	        handle(common: comom, seq: seq);
	    case .ping:
	        handlePing(seq: seq);
	    case .message(let msg):
	        handle(message: msg, seq: seq);
	    case .notification(let noti):
	        handle(notification: noti, seq: seq);
	    }
	}
	sock.readData(withTimeout: -1, tag: 0)
}
複製程式碼

值得注意的是,在接收到資料時,或者讀超時的時候需要重新呼叫readData(withTimeout:tag)方法 不然下個資料包到來時,不會再走這個方法。由於我們還有透傳體系,需要不間斷的監聽,所以timeout是-1無窮大

1.2 node.js 伺服器實現

var HOST = '0.0.0.0';
var PORT = 6969;
var server = net.createServer();
server.listen(PORT, HOST);
server.on('connection', function(sock) {

    logger.info('CONNECTED: ' + sock.remoteAddress +':'+ sock.remotePort);
    
    // 接收資料
    sock.on('data', function(data) {
    	
    }
    
    // 斷開連線
    sock.on('close', function(data) {      
        logger.info('CLOSED: ' + sock.remoteAddress + ' ' + sock.remotePort);
    });
    
}
複製程式碼

2 TCP部分的通訊協議的二進位制頭部設計

對於一個TCP資料包,它包含一個二進位制頭部,和一個包體。包體是protubuf序列化後的資料流。包頭一共8個位元組,從第一個位元組開始依次有以下含義

  • margic_num : 1個位元組 UInt8 魔法數字,一個特定的數字,伺服器與各個終端統一。主要作用是解析時判斷包有沒有損壞。如果解析出的值與設定值不同,則說明包損壞或者在拆包或解析過程中發生異常
  • sequence : 4個位元組 UInt32 序列號,用於區分不同的包,客戶端維護,伺服器根據不同的連線session+sequence區分不同的包
  • type : 1個位元組 UInt8 包含內容的型別:1心跳包 2普通資料請求 3聊天訊息 4推送 根據不同的型別路由到下級業務模組 著四種型別基本包含了一個IM系統主要的業務模組
  • length : 2個位元組 UInt16 包體的長度 通過它獲取當前資料包的包體,以進行下一步解析

3 二進位制頭部解析

3.1 iOS中二進位制頭部處理

先定義一個資料結構來處理頭部資訊

 struct BaseHeader {
    private let margic_num : UInt8 = 0b10000001
    var seq   : UInt32
    var type  : UInt8
    var length : UInt16
}
複製程式碼

3.1.1 swift的序列化方法

序列化方法

func toData()->Data{
    var marg = margic_num.bigEndian
    var seq  = self.seq.bigEndian
    var type =  self.type.bigEndian
    var length = self.length.bigEndian
    
    let mp = UnsafeBufferPointer(start: &marg, count: 1)
    let sp = UnsafeBufferPointer(start: &seq, count: 1)
    let tp = UnsafeBufferPointer(start: &type, count: 1)
    let lp = UnsafeBufferPointer(start: &length, count: 1)

    var data = Data(mp)
    data.append(sp)
    data.append(tp)
    data.append(lp)
    
    return data
}
複製程式碼

程式碼比較簡單,值得注意的是兩點

  • 基本資料型別轉化為Data必須先轉化為UnsafePointer,再轉化為UnsafeBufferPointer,再轉化為預期的Data資料。最後用data進行拼接
  • 再轉化為UnsafePointer之前,必須做Big-Endian轉化,swift中對應bigEndian計算屬性。對於這個問題可以參考這篇文章

如果你對位運算比較熟悉,也可以採用下面這種方式。將原始資料轉化為UInt8陣列再進行拼接

var buf = [UInt8]()
append(margic_num, bufArr: &buf)
append(self.seq, bufArr: &buf)
append(self.type, bufArr: &buf)
append(self.length, bufArr: &buf)
let result = Data(buf)

func append<T:FixedWidthInteger>(_ value:T, bufArr:inout [UInt8]){
    let size = MemoryLayout<T>.size
    for i in 1...size {
        let distance = (size - i) * 8;
        let sub  = (value >> distance) & 0xff
        let value = UInt8(sub & 0xff)
        bufArr.append(value)
    }
}
複製程式碼

3.1.1 swift的反序列化方法

對應的反序列化如下。

init?(data:Data){
    if data.count < header_length {
        return nil
    }
    var headerData  = Data(data)
    let tag : UInt8 = headerData[0..<1].withUnsafeBytes{ $0.pointee }
    if tag != margic_num {
        return nil
    }
    let seq : UInt32 = headerData[1..<5].withUnsafeBytes({$0.pointee })
    let typeValue : UInt8  =  headerData[5..<6].withUnsafeBytes({$0.pointee })
    let length : UInt16    =  headerData[6..<8].withUnsafeBytes({$0.pointee })
    
    self.seq  = seq.bigEndian
    self.type  = typeValue.bigEndian
    self.length = length.bigEndian
  
}
複製程式碼

Data結構體提供了很方便的下標索引方法

public subscript(bounds: Range<Data.Index>) -> Data
複製程式碼

得到的新的Data與原來的資料共用一塊記憶體,只是改變指標的偏移。也就是說,相比原始資料,代表儲存結構的_backing:_DataStorage屬性指向的是同一個物件,只是 _sliceRange:Range<Data.Index>不同

public func withUnsafeBytes<ResultType, ContentType>(_ body: (UnsafePointer<ContentType>) throws -> ResultType) rethrows -> ResultType
複製程式碼

利用這個帶範型的方法,可以很容易,對data裡面資料進行處理,提取出所需要型別的資料

同樣的你也可以在UInt8陣列上做文章

var index  : Int  = 0
let margic : UInt8   = getValue(data: headerData, index: &index)
let seqv   : UInt32  = getValue(data: headerData, index: &index)
let typev  : UInt8   = getValue(data: headerData, index: &index)
let len    : UInt16  = getValue(data: headerData, index: &index)

func getValue<T:FixedWidthInteger>(data:Data,index:inout Int)->T{
    let size = MemoryLayout<T>.size
    var value:T = 0
    for i in index..<(index+size) {
        let distance = size - (i - index) - 1
        value  += T(data[i]) << distance
    }
    index += size
    return value
}
複製程式碼

3.2 node.js中二進位制頭部處理

下面是反序列化程式碼,data是tcp接收到的資料

var header = data.slice(0,8)
var margic = header.readUInt8(0)
var seq    = header.readUInt32BE(1)
var type   = header.readUInt8(5)
var lenth  = header.readUInt16BE(6)
複製程式碼

序列化方法如下,body為需要傳送的包體資料

var margic = 129;
var lenth  = body.length;
var header = new Buffer(8);
header.writeUInt8(margic);
header.writeUInt32BE(seq,1);
header.writeUInt8(type,5);
header.writeInt16BE(lenth,6);
複製程式碼

node.js中,從socket中讀取或寫入的資料,都是Buffer。呼叫對應的read或write的方法,很容易從二進位制讀取或填充所需資料型別的資料。值得注意的是,除了UInt8之外,其餘方法都有BE字尾,這也和之前所說的Big-Endian有關

4 Protobuf的運用,及資料包體的解析

4.1 .proto檔案的編寫

採用最新的protobuf3.0的語法,去除了required、optional關鍵字,列舉型別統一從0開始。

根據從請求頭返回的type欄位,除了心跳包包體為空外,其他型別包體分別解析為響應的protobuf型別。

其中type=2,被解析為Common型別,對應的是普通資料請求。實際上這部分業務應該作為普通HTTP請求處理。這裡統一歸入TCP通訊自定義協議體系中。

syntax = "proto3";
import  "error.proto";

enum Common_method {
    common_method_user = 0;
    common_method_message = 1;
    common_method_friend   = 2;
    common_method_p2p_connect = 3;
    common_method_respond   = 4;
}

message Common {
    Common_method method = 1;
    bytes body = 2;
}

message CommonRespon {
    bool isSuc = 1;
    bytes respon = 2;
    ErrorMsg error  = 3;
}
複製程式碼
syntax = "proto3";


enum error_type {
    comom_err  = 0;
    invalid_params = 2;
}

message ErrorMsg {
    error_type type = 1;
    string msg = 2;
}
複製程式碼

Comon根據不同的type,他的body又可以被解析為對應的字型別資料,如signin_requestlogin_requestUser_info_request等等

syntax = "proto3"
import "base.proto";

enum User_cmd {
	User_cmd_sign_in = 0;
	User_cmd_login   = 2;
	User_cmd_logout  = 3;
	User_cmd_user_info = 4;
}

message User_msg {
	User_cmd cmd = 1;
	bytes body  = 2;
}

message signin_request {
	 string nick_name = 1;
	 string pwd = 2;
}

message login_request {
	string nick_name = 1; // 使用者名稱
	string pwd = 2;       // 密碼
	string ip = 3;        // 裝置當前的ip
	int32  port = 4;      // 裝置繫結的埠
	string device_name = 5; // iOS/Andoird
	string device_id = 6;   // 裝置識別符號
	string version  = 7;    // 軟體版本
}

message logout_request {
	 int32 uid = 1;
}

// 註冊成功 必須進行登入 統一返回uid token
message sigin_response {
	uint32 uid   = 1;
	string token = 2;
}

message login_response {
	 uint32 uid   = 1;
	 string token = 2;
}

// 查詢使用者資料
message User_info_request {
	uint32 uid = 1; // 所要查詢使用者的uid
}

message User_info_response {
	User_info user_info = 1;
}
複製程式碼

type = 3時,對應的是Base_msg型別,對應正兒八經的即時通訊業務模組

type=4時,Notification_msg型別,對應推送模組,及伺服器向客戶端傳送的通知

由於程式碼量還算比較大,就不貼了。大家自己看原始碼

4.2 iOS上protobuf的使用

4.2.1 準備工作

將protobuf-swift庫匯入工程中,在Podfile中加上

pod 'ProtocolBuffers-Swift', '4.0.1'
複製程式碼

電腦上安裝protobuf

brew install protobuf
複製程式碼

cd到.proto檔案目錄,編譯出swift平臺程式碼

protoc *.proto --swift_out="./"
複製程式碼

將得到的*.pb.swift檔案匯入到專案工程當中

4.2.1 序列化方法

以登入請求的包體構建為例為例子

let loginReq = LoginRequest().setPwd(pwd).setNickName(user)
let bodyData = try body.build().data()
let user  =  try UserMsg.Builder().setCmd(.userCmdLogin).setBody(bodyData).build().data()
let comom =  try Common.Builder().setMethod(.commonMethodUser).setBody(user).build()

let data = comom.data()
複製程式碼
4.2.2 反序列化方法

4.2.1 示例程式碼對應的反序列化,應該是這樣子的

do {
	let comon =  try Common.parseFrom(data:data)
	switch comon.type {
		case .commonMethodUser:
			let user  =  try UserMsg.parseFrom(data:comon.body)
			switch user.cmd {
				case .userCmdLogin:
					let login = try LoginRequest.parseFrom(data:user.body)
				...
			}
		...
	}
}catch let err {
	print(err)
}
複製程式碼

4.2.3 完整資料包的構建及解析

無論序列化還是反序列化,都要用到一箇中間橋架的結構體

enum RTPMessageGenerates {

    case ping
    case request(Common?)
    case message(Message?)
    case notification(NotificationMsg?)

    init?(type:UInt8,data:Data){
        switch type {
        case 1:
            self = .ping
        case 2:
            let comon =  try? Common.parseFrom(data:data)
            self = .request(comon)
        case 3:
            let msg = Message(data: data)
            self = .message(msg)
        case 4:
            let noti = try? NotificationMsg.parseFrom(data: data)
            self = .notification(noti)
        default:
            return nil
        }
    }

    var type : UInt8 {
        switch self {
        case .ping:
            return 1
        case .request(_):
            return 2
        case .message(_):
            return 3
        case .notification(_):
            return 4
        }
    }

    var data : Data? {
        switch self {
        case .ping:
            return Data()
        case .request(let req):
            return  req?.data()
        case .message(let msg):
            return  msg?.data
        case .notification(let noti):
            return noti?.data()
        }
    }

}
複製程式碼

構建過程如下

func rtpData(seq:UInt32,body:RTPMessageGenerates)->Data?{
    guard let bodyData = body.data  else  { return nil }
    let header = BaseHeader(seq: seq, type: body.type, length: UInt16(bodyData.count)).toData()
    let data = header + bodyData
    return data
}
複製程式碼

解析過程略微複雜點,需要進行拆包處理

func parse(data:Data)->[(seq:UInt32,body:RTPMessageGenerates)]{
    var curIndex : UInt16 = 0
    var temp = [(seq:UInt32,body:RTPMessageGenerates)]()
    while curIndex < data.count{
        if curIndex+header_length > data.count {
            break
        }
        let headData = data[curIndex..<curIndex+header_length]
        if let header = BaseHeader(data: headData) {
            let body = data[8..<8+header.length]
            if let msg = RTPMessageGenerates(type: header.type,data: body){
                temp.append((header.seq,msg))
            }
            curIndex += header.length + 8
        }else{
            break;
        }
    }
    return temp
}
複製程式碼

4.3 node.js伺服器protobuf的使用

4.3.1 準備工作

環境配置,包含資料庫及日誌庫環境

npm install log4js
npm install mysql
npm install google-protobuf
sudo npm install protobufjs
pm2 install pm2-intercom
複製程式碼

編譯.proto檔案

protoc --js_out=import_style=commonjs,binary:. *.proto
複製程式碼

將*_pb.js檔案匯入專案工程當中

4.3.2 probubuf的解析

需要匯入對應模組檔案

var builder = require("../impb/common_pb"),
    Common = builder.Common;
var MethodType = builder.Common_method;
複製程式碼
try {
    var datas  = Uint8Array(body);
    var common = new Common.deserializeBinary(datas);
    var method = common.getMethod();
    var body   = common.getBody();
}catch (err){
    console.log(err);
}
複製程式碼

需要留意以下幾點:

  • socket返回的資料都是Buffer型別的,而protobuf所生成的js檔案,相應方法接收的是Uint8Array型別資料,需要做一下轉化
  • 訪問屬性變數時不能用點語法,要用對應的get、set方法
  • 某些字元做了相應轉化,轉化為平臺的風格。_都被轉化為駝峰命名法;列舉型別所有字元都被轉化為了大寫
4.3.3 protobuf的序列化
var comon = new Common();
comon.setMethod(MethodType.COMMON_METHOD_RESPOND);
comon.setBody(respond.serializeBinary());

var resData = comon.serializeBinary();
複製程式碼

主要是serializeBinary()方法的使用。注意賦值的時候要用set方法。得到的是Uint8Array,如果要進行下一步操作需要轉化為Buffer型別

4.3.4 完整資料包的解析與構建

完整資料包解析

var tempData = new Buffer(data)
	while (tempData.length){
	    var header = data.slice(0,8)
	    var margic = header.readUInt8(0)
	    var seq    = header.readUInt32BE(1)
	    var type   = header.readUInt8(5)
	    var lenth  = header.readUInt16BE(6)
	    var body =   tempData.slice(8,lenth+8)
	    var lest = tempData.length - ( lenth + 8 )
	    logger.info("Receive data :" + "margic=" + margic + " seq=" + seq + " type=" + type + " legth=" + lenth )
	    var bodyData  = new  Uint8Array(body)
	    routeWithReiceData(type,header,bodyData)
	    if (lest.length > 0){
	        logger.info("Has one more data packetge");
	        tempData = data.slice(lenth+8,lest)
	    }else {
	        tempData = lest;
	        break
	    }
	}
}
複製程式碼

資料包的構建

var margic = 129;
var lenth  = body.length;
var header = new Buffer(8);
header.writeUInt8(margic);
header.writeUInt32BE(seq,1);
header.writeUInt8(type,5);
header.writeInt16BE(lenth,6);
var buf = Buffer(body);
var result = Buffer.concat([header,buf])
複製程式碼

5 心跳保活機制

由於存在NAT超時,我們必要在長時間沒有資料互動時,主動傳送資料包,來維持TCP連線。根據一些部落格資料,NAT的超時時間最低的在5分鐘左右。關於這些,可以參考這篇文章

我們設計的心跳間隔是3分鐘。心跳由客戶端控制,伺服器只負責再收到心跳包之後原樣返回。當心跳包的響應超時的時候,或重試三次,三次都失敗證明與伺服器連線中斷。主動斷開連線再嘗試重新連線。

心跳包大小是8個位元組,即一個只有包頭,包體為空的tcp資料包。

客戶端程式碼如下

extension  SocketManager {
    private var  pingDuration : TimeInterval  {  return 60 * 3 }

    static var reTryCount = 0;
    private func sentPing(){
        sentPing { (isSuc) in
            if isSuc {
                SocketManager.reTryCount = 0;
            }else{
                if SocketManager.reTryCount < 3 {
                    self.sentPing()
                    SocketManager.reTryCount += 1
                }else{
                    // 三次失敗 連線已經斷開 斷開再重連
                    self.disconnect()
                    self.reconect(){_ in }
                }
            }
        }
    }
    
    private func sentPing(completion:@escaping (Bool)->()){
        self.sent(msg: .ping, completion: SocketManager.SentMsgCompletion.ping(completion))
    }
    
    func stopPing(){
        self.pingTimer?.invalidate()
        self.pingTimer  = nil;
    }
    
    func startPing(){
        sentPing()
        if pingTimer == nil {
            pingTimer  = Timer(timeInterval:pingDuration , repeats: true, block: {[weak self] (timer) in
                self?.sentPing()
            })
        }
    }
    
}
複製程式碼

伺服器程式碼:

 function routeWithReiceData(type,header,body) {
    switch (type){
        case 1:
            // 收到心跳包原樣返回 客戶端控制傳送頻率 必要時斷開重連
            sock.write(data)
            break;
    }
 }
複製程式碼

附上原始碼專案地址:

客戶端程式碼

伺服器程式碼

相關文章