Manjusaka

Manjusaka

Swift Declarative Programming

In my first job as an iOS developer, I wrote an XML parser and a simple layout tool, both based on declarative interfaces. The XML parser was implemented using .plist files to achieve Objective-C class relationship mapping. The layout tool allowed you to create interface layouts using a tag-like syntax similar to HTML (though this tool assumes that AutoLayout & CollectionViews are used correctly).

Although neither of these libraries was perfect, they demonstrated four major advantages of declarative code:

  • Separation of Concerns: When we write code in a declarative style, we declare our intentions, allowing us to focus less on the specific underlying implementations; this separation occurs naturally.
  • Reduced Code Duplication: All declarative code shares a common style implementation, much of which belongs to configuration files, thus reducing the risks associated with duplicated code.
  • Excellent API Design: Declarative APIs allow users to customize existing implementations rather than treating them as fixed entities. This ensures that the degree of modification is minimized.
  • Good Readability: Honestly, code written according to declarative APIs is simply beautiful.

Most of the Swift code I write these days is very suitable for a declarative programming style.

Whether describing a particular data structure or implementing a specific feature, I most often use simple structs during the writing process. Different types are declared mainly based on generic classes, and these types are responsible for implementing specific functionalities or completing necessary tasks. We adopted this approach during our development process at PlanGrid to write our Swift code. This development method has had a significant impact on improving code readability and developer efficiency.

In this article, I want to discuss the API design used in the PlanGrid application, which was originally implemented using NSOperationQueue but now employs a more declarative approach—discussing this API should showcase the benefits of declarative programming in various aspects.

Building a Declarative Request Sequence in Swift#

The API we redesigned is used to synchronize local changes (which may also occur offline) with the API server. I won't discuss the details of this change tracking method but will focus on the generation and execution of network requests.

In this article, I want to focus on a specific type of request: uploading locally generated images. For various reasons (beyond the scope of this article), the image upload operation includes three requests:

  1. Initiate a request to the API server, which will respond with the information needed to upload the image to the AWS server.
  2. Upload the image to AWS (using the information obtained from the previous request).
  3. Initiate a request to the API server to confirm that the image upload was successful.

Since we have an upload task that includes these request sequences, we decided to abstract it into a special type and let our upload architecture support it.

Defining the Request Sequence Protocol#

We decided to introduce a separate type to describe the sequence of network requests. This type will be used by our uploader class, which is responsible for converting the description into actual network requests (note that we will not discuss the implementation of the uploader class in this article).

The next type is the essence of our control flow: we have a request sequence where each request may depend on the result of the previous request.

Tip: Some naming conventions for types in the following code may seem a bit odd, but most of them are named based on application-specific terminology (e.g., 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?
    }

By calling the nextRequest: method to generate a request from the request sequence, we provide a reference to the previous request, including NSURLResponse and the JSON response body (if it exists). The result of each request may produce the next request (which will return a PushRequest object), except when there is no next request (returning nil) or if something else occurs during the request process that prevents the necessary response from being returned (in which case the request sequence throws).

It is worth noting that PushRequest is not an ideal name for this return value type. This type merely describes the details of a request (terminator, HTTP method, etc.) and does not participate in any substantive work. This is an important aspect of declarative design.

You may have noticed that this protocol depends on a specific class; we did this because we realized that OperationRequestSequence is a state description type. It needs to be able to capture and use the results produced by previous requests (for example, the response result of the first request may be needed in the third request). This approach references the structure of mutating methods, and it must be said that this behavior seems to complicate the code related to upload operations (so reassigning mutable structs is not as simple as it seems).

After implementing our first request sequence based on the OperationRequestSequence protocol, we found that it was more appropriate to simply provide an array to hold the request chain than to implement the nextRequest method. So we added the ArrayRequestSequence protocol to provide an implementation of a request array:

    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
        }
    }

At this point, we defined a new upload sequence, which was just a small amount of work.

Implementing the Request Sequence Protocol#

As a small example, let's look at the upload sequence used to upload snapshots (in PlanGrid, snapshots refer to exportable blueprints or annotations drawn in images):

    /// 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
            )
        }

    }

