DiskStorage.swift 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import Foundation
  2. /// Save objects to file on disk
  3. final public class DiskStorage<T> {
  4. enum Error: Swift.Error {
  5. case fileEnumeratorFailed
  6. }
  7. /// File manager to read/write to the disk
  8. public let fileManager: FileManager
  9. /// Configuration
  10. private let config: DiskConfig
  11. /// The computed path `directory+name`
  12. public let path: String
  13. /// The closure to be called when single file has been removed
  14. var onRemove: ((String) -> Void)?
  15. private let transformer: Transformer<T>
  16. // MARK: - Initialization
  17. public convenience init(config: DiskConfig, fileManager: FileManager = FileManager.default, transformer: Transformer<T>) throws {
  18. let url: URL
  19. if let directory = config.directory {
  20. url = directory
  21. } else {
  22. url = try fileManager.url(
  23. for: .cachesDirectory,
  24. in: .userDomainMask,
  25. appropriateFor: nil,
  26. create: true
  27. )
  28. }
  29. // path
  30. let path = url.appendingPathComponent(config.name, isDirectory: true).path
  31. self.init(config: config, fileManager: fileManager, path: path, transformer: transformer)
  32. try createDirectory()
  33. // protection
  34. #if os(iOS) || os(tvOS)
  35. if let protectionType = config.protectionType {
  36. try setDirectoryAttributes([
  37. FileAttributeKey.protectionKey: protectionType
  38. ])
  39. }
  40. #endif
  41. }
  42. public required init(config: DiskConfig, fileManager: FileManager = FileManager.default, path: String, transformer: Transformer<T>) {
  43. self.config = config
  44. self.fileManager = fileManager
  45. self.path = path
  46. self.transformer = transformer
  47. }
  48. }
  49. extension DiskStorage: StorageAware {
  50. public func entry(forKey key: String) throws -> Entry<T> {
  51. let filePath = makeFilePath(for: key)
  52. let data = try Data(contentsOf: URL(fileURLWithPath: filePath))
  53. let attributes = try fileManager.attributesOfItem(atPath: filePath)
  54. let object = try transformer.fromData(data)
  55. guard let date = attributes[.modificationDate] as? Date else {
  56. throw StorageError.malformedFileAttributes
  57. }
  58. return Entry(
  59. object: object,
  60. expiry: Expiry.date(date),
  61. filePath: filePath
  62. )
  63. }
  64. public func setObject(_ object: T, forKey key: String, expiry: Expiry? = nil) throws {
  65. let expiry = expiry ?? config.expiry
  66. let data = try transformer.toData(object)
  67. let filePath = makeFilePath(for: key)
  68. _ = fileManager.createFile(atPath: filePath, contents: data, attributes: nil)
  69. try fileManager.setAttributes([.modificationDate: expiry.date], ofItemAtPath: filePath)
  70. }
  71. public func removeObject(forKey key: String) throws {
  72. let filePath = makeFilePath(for: key)
  73. try fileManager.removeItem(atPath: filePath)
  74. onRemove?(filePath)
  75. }
  76. public func removeAll() throws {
  77. try fileManager.removeItem(atPath: path)
  78. try createDirectory()
  79. }
  80. public func removeExpiredObjects() throws {
  81. let storageURL = URL(fileURLWithPath: path)
  82. let resourceKeys: [URLResourceKey] = [
  83. .isDirectoryKey,
  84. .contentModificationDateKey,
  85. .totalFileAllocatedSizeKey
  86. ]
  87. var resourceObjects = [ResourceObject]()
  88. var filesToDelete = [URL]()
  89. var totalSize: UInt = 0
  90. let fileEnumerator = fileManager.enumerator(
  91. at: storageURL,
  92. includingPropertiesForKeys: resourceKeys,
  93. options: .skipsHiddenFiles,
  94. errorHandler: nil
  95. )
  96. guard let urlArray = fileEnumerator?.allObjects as? [URL] else {
  97. throw Error.fileEnumeratorFailed
  98. }
  99. for url in urlArray {
  100. let resourceValues = try url.resourceValues(forKeys: Set(resourceKeys))
  101. guard resourceValues.isDirectory != true else {
  102. continue
  103. }
  104. if let expiryDate = resourceValues.contentModificationDate, expiryDate.inThePast {
  105. filesToDelete.append(url)
  106. continue
  107. }
  108. if let fileSize = resourceValues.totalFileAllocatedSize {
  109. totalSize += UInt(fileSize)
  110. resourceObjects.append((url: url, resourceValues: resourceValues))
  111. }
  112. }
  113. // Remove expired objects
  114. for url in filesToDelete {
  115. try fileManager.removeItem(at: url)
  116. onRemove?(url.path)
  117. }
  118. // Remove objects if storage size exceeds max size
  119. try removeResourceObjects(resourceObjects, totalSize: totalSize)
  120. }
  121. }
  122. extension DiskStorage {
  123. /**
  124. Sets attributes on the disk cache folder.
  125. - Parameter attributes: Directory attributes
  126. */
  127. func setDirectoryAttributes(_ attributes: [FileAttributeKey: Any]) throws {
  128. try fileManager.setAttributes(attributes, ofItemAtPath: path)
  129. }
  130. }
  131. typealias ResourceObject = (url: Foundation.URL, resourceValues: URLResourceValues)
  132. extension DiskStorage {
  133. /**
  134. Builds file name from the key.
  135. - Parameter key: Unique key to identify the object in the cache
  136. - Returns: A md5 string
  137. */
  138. func makeFileName(for key: String) -> String {
  139. let fileExtension = URL(fileURLWithPath: key).pathExtension
  140. let fileName = MD5(key)
  141. switch fileExtension.isEmpty {
  142. case true:
  143. return fileName
  144. case false:
  145. return "\(fileName).\(fileExtension)"
  146. }
  147. }
  148. /**
  149. Builds file path from the key.
  150. - Parameter key: Unique key to identify the object in the cache
  151. - Returns: A string path based on key
  152. */
  153. func makeFilePath(for key: String) -> String {
  154. return "\(path)/\(makeFileName(for: key))"
  155. }
  156. /// Calculates total disk cache size.
  157. func totalSize() throws -> UInt64 {
  158. var size: UInt64 = 0
  159. let contents = try fileManager.contentsOfDirectory(atPath: path)
  160. for pathComponent in contents {
  161. let filePath = NSString(string: path).appendingPathComponent(pathComponent)
  162. let attributes = try fileManager.attributesOfItem(atPath: filePath)
  163. if let fileSize = attributes[.size] as? UInt64 {
  164. size += fileSize
  165. }
  166. }
  167. return size
  168. }
  169. func createDirectory() throws {
  170. guard !fileManager.fileExists(atPath: path) else {
  171. return
  172. }
  173. try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true,
  174. attributes: nil)
  175. }
  176. /**
  177. Removes objects if storage size exceeds max size.
  178. - Parameter objects: Resource objects to remove
  179. - Parameter totalSize: Total size
  180. */
  181. func removeResourceObjects(_ objects: [ResourceObject], totalSize: UInt) throws {
  182. guard config.maxSize > 0 && totalSize > config.maxSize else {
  183. return
  184. }
  185. var totalSize = totalSize
  186. let targetSize = config.maxSize / 2
  187. let sortedFiles = objects.sorted {
  188. if let time1 = $0.resourceValues.contentModificationDate?.timeIntervalSinceReferenceDate,
  189. let time2 = $1.resourceValues.contentModificationDate?.timeIntervalSinceReferenceDate {
  190. return time1 > time2
  191. } else {
  192. return false
  193. }
  194. }
  195. for file in sortedFiles {
  196. try fileManager.removeItem(at: file.url)
  197. onRemove?(file.url.path)
  198. if let fileSize = file.resourceValues.totalFileAllocatedSize {
  199. totalSize -= UInt(fileSize)
  200. }
  201. if totalSize < targetSize {
  202. break
  203. }
  204. }
  205. }
  206. /**
  207. Removes the object from the cache if it's expired.
  208. - Parameter key: Unique key to identify the object in the cache
  209. */
  210. func removeObjectIfExpired(forKey key: String) throws {
  211. let filePath = makeFilePath(for: key)
  212. let attributes = try fileManager.attributesOfItem(atPath: filePath)
  213. if let expiryDate = attributes[.modificationDate] as? Date, expiryDate.inThePast {
  214. try fileManager.removeItem(atPath: filePath)
  215. onRemove?(filePath)
  216. }
  217. }
  218. }
  219. public extension DiskStorage {
  220. func transform<U>(transformer: Transformer<U>) -> DiskStorage<U> {
  221. let storage = DiskStorage<U>(
  222. config: config,
  223. fileManager: fileManager,
  224. path: path,
  225. transformer: transformer
  226. )
  227. return storage
  228. }
  229. }