Nest - A Swifty Cache
23rd March 2017When 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