ZhgChg.Li

Design Patterns 实战应用|封装 Socket.IO 即时通讯架构与七大设计模式解析

针对 iOS 与 Android 即时通讯需求,解析封装 Socket.IO 过程中遇到的复杂连线管理与跨平台挑战,透过七大设计模式优化连线复用、离线事件缓存与状态管理,提升系统稳定性与扩充性,助你打造高效且易维护的双向通讯架构。

Design Patterns 实战应用|封装 Socket.IO 即时通讯架构与七大设计模式解析

Design Patterns 的实战应用纪录

封装 Socket.IO Client Library 需求时遇到的问题场景及解决方法应用到的 Design Patterns

Photo by Daniel McCullough

Photo by Daniel McCullough

前言

此篇文章是真实的需求开发,所运用到 Design Pattern 解决问题的场景记录;内容篇幅会涵盖需求背景、实际遇到的问题场景 (What?)、为何要套用 Pattern 解决问题 (Why?)、实作上如何使用 (How?),建议可以从头阅读会比较有连贯性。

本文会介绍四个开发此需求遇到的场景及七个解决此场景的 Design Patterns 应用。

背景

组织架构

敝司于今年拆分出 Feature Teams (multiple) 与 Platform Team;前者不必多说主要负责使用者端需求、Platform Team 这边则面对的是公司内部的成员,其中一个工作项目就是技术引入、基础建设及做好系统性整合,为 Feature Teams 开发需求时先锋铺好道路。

当前需求

Feature Teams 要将原本的讯息功能 (进页面打 API 拿讯息资料,要更新最新讯息只能重整) 改为 即时通讯 (能即时收到最新讯息、对传讯息)。

Platform Team 工作

Platform Team 著重的点不只是当下的即时通讯需求,而是长远的建设与复用性;评估后 webSocket 双向通讯的机制在现代 App 中是不可或缺,除了此次的需求之外,以后也有很多机会都会用到,加上人力资源许可,故投入协助设计开发介面。

目标:

  • 封装 Pinkoi Server Side 与 Socket.IO 通讯、身份验证逻辑

  • 封装 Socket.IO 烦琐操作,提供基于 Pinkoi 商业需求的可扩充及方便使用介面

  • 统一双平台介面 (Socket.IO 的 Android 与 iOS Client Side Library 支援的功能及介面不相同)

  • Feature 端无需了解 Socket.IO 机制

  • Feature 端无需管理复杂的连线状态

  • 未来有 webSocket 双向通讯需求能直接使用

时间及人力:

  • iOS & Android 各投入一位

  • 开发时程:时程 3 周

技术细节

Web & iOS & Android 三平台均会支援此 Feature;要引入 webSocket 双向通讯协议来实现,后端预计直接使用 Socket.io 服务。

首先要说 Socket != WebSocket

关于 Socket 与 WebSocket 及技术细节可参考以下两篇文章:

简而言之:

Socket 是 TCP/UDP 传输层的抽象封装介面,而 WebSocket 是应用层的传输协议。
Socket 与 WebSocket 的关系就像狗跟热狗的关系一样,没有关系。

Socket.IO 是 Engine.IO 的一层抽象操作封装,Engine.IO 则是对 WebSocket 的使用封装,每层只负责对上对下之间的交流,不允许贯穿操作(e.g. Socket.IO 直接操作 WebSocket 连线)。

Socket.IO/Engine.IO 除了基本的 WebSocket 连线外还实做了很多方便好用的功能集合(e.g. 离线发送 Event 机制、类似 Http Request 机制、Room/Group 机制…等等)。

Platform Team 这层的主要职责是桥接 Socket.IO 与 Pinkoi Server Side 之间的逻辑,供应上层 Feature Teams 开发功能时使用。

Socket.IO Swift Client 有坑

  • 已许久未更新 (最新一版还在 2019),不确定是否还有在维护。

  • Client & Server Side Socket IO Version 要对齐,Server Side 可加上 {allowEIO3: true} / 或 Client Side 指定相同版本 .version 否则怎么连都连不上。

  • 命名方式、介面与官网范例很多都对不起来。

  • Socket.io 官网范例都是拿 Web 做介绍,实际上 Swift Client 并不一定有全支援官网写的功能 。 此次实作发现 iOS 这边 Library 并未实现离线发送 Event 机制 (我们是自行实现的,请往后继续阅读)

建议有要采用 Socket.IO 前先实验看看你想要的机制是否支援。

