Record of Practical Application of Design Patterns
Record of problem scenarios encountered and solutions applied when encapsulating Socket.IO Client Library requirements using Design Patterns
Photo by Daniel McCullough
Preface
This article is a record of real-world development requirements, where Design Patterns were applied to solve problems. The content will cover the background of the requirements, the actual problem scenarios encountered (What?), why Design Patterns were applied to solve the problems (Why?), and how they were implemented (How?). It is recommended to read from the beginning for coherence.
This article will introduce four scenarios encountered in developing this requirement and the application of seven Design Patterns to solve these scenarios.
Background
Organizational Structure
This year, the company split into Feature Teams (multiple) and Platform Team; the former mainly focuses on user-side requirements, while the Platform Team deals with internal members of the company. One of their tasks is to introduce technology, build infrastructure, and ensure systematic integration to pave the way for Feature Teams when developing requirements.
Current Requirement
The Feature Teams needed to change the original messaging feature (fetching message data by calling APIs on the page, requiring a refresh for the latest messages) to real-time communication (receiving the latest messages instantly, and sending messages).
Platform Team’s Work
The Platform Team’s focus was not only on the immediate real-time communication requirement but also on long-term development and reusability. After evaluation, it was deemed essential to have a WebSocket bidirectional communication mechanism in modern apps. Apart from the current requirement, there will be many future opportunities to use this mechanism. With the available resources, efforts were put into designing and developing the interface.
Goals:
- Encapsulate communication between Pinkoi Server Side and Socket.IO, including authentication logic
- Simplify Socket.IO operations, providing an extensible and user-friendly interface based on Pinkoi’s business requirements
- Standardize the interface for both platforms (Socket.IO’s Android and iOS Client Side Libraries have different functionalities and interfaces)
- Feature side does not need to understand Socket.IO mechanisms
- Feature side does not need to manage complex connection states
- Future bidirectional communication requirements using WebSocket can be directly implemented
Time and Resources:
- One developer each for iOS and Android
- Development timeline: 3 weeks
Technical Details
This Feature will be supported on Web, iOS, and Android platforms. WebSocket bidirectional communication protocol will be introduced for implementation, with the backend expected to directly use Socket.io service.
Firstly, Socket != WebSocket
For more information on Socket and WebSocket and technical details, refer to the following two articles:
- Difference between Socket, WebSocket, and Socket.io
- Why not use socket directly and define a new websocket?
In short:
1
2
Socket is an abstract encapsulation interface for the TCP/UDP transport layer, while WebSocket is a transmission protocol at the application layer.
The relationship between Socket and WebSocket is like that of a dog and a hot dog, they are unrelated.
Socket.IO is a layer of abstract operation encapsulation for Engine.IO, which encapsulates the use of WebSocket. Each layer is only responsible for communication between the upper and lower layers and does not allow operations to pass through (e.g. Socket.IO directly operating WebSocket connections).
In addition to basic WebSocket connections, Socket.IO/Engine.IO also implements many convenient and useful feature sets (e.g. offline event sending mechanism, similar to HTTP request mechanism, room/group mechanism, etc.).
The main responsibility of the Platform Team is to bridge the logic between Socket.IO and Pinkoi Server Side for use by the upper Feature Teams during development.
Socket.IO Swift Client has pitfalls
- Has not been updated for a long time (latest version is still in 2019), unsure if it is still being maintained.
- Client & Server Side Socket IO Version must be aligned, Server Side can add
{allowEIO3: true}
/ or Client Side specify the same version.version
Otherwise, it won’t connect. - Naming conventions, interfaces, and many examples on the official website do not match.
- Socket.IO official website examples are based on web, but in reality, the Swift Client may not fully support the functionalities written on the website. In this implementation, we found that the iOS library did not implement the offline event sending mechanism (we implemented it ourselves, please continue reading)
It is recommended to experiment with the mechanisms you want to use before adopting Socket.IO.
Socket.IO Swift Client is based on Starscream WebSocket Library, and can be downgraded to use Starscream if necessary.
1
Background information supplement ends here, let's move on to the main topic.
Design Patterns
Design patterns are simply solutions to common problems in software design. You don’t necessarily have to use design patterns to develop; design patterns may not be applicable to all scenarios, and there’s no rule against deriving new design patterns on your own.
The Catalog of Design Patterns
However, existing design patterns (The 23 Gang of Four Design Patterns) are common knowledge in software design. Just mentioning an XXX Pattern will trigger a corresponding mental blueprint in everyone’s mind, without the need for much explanation. It is easier to understand the context for future maintenance, and these methods have been validated by the industry, so there’s no need to spend time examining object dependency issues. Choosing the right pattern for the right scenario can reduce communication and maintenance costs, and improve development efficiency.
Design patterns can be combined, but it is not recommended to modify existing design patterns, forcibly apply patterns that do not fit, or apply patterns that do not belong to the category (e.g. using the Chain of Responsibility pattern to create objects), as it may lose its meaning and potentially cause misunderstandings for future maintainers.
Design Patterns mentioned in this article:
- Singleton Pattern
- Flyweight Pattern
- Factory Pattern
- Command Pattern
- Finite-State Machine + State Pattern
- Chain Of Responsibility
- Builder Pattern
I will translate the content into English:
This article focuses on the application of Design Patterns, not the operation of Socket.IO. Some examples may be simplified for descriptive purposes and may not be applicable to real Socket.IO encapsulation.
Due to space limitations, this article will not provide detailed introductions to the architecture of each design pattern. Please click on the links for each pattern to understand its architecture before continuing to read.
Demo Code will be written in Swift.
Scenario 1.
What?
- Reuse the same Path to obtain the same object when requesting a Connection on different pages or Objects.
- The Connection should be an abstract interface and should not directly depend on the Socket.IO Object.
Why?
- Reduce memory overhead and the time and cost of repeated connections.
- Reserve space for future replacement with other frameworks.
How?
- Singleton Pattern: A creational pattern that ensures only one instance of an object.
- Flyweight Pattern: A structural pattern that shares the state of multiple objects and reuses them.
- Factory Pattern: A creational pattern that provides a method for creating abstract objects, allowing them to be swapped externally.
Real-world usage:
- Singleton Pattern:
ConnectionManager
exists as a single object in the App Lifecycle, used to manageConnection
operations. - Flyweight Pattern:
ConnectionPool
is a shared pool of Connections, where Connections are retrieved from this pool, and the logic includes providing an existing Connection when the URL Path matches.ConnectionHandler
acts as an external operator and state manager forConnection
. - Factory Pattern:
ConnectionFactory
works with the Flyweight Pattern. When no reusableConnection
is found in the pool, this factory interface is used to create one.
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
Scenario 2.
What?
As mentioned in the background technical details, the Send Event
of the Socket.IO Swift Client does not support offline sending (but the Web/Android versions of the library do), so iOS needs to implement this feature on its own.
1
Interestingly, the Socket.IO Swift Client - onEvent supports offline subscription.
Why?
- Unified cross-platform functionality
- Easy-to-understand code
How?
- Command Pattern: A behavioral pattern that encapsulates operations into objects, providing a collection of operations such as queuing, delaying, canceling, etc.
- Command Pattern:
SIOManager
is the lowest-level encapsulation for communicating with Socket.IO, where thesend
andrequest
methods are operations for Socket.IO Send Event. When the current Socket.IO is found to be disconnected, the request parameters are placed inbufferedCommands
, and when connected, they are processed one by one (First In First Out).
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 on onEvent
.
Extension: You can further apply the Proxy Pattern to treat Buffer functionality as a type of Proxy.
Scenario 3.
What?
The Connection has multiple states, with ordered states and transitions between states, allowing different operations in each state.
- Created: Object is created, allowing transition to
Connected
or directly toDisconnected
- Connected: Connected to Socket.IO, allowing transition to
Disconnected
- Disconnected: Disconnected from Socket.IO, allowing transition to
Reconnectiong
orReleased
- Reconnectiong: Attempting to reconnect to Socket.IO, allowing transition to
Connected
orDisconnected
- Released: Object marked for pending memory release, no operations or state transitions allowed
Why?
- The logic and representation of state transitions are not straightforward
- Restricting operations in each state (e.g., State = Released cannot Call Send Event) using if…else directly makes the code hard to maintain and read
How?
- Finite State Machine: Manages transitions between states
- State Pattern: Behavioral Pattern, provides different responses when the object’s state changes
- Finite State Machine:
SIOConnectionStateMachine
implements the state machine,currentSIOConnectionState
represents the current state, andcreated, connected, disconnected, reconnecting, released
list the possible state transitions of this state machine.enterXXXState() throws
implements the allowed and disallowed (throw error) actions when transitioning from the Current State to a specific state. - State Pattern:
SIOConnectionState
is the interface abstraction for all operations that states may use.
1
// Code block translated comments only, code remains in English
Combining scenarios 1 and 2, with the ConnectionPool
flyweight pool and State Pattern state management; we continue to extend as described in the background goals, the Feature side does not need to worry about the connection mechanism behind the Connection; therefore, we have created a poller (named ConnectionKeeper
) that will periodically scan the ConnectionPool
for actively held Connection
and perform operations when the following conditions occur:
- If a
Connection
is in use and the state is notConnected
: change the state toReconnecting
and attempt to reconnect. - If a
Connection
is not in use and the state isConnected
: change the state toDisconnected
. - If a
Connection
is not in use and the state isDisconnected
: change the state toReleased
and remove it from theConnectionPool
.
Why?
- The three operations have a logical order and are mutually exclusive (disconnected -> released or reconnecting).
- Flexibility to swap and add operational scenarios.
- Without encapsulation, one would have to directly write the three checks and operations in a method (difficult to test the logic within).
- e.g.:
1
2
3
4
5
6
7
if !connection.isOccupied() && connection.state == .connected then
... connection.disconnected()
else if !connection.isOccupied() && state == .released then
... connection.release()
else if connection.isOccupied() && state == .disconnected then
... connection.reconnecting()
end
How?
- Chain Of Responsibility: A behavioral pattern, as the name suggests, is a chain where each node has corresponding operations. After inputting data, a node can decide whether to operate or pass it to the next node for processing. Another real-world application is the iOS Responder Chain.
By definition, the Chain of Responsibility Pattern does not allow a node to take over processing data and then pass it to the next node to continue processing. Either do it completely or don’t do it at all.
If the above scenario is more suitable, it should be the Interceptor Pattern.
- Chain of responsibility:
ConnectionKeeperHandler
is an abstract node of the chain, specifically extracting thecanExecute
method to avoid the situation where this node takes over processing but then wants to call the next node to continue execution,handle
connects the nodes in the chain, andexecute
is the logic of how to handle the processing.ConnectionKeeperHandlerContext
is used to store data that will be used,isOccupied
indicates 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
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
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 isOccupied: Bool
}
protocol ConnectionKeeperHandler {
var nextHandler: ConnectionKeeperHandler? { get set }
func handle(context: ConnectionKeeperHandlerContext)
func execute(context: ConnectionKeeperHandlerContext)
func canExecute(context: ConnectionKeeperHandlerContext) -> Bool
}
extension ConnectionKeeperHandler {
func handle(context: ConnectionKeeperHandlerContext) {
if canExecute(context: context) {
execute(context: context)
} else {
nextHandler?.handle(context: context)
}
}
}
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))
Requirement Scenario 4.
What?
We need to go through the setup of the Connection
we encapsulated before using it, such as providing the URL Path, setting Config, etc.
Why?
- Flexibility to add or remove building interfaces
- Reusability of building logic
- Without encapsulation, external entities can operate on classes unexpectedly
- 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 step-by-step construction of objects and reuses construction methods.
- Builder Pattern:
SIOConnectionBuilder
is the builder forConnection
, responsible for setting and storing data needed to buildConnection
;ConnectionConfiguration
abstract interface ensures that.connect()
must be called before usingConnection
to get theConnection
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
95
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()
Extension: Here you can also apply the Factory Pattern, to produce SIOConnection
using a factory.
End!
The above are the four scenarios encountered in encapsulating Socket.IO and the seven Design Patterns used to solve the problems.
Finally, here is the complete design blueprint for encapsulating Socket.IO
Contrary to the naming and demonstration in the text, this image represents the actual design architecture; there may be an opportunity for the original designer to share design concepts and open source the project.
Who?
Who designed these and is responsible for the Socket.IO encapsulation project?
Sean Zheng, Android Engineer @ Pinkoi
Main architect, evaluation and application of Design Patterns, implementation of design in Kotlin on the Android side.
ZhgChgLi, Enginner Lead/iOS Enginner @ Pinkoi
Project lead of the Platform Team, Pair programming, implementation of design in Swift on the iOS side, discussion and raising questions (a.k.a. speaking up), and finally writing this article to share with everyone.
Further Reading
- Practical application records of Design Patterns — In WKWebView with Builder, Strategy & Chain of Responsibility Pattern
- Visitor Pattern in Swift (Share Object to XXX Example)
- Visitor Pattern in TableView
If you have any questions or suggestions, feel free to contact me.
===
===
This article was first published in Traditional Chinese on Medium ➡️ View Here