socket.io-client-swift解读及应用

朋学良
  • 使用背景:由于业务的发展,普通的polling轮询通知消息已经不能满足将来的业务发展了,必须要提高消息触达的及时性,因此WebSocket引入被提上日程了,这边对socket.io-client-swift进行一下源码解读及在我们骑手端中的应用。在17年的时候也写过一篇关于WebSocket的文章,那里简单的介绍了一下WebSocket的原理及fackbook开源库SocketRocket(传送门),因此在这里就不过多的介绍WebSocket的原理原理等内容。

一、socket.io简介

socket.io是一个基于WebSocket协议的实时双向通信库,它的底层是基于engine.io,具有可靠性自动重新连接支持断开连接检测二进制支持简单和方便的API跨浏览器支持多路复用支持房间支持等新特性,可以让nodejs很轻松的实现一套WebSocket的服务和客户端。
socket.io官网:https://socket.io/
socket.io Github地址:https://github.com/socketio/socket.io/
客户端(iOS):https://github.com/socketio/socket.io-client-swift 客户端(Android):https://github.com/socketio/socket.io-client-java

  • 注:虽然socket.io是基于WebSocket协议,但是由于它封装各种的特色的功能,并且添加了很多元数据到每一个报文中,这就导致了websocket客户端不能够成功连接上Socket.io服务器,同样一个Socket.io客户端也连接不上Websocket服务器。

二、socket.io-client-swift引入

  • 由于该库是用swift语音编写的,所以在引入的时候与oc的库略有区别,该库也很好的适配了iOS8及以后的系统版本,让我们无需担心系统版本的兼容性。

1. cocoapods导入

use_frameworks!

target 'YourApp' do  
    pod 'Socket.IO-Client-Swift', '~> 13.2.0'
end  
  • 相对于OC的库,要导入swift第三方库,在Podfile文件中一定要加入 useframeworks! 。如果不使用useframeworks! 则是会生成响应的.a文件(静态库),通过static libraries来管理pod的代码;如果使用的话,则会生成相对应的.framework文件,使用 dynamic frameworks 来取代 static libraries 方式。由于swift不支持静态库,所以cocoapods 默认 useframeworks!,用cocoapods 导入Swift 框架到 swift项目和OC项目都必须要 useframeworks!

2. 工程引用

  • 由于我们骑手工程使用OC编写的,而socket.io-client-swift这个库是用swift语言编写的,所以在导入头文件方面就存在这些许差异。存在两种情况,如下:

1、在OC工程中添加swift文件的时候,xcode会自动提醒你是否需要添加桥接文件,如下图所示,当添加创建的时候,xcode会自动帮你创建桥接文件,然后在用到该swift文件的地方import该桥接文件即可。

2、使用cocoapods导入socket.io-client-swift库,可以直接在OC文件中导入socketio头文件。

@import SocketIO;

三、解读

socket.io-client-swift库主要存在如下类文件,这里将着重对SocketEngine、SocketIOClient、SocketManager这三个文件进行解读。

1、SocketEngine

顾名思义,这个类是一个引擎类,用来处理engine.io协议和传输。这个类里主要是去设置一些socket.io的参数配置。

1、//定义engine所处的队列  
public let engineQueue = DispatchQueue(label: "com.socketio.engineHandleQueue")

2、//连接期间发送的连接参数  
public var connectParams: [String: Any]? {  
    didSet {
        (urlPolling, urlWebSocket) = createURLs()
    }
}

3、//在连接期间设置额外http请求头  
public var extraHeaders: [String: String]?

4、//为ture的时候,将会强制使用Polling或强制使用WebSocket  
public private(set) var forcePolling = false  
public private(set) var forceWebsockets = false

5、  
...
  • 连接服务、断开连接、重置引擎、发送ping等操作都处于该队列中。
  • 在createURLs方法中,遍历connectParams中的参数并将其拼接到URL的后面。
  • 在这里可以设置额外的请求头,在骑手APP里,在extraHeaders中添加了User-Agent,用来验证用户的基本信息。也可以添加一些自定义的header在里面。
  • 一般默认false就好,因为某些浏览器或设备不支持WebSocket所以会通过Polling进行握手,再升级到WebSocket协议。
1、初始化  
public init(client: SocketEngineClient, url: URL, config: SocketIOClientConfiguration) {  
    self.client = client
    self.url = url
    super.init()
    setConfigs(config)
    sessionDelegate = sessionDelegate ?? self
    (urlPolling, urlWebSocket) = createURLs()
}