Socket.IO Swift Client 是基于 Starscream WebSocket Library 的封装,必要时可降级使用 Starscream。

背景资讯补充到此结束,接下来进入正题。

Design Patterns

设计模式说穿了就只是软体设计当中常见问题的解决方案,不一定要用设计模式才能开发、设计模式不一定能适用所有场景、也没人说不能自行归纳出新的设计模式。

The Catalog of Design Patterns

The Catalog of Design Patterns

但现有的设计模式 (The 23 Gang of Four Design Patterns) 已是软体设计中的共同知识,只要提到 XXX Pattern 大家脑中就会有相应的架构蓝图,不需多做解释、后续维护也比较好知道脉络、且已是经过业界验证的方法不太需要花时间审视物件依赖问题;在适合的场景选用适合的模式可以降低沟通及维护成本,提升开发效率。

设计模式可以组合使用,但不建议对现有设计模式魔改、强行为套用而套用、套用不符合分类的 Pattern (e.g. 用责任练模式来产生物件),会失去使用的意义更可能造成后续接手的人的误会。

本篇会提到的 Design Patterns:

会逐一在后面解释什么场境用了、为何要用。

本文著重在 Design Pattern 的应用,而非 Socket.IO 的操作,部分示例会因为描述方便而有所删减, 无法适用真实的 Socket.IO 封装

因篇幅有限,本文不会详细介绍每个设计模式的架构,请先点各个模式的连结进入了解该模式的架构后再继续阅读。

Demo Code 会使用 Swift 撰写。

需求场景 1.

What?

  • 使用相同的 Path 在不同页面、Object 请求 Connection 时能复用取得相同的物件。

  • Connection 需为抽象介面,不直接依赖 Socket.IO Object

Why?

  • 减少记忆体开销及重复连线的时间、流量成本。

  • 为未来抽换成其他框架预留空间

How?

  • Singleton Pattern :创建型 Pattern,保证一个物件只会有一个实体。

  • Flywieght Pattern :结构型 Pattern,基于共享多个物件相同的状态,重复使用。

  • Factory Pattern :创建型 Pattern,抽象物件产生方法,使其能在外部抽换。

实际案例使用:

  • Singleton Pattern: ConnectionManager 在 App Lifecycle 中仅存在一个的物件,用来管理 Connection 取用操作。

  • Flywieght Pattern: ConnectionPool 顾名思义就是 Connection 的共用池子,统一从这个池子的方法拿出 Connection,其中逻辑就会包含当发现 URL Path 一样时直接给予已经在池子里的 Connection。 ConnectionHandler 则做为 Connection 的外在操作、状态管理器。

  • Factory Pattern: ConnectionFactory 搭配上面 Flywieght Pattern 当发现池子没有可复用的 Connection 时则用此工厂介面去产生。

import Combine
import Foundation

protocol Connection {
    var url: URL {get}
    var id: UUID {get}
    
    init(url: URL)
    
    func connect()
    func disconnect()
    
    func sendEvent(_ event: String)
    func onEvent(_ event: String) -> AnyPublisher<Data?, Never>
}

protocol ConnectionFactory {
    func create(url: URL) -> Connection
}

class ConnectionPool {
    
    private let connectionFactory: ConnectionFactory
    private var connections: [Connection] = []
    
    init(connectionFactory: ConnectionFactory) {
        self.connectionFactory = connectionFactory
    }
    
    func getOrCreateConnection(url: URL) -> Connection {
        if let connection = connections.first(where: { $0.url == url }) {
            return connection
        } else {
            let connection = connectionFactory.create(url: url)
            connections.append(connection)
            return connection
        }
    }
    
}

class ConnectionHandler {
    private let connection: Connection
    init(connection: Connection) {
        self.connection = connection
    }
    
    func getConnectionUUID() -> UUID {
        return connection.id
    }
}

class ConnectionManager {
    static let shared = ConnectionManager(connectionPool: ConnectionPool(connectionFactory: SIOConnectionFactory()))
    private let connectionPool: ConnectionPool
    private init(connectionPool: ConnectionPool) {
        self.connectionPool = connectionPool
    }
    
    //
    func requestConnectionHandler(url: URL) -> ConnectionHandler {
        let connection = connectionPool.getOrCreateConnection(url: url)
        return ConnectionHandler(connection: connection)
    }
}

// Socket.IO Implementation
class SIOConnection: Connection {
    let url: URL
    let id: UUID = UUID()
    
