Geeking out on things that excite us

Tags


Nest - A Swifty Cache

23rd March 2017

When you build an app that relies on internet connection, caching is important! It contributes to the performance and efficiency on user data and energy consumption. There are many caching solutions out there, one for every platform, language, use case. How can we choose which best suits our needs? I will tell my story and I will try to explain why I finally ended implementing my own solution.

Use Cases

Defining the use cases is important. It helps us extract the specifications from real use scenarios.

Variable expiration policies

The Stoiximan sportsbook app displays content both time sensitive or not. For example the markets of an event are time sensitive because odds change all the time. On the other hand the list of the available sports does not change that often. So, the cache has to offer several expiration policies to cover all cases.

Cache - Storage

Another use case is how the application uses the cache. The application uses the MVC pattern, having a different controller for each entity. For example there is a User Controller, a Balance Controller and more. Each controller holds some data important for the operation of the application.
Some of this data may need to expire after some period of time, while other don’ t. In other words the cache should be used both as an expiring objects repository and as common storage for the app. For example the User Controller needs to store the user object inside the cache with a non expiration policy and provide a getter method to the outside world.

File persistance

Handling images gives as another interesting use case to explore. Images may be large in size, so we shouldn’ t keep them in memory longer than required and at the same time we shouldn’ t download them every time we need them. Remember that we need to be efficient on performance and data usage. What we need is to have a short expiration policy on memory, but persist the image to disk, so that the next time that the image will be requested from the UI, we will just read the file from the disk. So the cache has to support persisting the object to disk.
To do that we need to solve two problems, first, what kind of files can be persisted on disk and in which format? Second, for how long will we keep these files on disk? We need to be efficient and this means that we need to protect our app from increasing in size.

Groups

Finally, we need to be able to group the cached items based on who is responsible for them. Let me give an example. In the Stoiximan app, the User Controller stores the user object and many other user related objects into the cache. Some have an expiration policy, others do not. Upon user logout, we need to remove all the user related objects from the cache. We could just keep track of all the keys that our controller is responsible for, or we could group the cached objects and remove them with a single call.

Requirements

  • Short, medium, long and never expiration policies
  • File persistance expiration policies
  • Group cached items by owner
  • Thread safe
  • Friendly syntax
  • Networking framework independent
  • Store different types of data into the same data structure

Available Solutions

There are great caching implementation available out there. My highlights are Hyperoslo and AwesomeCache. They both cover almost all the feature set described above, but none of them covered my needs 100%.

  • They both offer different in-memory expiration policies but this also reflects to the file persistance policies. I need to remove an item from memory, but keep it in disk for later use.
  • None offer grouping the cached objects by owner. This can be very helpful in certain use cases as described above.
  • Code is more simple and easy to read/debug when the execution is synchronous. Both solutions have asynchronous implementations, although hypersolo offers a sync API too.

It may look that the above arguments are not that important to make me build my own implementation, but they actually are. They are because cache is key point of the application. It has to suit the needs of the business 100% because changing later may be challenging and time consuming.

Below is the Nest documentation, the caching solution I ended up developing for the Stoiximan iOS app.

Nest - Documentation

Nest is an easy to use cache library written in Swift 3.0, compatible with iOS, watchOS, macOS and Server Side Swift.

Features

  • Swifty syntax
  • Thread safe
  • File system persistance policies
  • No dependency on networking libraries
  • No limitation on the type of the cached items
  • Cached items can be grouped by owner, for grouped management
  • Synchronous API

Usage

Initialization

Initializes the cache and loads the persisted object containers from the disk. The actual persisted object is stored in a different file and will be loaded only if requested.

let _ = Nest.shared

Add item

Adds an object into the cache

let item = ["item1", "item2"]
let key = "key"
        
Nest.shared.add(item: item, withKey: key, expirationPolicy: .short)

Add - File Persistance

Adds an object into the cache and saves it to disk. In the following sample we are downloading an image from the web. Note that the im-memory policy is different from the persistance. We need to store the image to disk for a few days, but in memory, we need it just for a few minutes.

let item = ... // download an image from the web
let key = imageUrl //  let's say that the key here is the url of he image
        
Nest.shared.add(item: item, withKey: key, expirationPolicy: .short, andPersistancePolicy: .long))

Remove item

Removes an item from the cache.
If the file has a persistance policy, the file will be removed too.

Nest.shared.removeItem(with: key)

Get item

Fetches an object from cache.
If, based on the in-memory expiration policy, the object has expired and there is a persistance policy enabled, it will be loaded from disk and the the expiration policy will be reissued.

let item = Nest.shared[key]

Expiration Policies

There are 6 different expiration policies to choose from. Each one has a coresponding expiration interval. Of cource these values can be changed to match the business of each application. There is also a policy called custom where the expiration interval is specified explicitely.

public enum ExpirationPolicy: RawRepresentable {
    
    case short
    case medium
    case long
    case max
    case never
    case custom(TimeInterval)
}

Persistance Policies

The persistance policy is different from the expiration policy. For example we may choose to cache an image for 2 minutes in memory, but 2 days in the file system. After the memory expiration, the object is removed from the memory but its present in the file system. Persistance policies are available for objects implementing the NSCoding protocol. If the object does not implement the NSCoding protocol, the persistance policy is disabled.

public enum PersistancePolicy: RawRepresentable {
    
    case disabled
    case mirror     // mirrors the memory expiration policy
    case short
    case medium
    case long
}

Key Generator

The key is typically a String. In some implementations cached items are related under the same entity. These items may require some grouped management, especially on removal.
For example, in an application we have a User Controller to handle all the tasks related to the authenticated user. We may cache many of this data. When the user signs out, we need to remove all these items from our cache.
But how can we do that? We could keep record of all the keys that the controller is using. It works but it's not efficient.

Nest has the ability to know the "owner" of each cached item. This info is stored into the key.
Here is an example

class UserController {

    let identifier {

        return "UserController"
    }

    func fetchUserData() {

        // fetch the data..
        .....

        // add to cache
        let key = Nest.key(with: identifier, parameters: ["userData", username])
        Nest.shared.add(item: userData, withKey: key, expirationPolicy: .short)
    }

    func userDidLogout() {

        Nest.shared.clear(itemsOf: identifier)
    }
}

Threads and concurrent access

As mentioned above, this implementation is thread safe. Let's say a few words about this.
There are two ways to access the cache, to read (get or enumerate) and to write (add or remove). Swift and GCD (Grand Central Dispatch) gives us the tools to avoid the use of locks, at least not in a explicit way. So, all the write access is being driven via a dedicated serial queue, to avoid any race condition.

let queue = DispatchQueue(label: "com.nest.writeQueue")

func syncAdd(_ item: Seed, with key: String) {
        
    queue.sync { storage[key] = item }
}

func syncRemove(itemWith key: String) {
        
    let _ = queue.sync { storage.removeValue(forKey: key) }
}

As far as read access is concenred, the value type nature of the Dictionary solves the issue. Before any access or enumeration, a snapshot of the dictionary is being kept in a local variable. This "copy" of the dictionary is now safe to perform any read action.

subscript (key: String) -> Any? {
        
    let _storage = storage
    guard let object = _storage[key]?.object else {
            
        syncRemove(itemWith: key)
        return nil
    }
        
    return object
}

open func clear(ItemsOf owner: String? = nil) {
        
    let _storage = storage
        
     _storage.forEach { (key, item) in
         ...
     }
}

Note

For iOS applications whose state (background, foreground) changes quite often, we should make sure to call Nest.shared.clearExpired() in the UIApplicationDidBecomeActive event.

Please find the source code and tests on Github

View Comments