Manjusaka

Manjusaka

Swift 聲明式程式設計

在我第一份 iOS 開發工程師的工作中,我編寫了一個 XML 解析器和一個簡單的佈局工具,兩個東西都是基於聲明式接口。XML 解析器是基於 .plist 文件來實現 Objective-C 類關係映射。而佈局工具則允許你利用類似 HTML 一樣標籤化的語法來實現介面佈局(不過這個工具使用的前提是已經正確使用 AutoLayout & CollectionViews)。

儘管這兩個庫都不完美,它們還是展現了聲明式代碼的四大優點:

  • 關注點分離: 我們在使用聲明式風格編寫的代碼時聲明了意圖,從而無需關注具體的底層實現,可以說這樣的分離是自然發生的。
  • 減少重複的代碼: 所有聲明式代碼都共用一套樣式實現,這裡面很多屬於配置文件,這樣可以減少重複代碼所帶來的風險。
  • 優秀的 API 設計: 聲明式 API 可以讓用戶自行定制已有實現,而不是將已有實現做一種固定的存在看待。這樣可以保證修改程度降至最小。
  • 良好的可讀性: 講真,按照聲明式 API 所寫出來的代碼簡直優美無比。

這些天我寫的大多數 Swift 代碼非常適用於聲明式編程風格。

不管是對於某一種數據結構的描述,或者是對某個功能的實現,在編寫過程中,我最常使用的類型還是一些簡單的構造體。聲明不同的類型,主要是基於泛型類,然後這些東西負責實現具體的功能或者完成必要的工作。我們在 PlanGrid 開發過程中採用這種方法來編寫我們的 Swift 代碼。這種開發方式已經對代碼可讀性的提升還有開發人員的效率提升上產生了巨大的影響。

本文我想討論的是 PlanGrid 應用中所使用的 API 設計,它原本使用 NSOperationQueue 實現,現在使用了一種更接近聲明式的方法-討論這個 API 應該可以展示聲明式編程風格在各方面的好處。

在 Swift 中構建一個聲明式請求序列#

我們重新設計的 API 用來將本地變化(也可能是離線發生的)與 API 服務器進行同步。我不會討論這種變化追蹤方法的細節,而是將精力放在網絡請求的生成和執行上。

在這篇文章裡,我想專注於一個特定的請求類型上:上傳本地生成的圖片。出於多種因素的考慮(超出本文討論範圍),上傳圖片的操作包括三次請求:

  1. 向 API 服務器發起請求,API 服務器將會響應,響應內容為向 AWS 服務器上傳圖片所需信息。
  2. 上傳圖片至 AWS (使用上次請求得到的信息)。
  3. 向 API 服務器發起請求以確認圖片上傳成功。

既然我們有包括這些請求序列的上傳任務,我們決定將其抽象成一個特殊的類型,並讓我們的上傳架構支持它。

定義請求序列協議#

我們決定引入一個單獨的類型來對網絡請求序列進行描述。這個類型將被我們的上傳者類使用,上傳者類的作用是將描述轉化為實在的網絡請求(要提醒你們的是我們不會在本篇文章中討論上傳者類的實現)。

接下來這個類型是我們控制流的精髓:我們有一個請求序列,序列中的每個請求都可能依賴於前一個請求的結果。

小貼士:接下來的代碼裡的一些類型的命名方式看起來有點奇怪,但是它們中大多數是根據應用專屬術語集來命名的(如: Operation )。

    public typealias PreviousRequestTuple = (
    	request: PushRequest,
    	response: NSURLResponse,
    	responseBody: JsonValue?
    )

    /// A sequence of push requests required to sync this operation with the server.
    /// As soon as a request of this sequence completes,
    /// `PushSyncQueueManager` will poll the sequence for the next request.
    /// If `nil` is returned for the `nextRequest` then
    /// this sequence is considered complete.
    public protocol OperationRequestSequence: class {
        /// When this method returns `nil` the entire `OperationRequestSequence`
        /// is considered completed.
        func nextRequest(previousRequest: PreviousRequestTuple?) throws -> PushRequest?
    }