    required init(url: URL) {
        self.url = url
        //
    }
    
    func connect() {
        //
    }
    
    func disconnect() {
        //
    }
    
    func sendEvent(_ event: String) {
        //
    }
    
    func onEvent(_ event: String) -> AnyPublisher<Data?, Never> {
        //
        return PassthroughSubject<Data?, Never>().eraseToAnyPublisher()
    }
}

class SIOConnectionFactory: ConnectionFactory {
    func create(url: URL) -> Connection {
        //
        return SIOConnection(url: url)
    }
}
//

print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: "wss://pinkoi.com/1")!).getConnectionUUID().uuidString)
print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: "wss://pinkoi.com/1")!).getConnectionUUID().uuidString)

print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: "wss://pinkoi.com/2")!).getConnectionUUID().uuidString)

// output:
// D99F5429-1C6D-4EB5-A56E-9373D6F37307
// D99F5429-1C6D-4EB5-A56E-9373D6F37307
// 599CF16F-3D7C-49CF-817B-5A57C119FE31

需求场景 2.

What?

如背景技术细节所述,Socket.IO Swift Client 的 Send Event 并不支援离线发送 (但 Web/Android 版的 Library 却可以),因此 iOS 端需要自行实现此功能。

神奇的是 Socket.IO Swift Client - onEvent 是支援离线订阅的。

Why?

  • 跨平台功能统一

  • 程式码容易理解

How?

  • Command Pattern :行为型 Pattern,将操作包装成对象,提供队列、延迟、取消…等等集合操作。

  • Command Pattern: SIOManager 为与 Socket.IO 沟通的最底层封装,其中的 sendrequest 方法都是对 Socket.IO Send Event 的操作,当发现当前 Socket.IO 处于断线状态,则将请求参数放到 bufferedCommands 中,当连上之后就逐一拿出来处理 (First In First Out)。
protocol BufferedCommand {
    var sioManager: SIOManagerSpec? { get set }
    var event: String { get }
    
    func execute()
}

struct SendBufferedCommand: BufferedCommand {
    let event: String
    weak var sioManager: SIOManagerSpec?
    
    func execute() {
        sioManager?.send(event)
    }
}

struct RequestBufferedCommand: BufferedCommand {
    let event: String
    let callback: (Data?) -> Void
    weak var sioManager: SIOManagerSpec?
    
    func execute() {
        sioManager?.request(event, callback: callback)
    }
}

protocol SIOManagerSpec: AnyObject {
    func connect()
    func disconnect()
    func onEvent(event: String, callback: @escaping (Data?) -> Void)
    func send(_ event: String)
    func request(_ event: String, callback: @escaping (Data?) -> Void)
}

enum ConnectionState {
    case created
    case connected
    case disconnected
    case reconnecting
    case released
}

class SIOManager: SIOManagerSpec {
        
    var state: ConnectionState = .disconnected {
        didSet {
            if state == .connected {
                executeBufferedCommands()
            }
        }
    }
    
    private var bufferedCommands: [BufferedCommand] = []
    
    func connect() {
        state = .connected
    }
    
    func disconnect() {
        state = .disconnected
    }
    
    func send(_ event: String) {
        guard state == .connected else {
            appendBufferedCommands(connectionCommand: SendBufferedCommand(event: event, sioManager: self))
            return
        }
        
        print("Send:\(event)")
    }
    
    func request(_ event: String, callback: @escaping (Data?) -> Void) {
        guard state == .connected else {
            appendBufferedCommands(connectionCommand: RequestBufferedCommand(event: event, callback: callback, sioManager: self))
            return
        }
        
        print("request:\(event)")
    }
    
    func onEvent(event: String, callback: @escaping (Data?) -> Void) {
        //
    }
    
    func appendBufferedCommands(connectionCommand: BufferedCommand) {
        bufferedCommands.append(connectionCommand)
    }
    
    func executeBufferedCommands() {
        // First in, first out
        bufferedCommands.forEach { connectionCommand in
            connectionCommand.execute()
        }
        bufferedCommands.removeAll()
    }
    
    func removeAllBufferedCommands() {
        bufferedCommands.removeAll()
    }
}

let manager = SIOManager()
manager.send("send_event_1")
manager.send("send_event_2")
manager.request("request_event_1") { _ in
    //
}
manager.state = .connected

同理也可以实现到 onEvent 上。

