Geeking out on things that excite us

Tags


Building an automatic serialization / deserialization mechanism in Swift

30th November 2016

A typical problem when implementing API clients is the process of converting a JSON object o a class that means something to the application. From my experience this process has to be easy to use, easy to understand and highly customizable. There are several approaches on how to do that and there are several libraries that offer such functionality.
A typical solution is to create a class in our app mapping the JSON response using the same naming convention.
So let the JSON response of a get user endpoint be like this:

{ "firstName": "John", "lastName" : "Doe" }

The corresponding class would be like this:

 class User {

    var firstName: String?
    var lastName: String?
}

Sounds great, but how can this approach deal with minified JSON responses? In this case the firstName JSON key would probably be fn. Following the pattern above we should rename our property to fn. But should we? How does this change affects the readability of our code? It affects it a lot in my opinion and that’ s because sometimes it may not be clear what an abbreviation really means. How do we overcome this problem? We should use setters and getters to access the class properties wrapping the minified names of our properties.

Lets’ see how this affects our code:

 class User {

    var fn: String?
    var firstName: String? {

        get {

            return fn 
        }
        set (value) {

            fn = value 
        }
    }

    var ln: String?
    var lastName: String? {

        get {

            return ln 
        }
        set (value) {

            ln = value 
         } 
    } 
}

It seems that we ended up adding quite some lines in our code, just to access the properties of our model. And what if the type of the JSON object does not match the type of our property? For instance if the server sends the date object as a UNIX timestamp and we want to represent the date as a NSDate object? Then we would have to add some more lines to transform the data to the desired format.
This is a quite solid approach to our problem but I as a developer I don’ t want to write so many lines of code just to do deal with a problem that should have already been solved.
What I want, is to be able to change or add properties to my model with the minimum effort possible.

So, let’ s take an other approach. First of all let’ s use a base class to hide all the dirty work the serialization / deserialization requires.
Here are the specs:

  1. The naming of our properties should not be affected by the JSON object.
  2. The mechanism should offer both serialization and deserialization methods.
  3. We need to support custom typed properties and arrays of custom typed objects.
  4. We need the code to be clean and minimalistic.

To begin with, we need to write down what we need to do to perform a deserialization of an object and what is being offered by the system.

  1. Inheriting from the NSObject gives us access to the KVO (key, value, observation) APIs which can be very helpful in setting and getting values from our properties using the name of the property.
  2. We also need to map the JSON keys to the properties of our class.
  3. We need to know how to create a new instance of a custom typed object and an array of
    custom typed objects.

So taking all the above into consideration we will end up with a class like this:

class BaseModel: NSObject {

    var apiToLocalMap: [String: String]!
    var localToApiMap: [String: String]!

    var consctuctorMap: [String: () -> AnyObject]!

    func apiToLocalMaping() -> [String: String] { 

        return [String: String]() 
    }

    func propertyConstructorMappings() -> [String: () -> AnyObject] { 

        return [:] 
    }
}

The apiToLocalMap is a dictionary where the keys are the JSON names, the values are the local property names and it will be used during the deserialization. The localToAPIMap is exactly the opposite and will be used the during serialization.
The constructorMap is a dictionary where its keys represent the name of the class properties and the values are closures returning a new instance to set to the property.

Let’ s see how the User class mentioned above will transform when inheriting from the BaseModel.

class User: BaseModel {

    var firstName: String?
    var lastName: String?

    override func apiToLocalMaping() -> [String: String] {

        return [ "fn": "firstName", "ln" : "lastName" ]
    }
}

Now lets define a new class to represent a bet of a user.

class Bet: BaseModel {

    var betId: String?
    var amount: Double = 0

    override func apiToLocalMaping() -> [String: String] {

        return [ "i": "betId", "a" : "amount" ]
    }
}

Let's add a property to the user class with an array of the bets. To do that we will need to add a new entry to the apiToLocalMaping and to specify how to create an array of bets plus an element of this array.

class User: BaseModel {

    var firstName: String?
    var lastName: String?
    var bets: [Bet]?

    override func apiToLocalMaping() -> [String: String] {

        return [ "fn": "firstName", "ln" : "lastName", "b" : "bets" ]
    }

    override func propertyConstructorMappings() -> [String : () -> AnyObject] {

        let map = [ "bets"  : { () -> AnyObject in return [Bet]() },
                    "bets." : { () -> AnyObject in return Bet() } ]

        return map 
    }
}

As we can see we did override the propertyConstructorMappings method to tell the deserialization mechanism how to create a new array of Bets and how to create a new Bet instance inside the array. Note the naming convention to describe how to create an element of an array. We use the same key as with array and just append a dot "." at the end.
As far as the User and the Bet classes are concerned the job is done there. Now we need to go back to the BaseModel class and write the serialization and deserialization functionality.
The first thing we need to do in the BaseModel class is to setup the mappings and reverse the apiToLocalMap in order to generate the localToApiMap.

func configureMappings() {

     consctuctorMap = propertyConstructorMappings()
     apiToLocalMap = apiToLocalMaping()
     localToApiMap = [:]

     for (key, value) in apiToLocalMap { 

         localToApiMap[value] = key 
     }

     mappingsConfigured = true
}

