/
Cache.swift
280 lines (228 loc) · 10.3 KB
/
Cache.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
import Foundation
/// Represents the expiry of a cached object
public enum CacheExpiry {
case never
case seconds(TimeInterval)
case date(Foundation.Date)
}
/// A generic cache that persists objects to disk and is backed by a NSCache.
/// Supports an expiry date for every cached object. Expired objects are automatically deleted upon their next access via `objectForKey:`.
/// If you want to delete expired objects, call `removeAllExpiredObjects`.
///
/// Subclassing notes: This class fully supports subclassing.
/// The easiest way to implement a subclass is to override `objectForKey` and `setObject:forKey:expires:`,
/// e.g. to modify values prior to reading/writing to the cache.
open class Cache<T: NSCoding> {
open let name: String
open let cacheDirectory: URL
internal let cache = NSCache<NSString, CacheObject>() // marked internal for testing
fileprivate let fileManager = FileManager()
fileprivate let queue = DispatchQueue(label: "com.aschuch.cache.diskQueue", attributes: DispatchQueue.Attributes.concurrent)
/// Typealias to define the reusability in declaration of the closures.
public typealias CacheBlockClosure = (T, CacheExpiry) -> Void
public typealias ErrorClosure = (NSError?) -> Void
// MARK: Initializers
/// Designated initializer.
///
/// - parameter name: Name of this cache
/// - parameter directory: Objects in this cache are persisted to this directory.
/// If no directory is specified, a new directory is created in the system's Caches directory
/// - parameter fileProtection: Needs to be a valid value for `NSFileProtectionKey` (i.e. `NSFileProtectionNone`) and
/// adds the given value as an NSFileManager attribute.
///
/// - returns: A new cache with the given name and directory
public init(name: String, directory: URL?, fileProtection: String? = nil) throws {
self.name = name
cache.name = name
if let d = directory {
cacheDirectory = d
} else {
let url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
cacheDirectory = url.appendingPathComponent("com.aschuch.cache/\(name)")
}
// Create directory on disk if needed
try fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true, attributes: nil)
if let fileProtection = fileProtection {
// Set the correct NSFileProtectionKey
let protection = [FileAttributeKey.protectionKey: fileProtection]
try fileManager.setAttributes(protection, ofItemAtPath: cacheDirectory.path)
}
}
/// Convenience Initializer
///
/// - parameter name: Name of this cache
///
/// - returns A new cache with the given name and the default cache directory
public convenience init(name: String) throws {
try self.init(name: name, directory: nil)
}
// MARK: Awesome caching
/// Returns a cached object immediately or evaluates a cacheBlock.
/// The cacheBlock will not be re-evaluated until the object is expired or manually deleted.
/// If the cache already contains an object, the completion block is called with the cached object immediately.
///
/// If no object is found or the cached object is already expired, the `cacheBlock` is called.
/// You might perform any tasks (e.g. network calls) within this block. Upon completion of these tasks,
/// make sure to call the `success` or `failure` block that is passed to the `cacheBlock`.
/// The completion block is invoked as soon as the cacheBlock is finished and the object is cached.
///
/// - parameter key: The key to lookup the cached object
/// - parameter cacheBlock: This block gets called if there is no cached object or the cached object is already expired.
/// The supplied success or failure blocks must be called upon completion.
/// If the error block is called, the object is not cached and the completion block is invoked with this error.
/// - parameter completion: Called as soon as a cached object is available to use. The second parameter is true if the object was already cached.
open func setObject(forKey key: String, cacheBlock: (@escaping CacheBlockClosure, @escaping ErrorClosure) -> Void, completion: @escaping (T?, Bool, NSError?) -> Void) {
if let object = object(forKey: key) {
completion(object, true, nil)
} else {
let successBlock: CacheBlockClosure = { (obj, expires) in
self.setObject(obj, forKey: key, expires: expires)
completion(obj, false, nil)
}
let failureBlock: ErrorClosure = { (error) in
completion(nil, false, error)
}
cacheBlock(successBlock, failureBlock)
}
}
// MARK: Get object
/// Looks up and returns an object with the specified name if it exists.
/// If an object is already expired, `nil` will be returned.
///
/// - parameter key: The name of the object that should be returned
/// - parameter returnExpiredObjectIfPresent: If set to `true`, an expired
/// object may be returned if present. Defaults to `false`.
///
/// - returns: The cached object for the given name, or nil
open func object(forKey key: String, returnExpiredObjectIfPresent: Bool = false) -> T? {
var object: CacheObject?
queue.sync {
object = self.read(key)
}
// Check if object is not already expired and return
if let object = object, !object.isExpired() || returnExpiredObjectIfPresent {
return object.value as? T
}
return nil
}
open func allObjects(includeExpired: Bool = false) -> [T] {
var objects = [T]()
queue.sync {
let keys = self.allKeys()
let all = keys.map(self.read).flatMap { $0 }
let filtered = includeExpired ? all : all.filter { !$0.isExpired() }
objects = filtered.map { $0.value as? T }.flatMap { $0 }
}
return objects
}
open func isOnMemory(forKey key: String) -> Bool {
return cache.object(forKey: key as NSString) != nil
}
// MARK: Set object
/// Adds a given object to the cache.
/// The object is automatically marked as expired as soon as its expiry date is reached.
///
/// - parameter object: The object that should be cached
/// - parameter forKey: A key that represents this object in the cache
/// - parameter expires: The CacheExpiry that indicates when the given object should be expired
open func setObject(_ object: T, forKey key: String, expires: CacheExpiry = .never) {
let expiryDate = expiryDateForCacheExpiry(expires)
let cacheObject = CacheObject(value: object, expiryDate: expiryDate)
queue.sync(flags: .barrier, execute: {
self.add(cacheObject, key: key)
})
}
// MARK: Remove objects
/// Removes an object from the cache.
///
/// - parameter key: The key of the object that should be removed
open func removeObject(forKey key: String) {
cache.removeObject(forKey: key as NSString)
queue.sync(flags: .barrier, execute: {
self.removeFromDisk(key)
})
}
/// Removes all objects from the cache.
open func removeAllObjects() {
cache.removeAllObjects()
queue.sync(flags: .barrier, execute: {
let keys = self.allKeys()
keys.forEach(self.removeFromDisk)
})
}
/// Removes all expired objects from the cache.
open func removeExpiredObjects() {
queue.sync(flags: .barrier, execute: {
let keys = self.allKeys()
for key in keys {
let possibleObject = self.read(key)
if let object = possibleObject , object.isExpired() {
self.cache.removeObject(forKey: key as NSString)
self.removeFromDisk(key)
}
}
})
}
// MARK: Subscripting
open subscript(key: String) -> T? {
get {
return object(forKey: key)
}
set(newValue) {
if let value = newValue {
setObject(value, forKey: key)
} else {
removeObject(forKey: key)
}
}
}
// MARK: Private Helper (not thread safe)
fileprivate func add(_ object: CacheObject, key: String) {
// Set object in local cache
cache.setObject(object, forKey: key as NSString)
// Write object to disk
let path = urlForKey(key).path
NSKeyedArchiver.archiveRootObject(object, toFile: path)
}
fileprivate func read(_ key: String) -> CacheObject? {
// Check if object exists in local cache
if let object = cache.object(forKey: key as NSString) {
return object
}
// Otherwise, read from disk
let path = urlForKey(key).path
if fileManager.fileExists(atPath: path) {
return _awesomeCache_unarchiveObjectSafely(path) as? CacheObject
}
return nil
}
// Deletes an object from disk
fileprivate func removeFromDisk(_ key: String) {
let url = self.urlForKey(key)
_ = try? self.fileManager.removeItem(at: url)
}
// MARK: Private Helper
fileprivate func allKeys() -> [String] {
let urls = try? self.fileManager.contentsOfDirectory(at: self.cacheDirectory, includingPropertiesForKeys: nil, options: [])
return urls?.flatMap { $0.deletingPathExtension().lastPathComponent } ?? []
}
fileprivate func urlForKey(_ key: String) -> URL {
let k = sanitizedKey(key)
return cacheDirectory
.appendingPathComponent(k)
.appendingPathExtension("cache")
}
fileprivate func sanitizedKey(_ key: String) -> String {
return key.replacingOccurrences(of: "[^a-zA-Z0-9_]+", with: "-", options: .regularExpression, range: nil)
}
fileprivate func expiryDateForCacheExpiry(_ expiry: CacheExpiry) -> Date {
switch expiry {
case .never:
return Date.distantFuture
case .seconds(let seconds):
return Date().addingTimeInterval(seconds)
case .date(let date):
return date
}
}
}