Post

Practical Applications of Design Patterns

Challenges encountered while encapsulating the Socket.IO Client Library and the design patterns applied to solve them.

Practical Applications of Design Patterns

ℹ️ℹ️ℹ️ The following content is translated by OpenAI.

Click here to view the original Chinese version. | 點此查看本文中文版


Practical Applications of Design Patterns

This article documents real-world development scenarios encountered while encapsulating the Socket.IO Client Library, along with the design patterns applied to solve these issues.

Photo by [Daniel McCullough](https://unsplash.com/@d_mccullough?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Daniel McCullough

Introduction

This article records the practical application of design patterns in addressing real development needs. It covers the background of the requirements, the specific problems encountered (What?), the rationale for applying design patterns (Why?), and how to implement them (How?). It is recommended to read from the beginning for better coherence.

This article will introduce four scenarios encountered during the development of this requirement and seven design patterns applied to address these scenarios.

Background

Organizational Structure

Our company has recently split into multiple Feature Teams and a Platform Team. The Feature Teams primarily handle user-facing requirements, while the Platform Team focuses on internal technical integration, infrastructure, and paving the way for the Feature Teams during development.

Current Requirement

The Feature Teams need to transform the existing messaging functionality (where users had to refresh the page to get the latest messages) into real-time communication (where users can receive messages instantly and send messages).

Platform Team’s Role

The Platform Team’s focus is not just on the immediate real-time communication requirement but also on long-term infrastructure and reusability. After evaluation, we determined that the WebSocket bidirectional communication mechanism is essential in modern apps. This requirement will likely be needed in many future projects, so we decided to assist in designing and developing the interface.

Goals:

  • Encapsulate communication and authentication logic between the Pinkoi Server Side and Socket.IO.
  • Simplify the complex operations of Socket.IO, providing an extensible and user-friendly interface based on Pinkoi’s business needs.
  • Standardize the interface across both platforms (the functionalities and interfaces supported by Socket.IO’s Android and iOS Client Side Libraries differ).
  • Ensure that the Feature Teams do not need to understand the Socket.IO mechanism.
  • Allow the Feature Teams to avoid managing complex connection states.
  • Enable future use of WebSocket bidirectional communication directly.

Time and Resources:

  • One developer for iOS and one for Android.
  • Development timeline: 3 weeks.

Technical Details

The feature will be supported across Web, iOS, and Android platforms. We plan to implement the WebSocket bidirectional communication protocol using the Socket.IO service on the backend.

First, it’s important to clarify that Socket is not the same as WebSocket.

For more information on the differences between Socket and WebSocket, refer to the following articles:

In short:

1
2
Socket is an abstract interface for TCP/UDP transport layers, while WebSocket is an application layer transport protocol.
The relationship between Socket and WebSocket is like that of a dog and a hot dog; they are unrelated.

Socket.IO is an abstraction layer over Engine.IO, which itself encapsulates the use of WebSocket. Each layer is responsible for communication between the layers above and below it, and direct operations (e.g., Socket.IO directly manipulating WebSocket connections) are not allowed.

In addition to basic WebSocket connections, Socket.IO/Engine.IO implements many convenient features (e.g., offline event sending mechanism, HTTP request-like mechanism, Room/Group mechanism, etc.).

The primary responsibility of the Platform Team is to bridge the logic between Socket.IO and the Pinkoi Server Side for the Feature Teams to use when developing functionalities.

Challenges with Socket.IO Swift Client

  • The library has not been updated for a long time (the latest version is from 2019), and it’s unclear if it is still maintained.
  • The Client and Server Side Socket.IO versions must align; the Server Side can include {allowEIO3: true} or the Client Side can specify the same version with .version, otherwise, connections will fail.
  • Naming conventions and interfaces often do not match the official examples.
  • The Socket.IO official examples are primarily based on web implementations, and the Swift Client does not necessarily support all the features described on the official site. During this implementation, we found that the iOS library does not implement the offline event sending mechanism (which we implemented ourselves; please read on).

It is recommended to test the desired mechanisms before adopting Socket.IO.

The Socket.IO Swift Client is a wrapper around the Starscream WebSocket Library, which can be used directly if necessary.

1
This concludes the background information; let's move on to the main topic.

Design Patterns

Design patterns are essentially common solutions to recurring problems in software design. It is not necessary to use design patterns to develop software, and not all design patterns are suitable for every scenario. There is no rule against creating new design patterns.

[The Catalog of Design Patterns](https://refactoring.guru/design-patterns/catalog){:target="_blank"}

The Catalog of Design Patterns

However, the existing design patterns (the 23 Gang of Four Design Patterns) are common knowledge in software design. When a specific pattern is mentioned, a corresponding architectural blueprint comes to mind, requiring little explanation. This also aids in future maintenance, as the methods have been validated by the industry, reducing the time spent on reviewing object dependencies. Choosing the appropriate pattern for the right scenario can lower communication and maintenance costs and enhance development efficiency.

Design patterns can be used in combination, but it is not advisable to modify existing patterns excessively or to apply them forcefully inappropriately (e.g., using the Chain of Responsibility pattern to create objects), as this can defeat their purpose and lead to misunderstandings for future maintainers.

The Design Patterns Discussed in This Article:

Each pattern will be explained in the context of its application, along with the reasons for its use.

This article focuses on the application of design patterns rather than the operations of Socket.IO. Some examples may be simplified for clarity and are not applicable to the actual Socket.IO encapsulation.

Due to space limitations, this article will not provide detailed explanations of each design pattern’s structure. Please click on the links for each pattern to understand their structures before continuing to read.

Demo code will be written in Swift.

Requirement Scenario 1

What?

  • Reuse the same connection object across different pages and objects using the same path.
  • The connection must be an abstract interface, not directly dependent on the Socket.IO object.

Why?

  • Reduce memory overhead and the time and bandwidth costs of repeated connections.
  • Allow for future replacement with other frameworks.

How?

  • Singleton Pattern: A creational pattern that ensures a class has only one instance and provides a global point of access to it.
  • Flyweight Pattern: A structural pattern that allows sharing of objects to support a large number of fine-grained objects efficiently.
  • Factory Pattern: A creational pattern that provides an interface for creating objects in a superclass but allows subclasses to alter the type of objects that will be created.

Practical Example:

  • Singleton Pattern: The ConnectionManager exists as a single object throughout the app lifecycle, managing the operations for obtaining connections.
  • Flyweight Pattern: The ConnectionPool serves as a shared pool for connections, providing a method to retrieve connections from this pool. The logic includes returning an existing connection from the pool if the URL path matches. The ConnectionHandler acts as an external operator and state manager for the Connection.
  • Factory Pattern: The ConnectionFactory works with the Flyweight Pattern to create a new connection when none is available in the pool.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
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

Requirement Scenario 2

What?

As mentioned in the technical details, the Socket.IO Swift Client’s Send Event does not support offline sending (unlike the Web/Android versions). Therefore, the iOS side needs to implement this functionality.

1
Interestingly, the Socket.IO Swift Client's onEvent does support offline subscriptions.

Why?

  • To unify functionality across platforms.
  • To make the code easier to understand.

How?

  • Command Pattern: A behavioral pattern that encapsulates a request as an object, thereby allowing for parameterization of clients with queues, requests, and operations.

  • Command Pattern: The SIOManager serves as the lowest-level encapsulation for communicating with Socket.IO. Its send and request methods operate on the Socket.IO Send Event. When the current Socket.IO is disconnected, the request parameters are placed into bufferedCommands, which are processed sequentially (First In First Out) once the connection is reestablished.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
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

Similarly, this can also be implemented for onEvent.

As an extension, the Proxy Pattern can be applied, viewing the buffer functionality as a type of proxy.

Requirement Scenario 3

What?

The connection has multiple states, with ordered transitions between states, and different operations allowed in each state.

Here’s the translated text in naturalistic English while preserving the original markdown image source:

  • Created: The object has been created, allowing transitions to Connected or directly to Disconnected.
  • Connected: The connection to Socket.IO is established, allowing a transition to Disconnected.
  • Disconnected: The connection to Socket.IO has been lost, allowing transitions to Reconnecting or Released.
  • Reconnecting: Attempting to reconnect to Socket.IO, allowing transitions to Connected or Disconnected.
  • Released: The object has been marked for garbage collection, and no operations or state transitions are allowed.

Why?

  • The logic and expressions for state transitions are not easy to understand.
  • Each state needs to restrict operational methods (e.g., when State = Released, calling Send Event is not allowed). Using simple if-else statements makes the code hard to maintain and read.

How?

  • Finite State Machine: SIOConnectionStateMachine is the implementation of the state machine, with currentSIOConnectionState representing the current state. The states created, connected, disconnected, reconnecting, and released are listed as possible transitions for this state machine. The method enterXXXState() throws implements the allowed and disallowed transitions (throwing an error) when entering a certain state from the current state.
  • State Pattern: SIOConnectionState is the abstract interface for all operations that will be used by the states.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
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!")

Scenario 3

What?

Combining scenarios 1 and 2, with the ConnectionPool as a flyweight pool and the State Pattern for state management, we further extend the functionality. As described in the background objectives, the feature side should not concern itself with the underlying connection mechanism. Therefore, we create a poller (named ConnectionKeeper) that periodically scans the ConnectionPool for strongly held Connections and performs operations under the following conditions:

  • If a Connection is in use and its state is not Connected: change its state to Reconnecting and attempt to reconnect.
  • If a Connection is not in use and its state is Connected: change its state to Disconnected.
  • If a Connection is not in use and its state is Disconnected: change its state to Released and remove it from the ConnectionPool.

Why?

  • The three operations have a hierarchical relationship and are mutually exclusive (i.e., disconnected can lead to either released or reconnecting).
  • This allows for flexible substitution and addition of operational conditions.
  • Without encapsulation, the three checks and operations would have to be written directly in the method (making it difficult to test the logic).
  • For example:
1
2
3
4
5
6
7
if !connection.isOccupie() && connection.state == .connected {
    ... connection.disconnected()
} else if !connection.isOccupie() && state == .released {
    ... connection.release()
} else if connection.isOccupie() && state == .disconnected {
    ... connection.reconnecting()
}

How?

  • Chain Of Responsibility: A behavioral pattern that, as the name suggests, forms a chain where each node has a corresponding operation. After inputting data, the node can decide whether to process it or pass it to the next node. Another real-world application is the iOS Responder Chain.

According to the definition, the Chain of Responsibility Pattern does not allow a node that has already taken responsibility for processing data to pass it on to the next node for further processing. If you do it, do it completely; otherwise, don’t do it at all.

For the above scenario, a more suitable pattern would be the Interceptor Pattern.

  • Chain of Responsibility: ConnectionKeeperHandler is the abstract node of the chain, specifically extracting the canExecute method to avoid the situation where a node processes the data but then wants to call the next node to continue execution. The handle method connects the nodes in the chain, and the execute method defines the logic for how to handle the operation. ConnectionKeeperHandlerContext is used to store relevant data, with isOccupie indicating whether the Connection is in use.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
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)
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class DisconnectedConnectionKeeperHandler: ConnectionKeeperHandler {
    var nextHandler: ConnectionKeeperHandler?
    
    func execute(context: ConnectionKeeperHandlerContext) {
        context.connection.disconnect()
    }
    
    func canExecute(context: ConnectionKeeperHandlerContext) -> Bool {
        if context.connection.connectionState == .connected && !context.isOccupied {
            return true
        }
        return false
    }
}

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

class ReleasedConnectionKeeperHandler: ConnectionKeeperHandler {
    var nextHandler: ConnectionKeeperHandler?
    
    func execute(context: ConnectionKeeperHandlerContext) {
        context.connection.disconnect()
    }
    
    func canExecute(context: ConnectionKeeperHandlerContext) -> Bool {
        if context.connection.connectionState == .disconnected && !context.isOccupied {
            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, isOccupied: false))

Scenario 4

What?

The Connection we encapsulated needs to be set up before use, such as providing a URL path, configuring settings, etc.

Why?

  • Allows for flexible addition or removal of construction openings.
  • Enables reuse of construction logic.
  • If not encapsulated, external entities can operate the class in unexpected ways.
  • e.g.:
1
2
3
4
5
6
7
8
❌
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: A creational pattern that allows for step-by-step construction of objects and reuse of construction methods.

  • Builder Pattern: SIOConnectionBuilder serves as the builder for Connection, responsible for setting and storing data needed to construct a Connection; the ConnectionConfiguration abstract interface ensures that .connect() must be called before obtaining a Connection instance.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
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 a 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()

Additionally, the Factory Pattern can also be applied here to produce SIOConnection through a factory.

Conclusion

This concludes the four scenarios encountered during the encapsulation of Socket.IO and the seven design patterns used to solve these problems.

Finally, here is the complete design blueprint for the Socket.IO encapsulation.

This diagram represents the actual design architecture, which may differ slightly from the naming and examples in the text. We hope to have the original designer share their design philosophy and open-source it in the future.

Who?

Who designed and is responsible for the Socket.IO encapsulation project?

Sean Zheng, Android Engineer @ Pinkoi

Main architect designer, evaluated and applied design patterns, implemented the design using Kotlin on the Android side.

ZhgChgLi, Engineer Lead/iOS Engineer @ Pinkoi

Project lead for the Platform Team, engaged in pair programming, implemented the design using Swift on the iOS side, discussed and raised questions (a.k.a. contributed ideas), and finally wrote this article to share with everyone.

Further Reading

If you have any questions or feedback, feel free to contact me.


This article was first published on Medium ➡️ Click Here

Automatically converted and synchronized using ZMediumToMarkdown and Medium-to-jekyll-starter.

Improve this page on Github.

Buy me a beer

3,615 Total Views
Last Statistics Date: 2025-03-11 | 3,387 Views on Medium.
This post is licensed under CC BY 4.0 by the author.