2、创建连接  
private func createURLs() -> (URL, URL) {  
    if client == nil {
        return (URL(string: "http://localhost/")!, URL(string: "http://localhost/")!)
    }
    var urlPolling = URLComponents(string: url.absoluteString)!
    var urlWebSocket = URLComponents(string: url.absoluteString)!
    var queryString = ""

    urlWebSocket.path = socketPath
    urlPolling.path = socketPath
   if secure {
        urlPolling.scheme = "https"
        urlWebSocket.scheme = "wss"
    } else {
        urlPolling.scheme = "http"
        urlWebSocket.scheme = "ws"
    }

    if let connectParams = self.connectParams {
        for (key, value) in connectParams {
            let keyEsc = key.urlEncode()!
            let valueEsc = "\(value)".urlEncode()!

            queryString += "&\(keyEsc)=\(valueEsc)"
        }
    }
    urlWebSocket.percentEncodedQuery = "transport=websocket" + queryString
    urlPolling.percentEncodedQuery = "transport=polling&b64=1" + queryString
    return (urlPolling.url!, urlWebSocket.url!)
}

3、发送消息  
public func write(_ msg: String, withType type: SocketEnginePacketType, withData data: [Data]) {  
    engineQueue.async {
        guard self.connected else { return }
        guard !self.probing else {
            self.probeWait.append((msg, type, data))
            return
        }

        if self.polling {
            self.sendPollMessage(msg, withType: type, withData: data)
        } else {
            self.sendWebSocketMessage(msg, withType: type, withData: data)
        }
    }
}

4、升级协议  
private func upgradeTransport() {  
    if ws?.isConnected ?? false {
        DefaultSocketLogger.Logger.log("Upgrading transport to WebSockets", type: SocketEngine.logType)
        fastUpgrade = true
        sendPollMessage("", withType: .noop, withData: [])
        // After this point, we should not send anymore polling messages
    }
}
  • 初始化一个engine,并设置一些配置参数(config),设置代理及设置好Polling和WebSocket的URL,将connectParams里的连接参数拼上。
  • 存在这两种不同的连接WebSocket和Polling,这两种连接的不同之处就是它们的transport的不同,既可以用长连接,又可以用长轮询。在createURLs里会根据secure去设置Polling和WebSocket的安全性,通过拼接参数最好形成完整的URL。
  • 发送消息需要传入发送的消息msg,消息类型type及其他数据data,这里type分为open、close、ping、pong、message、upgrade、noop这几种类型,几乎涵盖了所有需要通信发消息的类型。
  • 当client通过Polling去握手成功以后,就会调用upgradeTransport去升级为WebSocket,在sendPollMessage之后,就不应该再发送Polling消息了。

2、SocketIOClient

这个类是创建一个SocketIO的客户端,所有和socket.io服务端进行的交互都可以通过该类的对象去完成,可以说该类是socket.io-client-swift库中最重要的一个类了。

1、//client连接的namespace  
@objc
public let nsp: String

2、//socket client的管理者  
@objc
public private(set) weak var manager: SocketManagerSpec?

3、//当前连接的状态  
@objc
public private(set) var status = SocketIOStatus.notConnected {  
    didSet {
        handleClientEvent(.statusChange, data: [status])
    }
}
//从未连接或已被重置
case notConnected  
//曾今连接过,但现在已经不连接了
case disconnected  
//正在连接中
case connecting  
//现在正在连接
case connected  
  • 这里就是上文所说的socket.io的特性之一Room support,在每个namespace通道中,您可以定义任意通道,可以将通知发送给一组用户,或者发送给连接到多个设备的给定用户。一定要以“/”开头,没有设置namespace的时候会默认是“/”
  • status主要是当前client的连接状态,可以更好的根据状态去做对应的处理。状态有notConnected、disconnected、connecting、connected