延伸:可以再套用 Proxy Pattern ,将 Buffer 功能视为一种 Proxy。

需求场景 3.

What?

Connection 有多个状态,有序的状态与状态间切换、各状态允许不同的操作。

  • Created:物件被建立,允许 -> Connected 或直接进 Disconnected

  • Connected:已连上 Socket.IO,允许 -> Disconnected

  • Disconnected:已与 Socket.IO 断线,允许 -> ReconnectiongReleased

  • Reconnectiong:正在尝试重新连上 Socket.IO,允许 -> ConnectedDisconnected

  • Released:物件已被标示为等待被记忆体回收,不允许任何操作及切换状态

Why?

  • 状态与状态的切换逻辑跟表述不容易

  • 各状态要限制操作方法(e.g. State = Released 时无法 Call Send Event),直接使用 if. .else 会让程式难以维护阅读

How?

  • Finite State MachineSIOConnectionStateMachine 为状态机实作, currentSIOConnectionState 为当前状态, created、connected、disconnected、reconnecting、released 表列出此状态机可能的切换状态。 enterXXXState() throws 为从 Current State 进入某个状态时的允许与不允许(throw error)实作。

  • State PatternSIOConnectionState 为所有状态会用到的操作方法介面抽象。

protocol SIOManagerSpec: AnyObject {
    func connect()
    func disconnect()
    func onEvent(event: String, callback: @escaping (Data?) -> Void)
    func send(_ event: String)
    func request(_ event: String, callback: @escaping (Data?) -> Void)
}

enum ConnectionState {
    case created
    case connected
    case disconnected
    case reconnecting
    case released
}

class SIOManager: SIOManagerSpec {
        
    var state: ConnectionState = .disconnected {
        didSet {
            if state == .connected {
                executeBufferedCommands()
            }
        }
    }
    
    private var bufferedCommands: [BufferedCommand] = []
    
    func connect() {
        state = .connected
    }
    
    func disconnect() {
        state = .disconnected
    }
    
    func send(_ event: String) {
        guard state == .connected else {
            appendBufferedCommands(connectionCommand: SendBufferedCommand(event: event, sioManager: self))
            return
        }
        
        print("Send:\(event)")
    }
    
    func request(_ event: String, callback: @escaping (Data?) -> Void) {
        guard state == .connected else {
            appendBufferedCommands(connectionCommand: RequestBufferedCommand(event: event, callback: callback, sioManager: self))
            return
        }
        
        print("request:\(event)")
    }
    
    func onEvent(event: String, callback: @escaping (Data?) -> Void) {
        //
    }
    
    func appendBufferedCommands(connectionCommand: BufferedCommand) {
        bufferedCommands.append(connectionCommand)
    }
    
    func executeBufferedCommands() {
        // First in, first out
        bufferedCommands.forEach { connectionCommand in
            connectionCommand.execute()
        }
        bufferedCommands.removeAll()
    }
    
    func removeAllBufferedCommands() {
        bufferedCommands.removeAll()
    }
}

let manager = SIOManager()
manager.send("send_event_1")
manager.send("send_event_2")
manager.request("request_event_1") { _ in
    //
}
manager.state = .connected

//

class SIOConnectionStateMachine {
    
    private(set) var currentSIOConnectionState: SIOConnectionState!

    private var created: SIOConnectionState!
    private var connected: SIOConnectionState!
    private var disconnected: SIOConnectionState!
    private var reconnecting: SIOConnectionState!
    private var released: SIOConnectionState!
    
    init() {
        self.created = SIOConnectionCreatedState(stateMachine: self)
        self.connected = SIOConnectionConnectedState(stateMachine: self)
        self.disconnected = SIOConnectionDisconnectedState(stateMachine: self)
        self.reconnecting = SIOConnectionReconnectingState(stateMachine: self)
        self.released = SIOConnectionReleasedState(stateMachine: self)
        
        self.currentSIOConnectionState = created
    }
    
    func enterConnected() throws {
        if [created.connectionState, reconnecting.connectionState].contains(currentSIOConnectionState.connectionState) {
            enter(connected)
        } else {
            throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) can't enter to Connected")
        }
    }
    
    func enterDisconnected() throws {
        if [created.connectionState, connected.connectionState, reconnecting.connectionState].contains(currentSIOConnectionState.connectionState) {
            enter(disconnected)
        } else {
            throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) can't enter to Disconnected")
        }
    }

    func enterReconnecting() throws {
        if [disconnected.connectionState].contains(currentSIOConnectionState.connectionState) {
            enter(reconnecting)
        } else {
            throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) can't enter to Reconnecting")
        }
    }

    func enterReleased() throws {
        if [disconnected.connectionState].contains(currentSIOConnectionState.connectionState) {
            enter(released)
        } else {
            throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) can't enter to Released")
        }
    }
    
    private func enter(_ state: SIOConnectionState) {
        currentSIOConnectionState = state
    }
}