通過調用 nextRequest: 方法來讓請求序列生成一個請求時,我們提供了一個對前一個請求的引用,包括 NSURLResponse 和 JSON 響應體(如果存在的話)。每一個請求的結果都可能在下一次請求時產生((將會返回一個 PushRequest 對象),除了沒有下一次請求(返回 nil )或者在請求過程中發生了一些以外的情況導致沒有返回必要的響應以外(請求序列在該情況下 throws )。

值得注意的是, PushRequest 並不是這個返回值類型的理想名。這個類型只是描述一個請求的詳情(結束符,HTTP 方法等等),其並不參與任何實質性的工作。這是聲明式設計中很重要的一個方面。

你可能已經注意到了這個協議依賴於一個特定 class ,我們這樣做是因為我們意識到 OperationRequestSequence 其是一個狀態描述類型。它需要能夠捕獲並使用前面的請求所產生的結果(比如:在第三個請求裡可能需要獲取第一個請求的響應結果)。這個做法參考了 mutating 方法的結構,不得不說這樣的行為貌似讓這部分有關上傳操作的代碼變得更為複雜了(所以說重新賦值變化結構體並不是一件那麼簡單的事兒)

在基於 OperationRequestSequence 協議實現了我們第一個請求序列後,我們發現相比實現 nextRequest 方法來說,簡單地提供一個數組來保存請求鏈更合適。於是我們便添加了 ArrayRequestSequence 協議來提供了一個請求數組的實現:

    public typealias RequestContinuation = (previous: PreviousRequestTuple?) throws -> PushRequest?

    public protocol ArrayRequestSequence: OperationRequestSequence {
        var currentRequestIndex: Int { get set }
        var requests: [RequestContinuation] { get }
    }

    extension ArrayRequestSequence {
        public func nextRequest(previous: PreviousRequestTuple?) throws -> PushRequest? {
            let nextRequest = try self.requests[self.currentRequestIndex](previous: previous)
            self.currentRequestIndex += 1
            return nextRequest
        }
    }

這個時候,我們定義了一個新的上傳序列,這只是很微小的一點工作。

實現請求序列協議#

作為一個小例子,讓我們看看用來上傳快照的上傳序列吧(在 PlanGrid 中,快照指的是在圖片中繪製的可導出的藍圖或者註釋):

    /// Describes a sequence of requests for uploading a snapshot.
    final class SnapshotUploadRequestSequence: ArrayRequestSequence {

        // Removed boilerplate initializer &
        // instance variable definition code...

        // This is the definition of the request sequence
        lazy var requests: [RequestContinuation] = {
            return [
                // 1\. Get AWS Upload Package from API
                self._allocationRequest,
                // 2\. Upload Snapshot to AWS
                self._awsUploadRequest,
                // 3\. Confirm Upload with API
                self._metadataRequest
            ]
        }()

        // It follows the detailed definition of the individual requests:

        func _allocationRequest(previous: PreviousRequestTuple?) throws -> PushRequest? {
        	// Generate an API request for this file upload
        	// Pass file size in JSON format in the request body
            return PushInMemoryRequestDescription(
                relativeURL: ApiEndpoints.snapshotAllocation(self.affectedModelUid.value),
                httpMethod: .POST,
                jsonBody: JsonValue(values:
                    [
                        "filesize" : self.imageUploadDescription.fullFileSize
                    ]
                ),
                operationId: self.operationId,
                affectedModelUid: self.affectedModelUid,
                requestIdentifier: SnapshotUploadRequestSequence.allocationRequest
            )
        }

        func _awsUploadRequest(previous: PreviousRequestTuple?) throws -> PushRequest? {
        	// Check for presence of AWS allocation data in response body
            guard let allocationData = previous?.responseBody else {
                throw ImageCreationOperationError.MissingAllocationData
            }

            // Attempt to parse AWS allocation data
            self.snapshotAllocationData = try AWSAllocationPackage(json: allocationData["snapshot"])

            guard let snapshotAllocationData = self.snapshotAllocationData else {
                throw ImageCreationOperationError.MissingAllocationData
            }

            // Get filesystem path for this snapshot
            let thumbImageFilePath = NSURL(fileURLWithPath:
                SnapshotModel.pathForUid(
                    self.imageUploadDescription.modelUid,
                    size: .Full
                )
            )

            // Generate a multipart/form-data request
            // that uploads the image to AWS
            return AWSMultiPartRequestDescription(
                targetURL: snapshotAllocationData.targetUrl,
                httpMethod: .POST,
                fileURL: thumbImageFilePath,
                filename: snapshotAllocationData.filename,
                operationId: self.operationId,
                affectedModelUid: self.affectedModelUid,
                requestIdentifier: SnapshotUploadRequestSequence.snapshotAWS,
                formParameters: snapshotAllocationData.fields
            )
        }

        func _metadataRequest(previous: PreviousRequestTuple?) throws -> PushRequest? {
            // Generate an API request to confirm the completed upload
            return PushInMemoryRequestDescription(
                relativeURL: ApiEndpoints.snapshotAllocation(self.affectedModelUid.value),
                httpMethod: .PUT,
                jsonBody: self.snapshotMetadata,
                operationId: self.operationId,
                affectedModelUid: self.affectedModelUid,
                requestIdentifier: SnapshotUploadRequestSequence.metadataRequest
            )
        }

    }

在實現的過程中你應該注意這樣幾件事情:

  • 這裡面幾乎沒有命令式代碼。大多數的代碼都通過實例變量和前次請求的結果來描述網絡請求。
  • 代碼並不調用網絡層,也沒有任何上傳操作的類型信息。它們只是對每個請求的詳情進行了描述。事實上,這段代碼沒有能被觀測到的副作用,它只更改了內部狀態。
  • 這段代碼裡可以說沒有任何的錯誤處理代碼。這個類型只負責處理該請求序列中發生的特定錯誤(比如前次請求並未返回任何結果等)。而其餘的錯誤通常都在網絡層予以處理了。
  • 我們使用 PushInMemoryRequestDescription/AWSMultipartRequestDescription 來對我們對自己的 API 服務器或者是對 AWS 服務器發起請求的行為進行抽象。我們的上傳代碼將會根據情況在兩者之間進行切換,對兩者使用不同的 URL 會話配置,以免將我們自有 API 服務器的認證信息發送至 AWS 。

我不會詳細討論整個代碼,但是我希望這個例子能充分展現我之前提到過的聲明式設計方法的一系列優點:

  • 關注點分離: 上面編寫的類型只有描述一系列請求這一單一功能。
  • 減少重複的代碼: 上面編寫的類型裡面只包含對請求進行描述的代碼,並不包含網絡請求及錯誤處理的代碼。
  • 優秀的 API 設計: 這樣的 API 設計能有效的減輕開發者的負擔,他們只需要實現一個簡單的協議以確保後續產生的請求是基於前一個請求結果的即可。
  • 良好的可讀性: 再次聲明,以上代碼非常集中;我們不需要在樣板代碼的海洋裡游泳,就可以找到代碼的意圖。那也說明,為了更快地理解這段代碼,你需要對我們的抽象方式有一定的了解。

現在可以想想如果利用 NSOperationQueue 來替代我們的方案會怎麼樣?

什麼是 NSOperationQueue#

採用 NSOperationQueue 的方案複雜了很多,所以在這篇文章裡給出相對應的代碼並不是一個很好的選擇。不過我們還是可以討論下這種方案。

關注點分離在這種方案中難以實現。和對請求序列進行簡單抽象不同的是,NSOperationQueue 中的 NSOperations 對象將負責網絡請求的開關操作。這裡面包含請求取消和錯誤處理等特性。在不同的位置都有相似的上傳代碼,同時這些代碼很難進行重用。在大多數上傳請求被抽象成一個 NSOperation 的情況下,使用子類並不是一個好選擇,雖然說我們的上傳請求隊列被抽象成為一個被 NSOperationQueue 所裝飾的 NSOperation

NSOperationQueue 中的無關信息相當多。。代碼中隨處可見對網絡層的操作和調用 NSOperation 中的特定方法,比如 mainfinish 方法。在沒有深入了解具體的 API 調用規則前,很難知道具體操作是用來做什麼的

這種 API 所採用的處理方式,某種意義上讓開發者的開發體驗變得更差了。和簡單的實現相對應的協議不同的是,在 Swift 中如果採用上述的開發方式,人們需要去了解一些約定俗成的規定,儘管這些規定可能並不強制要求你遵守。

** 這種處理方式將會顯著增加開發者的負擔。** 與實現一個簡單協議不同的是,在新版本的 Swift 中實現這樣的代碼的話,我們需要去理解一些特有的約定。儘管很多被記載下來的約定並不是與編程相關的。

由於一些其他原因,該 API 可能會導致一些與網絡請求的錯誤報告相關的 bug 。為了避免每個請求操作都執行自己的錯誤報告代碼,我們將其集中在一個地方進行處理。錯誤處理代碼將會在請求結束之後開始執行。然後代碼將會檢查請求類型中的 error 屬性的值是否存在。為了及時地反饋錯誤信息,開發者需要及時在操作完成之前設置 NSOperation 中的 error 屬性的值。由於這是一個非強制性約定導致一堆新代碼忘記設置其屬性的值,可能會導致諸多錯誤信息的遺失。

所以啊,我們很期待我們介紹的這樣一種新的方式能幫助開發者們在未來編寫上傳及其餘功能的代碼。

總結#

聲明式的編程方法已經對我們的編程技能和開發效率產生了巨大的影響。我們提供了一種受限的 API ,這種 API 用途單一且不會留下一堆迷之 Bug 。我們可以避免使用子類及多態等一系列手段,轉而使用基於泛型類型的聲明式風格代碼來替代它。我們可以寫出優美的代碼。我們所編寫的代碼都是能很方便的進行測試的(關於這點,編程愛好者們可能覺得在聲明式風格代碼中測試可能不是必要的。)所以你可能想問:“別告訴我這是一種完美無瑕的編程方式?”

首先,在具體的抽象過程中,我們可能會花費一些時間與精力。不過,這種花費可以通過仔細設計 API ,並並通過提供一些測試,代替用例實現功能,為使用者提供參考。

其次,請注意,聲明式編程並不是適用於任何時間任何業務的。要想適用聲明式編程,你的代碼庫裡至少要有一個用相似方法解決了多次的問題。如果你嘗試在一個需要高度可定制化的應用裡使用聲明式編程, 然後你又對整個代碼進行了錯誤的抽象,那麼最後你會得到如同亂麻一般的聲明式代碼。對於任何的抽象過程而言,過早地進行抽象都會造成一大堆令人費解的問題。

聲明式 API 有效地將 API 使用者身上的壓力轉移至 API 開發者身上,對於命令式 API 則不需要這樣。為了提供一組優秀的聲明式 API ,API 的開發者必須確保接口的使用與接口的實現細節進行嚴格的隔離。不過嚴格遵循這樣要求的 API 是很少的。React 和 GraphQL 證明了聲明式 API 能有效提升團隊編碼的體驗。

其實我覺得,這只是個開端,我們會慢慢發現在複雜的庫中所隱藏複雜的細節和對外提供的簡單易用的接口。期待有一天,我們能利用一個基於聲明式編程的 UI 庫來構建我們的 iOS 程序。

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。