1、连接服务  
//开始连接,timeoutAfter:连接超时时间,0为永不超时;handler:当过了超时时间调用
open func connect(timeoutAfter: Double, withHandler handler: (() -> ())?) {  
    //断言timeoutAfter必须大于等于0
    assert(timeoutAfter >= 0, "Invalid timeout: \(timeoutAfter)")
    guard let manager = self.manager, status != .connected else {
         //如果当前已经连接了,直接return
         DefaultSocketLogger.Logger.log("Tried connecting on an already connected socket", type: logType)
         return
    }
    //将状态设置为连接中
    status = .connecting
    //加入所设置的namespace
    joinNamespace()

    if manager.status == .connected && nsp == "/" {
        //状态是已连接,并且是默认的namespace时,直接断开原有连接,开始新的连接
        didConnect(toNamespace: nsp)
        return
    }

    //0为永不超时,直接return
    guard timeoutAfter != 0 else { return }
    manager.handleQueue.asyncAfter(deadline: DispatchTime.now() + timeoutAfter) {
        [weak self] in
        //已经超时了
        guard let this = self, this.status == .connecting || this.status == .notConnected else { return }
        //当状态为连接中或没有连接时,直接断开连接并离开已经加入的namespace,最后执行handler
        this.status = .disconnected
        this.leaveNamespace()

        handler?()
    }
}

2、断开连接  
open func disconnect() {  
    DefaultSocketLogger.Logger.log("Closing socket", type: logType)
    leaveNamespace()
}
open func leaveNamespace() {  
    //直接断开socket
    manager?.disconnectSocket(self)
}

3、发送消息  
func emit(_ data: [Any], ack: Int? = nil) {  
    guard status == .connected else {
        //没有连接成功,直接报错误信息,并return
        handleClientEvent(.error, data: ["Tried emitting when not connected"])
        return
    }
    //打包数据
    let packet = SocketPacket.packetFromEmit(data, id: ack ?? -1, nsp: nsp, ack: false)
    let str = packet.packetString
    //发送消息(最终调用到SocketEngine的发送消息方法)
    manager?.engine?.send(str, withData: packet.binary)
}

4、监听事件  
//全程监听事件
open func on(_ event: String, callback: @escaping NormalCallback) -> UUID {  
    let handler = SocketEventHandler(event: event, id: UUID(), callback: callback)
    handlers.append(handler)
    return handler.id
}
//只监听一次事件
open func once(_ event: String, callback: @escaping NormalCallback) -> UUID {  
    let id = UUID()
    let handler = SocketEventHandler(event: event, id: id) {[weak self] data, ack in
        guard let this = self else { return }
        this.off(id: id)
        callback(data, ack)
    }
    handlers.append(handler)
    return handler.id
}
//全程监听所有事件
open func onAny(_ handler: @escaping (SocketAnyEvent) -> ()) {  
    anyHandler = handler
}

5、...  
  • 总的来说,SocketIOClient类集合了所有基础操作的属性和方法,而且非常细心的将常用的方法进行细化,让使用者使用起来更加的方便,简单明了。

3、SocketManager

A manager for a socket.io connection. 这是官方对这个类的描述:“socket.io”连接的管理者。在这个类里管理着SocketIOClient的配置,以及它的初始化生成。

1、//获取默认的SocketIOClient  
public var defaultSocket: SocketIOClient {  
    return socket(forNamespace: "/")
}

2、//初始化一个manager对象  
public init(socketURL: URL, config: SocketIOClientConfiguration = []) {  
    self._config = config
    self.socketURL = socketURL
    if socketURL.absoluteString.hasPrefix("https://") {
        self._config.insert(.secure(true))
    }
    super.init()
    setConfigs(_config)
}

3、//设置配置  
open func setConfigs(_ config: SocketIOClientConfiguration) {  
    for option in config {
        switch option {
        case let .forceNew(new):
            //如果true,那么每次调用“connect”时,都会创建一个新引擎。
            self.forceNew = new
        case let .handleQueue(queue):
            //设置所有交互的队列(必须是串行)
            self.handleQueue = queue
        case let .reconnects(reconnects):
            //是否重连,在断连时是否重新连接
            self.reconnects = reconnects
        case let .reconnectAttempts(attempts):
            //重连次数,-1为无限重连
            self.reconnectAttempts = attempts
        case let .reconnectWait(wait):
            //重连等待时间
            reconnectWait = abs(wait)
        case let .log(log):
            //是否打印日志
            DefaultSocketLogger.Logger.log = log
        case let .logger(logger):
            DefaultSocketLogger.Logger = logger
        default:
            continue
        }
    }

    _config = config
    //添加路径
    _config.insert(.path("/socket.io/"), replacing: false)
    ...
}
  • defaultSocket会返回个namespace为"/"的client,如果需要指定的namespace就不能用该方法了。
  • 在初始化的方法中,记录一下需要连接的url和配置config,再根据URL的前缀去添加secure为true,这个secure在上面有讲到,最后就去设置配置了。