protocol SIOConnectionState {
    var connectionState: ConnectionState { get }
    var stateMachine: SIOConnectionStateMachine { get }
    init(stateMachine: SIOConnectionStateMachine)

    func onConnected() throws
    func onDisconnected() throws
    
    
    func connect(socketManager: SIOManagerSpec) throws
    func disconnect(socketManager: SIOManagerSpec) throws
    func release(socketManager: SIOManagerSpec) throws
    
    func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws
    func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws
    func send(socketManager: SIOManagerSpec, event: String) throws
}

struct SIOConnectionStateMachineError: Error {
    let message: String

    init(_ message: String) {
        self.message = message
    }

    var localizedDescription: String {
        return message
    }
}

class SIOConnectionCreatedState: SIOConnectionState {
    
    let connectionState: ConnectionState = .created
    let stateMachine: SIOConnectionStateMachine
    
    required init(stateMachine: SIOConnectionStateMachine) {
        self.stateMachine = stateMachine
    }

    func onConnected() throws {
        try stateMachine.enterConnected()
    }
    
    func onDisconnected() throws {
        try stateMachine.enterDisconnected()
    }
    
    func release(socketManager: SIOManagerSpec) throws {
        throw SIOConnectionStateMachineError("ConnectedState can't release!")
    }
    
    func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        // allow
        // can use Helper to reduce the repeating code
        // e.g. helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        // allow
        // can use Helper to reduce the repeating code
        // e.g. helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func send(socketManager: SIOManagerSpec, event: String) throws {
        // allow
        // can use Helper to reduce the repeating code
        // e.g. helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func connect(socketManager: SIOManagerSpec) throws {
        // allow
        // can use Helper to reduce the repeating code
        // e.g. helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func disconnect(socketManager: SIOManagerSpec) throws {
        throw SIOConnectionStateMachineError("CreatedState can't disconnect!")
    }
}

class SIOConnectionConnectedState: SIOConnectionState {
    
    let connectionState: ConnectionState = .connected
    let stateMachine: SIOConnectionStateMachine
    
    required init(stateMachine: SIOConnectionStateMachine) {
        self.stateMachine = stateMachine
    }
    
    func onConnected() throws {
        //
    }
    
    func onDisconnected() throws {
        try stateMachine.enterDisconnected()
    }
    
    func release(socketManager: SIOManagerSpec) throws {
        throw SIOConnectionStateMachineError("ConnectedState can't release!")
    }
    
    func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        // allow
        // can use Helper to reduce the repeating code
        // e.g. helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        // allow
        // can use Helper to reduce the repeating code
        // e.g. helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func send(socketManager: SIOManagerSpec, event: String) throws {
        // allow
        // can use Helper to reduce the repeating code
        // e.g. helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func connect(socketManager: SIOManagerSpec) throws {
        throw SIOConnectionStateMachineError("ConnectedState can't connect!")
    }
    
    func disconnect(socketManager: SIOManagerSpec) throws {
        // allow
        // can use Helper to reduce the repeating code
        // e.g. helper.XXX(socketManager: SIOManagerSpec, ....)
    }
}

class SIOConnectionDisconnectedState: SIOConnectionState {
    
    let connectionState: ConnectionState = .disconnected
    let stateMachine: SIOConnectionStateMachine
    
    required init(stateMachine: SIOConnectionStateMachine) {
        self.stateMachine = stateMachine
    }

    func onConnected() throws {
        try stateMachine.enterConnected()
    }
    
    func onDisconnected() throws {
        //
    }
    
    func release(socketManager: SIOManagerSpec) throws {
        try stateMachine.enterReleased()
        // allow
        // can use Helper to reduce the repeating code
        // e.g. helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        // allow
        // can use Helper to reduce the repeating code
        // e.g. helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        // allow
        // can use Helper to reduce the repeating code
        // e.g. helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func send(socketManager: SIOManagerSpec, event: String) throws {
        // allow
        // can use Helper to reduce the repeating code
        // e.g. helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func connect(socketManager: SIOManagerSpec) throws {
        try stateMachine.enterReconnecting()
        // allow
        // can use Helper to reduce the repeating code
        // e.g. helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func disconnect(socketManager: SIOManagerSpec) throws {
        // allow
        // can use Helper to reduce the repeating code
        // e.g. helper.XXX(socketManager: SIOManagerSpec, ....)
    }
}

class SIOConnectionReconnectingState: SIOConnectionState {
    
    let connectionState: ConnectionState = .reconnecting
    let stateMachine: SIOConnectionStateMachine
    
    required init(stateMachine: SIOConnectionStateMachine) {
        self.stateMachine = stateMachine
    }

    func onConnected() throws {
        try stateMachine.enterConnected()
    }
    
    func onDisconnected() throws {
        try stateMachine.enterDisconnected()
    }
    
    func release(socketManager: SIOManagerSpec) throws {
        throw SIOConnectionStateMachineError("ReconnectState can't release!")
    }
    
    func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        // allow
        // can use Helper to reduce the repeating code
        // e.g. helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        // allow
        // can use Helper to reduce the repeating code
        // e.g. helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func send(socketManager: SIOManagerSpec, event: String) throws {
        // allow
        // can use Helper to reduce the repeating code
        // e.g. helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func connect(socketManager: SIOManagerSpec) throws {
        throw SIOConnectionStateMachineError("ReconnectState can't connect!")
    }
    
    func disconnect(socketManager: SIOManagerSpec) throws {
        // allow
        // can use Helper to reduce the repeating code
        // e.g. helper.XXX(socketManager: SIOManagerSpec, ....)
    }
}

class SIOConnectionReleasedState: SIOConnectionState {
    
    let connectionState: ConnectionState = .released
    let stateMachine: SIOConnectionStateMachine
    
    required init(stateMachine: SIOConnectionStateMachine) {
        self.stateMachine = stateMachine
    }

    func onConnected() throws {
        throw SIOConnectionStateMachineError("ReleasedState can't onConnected!")
    }
    
    func onDisconnected() throws {
        throw SIOConnectionStateMachineError("ReleasedState can't onDisconnected!")
    }
    
    func release(socketManager: SIOManagerSpec) throws {
        throw SIOConnectionStateMachineError("ReleasedState can't release!")
    }
    
    func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        throw SIOConnectionStateMachineError("ReleasedState can't request!")
    }
    
    func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        throw SIOConnectionStateMachineError("ReleasedState can't receiveOn!")
    }
    
    func send(socketManager: SIOManagerSpec, event: String) throws {
        throw SIOConnectionStateMachineError("ReleasedState can't send!")
    }
    
    func connect(socketManager: SIOManagerSpec) throws {
        throw SIOConnectionStateMachineError("ReleasedState can't connect!")
    }
    
    func disconnect(socketManager: SIOManagerSpec) throws {
        throw SIOConnectionStateMachineError("ReleasedState can't disconnect!")
    }
}

do {
    let stateMachine = SIOConnectionStateMachine()
    // mock on socket.io connect:
    // socketIO.on(connect){
    try stateMachine.currentSIOConnectionState.onConnected()
    try stateMachine.currentSIOConnectionState.send(socketManager: manager, event: "test")
    try stateMachine.currentSIOConnectionState.release(socketManager: manager)
    try stateMachine.currentSIOConnectionState.send(socketManager: manager, event: "test")
    // }
} catch {
    print("error: \(error)")
}

// output:
// error: SIOConnectionStateMachineError(message: "ConnectedState can\'t release!")

需求场景 3.

What?

结合场景 1. 2.,有了 ConnectionPool 享元池子加上 State Pattern 状态管理后;我们继续往下延伸,如背景目标所述,Feature 端不需去管背后 Connection 的连线机制;因此我们建立了一个轮询器 (命名为 ConnectionKeeper ) 会定时扫描 ConnectionPool 中强持有的 Connection ,并在发生以下状况时做操作:

  • Connection 有人在使用且状态非 Connected :将状态改为 Reconnecting 并尝试重新连线

  • Connection 已无人使用且状态为 Connected :将状态改为 Disconnected

  • Connection 已无人使用且状态为 Disconnected :将状态改为 Released 并从 ConnectionPool 中移除

Why?

  • 三个操作有上下关系且互斥 (disconnected -> released or reconnecting)

  • 可弹性抽换、增加状况操作

  • 未封装的话只能将三个判断及操作直接写在方法中 (难以测试其中逻辑)

  • e.g:

if !connection.isOccupie() && connection.state == .connected then
... connection.disconnected()
else if !connection.isOccupie() && state == .released then
... connection.release()
else if connection.isOccupie() && state == .disconnected then
... connection.reconnecting()
end

How?

  • Chain Of Resposibility :行为型 Pattern,顾名思义是一条链,每个节点都有相应的操作,输入资料后节点可决定是否要操作还是丢给下一个节点处理,另一个现实应用是 iOS Responder Chain

照定义 Chain of responsibility Pattern 是不允许某个节点已经接下处理资料,但处理完又丢给下一个节点继续处理, 要做就做完,不然不要做

如果是上述场景比较适合的应该是 Interceptor Pattern

  • Chain of responsibility: ConnectionKeeperHandler 为炼的节点抽象,特别抽出 canExcute 方法避免发生上述 这个节点接下来处理了,但做完又想呼叫后面的节点继续执行的状况、 handle 为炼的节点串连、 excute 为要处理的话会怎么处理的逻辑。 ConnectionKeeperHandlerContext 用来存放会用到的资料, isOccupie 代表 Connection 有无人在使用。
enum ConnectionState {
    case created
    case connected
    case disconnected
    case reconnecting
    case released
}

protocol Connection {
    var connectionState: ConnectionState {get}
    var url: URL {get}
    var id: UUID {get}
    
    init(url: URL)
    
    func connect()
    func reconnect()
    func disconnect()
    
    func sendEvent(_ event: String)
    func onEvent(_ event: String) -> AnyPublisher<Data?, Never>
}

// Socket.IO Implementation
class SIOConnection: Connection {
    let connectionState: ConnectionState = .created
    let url: URL
    let id: UUID = UUID()
    
    required init(url: URL) {
        self.url = url
        //
    }
    
    func connect() {
        //
    }
    
    func disconnect() {
        //
    }
    
    func reconnect() {
        //
    }
    
    func sendEvent(_ event: String) {
        //
    }
    
    func onEvent(_ event: String) -> AnyPublisher<Data?, Never> {
        //
        return PassthroughSubject<Data?, Never>().eraseToAnyPublisher()
    }
}

//

struct ConnectionKeeperHandlerContext {
    let connection: Connection
    let isOccupie: Bool
}

protocol ConnectionKeeperHandler {
    var nextHandler: ConnectionKeeperHandler? { get set }
    
    func handle(context: ConnectionKeeperHandlerContext)
    func execute(context: ConnectionKeeperHandlerContext)
    func canExcute(context: ConnectionKeeperHandlerContext) -> Bool
}

extension ConnectionKeeperHandler {
    func handle(context: ConnectionKeeperHandlerContext) {
        if canExcute(context: context) {
            execute(context: context)
        } else {
            nextHandler?.handle(context: context)
        }
    }
}

class DisconnectedConnectionKeeperHandler: ConnectionKeeperHandler {
    var nextHandler: ConnectionKeeperHandler?
    
    func execute(context: ConnectionKeeperHandlerContext) {
        context.connection.disconnect()
    }
    
    func canExcute(context: ConnectionKeeperHandlerContext) -> Bool {
        if context.connection.connectionState == .connected && !context.isOccupie {
            return true
        }
        return false
    }
}

class ReconnectConnectionKeeperHandler: ConnectionKeeperHandler {
    var nextHandler: ConnectionKeeperHandler?
    
    func execute(context: ConnectionKeeperHandlerContext) {
        context.connection.reconnect()
    }
    
    func canExcute(context: ConnectionKeeperHandlerContext) -> Bool {
        if context.connection.connectionState == .disconnected && context.isOccupie {
            return true
        }
        return false
    }
}

class ReleasedConnectionKeeperHandler: ConnectionKeeperHandler {
    var nextHandler: ConnectionKeeperHandler?
    
    func execute(context: ConnectionKeeperHandlerContext) {
        context.connection.disconnect()
    }
    
    func canExcute(context: ConnectionKeeperHandlerContext) -> Bool {
        if context.connection.connectionState == .disconnected && !context.isOccupie {
            return true
        }
        return false
    }
}
let connection = SIOConnection(url: URL(string: "wss://pinkoi.com")!)
let disconnectedHandler = DisconnectedConnectionKeeperHandler()
let reconnectHandler = ReconnectConnectionKeeperHandler()
let releasedHandler = ReleasedConnectionKeeperHandler()
disconnectedHandler.nextHandler = reconnectHandler
reconnectHandler.nextHandler = releasedHandler

