Open Source Stories: From Cachable to Generic Storage in Cache
We have been doing open source for a while, you may have met some of our work on GitHub or read some of our stories. We don’t try to reinvent the wheel, but there are many components we need specifically for our workflow, or things that need to be customised for the apps we are building. So we built many frameworks and apps. And as we are using them in our production apps, we think it might be a good idea to share them with the world. This is a win win situation since we contribute back to the community, while getting lots of feedback and advice. Being a small iOS team, doing client projects full time while trying to get a bit of free time to work on open source is very challenging.
Open source is all about building abstractions. By separating responsibilities and making reusable frameworks, we learn the most of Swift, as well as grasping some nifty nitty details about the APIs we are working with. But we have never told about how we do things. So there will be a series of our open source stories, detailing the technical aspects behind our work as long as the open source experience.
Firstly, let’s talk about Cache, a framework to persist object. Here we learn how to evolve the APIs to support new features of Swift language and iOS, tvOS platforms, while ensuring them flexible and maintainable enough.
Cache doesn’t claim to be unique in this area, but it’s not another monster library that gives you a god’s power. It does nothing but caching, but it does it well. It offers a good public API with out-of-box implementations and great customization possibilities
There can be many solutions for caching in iOS platforms, like SQLite, CoreData or Realm, or other 3rd libraries. What we want from Cache is a simple way to store some JSON data to disk, expiry management and a APIs we are comfortable with.
From a user perspective, I want to reliably save and load an object using a key, and the ability to do that either synchronously or asynchronously. Here are the APIs we aimed to achieve.
In the first releases, we introduced
Cachable protocol, given the fact that objects should be serialised and deserialised to
Data for disk storage. We also conform most primitive types to Cachable, so users don’t have to do this themselves. For memory storage, we use
NSCache under the hood so object will be saved as is.
Cache is based on the concept of having front- and back- caches. A request to a front cache should be less time and memory consuming (
NSCacheis used by default here). The difference between front and back caching is that back caching is used for content that outlives the application life-cycle. See it more like a convenient way to store user information that should persist across application launches. Disk cache is the most reliable choice here
HybridCache has generic functions with
Cachable type constraints, so it is type safe for all
even for custom types, and
Cache is sync by default, it means all methods are blocking. To access cache in an async manner, there’s a convenient
async that leads to
AsyncHybridCache . All shares the same
CacheManager under the hood, so all objects remain the same, they are just different interfaces.
As we deal with JSON most of the time, there’s a
JSON enum that encapsulate top level JSON object or JSON array, using
JSONSerialization to convert to Data for
Codable in Swift 4
One of the most important feature of Swift 4 is
Codable . Types conforming to
Codable can be mapped to and from
JSON . There’s
JSONSerialization under the hood, but all we need to care is to conform our types to
Codable , and ensure our model properties matches those keys in JSON data. How cool it is to just declare a model, decode it from JSON and persist it to Cache, all without any hassle? So in Cache 4.0 we refactored the public APIs to better support Codable.
Given the rumour that
NSCache will be renamed to
Cache , and to avoid our struct
Cache stealing the
Cache namespace, we rename our
Cache classes to
Storage . With better encapsulated
Config objects for disk and memory storages, declaring your own Storage is easy.
Chain of responsibility
Cache is built based on Chain-of-responsibility pattern, in which there are many processing objects, each knows how to do 1 task and delegates to the next one. But that’s just implementation detail. All you need to know is Storage, it saves and loads Codable objects.
Storages are designed Chain of responsibility pattern in mind, where each
Storage acts as a processing object. We deal with
Storage only, but there is a chain under the hood
Storage -> SyncStorage -> TypeWrapperStorage -> HybridStorage -> DiskStorage & MemoryStorage
Each processing object contains logic that defines the types of command objects that it can handle; the rest are passed to the next processing object in the chain
Storage deals with constructing inner
Storages based on passed in configurations.
SyncStorage deals with managing serial queue for asynchronous access.
DiskStorage , …
What is TypeWrapper?
Primitive types like
Int, String, Bool, … conforms to
Codable , so it is perfectly fine to call
storage.save(“a string”, forKey: “myKey”) as the compiler is happy. But as we are using
JSONDecoder under the hood, simply using primitive types can lead to run time exception like “Top-level T encoded as number JSON fragment” or “Expected to decode T but found a dictionary instead.”, and that was the reason of the
Here we need to catch those error and use
PrimitiveWrapper in case of error, so that we always have a top level object that can be serialised to and from JSON data.
PrimitiveWrapper is a simple generic struct with
Later I though that it would be less code if we could just always perform wrapping, and that lead to my Add TypeWrapperStorage pull request. This way the code is easy to reason, but the overhead is there.
To make all
Storage easy “chainable”, they all conform to
StorageAware protocol, which defines a set of minimal functions a
Storage must support.
The cool thing about this is that we can leverage protocol extension in Swift to provide default implementation for
StorageAware conformers. From
Entry info, we can infer the
object and whether it exists or not.
All functions have
Codable constraints, so we have a very type safe experience.
Sync and Async
Storage is sync by default. You may have noticed that all sync functions are marked with
throws with error type
StorageError . We have designed that
try catch is for sync, and
Result is for async. For async, we can’t do
try catch as the result will be delivered at a later time. So we use
completion closure to invoke the caller about result asynchronously.
Storage conform to
AsyncStorageAware protocol just as we do for
StorageAware . To guarantee that no read and write happen at the same time, we use serial
DispatchQueue to dispatch operations in order
As we want to support both sync and async operations on the same
Storage, we initially share 1 serial queue between
AsyncStorage , so no matter how many operations get executed, they are all in safely order. But as we also use
SyncStorage to get blocking behaviour, and
AsyncStorage , this can cause deadlock !!! So eventually we went with different
AsyncStorage , this trades off the deadlock for the chances of critical section access violation if user call sync and async interchangeably.
But image does not conform to Codable
NSImage do not conform to
Codable. Since we designed the APIs to exclusively support
Codable , working with images is tricky. Simply conform
Codable does not work, and it does not make sense to do so.
Essentially, for images user should save them as
Data to disk, and persist their file URL in
Storage instead. But to support the unified experience as
Codable , we introduced
ImageWrapper . If existing types like
UIImage can’t conform to
Codable, then a wrapper can
Then all you have to do is to wrap
ImageWrapper , and get the same APIs support as
But this beautiful API has a caveat for overhead. It may not be a big deal, but for libraries that depends on Cache like Imaginary, where storing and fetching images a lot, can be a huge problem. Here is how the object is on disk.
There’s that top level JSON object and image conversion to string that cause the overhead. Ideally image should be saved as just
How about Any?
One way to support
UIImage is to remove the Codable constraint, and use
Any , something like below
This does not compile, as for
Codable to work, the type needs to be known at compile time. It’s because protocol can’t conform to itself, read Using JSON Encoder to encode a variable with Codable as type and Protocol doesn’t conform to itself? for more detailed explanation.
So we don’t go with this approach.
How about Data Convertible?
The difference between
Codable is how they can be converted in to
Data , and we need
Data convertible objects for disk storage. So we need to encapsulate just this requirement, start with
DataConvertible protocol and make
Codable conform to it
It is, however, not that easy. We can’t conform existing protocol
Codable to our protocol.
This approach is not feasible we don’t go with it.
How about Data producer?
Protocol extension for
Codable simply does not work. Let’s go back to object composition with class
DataProducer , this has generic
Codable constraint, while storing either
Codable . So when asking it to produce
Data , it checks for whether it has
This approach is feasible, and compiles well. For disk storage, we call
toData to produce data, and for memory storage we can just set the inner object to
NSCache . But the need to specify
DataProducer as a wrapper does not often make users happy. We need a different approach.
As of Cache 5.0, we tackle these overhead and pure
UIImage support, while allowing
Cache to be flexible and easily customisable.
A better way to support both
Codable is to have a generic
Storage where we can transform the type. This way we can transform to support other custom types if we want.
Storage is extremely type safe, you save and load objects with the same type at a time. But the
Storage can be transformable, the underlying storage mechanism remains the same, it’s just the public APIs support a different type. This is for the case when user wants to save both
UIImage to the same storage. However we still recommend to use different storage for each types.
Storage still conforms to
StorageAware so we have some nice default implementation in
StorageAware protocol extension. Note that since
Storage is generic, our
StorageAware now have
associatedtype T to reflect the generic value type in
Since we can’t define protocol with generic constraint as variable, we can freely chain all
Storage as before. Now in
Cache we have a fixed dependencies, it means that
SyncStorage now explicitly specifies
HybridStorage . We do, however, expose all the
Storage as public, so you can compose them the way you want. But the default
Storage composition should be good in most cases.
When users specify
Storage type, they need to specify a
Transformer as well. It is data structure that contains 2 functions
toData . This is needed for
DiskStorage as we must support a way for the generic type to be
UIImage are the most common format that we save and load to
Storage , we provide default
Storage to a new type, we simply move all the internal objects inside the
Storage to the new
Storage , they are all reference types so there’s overhead. We also need to specify the
Transformer for the new types. Every Storage has a
You should definitely take a look at the tests on how powerful Storage transformation is. Whenever a Storage is transformed, it is constrained to a new type so all operations are type safe, however all objects are saved to the same location.
Where to go from here
The new generic
Storage APIs is type safe and flexible. It also reduces all the workaround overhead. We have used it in our image fetcher, Imaginary, to improve performance.
The only “constant” is “change”. The Apple platforms and the Swift programming languages evolve faster than you think, and it’s good that our frameworks take advantage of all the new features. Hope you find
Cache and our stories in this refactoring journey useful.
Last but not least, thanks to all the contributors that make Cache happen. You are more than awesome ❤️
See you again in the next story.