综上,分析了socket.io-client-swift的SocketEngine,SocketIOClient和SocketManager三个类的主要属性和主要方法,这上面列的都是这个库的核心部分,只要涉及到WebSocket的连接,这些方法都是必须要去实现的,当然还有其他的一些方法,由于篇幅的原因,就暂时不在这里解读了。

四、应用

第一步:导入

//oc
@import SocketIO;

//swift
import SocketIO  
  • 上文有提到在OC里导入swift文件有两种方式,socket.io-client-swift库在工程里只要用以上方式导入即可。

第二步:创建WebSocket单例管理类

+ (instancetype)sharedManager {
    static DWDWebsocketManager *manager;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        manager = [[DWDWebsocketManager alloc] init];
    });
    return manager;
}
  • 这是OC里非常简单的单例类创建,创建单例类主要是为了方便管理,为后续业务的扩展做准备。

第三步:初始化

NSURL *url = [NSURL URLWithString:@"https://xxx"];  
NSDictionary *dic =@{@"log": @YES,  
                     @"forceWebsockets": @NO,
                     @"forcePolling": @NO,
                     @"compress": @YES,
                     @"reconnectAttempts":@(-1),
                     @"forceNew": @YES,
                     @"reconnectAttempts": @5,
                     @"extraHeaders": @{@"User-Agent": @"User-Agent"},
                    };
self.manager = [[SocketManager alloc] initWithSocketURL:url config:dic];  
self.socket = [self.manager socketForNamespace:@"/rider/task"];  
  • 在初始化中,主要设置了一些基本的配置参数和URL,并且这些参数的各个意思在上文中已经有所解释,相信看的仔细一点的同学会十分的清楚。下面就初始化了一个manager,再设置了一个namespace,获得一个client。这样开始准备工作就准备完毕了。

第四步:添加监听事件

[self.socket onAny:^(SocketAnyEvent * _Nonnull event) {
    NSLog(@"any is %@ heheh %@", event.event, event.items);
}];
[self.socket on:@"connect" callback:^(NSArray * _Nonnull data, SocketAckEmitter * _Nonnull ack) {
    NSLog(@"connect is %@", data);
}];
[self.socket on:@"ping" callback:^(NSArray* data, SocketAckEmitter* ack) {
    NSLog(@"ping is %@",data);
}];
[self.socket on:@"pong" callback:^(NSArray* data, SocketAckEmitter* ack) {
    NSLog(@"pong is %@",data);
}];
[self.socket on:@"disconnect" callback:^(NSArray * _Nonnull data, SocketAckEmitter * _Nonnull ack) {
    NSLog(@"disconnect is %@",data);
}];
[self.socket on:@"error" callback:^(NSArray * _Nonnull data, SocketAckEmitter * _Nonnull ack) {
    NSLog(@"error is %@", data);
    //errorcode处理
    [weakSelf errorCodeAction:data];
}];
  • 这主要是添加了几个监听事件,可以听过监听事件名来获取服务端返回的数据,来达到通信的效果。在这里主要添加了connect、ping、pong、disconnect、error等几个基础事件,监听到事件后可以做出对应的处理。例如:error事件,我们针对error的code不同来做出不同的操作处理。

第五步:连接及断连

//连接
[self.socket connect];
//断连
- (void)disConnect {
    if (self.socket.status) {
        ...
    }
    if (self.socket) {
        [self.socket disconnect];
        self.socket = nil;
    }
}
  • 相对于上面的几个操作,连接和断连是比较简单的。连接操作是没有什么特殊处理,断连操作最好做一下状态判断,在socketClient已经连接的情况下再去断开连接。

五、总结

总的来说,socket.io-client-swift库是非常轻便,利于对接的。主要问题就是OC和swift的混编问题,其他的问题还需要后续去继续的发现。

本篇文章介绍的相对简单一点,我也是这个季度才接触到这个库,由于现在业务还没有接进来,WebSocket在我们骑手APP里仅仅只是做一个建立连接的操作,进行压测,并没有做其他的业务处理,所以功能开起来相对比较简单,相信以后接入业务以后就会有一些其他的问题和收获,到时候会继续接着这篇文章继续分享。

参考资料

https://socket.io/https://github.com/socketio/socket.iohttps://segmentfault.com/a/1190000007076865https://blog.csdn.net/chenjh213/article/details/49335455