Now let’ s move on to the deserialization code.

func deserialize(object: [String: AnyObject]) -> Bool {

    if mappingsConfigured == false {

        configureMappings() 
    }

    for (key, value) in object {

        guard !value.isEqual(NSNull())  else { continue }

        // make sure the local path exists in the mappings
        guard let localPath = apiToLocalMap[key] else { continue }

        // check if the property has value
        if let objectValue = valueForKeyPath(localPath) {

            // the property has value, now check if its type supports serializing
            if let deserializedValue = tryDeserialize(objectValue, value: value, localPath: localPath) {

                setValue(deserializedValue, forKey: localPath)
            }
            else { 

                return false 
            }
        }
        else {

            // the property is nil, so we need to check if the constcuctor map knows
            //how to create a new object for this property
            if let closure = consctuctorMap[localPath] {

                // we know how to construct the new object, so go construct it
                let newObject = closure()

                // now try to feel it with the data
                if let deserializedValue = tryDeserialize(newObject, value: value, localPath: localPath) {

                    setValue(deserializedValue, forKey: localPath)
                }
                else {  

                    return false  
                }
            }
            else {

                // if the constructor map contains no info on how to create a new object,
                //simply set the value
                setValue(value, forKey: localPath)
            } 
        }
    }

    return true
}

The deserialization method uses a helper method called tryDeserialize. This method is responsible to "prepare" the new value before it will be set to the provided property.
There are possible 3 cases at this point:

  1. The property is a BaseModel subclass, thus we can deserialize it just by invoking its deserialize method.
  2. The property is an array. In this case we initialize the array with closure provided by the contructorMapings. Each element of the array can be either BaseModel subclass or primitive type. Either way we handle it and return the deserialized array.
  3. The property is either primitive or unknown type. In this case we simply return the value to be set to the appropriate property of the class.

Bellow we can see the implementation fo the tryDeserialize method handling all the above.

func tryDeserialize(property: AnyObject, value: AnyObject, localPath: String) -> AnyObject? {

    if let objectProperty = property as? BaseModel {

        // if its BaseModel, we can deserialize it by the calling the
        // deserialize method of the base class
        if let _rawObject = value as? [String: AnyObject] {

            let res = objectProperty.deserialize(_rawObject)
            return res ? objectProperty : nil
        }
    }
    else if var array = property as? [AnyObject] {

        if let rawArray = value as? [[String: AnyObject]] { // object

            array.removeAll() // clear the array

            // make sure that the object has provided a way to create the elements of the array
            guard let closure = consctuctorMap[String(format: "%@.", localPath)] else { return  nil }

            // iterate all the elements of the raw array and constuct the actual objects
            for rawElement in rawArray {

                // create the new object
                let element = closure()

                // if the created object is a subclass of the BaseModel deserialize it
                // and add it to the array
                if let baseModelelement =  element as? BaseModel {

                    let res = baseModelelement.deserialize(rawElement)
                    guard res == true else { return nil }
                    array.append(baseModelelement)
                } 
                else {

                    // if its not a BaseModel then just append it to the array
                    array.append(element)
                }
            }
            return array
        }
    }

    // at this point no special treatment needed for the given property,
    // so just return it as is so that the deserialize method will set it to the
    // property of the object
    return value
}

Now that we are done with the deserialization part, we can move on to the serialization method. The implementation follows the same principle but in reverse.
Let’ s see the code.

func serialize() -> [String: AnyObject] {

    if mappingsConfigured == false {  

        configureMappings() 
    }

    var object = [String: AnyObject]()

    for (key, value) in localToApiMap {

        if let propertyValue = self.valueForKeyPath(key) as? BaseModel {

            object[value] = propertyValue.serialize()
        }
        else if let propertyValue = self.valueForKeyPath(key) as? [BaseModel] {

            var array: [AnyObject] = []
            for baseModel in propertyValue {

                let item = baseModel.serialize()
                array.append(item)
            }
            object[value] = array
        }
        else if let propertyValue = self.valueForKeyPath(key) { 

            object[value] = propertyValue 
        }
    }

    return object
}

So, what we have actually done with the use of the BaseModel is to hide to single point all the dirty work the serialization / deserialization requires and hopefully we will not have to open that file again!

Furthermore, if an object requires a more complex deserialization or serialization procedure, we can always override the deserialize / serialize methods to do all the extra work required. Any special treatment required for one or more properties does not prevent us from taking advantage of the automated process. By removing a key from the mappings, we exclude it from the automated process and we can handle it manually by overriding the deserialize / serialize method and calling the super before our code.

With this mechanism adding, removing or modifying properties is a piece of cake for the developer. Also we should note that the use of KVO APIs slightly affects the performance and given the fact that this code will run on the client, the additional CPU that may be required will not affect the overall performance.

A nice addition to the BaseModel class would be the implementation of the NSCoding protocol. The NSCoder objects are quite similar to the dictionaries and we could use the mapping dictionaries to perform a different kind of serialization / deserialization.

Implementing the BaseModel class required a great effort both in writing and testing, but considering the amount of different models required in the Stoiximan sportsbook application and how often these classes need to change, I believe that the effort was not in vain.

Please find the Swift 3.0 source code on GitHub

View Comments