From 92aa73fd218baa5f6ae41d4e1ee9b9337bb09b8e Mon Sep 17 00:00:00 2001 From: zhgchgli Date: Sat, 4 Jun 2022 22:49:21 +0800 Subject: [PATCH] feat upgrade & bug fix --- Gemfile | 1 - Gemfile.lock | 3 - README.md | 3 + ZMediumToMarkdown.gemspec | 3 +- ZMediumToMarkdown_Github.gemspec | 5 +- ...5\200\213-design-patterns-78507a8de6a5.md" | 2306 ++++++++--------- lib/Models/Paragraph.rb | 33 +- lib/Parsers/BQParser.rb | 16 +- lib/Parsers/IframeParser.rb | 7 +- lib/Parsers/MIXTAPEEMBEDParser.rb | 4 +- lib/Parsers/MarkupParser.rb | 19 +- lib/Parsers/MarkupStyleRender.rb | 232 ++ lib/Post.rb | 21 +- lib/ZMediumFetcher.rb | 13 +- 14 files changed, 1464 insertions(+), 1202 deletions(-) create mode 100644 lib/Parsers/MarkupStyleRender.rb diff --git a/Gemfile b/Gemfile index 8842eb9..5aa0b6c 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,4 @@ source 'https://rubygems.org' gem 'net-http', '~> 0.1.0' gem 'nokogiri', '~> 1.13.1' -gem 'reverse_markdown', '~> 2.1.1' gem 'rubyzip', '~> 2.3.2' \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 944e33e..d6c8425 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,8 +9,6 @@ GEM nokogiri (1.13.6-x86_64-darwin) racc (~> 1.4) racc (1.6.0) - reverse_markdown (2.1.1) - nokogiri rubyzip (2.3.2) timeout (0.3.0) uri (0.11.0) @@ -21,7 +19,6 @@ PLATFORMS DEPENDENCIES net-http (~> 0.1.0) nokogiri (~> 1.13.1) - reverse_markdown (~> 2.1.1) rubyzip (~> 2.3.2) BUNDLED WITH diff --git a/README.md b/README.md index 9f0cad1..8974161 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This project can help you to make an auto-sync or auto-backup service from Mediu ## Features - [X] Support download post and convert to markdown format - [X] Support download all posts and convert to markdown format from any user without login access. +- [X] Support download paid content - [X] Support command line interface - [X] Download all of post's images to local and convert to local path - [X] Convert [Gist](https://gist.github.com/) source code to markdown code block @@ -17,6 +18,8 @@ This project can help you to make an auto-sync or auto-backup service from Mediu - [X] Auto skip when post has been downloaded and last modification date from Medium doesn't changed (convenient for auto-sync or auto-backup service, to save server's bandwidth and execution time) - [X] [Support using Github Action as auto sync/backup service](https://github.com/ZhgChgLi/ZMediumToMarkdown/tree/main#using-github-action-as-your-free-auto-syncbackup-service) - [X] Highly optimized markdown format for Medium +- [X] Native Markdown Style Render Engine +(Feel free to contribute if you any optimize idea! `MarkupStyleRender.rb`) ## Result - [Original post on Medium](https://medium.com/pinkoi-engineering/%E5%AF%A6%E6%88%B0%E7%B4%80%E9%8C%84-4-%E5%80%8B%E5%A0%B4%E6%99%AF-7-%E5%80%8B-design-patterns-78507a8de6a5) diff --git a/ZMediumToMarkdown.gemspec b/ZMediumToMarkdown.gemspec index 7eed2ba..5dcf791 100644 --- a/ZMediumToMarkdown.gemspec +++ b/ZMediumToMarkdown.gemspec @@ -6,12 +6,11 @@ Gem::Specification.new do |gem| gem.files = Dir['lib/**/*.*'] gem.executables = ['ZMediumToMarkdown'] gem.name = 'ZMediumToMarkdown' - gem.version = '1.5.0' + gem.version = '1.6.0' gem.license = "MIT" gem.add_dependency 'nokogiri', '~> 1.13.1' - gem.add_dependency 'reverse_markdown', '~> 2.1.1' gem.add_dependency 'net-http', '~> 0.1.0' gem.add_dependency 'rubyzip', '~> 2.3.2' end \ No newline at end of file diff --git a/ZMediumToMarkdown_Github.gemspec b/ZMediumToMarkdown_Github.gemspec index 7eed2ba..7cd7bf2 100644 --- a/ZMediumToMarkdown_Github.gemspec +++ b/ZMediumToMarkdown_Github.gemspec @@ -5,13 +5,12 @@ Gem::Specification.new do |gem| gem.homepage = 'https://github.com/ZhgChgLi/ZMediumToMarkdown' gem.files = Dir['lib/**/*.*'] gem.executables = ['ZMediumToMarkdown'] - gem.name = 'ZMediumToMarkdown' - gem.version = '1.5.0' + gem.name = 'zmediumtomarkdown' + gem.version = '1.6.0' gem.license = "MIT" gem.add_dependency 'nokogiri', '~> 1.13.1' - gem.add_dependency 'reverse_markdown', '~> 2.1.1' gem.add_dependency 'net-http', '~> 0.1.0' gem.add_dependency 'rubyzip', '~> 2.3.2' end \ No newline at end of file diff --git "a/example/\345\257\246\346\210\260\347\264\200\351\214\204-4-\345\200\213\345\240\264\346\231\257-7-\345\200\213-design-patterns-78507a8de6a5.md" "b/example/\345\257\246\346\210\260\347\264\200\351\214\204-4-\345\200\213\345\240\264\346\231\257-7-\345\200\213-design-patterns-78507a8de6a5.md" index 83fd5c5..9231072 100644 --- "a/example/\345\257\246\346\210\260\347\264\200\351\214\204-4-\345\200\213\345\240\264\346\231\257-7-\345\200\213-design-patterns-78507a8de6a5.md" +++ "b/example/\345\257\246\346\210\260\347\264\200\351\214\204-4-\345\200\213\345\240\264\346\231\257-7-\345\200\213-design-patterns-78507a8de6a5.md" @@ -1,1161 +1,1151 @@ ---- -title: Design Patterns 的實戰應用紀錄 -author: ZhgChgLi -date: 2022-04-07T22:49:17.715Z -tags: [ios-app-development,design-patterns,socketio,websocket,finite-state-machine] ---- - -### Design Patterns 的實戰應用紀錄 - -封裝 Socket.IO Client Library 需求時遇到的問題場景及解決方法應用到的 Design Patterns -![Photo by Daniel McCullough](images/78507a8de6a5/1*mkG0YtCzyPQpU9MG0HI79w.jpeg "Photo by Daniel McCullough") -### 前言 - -此篇文章是真實的需求開發,所運用到 Design Pattern 解決問題的場景記錄;內容篇幅會涵蓋需求背景、實際遇到的問題場景 (What?)、為何要套用 Pattern 解決問題 (Why?)、實作上如何使用 (How?),建議可以從頭閱讀會比較有連貫性。 -> _本文會介紹四個開發此需求遇到的場景及七個解決此場景的 Design Patterns 應用。_ -### 背景 -#### 組織架構 - -敝司於今年拆分出 Feature Teams (multiple) 與 Platform Team;前者不必多說主要負責使用者端需求、Platform Team 這邊則面對的是公司內部的成員,其中一個工作項目就是技術引入、基礎建設及做好系統性整合,為 Feature Teams 開發需求時先鋒鋪好道路。 -#### 當前需求 - -Feature Teams 要將原本的訊息功能 (進頁面打 API 拿訊息資料,要更新最新訊息只能重整) 改為 即時通訊 (能即時收到最新訊息、對傳訊息)。 -#### Platform Team 工作 - -Platform Team 著重的點不只是當下的即時通訊需求,而是長遠的建設與複用性;評估後 webSocket 雙向通訊的機制在現代 App 中是不可或缺,除了此次的需求之外,以後也有很多機會都會用到,加上人力資源許可,故投入協助設計開發介面。 - - **目標:** -- 封裝 Pinkoi Server Side 與 Socket.IO 通訊、身份驗證邏輯 -- 封裝 Socket.IO 煩瑣操作,提供基於 Pinkoi 商業需求的可擴充及方便使用介面 -- 統一雙平台介面 **(Socket.IO 的 Android 與 iOS Client Side Library 支援的功能及介面不相同)** - -- Feature 端無需了解 Socket.IO 機制 -- Feature 端無需管理複雜的連線狀態 -- 未來有 webSocket 雙向通訊需求能直接使用 - - - **時間及人力:** -- iOS & Android 各投入一位 -- 開發時程:時程 3 週 - -#### 技術細節 - -Web & iOS & Android 三平台均會支援此 Feature;要引入 webSocket 雙向通訊協議來實現,後端預計直接使用 [Socket.io](http://socket.io/) 服務。 - -> **_首先要說 Socket != WebSocket_** - -關於 Socket 與 WebSocket 及技術細節可參考以下兩篇文章: -- [Socket,Websocket,Socket.io的差異](https://leesonhsu.blogspot.com/2018/07/socketwebsocketsocketio.html) -- [为什么不直接使用socket ,还要定义一个新的websocket 的呢?](https://github.com/onlyliuxin/coding2017/issues/497) - - -簡而言之: -``` -Socket 是 TCP/UDP 傳輸層的抽象封裝介面,而 WebSocket 是應用層的傳輸協議。 -Socket 與 WebSocket 的關係就像狗跟熱狗的關係一樣, **沒有關係** 。 - - -``` -![](images/78507a8de6a5/1*MC_nQC382khMeWggLejWOA.jpeg "") - -Socket.IO 是 Engine.IO 的一層抽象操作封裝,Engine.IO 則是對 WebSocket 的使用封裝,每層只負責對上對下之間的交流,不允許貫穿操作(e.g. Socket.IO 直接操作 WebSocket 連線)。 - -Socket.IO/Engine.IO 除了基本的 WebSocket 連線外還實做了很多方便好用的功能集合(e.g. 離線發送 Event 機制、類似 Http Request 機制、Room/Group 機制…等等)。 - -Platform Team 這層的主要職責是橋接 Socket.IO 與 Pinkoi Server Side 之間的邏輯,供應上層 Feature Teams 開發功能時使用。 -#### [Socket.IO Swift Client](https://github.com/socketio/socket.io-client-swift) 有坑 -- 已許久未更新 (最新一版還在 2019),不確定是否還有在維護。 -- Client & Server Side Socket IO Version 要對齊,Server Side 可加上 `{allowEIO3: true}` / 或 Client Side 指定相同版本 `.version`否則怎麼連都連不上。 - -- 命名方式、介面與官網範例很多都對不起來。 -- Socket.io 官網範例都是拿 Web 做介紹,實際上 Swift Client **並不一定有全支援官網寫的功能** 。 -此次實作發現 iOS 這邊 Library 並未實現離線發送 Event 機制 -(我們是自行實現的,請往後繼續閱讀) - - -> **_建議有要採用 Socket.IO 前先實驗看看你想要的機制是否支援。_** -> _Socket.IO Swift Client 是基於_ [**_Starscream_**](https://github.com/daltoniam/Starscream) _WebSocket Library 的封裝,必要時可降級使用_ _Starscream。_ -``` -背景資訊補充到此結束,接下來進入正題。 -``` -### Design Patterns - -設計模式說穿了就只是軟體設計當中常見問題的解決方案,不一定要用設計模式才能開發、設計模式不一定能適用所有場景、也沒人說不能自行歸納出新的設計模式。 -![The Catalog of Design Patterns](images/78507a8de6a5/1*MAm5WPynbv7M9tdmW2lNGQ.jpeg "The Catalog of Design Patterns") - -但現有的設計模式 (The 23 Gang of Four Design Patterns) 已是軟體設計中的共同知識,只要提到 XXX Pattern 大家腦中就會有相應的架構藍圖,不需多做解釋、後續維護也比較好知道脈絡、且已是經過業界驗證的方法不太需要花時間審視物件依賴問題;在適合的場景選用適合的模式可以降低溝通及維護成本,提升開發效率。 -> **_設計模式可以組合使用,但不建議對現有設計模式魔改、強行為套用而套用、套用不符合分類的 Pattern (e.g. 用責任練模式來產生物件),會失去使用的意義更可能造成後續接手的人的誤會。_** -#### 本篇會提到的 Design Patterns: -- [Singleton Pattern](https://refactoring.guru/design-patterns/singleton) -- [Flywieght Pattern](https://refactoring.guru/design-patterns/flyweight) -- [Factory Pattern](https://refactoring.guru/design-patterns/factory-method) -- [Command Pattern](https://refactoring.guru/design-patterns/command) -- [Finite-State Machine](https://en.wikipedia.org/wiki/Finite-state_machine) + [State Pattern](https://refactoring.guru/design-patterns/state) -- [Chain Of Resposibility](https://refactoring.guru/design-patterns/chain-of-responsibility) -- [Builder Pattern](https://refactoring.guru/design-patterns/builder) - - -會逐一在後面解釋什麼場境用了、為何要用。 -> _本文著重在 Design Pattern 的應用,而非 Socket.IO 的操作,部分示例會因為描述方便而有所刪減,_ **_無法適用真實的 Socket.IO 封裝_** _。_ -> _因篇幅有限,本文不會詳細介紹每個設計模式的架構,請先點各個模式的連結進入了解該模式的架構後再繼續閱讀。_ -> _Demo Code 會使用 Swift 撰寫。_ -### 需求場景 1. -#### What? -- 使用相同的 Path 在不同頁面、Object 請求 Connection 時能複用取得相同的物件。 -- Connection 需為抽象介面,不直接依賴 Socket.IO Object - -#### Why? -- 減少記憶體開銷及重複連線的時間、流量成本。 -- 為未來抽換成其他框架預留空間 - -#### How? -- [Singleton Pattern](https://refactoring.guru/design-patterns/singleton):創建型 Pattern,保證一個物件只會有一個實體。 -- [Flywieght Pattern](https://refactoring.guru/design-patterns/flyweight):結構型 Pattern,基於共享多個物件相同的狀態,重複使用。 -- [Factory Pattern](https://refactoring.guru/design-patterns/factory-method):創建型 Pattern,抽象物件產生方法,使其能在外部抽換。 - - - **實際案例使用:** -![](images/78507a8de6a5/1*flQa_EfErGBwbmEwpI7ZgQ.png "") -- **Singleton Pattern:** `ConnectionManager`在 App Lifecycle 中僅存在一個的物件,用來管理 `Connection` 取用操作。 -- **Flywieght Pattern:** `ConnectionPool` 顧名思義就是 Connection 的共用池子,統一從這個池子的方法拿出 Connection,其中邏輯就會包含當發現 URL Path 一樣時直接給予已經在池子裡的 Connection。 -`ConnectionHandler` 則做為 `Connection` 的外在操作、狀態管理器。 -- **Factory Pattern:** `ConnectionFactory` 搭配上面 Flywieght Pattern 當發現池子沒有可複用的 `Connection` 時則用此工廠介面去產生。 - -```Swift -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 -} - -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 { - // - return PassthroughSubject().eraseToAnyPublisher() - } -} - -class SIOConnectionFactory: ConnectionFactory { - func create(url: URL) -> Connection { - // - return SIOConnection(url: url) - } -} -// - -print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: "wss://pinkoi.com/1")!).getConnectionUUID().uuidString) -print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: "wss://pinkoi.com/1")!).getConnectionUUID().uuidString) - -print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: "wss://pinkoi.com/2")!).getConnectionUUID().uuidString) - -// output: -// D99F5429-1C6D-4EB5-A56E-9373D6F37307 -// D99F5429-1C6D-4EB5-A56E-9373D6F37307 -// 599CF16F-3D7C-49CF-817B-5A57C119FE31 -``` -### 需求場景 2. -#### What? - -如背景技術細節所述,Socket.IO Swift Client 的 `Send Event` 並不支援離線發送 (但 Web/Android 版的 Library 卻可以),因此 iOS 端需要自行實現此功能。 - -``` -神奇的是 Socket.IO Swift Client - onEvent 是支援離線訂閱的。 -``` -#### Why? -- 跨平台功能統一 -- 程式碼容易理解 - -#### How? -- [Command Pattern](https://refactoring.guru/design-patterns/command):行為型 Pattern,將操作包裝成對象,提供隊列、延遲、取消…等等集合操作。 - -![](images/78507a8de6a5/1*O9zc28nMx64HDiDy4aiexA.png "") -- **Command Pattern:** `SIOManager` 為與 Socket.IO 溝通的最底層封裝,其中的 `send` 、`request` 方法都是對 Socket.IO Send Event 的操作,當發現當前 Socket.IO 處於斷線狀態,則將請求參數放到`bufferedCommands` 中,當連上之後就逐一拿出來處理 (First In First Out)。 - -```Swift -protocol BufferedCommand { - var sioManager: SIOManagerSpec? { get set } - var event: String { get } - - func execute() -} - -struct SendBufferedCommand: BufferedCommand { - let event: String - weak var sioManager: SIOManagerSpec? - - func execute() { - sioManager?.send(event) - } -} - -struct RequestBufferedCommand: BufferedCommand { - let event: String - let callback: (Data?) -> Void - weak var sioManager: SIOManagerSpec? - - func execute() { - sioManager?.request(event, callback: callback) - } -} - -protocol SIOManagerSpec: AnyObject { - func connect() - func disconnect() - func onEvent(event: String, callback: @escaping (Data?) -> Void) - func send(_ event: String) - func request(_ event: String, callback: @escaping (Data?) -> Void) -} - -enum ConnectionState { - case created - case connected - case disconnected - case reconnecting - case released -} - -class SIOManager: SIOManagerSpec { - - var state: ConnectionState = .disconnected { - didSet { - if state == .connected { - executeBufferedCommands() - } - } - } - - private var bufferedCommands: [BufferedCommand] = [] - - func connect() { - state = .connected - } - - func disconnect() { - state = .disconnected - } - - func send(_ event: String) { - guard state == .connected else { - appendBufferedCommands(connectionCommand: SendBufferedCommand(event: event, sioManager: self)) - return - } - - print("Send:\(event)") - } - - func request(_ event: String, callback: @escaping (Data?) -> Void) { - guard state == .connected else { - appendBufferedCommands(connectionCommand: RequestBufferedCommand(event: event, callback: callback, sioManager: self)) - return - } - - print("request:\(event)") - } - - func onEvent(event: String, callback: @escaping (Data?) -> Void) { - // - } - - func appendBufferedCommands(connectionCommand: BufferedCommand) { - bufferedCommands.append(connectionCommand) - } - - func executeBufferedCommands() { - // First in, first out - bufferedCommands.forEach { connectionCommand in - connectionCommand.execute() - } - bufferedCommands.removeAll() - } - - func removeAllBufferedCommands() { - bufferedCommands.removeAll() - } -} - -let manager = SIOManager() -manager.send("send_event_1") -manager.send("send_event_2") -manager.request("request_event_1") { _ in - // -} -manager.state = .connected -``` - -同理也可以實現到 `onEvent` 上。 - - -延伸:可以再套用 [Proxy Pattern](https://refactoring.guru/design-patterns/proxy),將 Buffer 功能視為一種 Proxy。 - -### 需求場景 3. -#### What? - -Connection 有多個狀態,有序的狀態與狀態間切換、各狀態允許不同的操作。 -![](images/78507a8de6a5/1*DBl6K1cPQc_cHOYXZ1VQ8A.jpeg "") -![](images/78507a8de6a5/1*-Xk_TT6SMW5Jxd-c8iSCcw.jpeg "") -- Created:物件被建立,允許 -\> `Connected` 或直接進 `Disconnected` - -- Connected:已連上 Socket.IO,允許 -\> `Disconnected` - -- Disconnected:已與 Socket.IO 斷線,允許 -\> `Reconnectiong`、`Released` - -- Reconnectiong:正在嘗試重新連上 Socket.IO,允許 -\> `Connected`、`Disconnected` - -- Released:物件已被標示為等待被記憶體回收,不允許任何操作及切換狀態 - -#### Why? -- 狀態與狀態的切換邏輯跟表述不容易 -- 各狀態要限制操作方法(e.g. State = Released 時無法 Call Send Event),直接使用 if..else 會讓程式難以維護閱讀 - -#### How? -- [Finite State Machine](https://en.wikipedia.org/wiki/Finite-state_machine):管理狀態間的切換 -- [State Pattern](https://refactoring.guru/design-patterns/state):行為型 Pattern,對象的狀態有變化時,有不同的相應處理 - -![](images/78507a8de6a5/1*NgehABZTiXL_fFEYQh63Hg.png "") -- **Finite State Machine** :`SIOConnectionStateMachine` 為狀態機實作,`currentSIOConnectionState` 為當前狀態,`created、connected、disconnected、reconnecting、released` 表列出此狀態機可能的切換狀態。 -`enterXXXState() throws` 為從 Current State 進入某個狀態時的允許與不允許(throw error)實作。 -- **State Pattern** :`SIOConnectionState` 為所有狀態會用到的操作方法介面抽象。 - -```Swift -protocol SIOManagerSpec: AnyObject { - func connect() - func disconnect() - func onEvent(event: String, callback: @escaping (Data?) -> Void) - func send(_ event: String) - func request(_ event: String, callback: @escaping (Data?) -> Void) -} - -enum ConnectionState { - case created - case connected - case disconnected - case reconnecting - case released -} - -class SIOManager: SIOManagerSpec { - - var state: ConnectionState = .disconnected { - didSet { - if state == .connected { - executeBufferedCommands() - } - } - } - - private var bufferedCommands: [BufferedCommand] = [] - - func connect() { - state = .connected - } - - func disconnect() { - state = .disconnected - } - - func send(_ event: String) { - guard state == .connected else { - appendBufferedCommands(connectionCommand: SendBufferedCommand(event: event, sioManager: self)) - return - } - - print("Send:\(event)") - } - - func request(_ event: String, callback: @escaping (Data?) -> Void) { - guard state == .connected else { - appendBufferedCommands(connectionCommand: RequestBufferedCommand(event: event, callback: callback, sioManager: self)) - return - } - - print("request:\(event)") - } - - func onEvent(event: String, callback: @escaping (Data?) -> Void) { - // - } - - func appendBufferedCommands(connectionCommand: BufferedCommand) { - bufferedCommands.append(connectionCommand) - } - - func executeBufferedCommands() { - // First in, first out - bufferedCommands.forEach { connectionCommand in - connectionCommand.execute() - } - bufferedCommands.removeAll() - } - - func removeAllBufferedCommands() { - bufferedCommands.removeAll() - } -} - -let manager = SIOManager() -manager.send("send_event_1") -manager.send("send_event_2") -manager.request("request_event_1") { _ in - // -} -manager.state = .connected - -// - -class SIOConnectionStateMachine { - - private(set) var currentSIOConnectionState: SIOConnectionState! - - private var created: SIOConnectionState! - private var connected: SIOConnectionState! - private var disconnected: SIOConnectionState! - private var reconnecting: SIOConnectionState! - private var released: SIOConnectionState! - - init() { - self.created = SIOConnectionCreatedState(stateMachine: self) - self.connected = SIOConnectionConnectedState(stateMachine: self) - self.disconnected = SIOConnectionDisconnectedState(stateMachine: self) - self.reconnecting = SIOConnectionReconnectingState(stateMachine: self) - self.released = SIOConnectionReleasedState(stateMachine: self) - - self.currentSIOConnectionState = created - } - - func enterConnected() throws { - if [created.connectionState, reconnecting.connectionState].contains(currentSIOConnectionState.connectionState) { - enter(connected) - } else { - throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) can't enter to Connected") - } - } - - func enterDisconnected() throws { - if [created.connectionState, connected.connectionState, reconnecting.connectionState].contains(currentSIOConnectionState.connectionState) { - enter(disconnected) - } else { - throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) can't enter to Disconnected") - } - } - - func enterReconnecting() throws { - if [disconnected.connectionState].contains(currentSIOConnectionState.connectionState) { - enter(reconnecting) - } else { - throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) can't enter to Reconnecting") - } - } - - func enterReleased() throws { - if [disconnected.connectionState].contains(currentSIOConnectionState.connectionState) { - enter(released) - } else { - throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) can't enter to Released") - } - } - - private func enter(_ state: SIOConnectionState) { - currentSIOConnectionState = state - } -} - - -protocol SIOConnectionState { - var connectionState: ConnectionState { get } - var stateMachine: SIOConnectionStateMachine { get } - init(stateMachine: SIOConnectionStateMachine) - - func onConnected() throws - func onDisconnected() throws - - - func connect(socketManager: SIOManagerSpec) throws - func disconnect(socketManager: SIOManagerSpec) throws - func release(socketManager: SIOManagerSpec) throws - - func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws - func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws - func send(socketManager: SIOManagerSpec, event: String) throws -} - -struct SIOConnectionStateMachineError: Error { - let message: String - - init(_ message: String) { - self.message = message - } - - var localizedDescription: String { - return message - } -} - -class SIOConnectionCreatedState: SIOConnectionState { - - let connectionState: ConnectionState = .created - let stateMachine: SIOConnectionStateMachine - - required init(stateMachine: SIOConnectionStateMachine) { - self.stateMachine = stateMachine - } - - func onConnected() throws { - try stateMachine.enterConnected() - } - - func onDisconnected() throws { - try stateMachine.enterDisconnected() - } - - func release(socketManager: SIOManagerSpec) throws { - throw SIOConnectionStateMachineError("ConnectedState can't release!") - } - - func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { - // allow - // can use Helper to reduce the repeating code - // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) - } - - func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { - // allow - // can use Helper to reduce the repeating code - // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) - } - - func send(socketManager: SIOManagerSpec, event: String) throws { - // allow - // can use Helper to reduce the repeating code - // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) - } - - func connect(socketManager: SIOManagerSpec) throws { - // allow - // can use Helper to reduce the repeating code - // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) - } - - func disconnect(socketManager: SIOManagerSpec) throws { - throw SIOConnectionStateMachineError("CreatedState can't disconnect!") - } -} - -class SIOConnectionConnectedState: SIOConnectionState { - - let connectionState: ConnectionState = .connected - let stateMachine: SIOConnectionStateMachine - - required init(stateMachine: SIOConnectionStateMachine) { - self.stateMachine = stateMachine - } - - func onConnected() throws { - // - } - - func onDisconnected() throws { - try stateMachine.enterDisconnected() - } - - func release(socketManager: SIOManagerSpec) throws { - throw SIOConnectionStateMachineError("ConnectedState can't release!") - } - - func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { - // allow - // can use Helper to reduce the repeating code - // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) - } - - func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { - // allow - // can use Helper to reduce the repeating code - // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) - } - - func send(socketManager: SIOManagerSpec, event: String) throws { - // allow - // can use Helper to reduce the repeating code - // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) - } - - func connect(socketManager: SIOManagerSpec) throws { - throw SIOConnectionStateMachineError("ConnectedState can't connect!") - } - - func disconnect(socketManager: SIOManagerSpec) throws { - // allow - // can use Helper to reduce the repeating code - // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) - } -} - -class SIOConnectionDisconnectedState: SIOConnectionState { - - let connectionState: ConnectionState = .disconnected - let stateMachine: SIOConnectionStateMachine - - required init(stateMachine: SIOConnectionStateMachine) { - self.stateMachine = stateMachine - } - - func onConnected() throws { - try stateMachine.enterConnected() - } - - func onDisconnected() throws { - // - } - - func release(socketManager: SIOManagerSpec) throws { - try stateMachine.enterReleased() - // allow - // can use Helper to reduce the repeating code - // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) - } - - func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { - // allow - // can use Helper to reduce the repeating code - // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) - } - - func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { - // allow - // can use Helper to reduce the repeating code - // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) - } - - func send(socketManager: SIOManagerSpec, event: String) throws { - // allow - // can use Helper to reduce the repeating code - // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) - } - - func connect(socketManager: SIOManagerSpec) throws { - try stateMachine.enterReconnecting() - // allow - // can use Helper to reduce the repeating code - // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) - } - - func disconnect(socketManager: SIOManagerSpec) throws { - // allow - // can use Helper to reduce the repeating code - // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) - } -} - -class SIOConnectionReconnectingState: SIOConnectionState { - - let connectionState: ConnectionState = .reconnecting - let stateMachine: SIOConnectionStateMachine - - required init(stateMachine: SIOConnectionStateMachine) { - self.stateMachine = stateMachine - } - - func onConnected() throws { - try stateMachine.enterConnected() - } - - func onDisconnected() throws { - try stateMachine.enterDisconnected() - } - - func release(socketManager: SIOManagerSpec) throws { - throw SIOConnectionStateMachineError("ReconnectState can't release!") - } - - func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { - // allow - // can use Helper to reduce the repeating code - // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) - } - - func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { - // allow - // can use Helper to reduce the repeating code - // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) - } - - func send(socketManager: SIOManagerSpec, event: String) throws { - // allow - // can use Helper to reduce the repeating code - // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) - } - - func connect(socketManager: SIOManagerSpec) throws { - throw SIOConnectionStateMachineError("ReconnectState can't connect!") - } - - func disconnect(socketManager: SIOManagerSpec) throws { - // allow - // can use Helper to reduce the repeating code - // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) - } -} - -class SIOConnectionReleasedState: SIOConnectionState { - - let connectionState: ConnectionState = .released - let stateMachine: SIOConnectionStateMachine - - required init(stateMachine: SIOConnectionStateMachine) { - self.stateMachine = stateMachine - } - - func onConnected() throws { - throw SIOConnectionStateMachineError("ReleasedState can't onConnected!") - } - - func onDisconnected() throws { - throw SIOConnectionStateMachineError("ReleasedState can't onDisconnected!") - } - - func release(socketManager: SIOManagerSpec) throws { - throw SIOConnectionStateMachineError("ReleasedState can't release!") - } - - func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { - throw SIOConnectionStateMachineError("ReleasedState can't request!") - } - - func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { - throw SIOConnectionStateMachineError("ReleasedState can't receiveOn!") - } - - func send(socketManager: SIOManagerSpec, event: String) throws { - throw SIOConnectionStateMachineError("ReleasedState can't send!") - } - - func connect(socketManager: SIOManagerSpec) throws { - throw SIOConnectionStateMachineError("ReleasedState can't connect!") - } - - func disconnect(socketManager: SIOManagerSpec) throws { - throw SIOConnectionStateMachineError("ReleasedState can't disconnect!") - } -} - -do { - let stateMachine = SIOConnectionStateMachine() - // mock on socket.io connect: - // socketIO.on(connect){ - try stateMachine.currentSIOConnectionState.onConnected() - try stateMachine.currentSIOConnectionState.send(socketManager: manager, event: "test") - try stateMachine.currentSIOConnectionState.release(socketManager: manager) - try stateMachine.currentSIOConnectionState.send(socketManager: manager, event: "test") - // } -} catch { - print("error: \(error)") -} - -// output: -// error: SIOConnectionStateMachineError(message: "ConnectedState can\'t release!") -``` -### 需求場景 3. -#### What? - -結合場景 1. 2.,有了 `ConnectionPool` 享元池子加上 State Pattern 狀態管理後;我們繼續往下延伸,如背景目標所述,Feature 端不需去管背後 Connection 的連線機制;因此我們建立了一個輪詢器 (命名為 `ConnectionKeeper`) 會定時掃描 `ConnectionPool` 中強持有的 `Connection`,並在發生以下狀況時做操作: - -- `Connection` 有人在使用且狀態非 `Connected`:將狀態改為 `Reconnecting` 並嘗試重新連線 -- `Connection` 已無人使用且狀態為 `Connected`:將狀態改為 `Disconnected` -- `Connection` 已無人使用且狀態為 `Disconnected`:將狀態改為 `Released` 並從 `ConnectionPool` 中移除 - -#### Why? -- 三個操作有上下關係且互斥 (disconnected -> released or reconnecting) -- 可彈性抽換、增加狀況操作 -- 未封裝的話只能將三個判斷及操作直接寫在方法中 (難以測試其中邏輯) -- e.g: - -``` -if !connection.isOccupie() && connection.state == .connected then -... connection.disconnected() -else if !connection.isOccupie() && state == .released then -... connection.release() -else if connection.isOccupie() && state == .disconnected then -... connection.reconnecting() -end -``` -#### How? -- [Chain Of Resposibility](https://refactoring.guru/design-patterns/chain-of-responsibility):行為型 Pattern,顧名思義是一條鏈,每個節點都有相應的操作,輸入資料後節點可決定是否要操作還是丟給下一個節點處理,另一個現實應用是 [iOS Responder Chain](https://swiftrocks.com/understanding-the-ios-responder-chain)。 - -> _照定義 Chain of responsibility Pattern 是不允許某個節點已經接下處理資料,但處理完又丟給下一個節點繼續處理,_ **_要做就做完,不然不要做_** _。 -> 如果是上述場景比較適合的應該是_ [_Interceptor Pattern_](https://stackoverflow.com/questions/7951306/chain-of-responsibility-vs-interceptor)_。_ -![](images/78507a8de6a5/1*e8jHpykN1m3Y66Ukf-5OJA.png "") -- **Chain of responsibility:** `ConnectionKeeperHandler` 為鍊的節點抽象,特別抽出 `canExcute` 方法避免發生上述 這個節點接下來處理了,但做完又想呼叫後面的節點繼續執行的狀況、`handle` 為鍊的節點串連、`excute` 為要處理的話會怎麼處理的邏輯。 -`ConnectionKeeperHandlerContext` 用來存放會用到的資料,`isOccupie` 代表 Connection 有無人在使用。 - -```Swift -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 -} - -// 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 { - // - return PassthroughSubject().eraseToAnyPublisher() - } -} - -// - -struct ConnectionKeeperHandlerContext { - let connection: Connection - let isOccupie: Bool -} - -protocol ConnectionKeeperHandler { - var nextHandler: ConnectionKeeperHandler? { get set } - - func handle(context: ConnectionKeeperHandlerContext) - func execute(context: ConnectionKeeperHandlerContext) - func canExcute(context: ConnectionKeeperHandlerContext) -> Bool -} - -extension ConnectionKeeperHandler { - func handle(context: ConnectionKeeperHandlerContext) { - if canExcute(context: context) { - execute(context: context) - } else { - nextHandler?.handle(context: context) - } - } -} - -class DisconnectedConnectionKeeperHandler: ConnectionKeeperHandler { - var nextHandler: ConnectionKeeperHandler? - - func execute(context: ConnectionKeeperHandlerContext) { - context.connection.disconnect() - } - - func canExcute(context: ConnectionKeeperHandlerContext) -> Bool { - if context.connection.connectionState == .connected && !context.isOccupie { - return true - } - return false - } -} - -class ReconnectConnectionKeeperHandler: ConnectionKeeperHandler { - var nextHandler: ConnectionKeeperHandler? - - func execute(context: ConnectionKeeperHandlerContext) { - context.connection.reconnect() - } - - func canExcute(context: ConnectionKeeperHandlerContext) -> Bool { - if context.connection.connectionState == .disconnected && context.isOccupie { - return true - } - return false - } -} - -class ReleasedConnectionKeeperHandler: ConnectionKeeperHandler { - var nextHandler: ConnectionKeeperHandler? - - func execute(context: ConnectionKeeperHandlerContext) { - context.connection.disconnect() - } - - func canExcute(context: ConnectionKeeperHandlerContext) -> Bool { - if context.connection.connectionState == .disconnected && !context.isOccupie { - return true - } - return false - } -} -let connection = SIOConnection(url: URL(string: "wss://pinkoi.com")!) -let disconnectedHandler = DisconnectedConnectionKeeperHandler() -let reconnectHandler = ReconnectConnectionKeeperHandler() -let releasedHandler = ReleasedConnectionKeeperHandler() -disconnectedHandler.nextHandler = reconnectHandler -reconnectHandler.nextHandler = releasedHandler - -disconnectedHandler.handle(context: ConnectionKeeperHandlerContext(connection: connection, isOccupie: false)) - -``` -### 需求場景 4. -#### What? - -我們封裝出的 `Connection` 需要經過 setup 後才能使用,例如給予 URL Path、設定 Config…等等 - -#### Why? -- 可以彈性的增減構建開口 -- 可複用構建邏輯 -- 未封裝的話,外部可以不照預期操作類別 -- e.g.: - -``` -❌ -let connection = Connection() -connection.send(event) // unexpected method call, should call .connect() first -✅ -let connection = Connection() -connection.connect() -connection.send(event) -// but...who knows??? -``` -#### How? -- [Builder Pattern](https://refactoring.guru/design-patterns/builder):創建型 Pattern,能夠分步驟構建對象及複用構建方法。 - -![](images/78507a8de6a5/1*J5eKaks1-fT6u8FojeUkUQ.png "") -- **Builder Pattern:** `SIOConnectionBuilder` 為 `Connection` 的構建器,負責設定、存放構建 `Connection` 時會用到的資料;`ConnectionConfiguration` 抽象介面用來保證要使用 `Connection` 前必須呼叫 `.connect()` 才能拿到 `Connection` 實體。 - -```Swift -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 -} - -// 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 { - // - return PassthroughSubject().eraseToAnyPublisher() - } -} - -// -class SIOConnectionClient: ConnectionConfiguration { - private let url: URL - private let config: [String: Any] - - init(url: URL, config: [String: Any]) { - self.url = url - self.config = config - } - - func connect() -> Connection { - // set config - return SIOConnection(url: url) - } -} - -protocol ConnectionConfiguration { - func connect() -> Connection -} - -class SIOConnectionBuilder { - private(set) var config: [String: Any] = [:] - - func setConfig(_ config: [String: Any]) -> SIOConnectionBuilder { - self.config = config - return self - } - - // url is required parameter - func build(url: URL) -> ConnectionConfiguration { - return SIOConnectionClient(url: url, config: self.config) - } -} - -let builder = SIOConnectionBuilder().setConfig(["test":123]) - - -let connection1 = builder.build(url: URL(string: "wss://pinkoi.com/1")!).connect() -let connection2 = builder.build(url: URL(string: "wss://pinkoi.com/1")!).connect() -``` - -延伸:這裏也可以再套用 [Factory Pattern](https://refactoring.guru/design-patterns/factory-method),將用工廠產出 `SIOConnection`。 - -### 完結! - -以上就是本次封裝 Socket.IO 中遇到的四個場景及七個使用到解決問題的 Design Patterns。 -#### 最後附上此次封裝 Socket.IO 的完整設計藍圖 -![](images/78507a8de6a5/1*DMfFpmF7aVCIIM1dskn97w.jpeg "") - -與文中命名、示範略為不同,這張圖才是真實的設計架構;有機會再請原設計者分享設計理念及開源。 -### Who? - -誰做了這些設計跟負責 Socket.IO 封裝專案呢? -#### [Sean Zheng](https://www.linkedin.com/in/%E5%AE%87%E7%BF%94-%E9%84%AD-9b3409175/), Android Engineer @ Pinkoi -![](images/78507a8de6a5/1*Q_35023LtcZbOtnfvSxv-A.jpeg "") - -主要架構設計者、Design Pattern 評估套用、在 Android 端使用 Kotlin 實現設計。 -#### [ZhgChgLi](https://www.linkedin.com/in/zhgchgli/), Enginner Lead/iOS Enginner @ Pinkoi -![](images/78507a8de6a5/1*1NCE3Q7fO5Mh15NT2xoYlA.png "") - -Platform Team 專案負責人、Pair programming、在 iOS 端使用 Swift 實現設計、討論並提出質疑(a.k.a. 出一張嘴)及最後撰寫本文與大家分享。 -### 延伸閱讀 -- [Visitor Pattern in Swift](https://medium.com/zrealm-ios-dev/visitor-pattern-in-ios-swift-ba5773a7bfea) - -[Like Z Realm's work](https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fbutton.like.co%2Fin%2Fembed%2Fzhgchgli%2Fbutton&display_name=LikeCoin&url=https%3A%2F%2Fbutton.like.co%2Fzhgchgli&image=https%3A%2F%2Fstorage.googleapis.com%2Flikecoin-foundation.appspot.com%2Flikecoin_store_user_zhgchgli_main%3FGoogleAccessId%3Dfirebase-adminsdk-eyzut%2540likecoin-foundation.iam.gserviceaccount.com%26Expires%3D2430432000%26Signature%3DgFRSNto%252BjjxXpRoYyuEMD5Ecm7mLK2uVo1vGz4NinmwLnAK0BGjcfKnItFpt%252BcYurx3wiwKTvrxvU019ruiCeNav7s7QUs5lgDDBc7c6zSVRbgcWhnJoKgReRkRu6Gd93WvGf%252BOdm4FPPgvpaJV9UE7h2MySR6%252B%252F4a%252B4kJCspzCTmLgIewm8W99pSbkX%252BQSlZ4t5Pw22SANS%252BlGl1nBCX48fGg%252Btg0vTghBGrAD2%252FMEXpGNJCdTPx8Gd9urOpqtwV4L1I2e2kYSC4YPDBD6pof1O6fKX%252BI8lGLEYiYP1sthjgf8Y4ZbgQr4Kt%252BRYIicx%252Bg6w3YWTg5zgHxAYhOINXw%253D%253D&key=a19fcc184b9711e1b4764040d3dc5c07&type=text%2Fhtml&schema=like) - -有任何問題及指教歡迎[與我聯絡](https://www.zhgchg.li/contact)。 - +--- +title: Design Patterns 的實戰應用紀錄 +author: ZhgChgLi +date: 2022-04-07T22:49:17.715Z +tags: [ios-app-development,design-patterns,socketio,websocket,finite-state-machine] +--- + +### Design Patterns 的實戰應用紀錄 + +封裝 Socket.IO Client Library 需求時遇到的問題場景及解決方法應用到的 Design Patterns +![Photo by [Daniel McCullough](https://unsplash.com/@d_mccullough?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)](images/78507a8de6a5/1*mkG0YtCzyPQpU9MG0HI79w.jpeg "Photo by [Daniel McCullough](https://unsplash.com/@d_mccullough?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)") +### 前言 + +此篇文章是真實的需求開發,所運用到 Design Pattern 解決問題的場景記錄;內容篇幅會涵蓋需求背景、實際遇到的問題場景 (What?)、為何要套用 Pattern 解決問題 (Why?)、實作上如何使用 (How?),建議可以從頭閱讀會比較有連貫性。 +> _本文會介紹四個開發此需求遇到的場景及七個解決此場景的 Design Patterns 應用。_ + +### 背景 +#### 組織架構 + +敝司於今年拆分出 Feature Teams (multiple) 與 Platform Team;前者不必多說主要負責使用者端需求、Platform Team 這邊則面對的是公司內部的成員,其中一個工作項目就是技術引入、基礎建設及做好系統性整合,為 Feature Teams 開發需求時先鋒鋪好道路。 +#### 當前需求 + +Feature Teams 要將原本的訊息功能 (進頁面打 API 拿訊息資料,要更新最新訊息只能重整) 改為 即時通訊 (能即時收到最新訊息、對傳訊息)。 +#### Platform Team 工作 + +Platform Team 著重的點不只是當下的即時通訊需求,而是長遠的建設與複用性;評估後 webSocket 雙向通訊的機制在現代 App 中是不可或缺,除了此次的需求之外,以後也有很多機會都會用到,加上人力資源許可,故投入協助設計開發介面。 + +**目標:** +- 封裝 Pinkoi Server Side 與 Socket.IO 通訊、身份驗證邏輯 +- 封裝 Socket.IO 煩瑣操作,提供基於 Pinkoi 商業需求的可擴充及方便使用介面 +- 統一雙平台介面 **(Socket.IO 的 Android 與 iOS Client Side Library 支援的功能及介面不相同)** +- Feature 端無需了解 Socket.IO 機制 +- Feature 端無需管理複雜的連線狀態 +- 未來有 webSocket 雙向通訊需求能直接使用 + + +**時間及人力:** +- iOS & Android 各投入一位 +- 開發時程:時程 3 週 + +#### 技術細節 + +Web & iOS & Android 三平台均會支援此 Feature;要引入 webSocket 雙向通訊協議來實現,後端預計直接使用 [Socket.io](http://socket.io/) 服務。 +> **_首先要說 Socket != WebSocket_** + + +關於 Socket 與 WebSocket 及技術細節可參考以下兩篇文章: +- [Socket,Websocket,Socket.io的差異](https://leesonhsu.blogspot.com/2018/07/socketwebsocketsocketio.html) +- [为什么不直接使用socket ,还要定义一个新的websocket 的呢?](https://github.com/onlyliuxin/coding2017/issues/497) + + +簡而言之: +``` +Socket 是 TCP/UDP 傳輸層的抽象封裝介面,而 WebSocket 是應用層的傳輸協議。 +Socket 與 WebSocket 的關係就像狗跟熱狗的關係一樣, **沒有關係** 。 +``` +![](images/78507a8de6a5/1*MC_nQC382khMeWggLejWOA.jpeg "") + +Socket.IO 是 Engine.IO 的一層抽象操作封裝,Engine.IO 則是對 WebSocket 的使用封裝,每層只負責對上對下之間的交流,不允許貫穿操作(e.g. Socket.IO 直接操作 WebSocket 連線)。 + +Socket.IO/Engine.IO 除了基本的 WebSocket 連線外還實做了很多方便好用的功能集合(e.g. 離線發送 Event 機制、類似 Http Request 機制、Room/Group 機制…等等)。 + +Platform Team 這層的主要職責是橋接 Socket.IO 與 Pinkoi Server Side 之間的邏輯,供應上層 Feature Teams 開發功能時使用。 +#### [Socket.IO Swift Client](https://github.com/socketio/socket.io-client-swift) 有坑 +- 已許久未更新 (最新一版還在 2019),不確定是否還有在維護。 +- Client & Server Side Socket IO Version 要對齊,Server Side 可加上 `{ [allowEIO3: true](https://socket.io/blog/socket-io-3-1-0/) }` / 或 Client Side 指定相同版本 `.version` +否則怎麼連都連不上。 +- 命名方式、介面與官網範例很多都對不起來。 +- Socket.io 官網範例都是拿 Web 做介紹,實際上 Swift Client **並不一定有全支援官網寫的功能** 。 +此次實作發現 iOS 這邊 Library 並未實現離線發送 Event 機制 +(我們是自行實現的,請往後繼續閱讀) + +> **_建議有要採用 Socket.IO 前先實驗看看你想要的機制是否支援。_** +_Socket.IO Swift Client 是基於 **[Starscream](https://github.com/daltoniam/Starscream)** WebSocket Library 的封裝,必要時可降級使用 Starscream。_ + +``` +背景資訊補充到此結束,接下來進入正題。 +``` +### Design Patterns + +設計模式說穿了就只是軟體設計當中常見問題的解決方案,不一定要用設計模式才能開發、設計模式不一定能適用所有場景、也沒人說不能自行歸納出新的設計模式。 +![[The Catalog of Design Patterns](https://refactoring.guru/design-patterns/catalog)](images/78507a8de6a5/1*MAm5WPynbv7M9tdmW2lNGQ.jpeg "[The Catalog of Design Patterns](https://refactoring.guru/design-patterns/catalog)") + +但現有的設計模式 (The 23 Gang of Four Design Patterns) 已是軟體設計中的共同知識,只要提到 XXX Pattern 大家腦中就會有相應的架構藍圖,不需多做解釋、後續維護也比較好知道脈絡、且已是經過業界驗證的方法不太需要花時間審視物件依賴問題;在適合的場景選用適合的模式可以降低溝通及維護成本,提升開發效率。 +> **_設計模式可以組合使用,但不建議對現有設計模式魔改、強行為套用而套用、套用不符合分類的 Pattern (e.g. 用責任練模式來產生物件),會失去使用的意義更可能造成後續接手的人的誤會。_** + +#### 本篇會提到的 Design Patterns: +- [Singleton Pattern](https://refactoring.guru/design-patterns/singleton) +- [Flywieght Pattern](https://refactoring.guru/design-patterns/flyweight) +- [Factory Pattern](https://refactoring.guru/design-patterns/factory-method) +- [Command Pattern](https://refactoring.guru/design-patterns/command) +- [Finite-State Machine](https://en.wikipedia.org/wiki/Finite-state_machine) + [State Pattern](https://refactoring.guru/design-patterns/state) +- [Chain Of Resposibility](https://refactoring.guru/design-patterns/chain-of-responsibility) +- [Builder Pattern](https://refactoring.guru/design-patterns/builder) + + +會逐一在後面解釋什麼場境用了、為何要用。 +> _本文著重在 Design Pattern 的應用,而非 Socket.IO 的操作,部分示例會因為描述方便而有所刪減, **無法適用真實的 Socket.IO 封裝** 。_ +> _因篇幅有限,本文不會詳細介紹每個設計模式的架構,請先點各個模式的連結進入了解該模式的架構後再繼續閱讀。_ +> _Demo Code 會使用 Swift 撰寫。_ + +### 需求場景 1. +#### What? +- 使用相同的 Path 在不同頁面、Object 請求 Connection 時能複用取得相同的物件。 +- Connection 需為抽象介面,不直接依賴 Socket.IO Object + +#### Why? +- 減少記憶體開銷及重複連線的時間、流量成本。 +- 為未來抽換成其他框架預留空間 + +#### How? +- [Singleton Pattern](https://refactoring.guru/design-patterns/singleton) :創建型 Pattern,保證一個物件只會有一個實體。 +- [Flywieght Pattern](https://refactoring.guru/design-patterns/flyweight) :結構型 Pattern,基於共享多個物件相同的狀態,重複使用。 +- [Factory Pattern](https://refactoring.guru/design-patterns/factory-method) :創建型 Pattern,抽象物件產生方法,使其能在外部抽換。 + + +**實際案例使用:** +![](images/78507a8de6a5/1*flQa_EfErGBwbmEwpI7ZgQ.png "") +- **Singleton Pattern:** `ConnectionManager` 在 App Lifecycle 中僅存在一個的物件,用來管理 `Connection` 取用操作。 +- **Flywieght Pattern:** `ConnectionPool` 顧名思義就是 Connection 的共用池子,統一從這個池子的方法拿出 Connection,其中邏輯就會包含當發現 URL Path 一樣時直接給予已經在池子裡的 Connection。 +`ConnectionHandler` 則做為 `Connection` 的外在操作、狀態管理器。 +- **Factory Pattern:** `ConnectionFactory` 搭配上面 Flywieght Pattern 當發現池子沒有可複用的 `Connection` 時則用此工廠介面去產生。 + +```Swift +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 +} + +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 { + // + return PassthroughSubject().eraseToAnyPublisher() + } +} + +class SIOConnectionFactory: ConnectionFactory { + func create(url: URL) -> Connection { + // + return SIOConnection(url: url) + } +} +// + +print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: "wss://pinkoi.com/1")!).getConnectionUUID().uuidString) +print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: "wss://pinkoi.com/1")!).getConnectionUUID().uuidString) + +print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: "wss://pinkoi.com/2")!).getConnectionUUID().uuidString) + +// output: +// D99F5429-1C6D-4EB5-A56E-9373D6F37307 +// D99F5429-1C6D-4EB5-A56E-9373D6F37307 +// 599CF16F-3D7C-49CF-817B-5A57C119FE31 +``` +### 需求場景 2. +#### What? + +如背景技術細節所述,Socket.IO Swift Client 的 `Send Event` 並不支援離線發送 (但 Web/Android 版的 Library 卻可以),因此 iOS 端需要自行實現此功能。 +``` +神奇的是 Socket.IO Swift Client - onEvent 是支援離線訂閱的。 +``` +#### Why? +- 跨平台功能統一 +- 程式碼容易理解 + +#### How? +- [Command Pattern](https://refactoring.guru/design-patterns/command) :行為型 Pattern,將操作包裝成對象,提供隊列、延遲、取消…等等集合操作。 + +![](images/78507a8de6a5/1*O9zc28nMx64HDiDy4aiexA.png "") +- **Command Pattern:** `SIOManager` 為與 Socket.IO 溝通的最底層封裝,其中的 `send` 、 `request` 方法都是對 Socket.IO Send Event 的操作,當發現當前 Socket.IO 處於斷線狀態,則將請求參數放到 `bufferedCommands` 中,當連上之後就逐一拿出來處理 (First In First Out)。 + +```Swift +protocol BufferedCommand { + var sioManager: SIOManagerSpec? { get set } + var event: String { get } + + func execute() +} + +struct SendBufferedCommand: BufferedCommand { + let event: String + weak var sioManager: SIOManagerSpec? + + func execute() { + sioManager?.send(event) + } +} + +struct RequestBufferedCommand: BufferedCommand { + let event: String + let callback: (Data?) -> Void + weak var sioManager: SIOManagerSpec? + + func execute() { + sioManager?.request(event, callback: callback) + } +} + +protocol SIOManagerSpec: AnyObject { + func connect() + func disconnect() + func onEvent(event: String, callback: @escaping (Data?) -> Void) + func send(_ event: String) + func request(_ event: String, callback: @escaping (Data?) -> Void) +} + +enum ConnectionState { + case created + case connected + case disconnected + case reconnecting + case released +} + +class SIOManager: SIOManagerSpec { + + var state: ConnectionState = .disconnected { + didSet { + if state == .connected { + executeBufferedCommands() + } + } + } + + private var bufferedCommands: [BufferedCommand] = [] + + func connect() { + state = .connected + } + + func disconnect() { + state = .disconnected + } + + func send(_ event: String) { + guard state == .connected else { + appendBufferedCommands(connectionCommand: SendBufferedCommand(event: event, sioManager: self)) + return + } + + print("Send:\(event)") + } + + func request(_ event: String, callback: @escaping (Data?) -> Void) { + guard state == .connected else { + appendBufferedCommands(connectionCommand: RequestBufferedCommand(event: event, callback: callback, sioManager: self)) + return + } + + print("request:\(event)") + } + + func onEvent(event: String, callback: @escaping (Data?) -> Void) { + // + } + + func appendBufferedCommands(connectionCommand: BufferedCommand) { + bufferedCommands.append(connectionCommand) + } + + func executeBufferedCommands() { + // First in, first out + bufferedCommands.forEach { connectionCommand in + connectionCommand.execute() + } + bufferedCommands.removeAll() + } + + func removeAllBufferedCommands() { + bufferedCommands.removeAll() + } +} + +let manager = SIOManager() +manager.send("send_event_1") +manager.send("send_event_2") +manager.request("request_event_1") { _ in + // +} +manager.state = .connected +``` + +同理也可以實現到 `onEvent` 上。 + +延伸:可以再套用 [Proxy Pattern](https://refactoring.guru/design-patterns/proxy) ,將 Buffer 功能視為一種 Proxy。 +### 需求場景 3. +#### What? + +Connection 有多個狀態,有序的狀態與狀態間切換、各狀態允許不同的操作。 +![](images/78507a8de6a5/1*DBl6K1cPQc_cHOYXZ1VQ8A.jpeg "") +![](images/78507a8de6a5/1*-Xk_TT6SMW5Jxd-c8iSCcw.jpeg "") +- Created:物件被建立,允許 -> `Connected` 或直接進 `Disconnected` +- Connected:已連上 Socket.IO,允許 -> `Disconnected` +- Disconnected:已與 Socket.IO 斷線,允許 -> `Reconnectiong` 、 `Released` +- Reconnectiong:正在嘗試重新連上 Socket.IO,允許 -> `Connected` 、 `Disconnected` +- Released:物件已被標示為等待被記憶體回收,不允許任何操作及切換狀態 + +#### Why? +- 狀態與狀態的切換邏輯跟表述不容易 +- 各狀態要限制操作方法(e.g. State = Released 時無法 Call Send Event),直接使用 if..else 會讓程式難以維護閱讀 + +#### How? +- [Finite State Machine](https://en.wikipedia.org/wiki/Finite-state_machine) :管理狀態間的切換 +- [State Pattern](https://refactoring.guru/design-patterns/state) :行為型 Pattern,對象的狀態有變化時,有不同的相應處理 + +![](images/78507a8de6a5/1*NgehABZTiXL_fFEYQh63Hg.png "") +- **Finite State Machine** : `SIOConnectionStateMachine` 為狀態機實作, `currentSIOConnectionState` 為當前狀態, `created、connected、disconnected、reconnecting、released` 表列出此狀態機可能的切換狀態。 +`enterXXXState() throws` 為從 Current State 進入某個狀態時的允許與不允許(throw error)實作。 +- **State Pattern** : `SIOConnectionState` 為所有狀態會用到的操作方法介面抽象。 + +```Swift +protocol SIOManagerSpec: AnyObject { + func connect() + func disconnect() + func onEvent(event: String, callback: @escaping (Data?) -> Void) + func send(_ event: String) + func request(_ event: String, callback: @escaping (Data?) -> Void) +} + +enum ConnectionState { + case created + case connected + case disconnected + case reconnecting + case released +} + +class SIOManager: SIOManagerSpec { + + var state: ConnectionState = .disconnected { + didSet { + if state == .connected { + executeBufferedCommands() + } + } + } + + private var bufferedCommands: [BufferedCommand] = [] + + func connect() { + state = .connected + } + + func disconnect() { + state = .disconnected + } + + func send(_ event: String) { + guard state == .connected else { + appendBufferedCommands(connectionCommand: SendBufferedCommand(event: event, sioManager: self)) + return + } + + print("Send:\(event)") + } + + func request(_ event: String, callback: @escaping (Data?) -> Void) { + guard state == .connected else { + appendBufferedCommands(connectionCommand: RequestBufferedCommand(event: event, callback: callback, sioManager: self)) + return + } + + print("request:\(event)") + } + + func onEvent(event: String, callback: @escaping (Data?) -> Void) { + // + } + + func appendBufferedCommands(connectionCommand: BufferedCommand) { + bufferedCommands.append(connectionCommand) + } + + func executeBufferedCommands() { + // First in, first out + bufferedCommands.forEach { connectionCommand in + connectionCommand.execute() + } + bufferedCommands.removeAll() + } + + func removeAllBufferedCommands() { + bufferedCommands.removeAll() + } +} + +let manager = SIOManager() +manager.send("send_event_1") +manager.send("send_event_2") +manager.request("request_event_1") { _ in + // +} +manager.state = .connected + +// + +class SIOConnectionStateMachine { + + private(set) var currentSIOConnectionState: SIOConnectionState! + + private var created: SIOConnectionState! + private var connected: SIOConnectionState! + private var disconnected: SIOConnectionState! + private var reconnecting: SIOConnectionState! + private var released: SIOConnectionState! + + init() { + self.created = SIOConnectionCreatedState(stateMachine: self) + self.connected = SIOConnectionConnectedState(stateMachine: self) + self.disconnected = SIOConnectionDisconnectedState(stateMachine: self) + self.reconnecting = SIOConnectionReconnectingState(stateMachine: self) + self.released = SIOConnectionReleasedState(stateMachine: self) + + self.currentSIOConnectionState = created + } + + func enterConnected() throws { + if [created.connectionState, reconnecting.connectionState].contains(currentSIOConnectionState.connectionState) { + enter(connected) + } else { + throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) can't enter to Connected") + } + } + + func enterDisconnected() throws { + if [created.connectionState, connected.connectionState, reconnecting.connectionState].contains(currentSIOConnectionState.connectionState) { + enter(disconnected) + } else { + throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) can't enter to Disconnected") + } + } + + func enterReconnecting() throws { + if [disconnected.connectionState].contains(currentSIOConnectionState.connectionState) { + enter(reconnecting) + } else { + throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) can't enter to Reconnecting") + } + } + + func enterReleased() throws { + if [disconnected.connectionState].contains(currentSIOConnectionState.connectionState) { + enter(released) + } else { + throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) can't enter to Released") + } + } + + private func enter(_ state: SIOConnectionState) { + currentSIOConnectionState = state + } +} + + +protocol SIOConnectionState { + var connectionState: ConnectionState { get } + var stateMachine: SIOConnectionStateMachine { get } + init(stateMachine: SIOConnectionStateMachine) + + func onConnected() throws + func onDisconnected() throws + + + func connect(socketManager: SIOManagerSpec) throws + func disconnect(socketManager: SIOManagerSpec) throws + func release(socketManager: SIOManagerSpec) throws + + func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws + func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws + func send(socketManager: SIOManagerSpec, event: String) throws +} + +struct SIOConnectionStateMachineError: Error { + let message: String + + init(_ message: String) { + self.message = message + } + + var localizedDescription: String { + return message + } +} + +class SIOConnectionCreatedState: SIOConnectionState { + + let connectionState: ConnectionState = .created + let stateMachine: SIOConnectionStateMachine + + required init(stateMachine: SIOConnectionStateMachine) { + self.stateMachine = stateMachine + } + + func onConnected() throws { + try stateMachine.enterConnected() + } + + func onDisconnected() throws { + try stateMachine.enterDisconnected() + } + + func release(socketManager: SIOManagerSpec) throws { + throw SIOConnectionStateMachineError("ConnectedState can't release!") + } + + func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func send(socketManager: SIOManagerSpec, event: String) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func connect(socketManager: SIOManagerSpec) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func disconnect(socketManager: SIOManagerSpec) throws { + throw SIOConnectionStateMachineError("CreatedState can't disconnect!") + } +} + +class SIOConnectionConnectedState: SIOConnectionState { + + let connectionState: ConnectionState = .connected + let stateMachine: SIOConnectionStateMachine + + required init(stateMachine: SIOConnectionStateMachine) { + self.stateMachine = stateMachine + } + + func onConnected() throws { + // + } + + func onDisconnected() throws { + try stateMachine.enterDisconnected() + } + + func release(socketManager: SIOManagerSpec) throws { + throw SIOConnectionStateMachineError("ConnectedState can't release!") + } + + func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func send(socketManager: SIOManagerSpec, event: String) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func connect(socketManager: SIOManagerSpec) throws { + throw SIOConnectionStateMachineError("ConnectedState can't connect!") + } + + func disconnect(socketManager: SIOManagerSpec) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } +} + +class SIOConnectionDisconnectedState: SIOConnectionState { + + let connectionState: ConnectionState = .disconnected + let stateMachine: SIOConnectionStateMachine + + required init(stateMachine: SIOConnectionStateMachine) { + self.stateMachine = stateMachine + } + + func onConnected() throws { + try stateMachine.enterConnected() + } + + func onDisconnected() throws { + // + } + + func release(socketManager: SIOManagerSpec) throws { + try stateMachine.enterReleased() + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func send(socketManager: SIOManagerSpec, event: String) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func connect(socketManager: SIOManagerSpec) throws { + try stateMachine.enterReconnecting() + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func disconnect(socketManager: SIOManagerSpec) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } +} + +class SIOConnectionReconnectingState: SIOConnectionState { + + let connectionState: ConnectionState = .reconnecting + let stateMachine: SIOConnectionStateMachine + + required init(stateMachine: SIOConnectionStateMachine) { + self.stateMachine = stateMachine + } + + func onConnected() throws { + try stateMachine.enterConnected() + } + + func onDisconnected() throws { + try stateMachine.enterDisconnected() + } + + func release(socketManager: SIOManagerSpec) throws { + throw SIOConnectionStateMachineError("ReconnectState can't release!") + } + + func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func send(socketManager: SIOManagerSpec, event: String) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func connect(socketManager: SIOManagerSpec) throws { + throw SIOConnectionStateMachineError("ReconnectState can't connect!") + } + + func disconnect(socketManager: SIOManagerSpec) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } +} + +class SIOConnectionReleasedState: SIOConnectionState { + + let connectionState: ConnectionState = .released + let stateMachine: SIOConnectionStateMachine + + required init(stateMachine: SIOConnectionStateMachine) { + self.stateMachine = stateMachine + } + + func onConnected() throws { + throw SIOConnectionStateMachineError("ReleasedState can't onConnected!") + } + + func onDisconnected() throws { + throw SIOConnectionStateMachineError("ReleasedState can't onDisconnected!") + } + + func release(socketManager: SIOManagerSpec) throws { + throw SIOConnectionStateMachineError("ReleasedState can't release!") + } + + func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + throw SIOConnectionStateMachineError("ReleasedState can't request!") + } + + func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + throw SIOConnectionStateMachineError("ReleasedState can't receiveOn!") + } + + func send(socketManager: SIOManagerSpec, event: String) throws { + throw SIOConnectionStateMachineError("ReleasedState can't send!") + } + + func connect(socketManager: SIOManagerSpec) throws { + throw SIOConnectionStateMachineError("ReleasedState can't connect!") + } + + func disconnect(socketManager: SIOManagerSpec) throws { + throw SIOConnectionStateMachineError("ReleasedState can't disconnect!") + } +} + +do { + let stateMachine = SIOConnectionStateMachine() + // mock on socket.io connect: + // socketIO.on(connect){ + try stateMachine.currentSIOConnectionState.onConnected() + try stateMachine.currentSIOConnectionState.send(socketManager: manager, event: "test") + try stateMachine.currentSIOConnectionState.release(socketManager: manager) + try stateMachine.currentSIOConnectionState.send(socketManager: manager, event: "test") + // } +} catch { + print("error: \(error)") +} + +// output: +// error: SIOConnectionStateMachineError(message: "ConnectedState can\'t release!") +``` +### 需求場景 3. +#### What? + +結合場景 1. 2.,有了 `ConnectionPool` 享元池子加上 State Pattern 狀態管理後;我們繼續往下延伸,如背景目標所述,Feature 端不需去管背後 Connection 的連線機制;因此我們建立了一個輪詢器 (命名為 `ConnectionKeeper` ) 會定時掃描 `ConnectionPool` 中強持有的 `Connection` ,並在發生以下狀況時做操作: +- `Connection` 有人在使用且狀態非 `Connected` :將狀態改為 `Reconnecting` 並嘗試重新連線 +- `Connection` 已無人使用且狀態為 `Connected` :將狀態改為 `Disconnected` +- `Connection` 已無人使用且狀態為 `Disconnected` :將狀態改為 `Released` 並從 `ConnectionPool` 中移除 + +#### Why? +- 三個操作有上下關係且互斥 (disconnected -> released or reconnecting) +- 可彈性抽換、增加狀況操作 +- 未封裝的話只能將三個判斷及操作直接寫在方法中 (難以測試其中邏輯) +- e.g: + +``` +if !connection.isOccupie() && connection.state == .connected then +... connection.disconnected() +else if !connection.isOccupie() && state == .released then +... connection.release() +else if connection.isOccupie() && state == .disconnected then +... connection.reconnecting() +end +``` +#### How? +- [Chain Of Resposibility](https://refactoring.guru/design-patterns/chain-of-responsibility) :行為型 Pattern,顧名思義是一條鏈,每個節點都有相應的操作,輸入資料後節點可決定是否要操作還是丟給下一個節點處理,另一個現實應用是 [iOS Responder Chain](https://swiftrocks.com/understanding-the-ios-responder-chain) 。 + +> _照定義 Chain of responsibility Pattern 是不允許某個節點已經接下處理資料,但處理完又丟給下一個節點繼續處理, **要做就做完,不然不要做** 。_ +_如果是上述場景比較適合的應該是 [Interceptor Pattern](https://stackoverflow.com/questions/7951306/chain-of-responsibility-vs-interceptor) 。_ + +![](images/78507a8de6a5/1*e8jHpykN1m3Y66Ukf-5OJA.png "") +- **Chain of responsibility:** `ConnectionKeeperHandler` 為鍊的節點抽象,特別抽出 `canExcute` 方法避免發生上述 這個節點接下來處理了,但做完又想呼叫後面的節點繼續執行的狀況、 `handle` 為鍊的節點串連、 `excute` 為要處理的話會怎麼處理的邏輯。 +`ConnectionKeeperHandlerContext` 用來存放會用到的資料, `isOccupie` 代表 Connection 有無人在使用。 + +```Swift +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 +} + +// 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 { + // + return PassthroughSubject().eraseToAnyPublisher() + } +} + +// + +struct ConnectionKeeperHandlerContext { + let connection: Connection + let isOccupie: Bool +} + +protocol ConnectionKeeperHandler { + var nextHandler: ConnectionKeeperHandler? { get set } + + func handle(context: ConnectionKeeperHandlerContext) + func execute(context: ConnectionKeeperHandlerContext) + func canExcute(context: ConnectionKeeperHandlerContext) -> Bool +} + +extension ConnectionKeeperHandler { + func handle(context: ConnectionKeeperHandlerContext) { + if canExcute(context: context) { + execute(context: context) + } else { + nextHandler?.handle(context: context) + } + } +} + +class DisconnectedConnectionKeeperHandler: ConnectionKeeperHandler { + var nextHandler: ConnectionKeeperHandler? + + func execute(context: ConnectionKeeperHandlerContext) { + context.connection.disconnect() + } + + func canExcute(context: ConnectionKeeperHandlerContext) -> Bool { + if context.connection.connectionState == .connected && !context.isOccupie { + return true + } + return false + } +} + +class ReconnectConnectionKeeperHandler: ConnectionKeeperHandler { + var nextHandler: ConnectionKeeperHandler? + + func execute(context: ConnectionKeeperHandlerContext) { + context.connection.reconnect() + } + + func canExcute(context: ConnectionKeeperHandlerContext) -> Bool { + if context.connection.connectionState == .disconnected && context.isOccupie { + return true + } + return false + } +} + +class ReleasedConnectionKeeperHandler: ConnectionKeeperHandler { + var nextHandler: ConnectionKeeperHandler? + + func execute(context: ConnectionKeeperHandlerContext) { + context.connection.disconnect() + } + + func canExcute(context: ConnectionKeeperHandlerContext) -> Bool { + if context.connection.connectionState == .disconnected && !context.isOccupie { + return true + } + return false + } +} +let connection = SIOConnection(url: URL(string: "wss://pinkoi.com")!) +let disconnectedHandler = DisconnectedConnectionKeeperHandler() +let reconnectHandler = ReconnectConnectionKeeperHandler() +let releasedHandler = ReleasedConnectionKeeperHandler() +disconnectedHandler.nextHandler = reconnectHandler +reconnectHandler.nextHandler = releasedHandler + +disconnectedHandler.handle(context: ConnectionKeeperHandlerContext(connection: connection, isOccupie: false)) + +``` +### 需求場景 4. +#### What? + +我們封裝出的 `Connection` 需要經過 setup 後才能使用,例如給予 URL Path、設定 Config…等等 +#### Why? +- 可以彈性的增減構建開口 +- 可複用構建邏輯 +- 未封裝的話,外部可以不照預期操作類別 +- e.g.: + +``` +❌ +let connection = Connection() +connection.send(event) // unexpected method call, should call .connect() first +✅ +let connection = Connection() +connection.connect() +connection.send(event) +// but...who knows??? +``` +#### How? +- [Builder Pattern](https://refactoring.guru/design-patterns/builder) :創建型 Pattern,能夠分步驟構建對象及複用構建方法。 + +![](images/78507a8de6a5/1*J5eKaks1-fT6u8FojeUkUQ.png "") +- **Builder Pattern:** `SIOConnectionBuilder` 為 `Connection` 的構建器,負責設定、存放構建 `Connection` 時會用到的資料; `ConnectionConfiguration` 抽象介面用來保證要使用 `Connection` 前必須呼叫 `.connect()` 才能拿到 `Connection` 實體。 + +```Swift +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 +} + +// 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 { + // + return PassthroughSubject().eraseToAnyPublisher() + } +} + +// +class SIOConnectionClient: ConnectionConfiguration { + private let url: URL + private let config: [String: Any] + + init(url: URL, config: [String: Any]) { + self.url = url + self.config = config + } + + func connect() -> Connection { + // set config + return SIOConnection(url: url) + } +} + +protocol ConnectionConfiguration { + func connect() -> Connection +} + +class SIOConnectionBuilder { + private(set) var config: [String: Any] = [:] + + func setConfig(_ config: [String: Any]) -> SIOConnectionBuilder { + self.config = config + return self + } + + // url is required parameter + func build(url: URL) -> ConnectionConfiguration { + return SIOConnectionClient(url: url, config: self.config) + } +} + +let builder = SIOConnectionBuilder().setConfig(["test":123]) + + +let connection1 = builder.build(url: URL(string: "wss://pinkoi.com/1")!).connect() +let connection2 = builder.build(url: URL(string: "wss://pinkoi.com/1")!).connect() +``` + +延伸:這裏也可以再套用 [Factory Pattern](https://refactoring.guru/design-patterns/factory-method) ,將用工廠產出 `SIOConnection` 。 +### 完結! + +以上就是本次封裝 Socket.IO 中遇到的四個場景及七個使用到解決問題的 Design Patterns。 +#### 最後附上此次封裝 Socket.IO 的完整設計藍圖 +![](images/78507a8de6a5/1*DMfFpmF7aVCIIM1dskn97w.jpeg "") + +與文中命名、示範略為不同,這張圖才是真實的設計架構;有機會再請原設計者分享設計理念及開源。 +### Who? + +誰做了這些設計跟負責 Socket.IO 封裝專案呢? +#### [Sean Zheng](https://www.linkedin.com/in/%E5%AE%87%E7%BF%94-%E9%84%AD-9b3409175/) , Android Engineer @ Pinkoi +![](images/78507a8de6a5/1*Q_35023LtcZbOtnfvSxv-A.jpeg "") + +主要架構設計者、Design Pattern 評估套用、在 Android 端使用 Kotlin 實現設計。 +#### [ZhgChgLi](https://www.linkedin.com/in/zhgchgli/) , Enginner Lead/iOS Enginner @ Pinkoi +![](images/78507a8de6a5/1*1NCE3Q7fO5Mh15NT2xoYlA.png "") + +Platform Team 專案負責人、Pair programming、在 iOS 端使用 Swift 實現設計、討論並提出質疑(a.k.a. 出一張嘴)及最後撰寫本文與大家分享。 +### 延伸閱讀 +- [Visitor Pattern in Swift](visitor-pattern-in-ios-swift-ba5773a7bfea) + +[Like Z Realm's work](https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fbutton.like.co%2Fin%2Fembed%2Fzhgchgli%2Fbutton&display_name=LikeCoin&url=https%3A%2F%2Fbutton.like.co%2Fzhgchgli&image=https%3A%2F%2Fstorage.googleapis.com%2Flikecoin-foundation.appspot.com%2Flikecoin_store_user_zhgchgli_main%3FGoogleAccessId%3Dfirebase-adminsdk-eyzut%2540likecoin-foundation.iam.gserviceaccount.com%26Expires%3D2430432000%26Signature%3DgFRSNto%252BjjxXpRoYyuEMD5Ecm7mLK2uVo1vGz4NinmwLnAK0BGjcfKnItFpt%252BcYurx3wiwKTvrxvU019ruiCeNav7s7QUs5lgDDBc7c6zSVRbgcWhnJoKgReRkRu6Gd93WvGf%252BOdm4FPPgvpaJV9UE7h2MySR6%252B%252F4a%252B4kJCspzCTmLgIewm8W99pSbkX%252BQSlZ4t5Pw22SANS%252BlGl1nBCX48fGg%252Btg0vTghBGrAD2%252FMEXpGNJCdTPx8Gd9urOpqtwV4L1I2e2kYSC4YPDBD6pof1O6fKX%252BI8lGLEYiYP1sthjgf8Y4ZbgQr4Kt%252BRYIicx%252Bg6w3YWTg5zgHxAYhOINXw%253D%253D&key=a19fcc184b9711e1b4764040d3dc5c07&type=text%2Fhtml&schema=like) + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact) 。 diff --git a/lib/Models/Paragraph.rb b/lib/Models/Paragraph.rb index 335a637..79dd958 100644 --- a/lib/Models/Paragraph.rb +++ b/lib/Models/Paragraph.rb @@ -4,7 +4,7 @@ require 'securerandom' class Paragraph - attr_accessor :postID, :name, :text, :type, :href, :metadata, :mixtapeMetadata, :iframe, :hasMarkup, :oliIndex, :markupLinks + attr_accessor :postID, :name, :orgText, :text, :type, :href, :metadata, :mixtapeMetadata, :iframe, :oliIndex, :markups, :markupLinks class Iframe attr_accessor :id, :title, :type, :src @@ -20,6 +20,19 @@ def parse() end end + class Markup + attr_accessor :type, :start, :end, :href, :anchorType, :userId, :linkMetadata + def initialize(json) + @type = json['type'] + @start = json['start'] + @end = json['end'] + @href = json['href'] + @anchorType = json['anchorType'] + @userId = json['userId'] + @linkMetadata = json['linkMetadata'] + end + end + class MetaData attr_accessor :id, :type def initialize(json) @@ -41,12 +54,13 @@ def self.makeBlankParagraph(postID) "text" => "", "type" => PParser.getTypeString() } - Paragraph.new(json, postID, nil) + Paragraph.new(json, postID) end - def initialize(json, postID, resource) + def initialize(json, postID) @name = json['name'] @text = json['text'] + @orgText = json['text'] @type = json['type'] @href = json['href'] @postID = postID @@ -54,7 +68,7 @@ def initialize(json, postID, resource) if json['metadata'].nil? @metadata = nil else - @metadata = MetaData.new(resource[json['metadata']['__ref']]) + @metadata = MetaData.new(json['metadata']) end if json['mixtapeMetadata'].nil? @@ -66,17 +80,22 @@ def initialize(json, postID, resource) if json['iframe'].nil? @iframe = nil else - @iframe = Iframe.new(resource[json['iframe']['mediaResource']['__ref']]) + @iframe = Iframe.new(json['iframe']['mediaResource']) end if !json['markups'].nil? && json['markups'].length > 0 + markups = [] + json['markups'].each do |markup| + markups.append(Markup.new(markup)) + end + @markups = markups + links = json['markups'].select{ |markup| markup["type"] == "A" } if !links.nil? && links.length > 0 @markupLinks = links.map{ |link| link["href"] } end - @hasMarkup = true else - @hasMarkup = false + @markups = nil end end end \ No newline at end of file diff --git a/lib/Parsers/BQParser.rb b/lib/Parsers/BQParser.rb index eeeb25c..958df93 100644 --- a/lib/Parsers/BQParser.rb +++ b/lib/Parsers/BQParser.rb @@ -5,12 +5,18 @@ class BQParser < Parser attr_accessor :nextParser + + def self.isBQ(paragraph) + if paragraph.nil? + false + else + paragraph.type == "BQ" + end + end + def parse(paragraph) - if paragraph.type == 'BQ' - result = "" - paragraph.text.each_line do |p| - result += "> #{p}" - end + if BQParser.isBQ(paragraph) + result = "> #{paragraph.text}" result else if !nextParser.nil? diff --git a/lib/Parsers/IframeParser.rb b/lib/Parsers/IframeParser.rb index 1b6ee70..4cebba9 100644 --- a/lib/Parsers/IframeParser.rb +++ b/lib/Parsers/IframeParser.rb @@ -24,6 +24,7 @@ def parse(paragraph) # is youtube youtubeURL = URI(URI.decode(url)).query params = URI::decode_www_form(youtubeURL).to_h + if !params["image"].nil? && !params["url"].nil? fileName = "#{paragraph.name}_#{URI(params["image"]).path.split("/").last}" #21de_default.jpg @@ -31,12 +32,12 @@ def parse(paragraph) imageURL = params["image"] imagePathPolicy = PathPolicy.new(pathPolicy.getAbsolutePath(nil), paragraph.postID) absolutePath = imagePathPolicy.getAbsolutePath(fileName) - + title = paragraph.iframe.title if ImageDownloader.download(absolutePath, imageURL) relativePath = "#{pathPolicy.getRelativePath(nil)}/#{imagePathPolicy.getRelativePath(fileName)}" - result = "\n[![YouTube](#{relativePath} \"YouTube\")](#{params["url"]})" + result = "\n[![#{title}](#{relativePath} \"#{title}\")](#{params["url"]})" else - result = "\n[YouTube](#{params["url"]})" + result = "\n[#{title}](#{params["url"]})" end end else diff --git a/lib/Parsers/MIXTAPEEMBEDParser.rb b/lib/Parsers/MIXTAPEEMBEDParser.rb index 3b9a1bc..6eb6054 100644 --- a/lib/Parsers/MIXTAPEEMBEDParser.rb +++ b/lib/Parsers/MIXTAPEEMBEDParser.rb @@ -8,9 +8,9 @@ class MIXTAPEEMBEDParser < Parser def parse(paragraph) if paragraph.type == 'MIXTAPE_EMBED' if !paragraph.mixtapeMetadata.nil? && !paragraph.mixtapeMetadata.href.nil? - "\n[#{paragraph.text}](#{paragraph.mixtapeMetadata.href})" + "\n[#{paragraph.orgText}](#{paragraph.mixtapeMetadata.href})" else - "\n#{paragraph.text}" + "\n#{paragraph.orgText}" end else if !nextParser.nil? diff --git a/lib/Parsers/MarkupParser.rb b/lib/Parsers/MarkupParser.rb index da83f8d..811a9ee 100644 --- a/lib/Parsers/MarkupParser.rb +++ b/lib/Parsers/MarkupParser.rb @@ -1,23 +1,28 @@ $lib = File.expand_path('../', File.dirname(__FILE__)) require 'Models/Paragraph' -require 'reverse_markdown' +require 'Parsers/MarkupStyleRender' require 'nokogiri' +require 'securerandom' +require 'User' class MarkupParser attr_accessor :body, :paragraph - def initialize(html, paragraph) - @body = html.search("body").first + def initialize(paragraph) @paragraph = paragraph end def parse() result = paragraph.text - if paragraph.hasMarkup - p = body.at_css("##{paragraph.name}") - if !p.nil? - result = ReverseMarkdown.convert p.inner_html + if !paragraph.markups.nil? && paragraph.markups.length > 0 + markupRender = MarkupStyleRender.new(paragraph) + + begin + result = markupRender.parse() + rescue => e + puts e.backtrace + Helper.makeWarningText("Error occurred during render markup text, please help to open an issue on github.") end end diff --git a/lib/Parsers/MarkupStyleRender.rb b/lib/Parsers/MarkupStyleRender.rb new file mode 100644 index 0000000..f841952 --- /dev/null +++ b/lib/Parsers/MarkupStyleRender.rb @@ -0,0 +1,232 @@ + +$lib = File.expand_path('../', File.dirname(__FILE__)) + +require 'Models/Paragraph' + +class MarkupStyleRender + attr_accessor :paragraph, :chars, :encodeType + + class TextChar + attr_accessor :chars, :type + def initialize(chars, type) + @chars = chars + @type = type + end + end + + class TagChar < TextChar + attr_accessor :sort, :startIndex, :endIndex, :startChars, :endChars + def initialize(sort, startIndex, endIndex, startChars, endChars) + @sort = sort + @startIndex = startIndex + @endIndex = endIndex - 1 + @startChars = TextChar.new(startChars.chars, 'TagStart') + @endChars = TextChar.new(endChars.chars, 'TagEnd') + end + end + + + def initialize(paragraph) + @paragraph = paragraph + + chars = {} + index = 0 + + emojiRegex = /[\u{203C}\u{2049}\u{20E3}\u{2122}\u{2139}\u{2194}-\u{2199}\u{21A9}-\u{21AA}\u{231A}-\u{231B}\u{23E9}-\u{23EC}\u{23F0}\u{23F3}\u{24C2}\u{25AA}-\u{25AB}\u{25B6}\u{25C0}\u{25FB}-\u{25FE}\u{2600}-\u{2601}\u{260E}\u{2611}\u{2614}-\u{2615}\u{261D}\u{263A}\u{2648}-\u{2653}\u{2660}\u{2663}\u{2665}-\u{2666}\u{2668}\u{267B}\u{267F}\u{2693}\u{26A0}-\u{26A1}\u{26AA}-\u{26AB}\u{26BD}-\u{26BE}\u{26C4}-\u{26C5}\u{26CE}\u{26D4}\u{26EA}\u{26F2}-\u{26F3}\u{26F5}\u{26FA}\u{26FD}\u{2702}\u{2705}\u{2708}-\u{270C}\u{270F}\u{2712}\u{2714}\u{2716}\u{2728}\u{2733}-\u{2734}\u{2744}\u{2747}\u{274C}\u{274E}\u{2753}-\u{2755}\u{2757}\u{2764}\u{2795}-\u{2797}\u{27A1}\u{27B0}\u{2934}-\u{2935}\u{2B05}-\u{2B07}\u{2B1B}-\u{2B1C}\u{2B50}\u{2B55}\u{3030}\u{303D}\u{3297}\u{3299}\u{1F004}\u{1F0CF}\u{1F170}-\u{1F171}\u{1F17E}-\u{1F17F}\u{1F18E}\u{1F191}-\u{1F19A}\u{1F1E7}-\u{1F1EC}\u{1F1EE}-\u{1F1F0}\u{1F1F3}\u{1F1F5}\u{1F1F7}-\u{1F1FA}\u{1F201}-\u{1F202}\u{1F21A}\u{1F22F}\u{1F232}-\u{1F23A}\u{1F250}-\u{1F251}\u{1F300}-\u{1F320}\u{1F330}-\u{1F335}\u{1F337}-\u{1F37C}\u{1F380}-\u{1F393}\u{1F3A0}-\u{1F3C4}\u{1F3C6}-\u{1F3CA}\u{1F3E0}-\u{1F3F0}\u{1F400}-\u{1F43E}\u{1F440}\u{1F442}-\u{1F4F7}\u{1F4F9}-\u{1F4FC}\u{1F500}-\u{1F507}\u{1F509}-\u{1F53D}\u{1F550}-\u{1F567}\u{1F5FB}-\u{1F640}\u{1F645}-\u{1F64F}\u{1F680}-\u{1F68A}]/ + excludesEmojis = ["⚠"] + paragraph.text.each_char do |char| + chars[index] = TextChar.new([char], "Text") + index += 1 + if char =~ emojiRegex && !excludesEmojis.include?(char) + # some emoji need more space (in Medium) + chars[index] = TextChar.new([], "Text") + index += 1 + end + end + + @chars = chars + end + + def optimize(chars) + while true + hasExcute = false + + index = 0 + startTagIndex = nil + preTag = nil + preTagIndex = nil + preTextChar = nil + preTextIndex = nil + chars.each do |char| + + if !preTag.nil? + if preTag.type == "TagStart" && char.type == "TagEnd" + chars.delete_at(index) + chars.delete_at(preTagIndex) + hasExcute = true + break + end + end + + if char.type == "TagStart" && (preTag == nil || preTag.type == "TagEnd" || preTag.type == "Text") + startTagIndex = index + elsif (char.type == "TagEnd" || char.type == "Text") && startTagIndex != nil + if preTextChar != nil && preTextChar.chars.join() != "\n" + # not first tag & insert blank between start tag and before text + if preTextChar.chars.join() != " " + chars.insert(startTagIndex, TextChar.new(" ".chars, "Text")) + hasExcute = true + break + end + end + startTagIndex = nil + end + + if !preTag.nil? + if preTag.type == "TagStart" && char.type == "Text" + # delete blank between start tag and after text + if char.chars.join().strip == "" + chars.delete_at(index) + hasExcute = true + break + end + end + + if preTag.type == "Text" && char.type == "TagEnd" + if preTextChar.chars.join().strip == "" && preTextChar.chars.join() != "\n" + chars.delete_at(preTextIndex) + hasExcute = true + break + end + end + + if preTag.type == "TagEnd" && char.type == "Text" + if char.chars.join() != " " + chars.insert(index, TextChar.new(" ".chars, "Text")) + hasExcute = true + break + end + end + + end + + if char.type == "Text" + preTextChar = char + preTextIndex = index + end + + preTag = char + preTagIndex = index + + index += 1 + end + + if !hasExcute + break + end + end + + chars + end + + def parse() + result = paragraph.text + + if !paragraph.markups.nil? && paragraph.markups.length > 0 + + tags = [] + paragraph.markups.each do |markup| + tag = nil + if markup.type == "EM" + tag = TagChar.new(2, markup.start, markup.end, "_", "_") + elsif markup.type == "CODE" + tag = TagChar.new(3, markup.start, markup.end, "`", "`") + elsif markup.type == "STRONG" + tag = TagChar.new(2, markup.start, markup.end, "**", "**") + elsif markup.type == "A" + url = markup.href + if markup.anchorType == "LINK" + url = markup.href + elsif markup.anchorType == "USER" + url = "https://medium.com/u/#{markup.userId}" + end + + tag = TagChar.new(1, markup.start, markup.end, "[", "](#{url})") + else + Helper.makeWarningText("Undefined Markup Type: #{markup.type}.") + end + + if !tag.nil? + tags.append(tag) + end + end + + tags.sort_by(&:startIndex) + + response = [] + stack = [] + + chars.each do |index, char| + + if char.chars.join() == "\n" + brStack = stack.dup + while brStack.length > 0 + tag = brStack.pop + response.push(tag.endChars) + end + response.append(TextChar.new(char.chars, 'Text')) + brStack = stack.dup.reverse + while brStack.length > 0 + tag = brStack.pop + response.push(tag.startChars) + end + end + + startTags = tags.select { |tag| tag.startIndex == index }.sort_by(&:sort) + if !startTags.nil? + startTags.each do |tag| + response.append(tag.startChars) + stack.append(tag) + end + end + + if char.chars.join() != "\n" + response.append(TextChar.new(char.chars, 'Text')) + end + + endTags = tags.select { |tag| tag.endIndex == index } + if !endTags.nil? && endTags.length > 0 + mismatchTags = [] + while endTags.length > 0 + stackTag = stack.pop + stackTagInEndTagsIndex = endTags.find_index(stackTag) + if !stackTagInEndTagsIndex.nil? + # as expected + endTags.delete_at(stackTagInEndTagsIndex) + else + mismatchTags.append(stackTag) + end + response.append(stackTag.endChars) + end + + while mismatchTags.length > 0 + mismatchTag = mismatchTags.pop + response.append(mismatchTag.startChars) + stack.append(mismatchTag) + end + end + end + + while stack.length > 0 + tag = stack.pop + response.push(tag.endChars) + end + + response = optimize(response) + result = response.map{ |response| response.chars }.join() + end + + result + end + +end \ No newline at end of file diff --git a/lib/Post.rb b/lib/Post.rb index 3b57b31..185847c 100644 --- a/lib/Post.rb +++ b/lib/Post.rb @@ -38,12 +38,23 @@ def self.parsePostContentFromHTML(html) json end - def self.parsePostParagraphsFromPostContent(content, postID) - result = content&.dig("Post:#{postID}", "content({\"postMeteringOptions\":null})", "bodyModel", "paragraphs") - if result.nil? - nil + def self.fetchPostParagraphs(postID) + query = [ + { + "operationName": "PostViewerEdgeContentQuery", + "variables": { + "postId": postID + }, + "query": "query PostViewerEdgeContentQuery($postId: ID!, $postMeteringOptions: PostMeteringOptions) {\n post(id: $postId) {\n ... on Post {\n id\n viewerEdge {\n id\n fullContent(postMeteringOptions: $postMeteringOptions) {\n isLockedPreviewOnly\n validatedShareKey\n bodyModel {\n ...PostBody_bodyModel\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n}\n\nfragment PostBody_bodyModel on RichText {\n sections {\n name\n startIndex\n textLayout\n imageLayout\n backgroundImage {\n id\n originalHeight\n originalWidth\n __typename\n }\n videoLayout\n backgroundVideo {\n videoId\n originalHeight\n originalWidth\n previewImageId\n __typename\n }\n __typename\n }\n paragraphs {\n id\n ...PostBodySection_paragraph\n __typename\n }\n ...normalizedBodyModel_richText\n __typename\n}\n\nfragment PostBodySection_paragraph on Paragraph {\n name\n ...PostBodyParagraph_paragraph\n __typename\n id\n}\n\nfragment PostBodyParagraph_paragraph on Paragraph {\n name\n type\n ...ImageParagraph_paragraph\n ...TextParagraph_paragraph\n ...IframeParagraph_paragraph\n ...MixtapeParagraph_paragraph\n __typename\n id\n}\n\nfragment ImageParagraph_paragraph on Paragraph {\n href\n layout\n metadata {\n id\n originalHeight\n originalWidth\n focusPercentX\n focusPercentY\n alt\n __typename\n }\n ...Markups_paragraph\n ...ParagraphRefsMapContext_paragraph\n ...PostAnnotationsMarker_paragraph\n __typename\n id\n}\n\nfragment Markups_paragraph on Paragraph {\n name\n text\n hasDropCap\n dropCapImage {\n ...MarkupNode_data_dropCapImage\n __typename\n id\n }\n markups {\n type\n start\n end\n href\n anchorType\n userId\n linkMetadata {\n httpStatus\n __typename\n }\n __typename\n }\n __typename\n id\n}\n\nfragment MarkupNode_data_dropCapImage on ImageMetadata {\n ...DropCap_image\n __typename\n id\n}\n\nfragment DropCap_image on ImageMetadata {\n id\n originalHeight\n originalWidth\n __typename\n}\n\nfragment ParagraphRefsMapContext_paragraph on Paragraph {\n id\n name\n text\n __typename\n}\n\nfragment PostAnnotationsMarker_paragraph on Paragraph {\n ...PostViewNoteCard_paragraph\n __typename\n id\n}\n\nfragment PostViewNoteCard_paragraph on Paragraph {\n name\n __typename\n id\n}\n\nfragment TextParagraph_paragraph on Paragraph {\n type\n hasDropCap\n ...Markups_paragraph\n ...ParagraphRefsMapContext_paragraph\n __typename\n id\n}\n\nfragment IframeParagraph_paragraph on Paragraph {\n iframe {\n mediaResource {\n id\n iframeSrc\n iframeHeight\n iframeWidth\n title\n __typename\n }\n __typename\n }\n layout\n ...getEmbedlyCardUrlParams_paragraph\n ...Markups_paragraph\n __typename\n id\n}\n\nfragment getEmbedlyCardUrlParams_paragraph on Paragraph {\n type\n iframe {\n mediaResource {\n iframeSrc\n __typename\n }\n __typename\n }\n __typename\n id\n}\n\nfragment MixtapeParagraph_paragraph on Paragraph {\n type\n mixtapeMetadata {\n href\n mediaResource {\n mediumCatalog {\n id\n __typename\n }\n __typename\n }\n __typename\n }\n ...GenericMixtapeParagraph_paragraph\n __typename\n id\n}\n\nfragment GenericMixtapeParagraph_paragraph on Paragraph {\n text\n mixtapeMetadata {\n href\n thumbnailImageId\n __typename\n }\n markups {\n start\n end\n type\n href\n __typename\n }\n __typename\n id\n}\n\nfragment normalizedBodyModel_richText on RichText {\n paragraphs {\n markups {\n type\n __typename\n }\n ...getParagraphHighlights_paragraph\n ...getParagraphPrivateNotes_paragraph\n __typename\n }\n sections {\n startIndex\n ...getSectionEndIndex_section\n __typename\n }\n ...getParagraphStyles_richText\n ...getParagraphSpaces_richText\n __typename\n}\n\nfragment getParagraphHighlights_paragraph on Paragraph {\n name\n __typename\n id\n}\n\nfragment getParagraphPrivateNotes_paragraph on Paragraph {\n name\n __typename\n id\n}\n\nfragment getSectionEndIndex_section on Section {\n startIndex\n __typename\n}\n\nfragment getParagraphStyles_richText on RichText {\n paragraphs {\n text\n type\n __typename\n }\n sections {\n ...getSectionEndIndex_section\n __typename\n }\n __typename\n}\n\nfragment getParagraphSpaces_richText on RichText {\n paragraphs {\n layout\n metadata {\n originalHeight\n originalWidth\n __typename\n }\n type\n ...paragraphExtendsImageGrid_paragraph\n __typename\n }\n ...getSeriesParagraphTopSpacings_richText\n ...getPostParagraphTopSpacings_richText\n __typename\n}\n\nfragment paragraphExtendsImageGrid_paragraph on Paragraph {\n layout\n type\n __typename\n id\n}\n\nfragment getSeriesParagraphTopSpacings_richText on RichText {\n paragraphs {\n id\n __typename\n }\n sections {\n startIndex\n __typename\n }\n __typename\n}\n\nfragment getPostParagraphTopSpacings_richText on RichText {\n paragraphs {\n layout\n text\n __typename\n }\n sections {\n startIndex\n __typename\n }\n __typename\n}\n" + } + ] + + body = Request.body(Request.URL("https://medium.com/_/graphql", "POST", query)) + if !body.nil? + json = JSON.parse(body) + json&.dig(0, "data", "post", "viewerEdge", "fullContent", "bodyModel", "paragraphs") else - result.map { |paragraph| content[paragraph["__ref"]] } + nil end end diff --git a/lib/ZMediumFetcher.rb b/lib/ZMediumFetcher.rb index 6621512..08441ec 100644 --- a/lib/ZMediumFetcher.rb +++ b/lib/ZMediumFetcher.rb @@ -127,7 +127,7 @@ def downloadPost(postURL, pathPolicy) postInfo = Post.parsePostInfoFromPostContent(postContent, postID) - sourceParagraphs = Post.parsePostParagraphsFromPostContent(postContent, postID) + sourceParagraphs = Post.fetchPostParagraphs(postID) if sourceParagraphs.nil? raise "Error: Paragraph not found! PostURL: #{postURL}" end @@ -140,7 +140,7 @@ def downloadPost(postURL, pathPolicy) previousParagraph = nil preTypeParagraphs = [] sourceParagraphs.each do |sourcParagraph| - paragraph = Paragraph.new(sourcParagraph, postID, postContent) + paragraph = Paragraph.new(sourcParagraph, postID) if OLIParser.isOLI(paragraph) oliIndex += 1 paragraph.oliIndex = oliIndex @@ -148,10 +148,11 @@ def downloadPost(postURL, pathPolicy) oliIndex = 0 end - # if previous is OLI or ULI and current is not OLI or ULI + # if previous is OLI or ULI or BQ and current is not OLI or ULI or BQ # than insert a blank paragraph to keep markdown foramt correct if (OLIParser.isOLI(previousParagraph) && !OLIParser.isOLI(paragraph)) || - (ULIParser.isULI(previousParagraph) && !ULIParser.isULI(paragraph)) + (ULIParser.isULI(previousParagraph) && !ULIParser.isULI(paragraph))|| + (BQParser.isBQ(previousParagraph) && !BQParser.isBQ(paragraph)) paragraphs.append(Paragraph.makeBlankParagraph(postID)) end @@ -178,7 +179,7 @@ def downloadPost(postURL, pathPolicy) groupByText += "\n" end - markupParser = MarkupParser.new(postHtml, preTypeParagraph) + markupParser = MarkupParser.new(preTypeParagraph) groupByText += markupParser.parse() end @@ -227,7 +228,7 @@ def downloadPost(postURL, pathPolicy) index = 0 paragraphs.each do |paragraph| - markupParser = MarkupParser.new(postHtml, paragraph) + markupParser = MarkupParser.new(paragraph) paragraph.text = markupParser.parse() result = startParser.parse(paragraph)