During the implementation process, you should note a few things:

  • There is almost no imperative code here. Most of the code describes network requests through instance variables and the results of previous requests.
  • The code does not call the network layer and has no type information for any upload operations. It merely describes the details of each request. In fact, this code has no observable side effects; it only changes internal state.
  • There is essentially no error handling code in this code. This type only handles specific errors that occur within the request sequence (e.g., the previous request did not return any results, etc.). Other errors are typically handled at the network layer.
  • We use PushInMemoryRequestDescription/AWSMultipartRequestDescription to abstract our requests to our API server or to the AWS server. Our upload code will switch between the two as needed, using different URL session configurations for each to avoid sending our own API server's authentication information to AWS.

I won't discuss the entire code in detail, but I hope this example adequately demonstrates the series of advantages of the declarative design approach that I mentioned earlier:

  • Separation of Concerns: The types written above only have the single function of describing a series of requests.
  • Reduced Code Duplication: The types written above only contain code that describes requests and do not include code for network requests or error handling.
  • Excellent API Design: This API design effectively reduces the burden on developers; they only need to implement a simple protocol to ensure that subsequent requests are based on the results of the previous request.
  • Good Readability: Again, the above code is very focused; we can find the intent of the code without swimming in a sea of boilerplate code. This also means that to understand this code quickly, you need to have some understanding of our abstraction approach.

Now, consider what would happen if we used NSOperationQueue to replace our solution.

What is NSOperationQueue?#

The solution using NSOperationQueue is much more complex, so providing corresponding code in this article is not a good choice. However, we can still discuss this approach.

Separation of Concerns is difficult to achieve in this approach. Unlike the simple abstraction of request sequences, NSOperations in NSOperationQueue will be responsible for the switching operations of network requests. This includes features like request cancellation and error handling. Similar upload code exists in different places, making it difficult to reuse. In most cases where upload requests are abstracted into an NSOperation, using subclasses is not a good choice, even though our upload request queue is abstracted into an NSOperation decorated by NSOperationQueue.

There is a lot of irrelevant information in NSOperationQueue. The code is filled with operations and calls to specific methods in NSOperation, such as main and finish. Without a deep understanding of the specific API calling rules, it is hard to know what the specific operations are for.

The way this API is handled, in a sense, worsens the developer experience. Unlike the simple implementation of a corresponding protocol, in Swift, if the above development approach is adopted, people need to understand some conventional rules, even if these rules may not be strictly enforced.

This handling will significantly increase the burden on developers. Unlike implementing a simple protocol, in the new version of Swift, implementing such code requires understanding some unique conventions. Although many of the recorded conventions are not related to programming.

For some other reasons, this API may lead to bugs related to error reporting for network requests. To avoid each request operation executing its own error reporting code, we centralized it in one place. The error handling code will begin executing after the request ends. The code will then check whether the value of the error property in the request type exists. To provide timely feedback on error messages, developers need to set the value of the error property in NSOperation before the operation completes. Due to this non-mandatory convention, a lot of new code forgets to set its property value, which may lead to the loss of many error messages.

So, we look forward to this new approach we introduced to help developers write upload and other functional code in the future.

Conclusion#

The declarative programming approach has had a significant impact on our programming skills and development efficiency. We provide a limited API that is single-purpose and does not leave behind a bunch of mysterious bugs. We can avoid using subclasses and polymorphism, opting instead for declarative-style code based on generic types. We can write beautiful code. The code we write is also easily testable (about this, programming enthusiasts may feel that testing in declarative-style code may not be necessary). So you might ask, "Don't tell me this is a flawless programming approach?"

First, in the specific abstraction process, we may spend some time and effort. However, this expenditure can be offset by carefully designing the API and providing some tests to implement functionality as a reference for users.

Second, please note that declarative programming is not suitable for every situation or business. To apply declarative programming, your codebase must have at least one solution that has solved similar problems multiple times. If you try to use declarative programming in an application that requires high customization and then incorrectly abstract the entire code, you will end up with a half declarative code that resembles a tangled mess. For any abstraction process, premature abstraction can lead to a host of confusing issues.

Declarative APIs effectively shift the pressure from API users to API developers, which is not the case with imperative APIs. To provide a set of excellent declarative APIs, API developers must ensure a strict separation between the usage of the interface and the implementation details of the interface. However, APIs that strictly adhere to this requirement are rare. React and GraphQL have proven that declarative APIs can effectively enhance the coding experience for teams.

In fact, I believe this is just the beginning; we will gradually discover the complex details hidden in complex libraries and the simple, user-friendly interfaces they provide. I look forward to the day when we can build our iOS applications using a UI library based on declarative programming.

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.