本文預設您已具備以下知識:
- 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_request
、login_request
、User_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;
}
}
複製程式碼
附上原始碼專案地址: