ZhgChg.Li

Design Patterns|Socket.IO Client封裝で直面した課題と実践的解決策

Socket.IO Clientの封装時に発生した具体的な問題を分析し、Design Patternsを活用して効率的に解決。開発者が直面する課題をパターン適用で克服し、堅牢なコード設計を実現する方法を詳述。

Design Patterns|Socket.IO Client封裝で直面した課題と実践的解決策
本記事は AI による翻訳です。お気づきの点があればお知らせください。

Design Patterns の実践応用記録

Socket.IO クライアントライブラリのラッピングで直面した問題シナリオと解決方法に適用したデザインパターン

Photo by Daniel McCullough

Photo by Daniel McCullough

はじめに

此記事は実際の要件開発で、デザインパターンを用いて問題を解決した事例の記録です。内容は要件の背景、実際に直面した問題の場面(What?)、なぜパターンを適用して問題を解決するのか(Why?)、実装でどのように使うか(How?)を含みます。最初から読むことをおすすめします。

本記事では、この要件の開発で直面した4つのシナリオと、それらのシナリオを解決するための7つのデザインパターンの適用について紹介します。

背景

組織構成

敝社は今年、Feature Teams(複数)とPlatform Teamに分割しました。前者は主にユーザー側の要件を担当し、Platform Teamは社内メンバーを対象としています。Platform Teamの主な業務の一つは技術導入、インフラ整備、システム統合を行い、Feature Teamsの開発要件に先駆けて道を整えることです。

現在の要件

Feature Teams は、元のメッセージ機能(ページ遷移時にAPIでメッセージデータを取得し、最新メッセージの更新はリロードが必要)をリアルタイム通信(最新メッセージをリアルタイムで受信し、メッセージ送信が可能)に変更する必要があります。

Platform Team の仕事

Platform Team が重視しているのは、単なる即時通信のニーズだけでなく、長期的な構築と再利用性です。評価の結果、webSocket の双方向通信の仕組みは現代のアプリにおいて不可欠であり、今回の要件以外にも将来的に多くの機会で利用されるため、人員リソースが許す限り、設計と開発の支援に注力しています。

目標:

  • Pinkoi サーバーサイドと Socket.IO 通信、認証ロジックのラップ

  • Socket.IO の複雑な操作を封装し、Pinkoi のビジネスニーズに応じた拡張性と使いやすさを備えたインターフェースを提供する

  • 統一されたクロスプラットフォームインターフェース (Socket.IO の Android と iOS クライアントライブラリは機能やインターフェースが異なります)

  • Feature 側は Socket.IO の仕組みを理解する必要がない

  • Feature 側で複雑な接続状態を管理する必要がない

  • 将来、WebSocket の双方向通信が必要な場合は直接利用可能です。

時間と人員:

  • iOS と Android にそれぞれ1名ずつ配置

  • 開発スケジュール:期間 3 週間

技術的詳細情報

Web & iOS & Android の三つのプラットフォームでこの機能をサポートします。双方向通信プロトコルとして WebSocket を導入し、バックエンドは直接 Socket.io サービスを使用する予定です。

まず最初に言いたいのは Socket != WebSocket です

Socket と WebSocket および技術的な詳細については、以下の2つの記事をご参照ください:

簡単に言うと:

Socket は TCP/UDP トランスポート層の抽象的なラッピングインターフェースであり、WebSocket はアプリケーション層の通信プロトコルです。
Socket と WebSocket の関係は、犬とホットドッグの関係のように、全く関係がありません。

Socket.IO は Engine.IO の一層の抽象操作ラッパーであり、Engine.IO は WebSocket の利用をラップしています。各層は上下間の通信のみを担当し、貫通操作(例:Socket.IO が直接 WebSocket 接続を操作すること)は許可されていません。

Socket.IO/Engine.IO は基本的な WebSocket 接続に加えて、多くの便利な機能群(例:オフラインイベント送信機能、HTTPリクエストに似た機構、Room/Group 機能など)を実装しています。

Platform Team の主な役割は、Socket.IO と Pinkoi サーバーサイド間のロジックを橋渡しし、上位の Feature Teams が機能開発を行う際に利用できるようにすることです。

Socket.IO Swift Client に落とし穴あり

  • 長い間更新されておらず(最新バージョンはまだ2019年で)、メンテナンスされているかは不明です。

  • クライアントとサーバーの Socket.IO バージョンは合わせる必要があります。サーバー側では {allowEIO3: true} を追加するか、クライアント側で同じバージョンを .version で指定してください。
    そうしないと接続できません。

  • 命名規則やインターフェースが公式サンプルと多く異なっています。

  • Socket.IO 公式サイトのサンプルはすべて Web を対象にしており、実際には Swift クライアントが公式サイトの機能をすべてサポートしているわけではありません。
    今回の実装で、iOS のライブラリにはオフライン時のイベント送信機能が実装されていないことが判明しました。
    (私たちは独自に実装していますので、引き続きお読みください)

Socket.IO を採用する前に、必要な機能がサポートされているかどうかをテストして確認することをお勧めします。

Socket.IO Swift Client は Starscream WebSocket ライブラリをベースにしたラッパーであり、必要に応じて Starscream にフォールバックできます。

背景情報の補足はここまでです。次に本題に入ります。

デザインパターン

設計パターンとは、ソフトウェア設計におけるよくある問題の解決策に過ぎません。設計パターンを使わなければ開発できないわけでもなく、すべての場面に適用できるわけでもありませんし、新しい設計パターンを独自にまとめても問題ありません。

The Catalog of Design Patterns

The Catalog of Design Patterns

しかし既存の設計パターン(The 23 Gang of Four Design Patterns)はソフトウェア設計の共通知識であり、XXXパターンと言えば誰もが対応するアーキテクチャの青写真を思い浮かべるため、詳しい説明は不要です。後続の保守も文脈を把握しやすく、業界で検証済みの手法なのでオブジェクト依存問題を検討する時間もあまり必要ありません。適切な場面で適切なパターンを選択することで、コミュニケーションや保守コストを削減し、開発効率を向上させることができます。

デザインパターンは組み合わせて使用できますが、既存のデザインパターンを無理に改変したり、無理やり適用したり、分類に合わないパターンを適用すること(例:チェーン・オブ・リスポンシビリティパターンでオブジェクトを生成する)は推奨されません。そうすると使用の意義を失い、後から引き継ぐ人の誤解を招く可能性があります。

本記事で取り上げるデザインパターン:

後でそれぞれのシナリオで何を使い、なぜ使ったのかを順に説明します。

本記事はデザインパターンの応用に重点を置いており、Socket.IOの操作方法についてではありません。説明を簡潔にするため、一部の例は省略しており、実際のSocket.IOのラッピングには適用できません

本文の分量の関係で、各デザインパターンの構造について詳細には説明しません。各パターンのリンクをクリックして構造を理解してから、読み進めてください。

Demo Code は Swift で記述します。

要求シナリオ 1.

何?

  • 同じパスで異なるページやオブジェクトが接続を要求する際に、同じオブジェクトを再利用できるようにする。

  • Connection は抽象インターフェースであり、Socket.IO オブジェクトに直接依存しないこと

なぜ?

  • メモリ使用量と重複接続の時間、通信コストを削減する。

  • 将来的に他のフレームワークに切り替える余地を残す。

どのように?

  • Singleton Pattern :生成に関するパターンで、オブジェクトがただ一つのインスタンスだけを持つことを保証します。

  • Flyweight Pattern :構造型パターンで、複数のオブジェクトが同じ状態を共有し、再利用することに基づいています。

  • Factory Pattern :生成に関するパターンで、オブジェクトの生成方法を抽象化し、外部から差し替え可能にします。

実際のケース使用:

  • Singletonパターン: ConnectionManager はアプリのライフサイクル中に一つだけ存在し、Connection の取得と操作を管理します。

  • Flyweightパターン: ConnectionPool は名前の通り Connection の共有プールであり、このプールのメソッドから Connection を取得します。その中のロジックは、URL Path が同じ場合は既にプール内にある Connection を直接返します。
    ConnectionHandlerConnection の外部操作および状態管理を担当します。

  • Factoryパターン: ConnectionFactory は上記の Flyweightパターン と組み合わせて、プールに再利用可能な 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 実装
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)

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

要求シナリオ 2.

何?

背景技術の詳細にあるように、Socket.IO Swift Client の Send Event はオフライン送信をサポートしていません(しかし Web/Android 版のライブラリは対応しています)。そのため、iOS 側でこの機能を独自に実装する必要があります。

驚くべきことに、Socket.IO Swift Client の onEvent はオフライン購読をサポートしています。

なぜ?

  • クロスプラットフォーム機能の統一

  • コードが理解しやすい

どうやって?

  • Command Pattern :振る舞いパターンで、操作をオブジェクトとしてラップし、キューイング、遅延、キャンセルなどの一括操作を提供します。

  • Command Pattern: SIOManager は Socket.IO と通信する最下層のラッパーであり、その中の sendrequest メソッドは Socket.IO の送信イベント操作に対応しています。現在の Socket.IO が切断状態であると判断した場合、リクエストパラメータを bufferedCommands に保存し、接続が復旧した際に順番に取り出して処理します(先入れ先出し)。
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("送信:\(event)")
    }
    
    func request(_ event: String, callback: @escaping (Data?) -> Void) {
        guard state == .connected else {
            appendBufferedCommands(connectionCommand: RequestBufferedCommand(event: event, callback: callback, sioManager: self))
            return
        }
        
        print("リクエスト:\(event)")
    }
    
    func onEvent(event: String, callback: @escaping (Data?) -> Void) {
        //
    }
    
    func appendBufferedCommands(connectionCommand: BufferedCommand) {
        bufferedCommands.append(connectionCommand)
    }
    
    func executeBufferedCommands() {
        // 先入れ先出し
        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 にも実装可能です。

拡張:Buffer 機能を Proxy の一種として扱うために、Proxy Pattern を適用することもできます。

要求シナリオ 3.

何?

Connection は複数の状態を持ち、状態間の切り替えは順序立てられており、各状態で許可される操作が異なります。

  • Created:オブジェクトが作成され、Connected または直接 Disconnected に遷移可能

  • Connected:Socket.IOに接続済み、許可 -> Disconnected

  • Disconnected:Socket.IO との接続が切断されました。許可される状態 -> ReconnectiongReleased

  • Reconnectiong:Socket.IOへの再接続を試み中、許可される状態 -> ConnectedDisconnected

  • Released:オブジェクトはメモリ回収待ちとしてマークされており、操作や状態変更は許可されていません

なぜ?

  • 状態と状態の切り替えロジックや表現が難しい

  • 各状態で操作方法を制限する必要があります(例:State = Released のときは Send Event を呼び出せません)。if…else を直接使うと、コードの保守や読みやすさが難しくなります。

どうやって?

  • Finite State Machine :状態遷移の管理

  • State Pattern :オブジェクトの状態変化に応じて異なる振る舞いを実現するパターンです。

  • Finite State MachineSIOConnectionStateMachine は状態機の実装であり、currentSIOConnectionState は現在の状態を示します。created、connected、disconnected、reconnecting、released はこの状態機が遷移可能な状態を列挙しています。
    enterXXXState() throws は現在の状態から特定の状態に入る際の許可・不許可(エラーをスローする)を実装しています。

  • 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() {
        // 先入れ先出し
        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) は Connected に遷移できません")
        }
    }
    
    func enterDisconnected() throws {
        if [created.connectionState, connected.connectionState, reconnecting.connectionState].contains(currentSIOConnectionState.connectionState) {
            enter(disconnected)
        } else {
            throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) は Disconnected に遷移できません")
        }
    }

    func enterReconnecting() throws {
        if [disconnected.connectionState].contains(currentSIOConnectionState.connectionState) {
            enter(reconnecting)
        } else {
            throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) は Reconnecting に遷移できません")
        }
    }

    func enterReleased() throws {
        if [disconnected.connectionState].contains(currentSIOConnectionState.connectionState) {
            enter(released)
        } else {
            throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) は 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("CreatedState はリリースできません!")
    }
    
    func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        // 許可
        // 繰り返しコードを減らすためにヘルパーを使うことができます
        // 例: helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        // 許可
        // 繰り返しコードを減らすためにヘルパーを使うことができます
        // 例: helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func send(socketManager: SIOManagerSpec, event: String) throws {
        // 許可
        // 繰り返しコードを減らすためにヘルパーを使うことができます
        // 例: helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func connect(socketManager: SIOManagerSpec) throws {
        // 許可
        // 繰り返しコードを減らすためにヘルパーを使うことができます
        // 例: helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func disconnect(socketManager: SIOManagerSpec) throws {
        throw SIOConnectionStateMachineError("CreatedState は切断できません!")
    }
}

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 はリリースできません!")
    }
    
    func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        // 許可
        // 繰り返しコードを減らすためにヘルパーを使うことができます
        // 例: helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        // 許可
        // 繰り返しコードを減らすためにヘルパーを使うことができます
        // 例: helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func send(socketManager: SIOManagerSpec, event: String) throws {
        // 許可
        // 繰り返しコードを減らすためにヘルパーを使うことができます
        // 例: helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func connect(socketManager: SIOManagerSpec) throws {
        throw SIOConnectionStateMachineError("ConnectedState は接続できません!")
    }
    
    func disconnect(socketManager: SIOManagerSpec) throws {
        // 許可
        // 繰り返しコードを減らすためにヘルパーを使うことができます
        // 例: 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()
        // 許可
        // 繰り返しコードを減らすためにヘルパーを使うことができます
        // 例: helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        // 許可
        // 繰り返しコードを減らすためにヘルパーを使うことができます
        // 例: helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        // 許可
        // 繰り返しコードを減らすためにヘルパーを使うことができます
        // 例: helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func send(socketManager: SIOManagerSpec, event: String) throws {
        // 許可
        // 繰り返しコードを減らすためにヘルパーを使うことができます
        // 例: helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func connect(socketManager: SIOManagerSpec) throws {
        try stateMachine.enterReconnecting()
        // 許可
        // 繰り返しコードを減らすためにヘルパーを使うことができます
        // 例: helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func disconnect(socketManager: SIOManagerSpec) throws {
        // 許可
        // 繰り返しコードを減らすためにヘルパーを使うことができます
        // 例: 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 はリリースできません!")
    }
    
    func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        // 許可
        // 繰り返しコードを減らすためにヘルパーを使うことができます
        // 例: helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        // 許可
        // 繰り返しコードを減らすためにヘルパーを使うことができます
        // 例: helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func send(socketManager: SIOManagerSpec, event: String) throws {
        // 許可
        // 繰り返しコードを減らすためにヘルパーを使うことができます
        // 例: helper.XXX(socketManager: SIOManagerSpec, ....)
    }
    
    func connect(socketManager: SIOManagerSpec) throws {
        throw SIOConnectionStateMachineError("ReconnectState は接続できません!")
    }
    
    func disconnect(socketManager: SIOManagerSpec) throws {
        // 許可
        // 繰り返しコードを減らすためにヘルパーを使うことができます
        // 例: 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 は onConnected できません!")
    }
    
    func onDisconnected() throws {
        throw SIOConnectionStateMachineError("ReleasedState は onDisconnected できません!")
    }
    
    func release(socketManager: SIOManagerSpec) throws {
        throw SIOConnectionStateMachineError("ReleasedState はリリースできません!")
    }
    
    func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        throw SIOConnectionStateMachineError("ReleasedState は request できません!")
    }
    
    func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws {
        throw SIOConnectionStateMachineError("ReleasedState は receiveOn できません!")
    }
    
    func send(socketManager: SIOManagerSpec, event: String) throws {
        throw SIOConnectionStateMachineError("ReleasedState は send できません!")
    }
    
    func connect(socketManager: SIOManagerSpec) throws {
        throw SIOConnectionStateMachineError("ReleasedState は connect できません!")
    }
    
    func disconnect(socketManager: SIOManagerSpec) throws {
        throw SIOConnectionStateMachineError("ReleasedState は disconnect できません!")
    }
}

do {
    let stateMachine = SIOConnectionStateMachine()
    // socket.io 接続時のモック:
    // 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)")
}

// 出力:
// error: SIOConnectionStateMachineError(message: "ConnectedState はリリースできません!")

要求シナリオ 3.

何?

場面1と2を組み合わせて、ConnectionPoolというフライウェイトプールとState Patternによる状態管理を導入しました。その後、背景目標にあるように、Feature側は背後のConnectionの接続機構を意識する必要がありません。そこで、ConnectionKeeperと名付けたポーリング機能を作成し、ConnectionPool内で強く保持されているConnectionを定期的にスキャンし、以下の状況が発生した場合に操作を行います:

  • Connection を使用中で状態が Connected でない場合:状態を Reconnecting に変更し、再接続を試みる

  • Connection が使用されておらず、状態が Connected の場合:状態を Disconnected に変更する

  • Connection が未使用で状態が Disconnected の場合:状態を Released に変更し、ConnectionPool から削除する

なぜ?

  • 三つの操作は上下関係があり、排他(disconnected -> released または reconnecting)

  • 状況操作の柔軟な差し替え・追加が可能

  • 未封装の場合、3つの判定と操作を直接メソッド内に書くしかなく(ロジックのテストが難しい)

  • 例:

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

どうやって?

  • Chain Of Resposibility :振る舞いパターンの一つで、その名の通りチェーン状になっており、各ノードが対応する処理を持ちます。入力データを受け取ったノードは処理を行うか、次のノードに渡すかを決定します。もう一つの例はiOS Responder Chainです。