disconnectedHandler.handle(context: ConnectionKeeperHandlerContext(connection: connection, isOccupie: false))

需求场景 4.

What?

我们封装出的 Connection 需要经过 setup 后才能使用,例如给予 URL Path、设定 Config…等等

Why?

  • 可以弹性的增减构建开口

  • 可复用构建逻辑

  • 未封装的话,外部可以不照预期操作类别

  • e.g.:

❌
let connection = Connection()
connection.send(event) // unexpected method call, should call .connect() first
✅
let connection = Connection()
connection.connect()
connection.send(event)
// but...who knows???

How?

  • Builder Pattern :创建型 Pattern,能够分步骤构建对象及复用构建方法。

  • Builder Pattern: SIOConnectionBuilderConnection 的构建器,负责设定、存放构建 Connection 时会用到的资料; ConnectionConfiguration 抽象介面用来保证要使用 Connection 前必须呼叫 .connect() 才能拿到 Connection 实体。
enum ConnectionState {
    case created
    case connected
    case disconnected
    case reconnecting
    case released
}

protocol Connection {
    var connectionState: ConnectionState {get}
    var url: URL {get}
    var id: UUID {get}
    
    init(url: URL)
    
    func connect()
    func reconnect()
    func disconnect()
    
    func sendEvent(_ event: String)
    func onEvent(_ event: String) -> AnyPublisher<Data?, Never>
}

// Socket.IO Implementation
class SIOConnection: Connection {
    let connectionState: ConnectionState = .created
    let url: URL
    let id: UUID = UUID()
    
    required init(url: URL) {
        self.url = url
        //
    }
    
    func connect() {
        //
    }
    
    func disconnect() {
        //
    }
    
    func reconnect() {
        //
    }
    
    func sendEvent(_ event: String) {
        //
    }
    
    func onEvent(_ event: String) -> AnyPublisher<Data?, Never> {
        //
        return PassthroughSubject<Data?, Never>().eraseToAnyPublisher()
    }
}

//
class SIOConnectionClient: ConnectionConfiguration {
    private let url: URL
    private let config: [String: Any]
    
    init(url: URL, config: [String: Any]) {
        self.url = url
        self.config = config
    }
    
    func connect() -> Connection {
        // set config
        return SIOConnection(url: url)
    }
}

protocol ConnectionConfiguration {
    func connect() -> Connection
}

class SIOConnectionBuilder {
    private(set) var config: [String: Any] = [:]
    
    func setConfig(_ config: [String: Any]) -> SIOConnectionBuilder {
        self.config = config
        return self
    }
    
    // url is required parameter
    func build(url: URL) -> ConnectionConfiguration {
        return SIOConnectionClient(url: url, config: self.config)
    }
}

let builder = SIOConnectionBuilder().setConfig(["test":123])


let connection1 = builder.build(url: URL(string: "wss://pinkoi.com/1")!).connect()
let connection2 = builder.build(url: URL(string: "wss://pinkoi.com/1")!).connect()

延伸:这里也可以再套用 Factory Pattern ,将用工厂产出 SIOConnection

完结!

以上就是本次封装 Socket.IO 中遇到的四个场景及七个使用到解决问题的 Design Patterns。

最后附上此次封装 Socket.IO 的完整设计蓝图

与文中命名、示范略为不同,这张图才是真实的设计架构;有机会再请原设计者分享设计理念及开源。

Who?

谁做了这些设计跟负责 Socket.IO 封装专案呢?

Sean Zheng , Android Engineer @ Pinkoi

主要架构设计者、Design Pattern 评估套用、在 Android 端使用 Kotlin 实现设计。

ZhgChgLi , Enginner Lead/iOS Enginner @ Pinkoi

Platform Team 专案负责人、Pair programming、在 iOS 端使用 Swift 实现设计、讨论并提出质疑(a.k.a. 出一张嘴)及最后撰写本文与大家分享。

延伸阅读

在 GitHub 上补充修正
编辑这篇文章
本文首次发表于 Medium
点此查看原文
分享这篇文章
复制链接 · 分享到社群
ZhgChgLi
作者

ZhgChgLi

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing.

留言 · Comments