定義によると、Chain of Responsibilityパターンでは、あるノードが処理を引き受けた場合、処理を完了してから次のノードに渡す必要があります。途中で処理を終えずに次に渡すことは許されません。

もし上記のシナリオに適しているのは 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 実装
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.

何?

私たちがラップした Connection は、使用する前に setup を経る必要があります。例えば、URL Path の指定や Config の設定などです。

なぜ?

  • 構築のステップを柔軟に増減可能

  • 再利用可能な構築ロジック

  • 封装していない場合、外部が期待通りにクラスを操作しない可能性があります

  • 例:

❌
let connection = Connection()
connection.send(event) // 予期しないメソッド呼び出し、先に.connect()を呼ぶべき
✅
let connection = Connection()
connection.connect()
connection.send(event)
// しかし…誰が知っているのか???

どうやって?

  • Builder Pattern :生成に関するパターンで、段階的にオブジェクトを構築し、構築手順を再利用できます。

  • Builderパターン: 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 実装
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 {
        // 設定をセット
        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 は必須パラメータ
    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 封装で直面した4つのシナリオと、それらの問題解決に用いた7つのデザインパターンです。

最後に今回の Socket.IO パッケージ化の完全な設計図を添付します

本文中の命名や例示とは少し異なりますが、この図が実際の設計アーキテクチャです。機会があれば、元の設計者に設計理念やオープンソースについて共有してもらいたいです。

誰が?

これらの設計を行い、Socket.IOのラッピングプロジェクトを担当したのは誰ですか?

Sean Zheng 、PinkoiのAndroidエンジニア

主なアーキテクチャ設計者、デザインパターンの評価と適用、Android側でKotlinを使用して設計を実装。

ZhgChgLi , エンジニアリード / iOSエンジニア @ Pinkoi

Platform Team プロジェクトリーダー、ペアプログラミング、iOS側で Swift を使った設計実装、議論と疑問提起(いわゆる口だけ参加)、そして最後にこの記事を執筆して皆さんと共有。

関連記事

Post Mediumから変換 by ZMediumToMarkdown.

GitHub で編集
この記事を改善
本記事は Medium で初公開
オリジナルを読む
この記事をシェア
リンクをコピー · SNS でシェア
ZhgChgLi
著者

ZhgChgLi

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

コメント