From 3f4efc8c8900c08c628b2316998210ed42c77c60 Mon Sep 17 00:00:00 2001 From: Michael Long Date: Fri, 21 Apr 2023 14:11:34 -0500 Subject: [PATCH] Update 2.1.5 with timeToLive --- CHANGELOG | 4 ++ Factory.podspec | 2 +- Sources/Factory/Factory.docc/Basics/Scopes.md | 22 ++++++++- Sources/Factory/Factory/Modifiers.swift | 10 ++++ Sources/Factory/Factory/Registrations.swift | 5 +- Sources/Factory/Factory/Scopes.swift | 48 ++++++++++++------- .../FactoryTests/FactoryComponentTests.swift | 4 +- Tests/FactoryTests/FactoryScopeTests.swift | 14 ++++++ 8 files changed, 87 insertions(+), 22 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 42f22b7a..7507eefa 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,9 @@ # Factory Changelog +## 2.1.5 + +* Adds TimeToLive to scoped instances. + ## 2.1.4 * Fix singletons in multiple modules keyed with same type and name. Issue - #99 diff --git a/Factory.podspec b/Factory.podspec index 99f57162..90f0a241 100644 --- a/Factory.podspec +++ b/Factory.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "Factory" - s.version = "2.1.4" + s.version = "2.1.5" s.summary = "A Modern Dependency Injection / Service Locator framework for Swift on iOS." s.homepage = "https://github.com/hmlongco/Factory" s.license = "MIT" diff --git a/Sources/Factory/Factory.docc/Basics/Scopes.md b/Sources/Factory/Factory.docc/Basics/Scopes.md index ba75e911..275023ee 100644 --- a/Sources/Factory/Factory.docc/Basics/Scopes.md +++ b/Sources/Factory/Factory.docc/Basics/Scopes.md @@ -205,9 +205,29 @@ Scope is managed by the container. See the "Releasing a Container" discussion in for more information. +## TimeToLive + +Factory provides a "time to live" option for scoped dependencies. + +```swift +extension Container { + var authenticatedUser: Factory { + self { AuthenticatedUser() } + .scope(.session) + .timeToLive(60 * 20) // (60 seconds * 20) = 20 minutes + } +} +``` + +As shown above, set a time to live for 20 minutes and any new request for that dependency that occurs *after* that period will discard the previously cached item, caching and returning a new instance instead. + +Requesting a cached item before the timeout period ends returns the currently cached item and effectively restarts the clock for that item. + +Like registrations, setting a time to live on a dependency only affects the *next* resolution for that item. Anything already resolved and referenced stays resolved and referenced. + ## Reset -As shown above, individual scope caches on a container can be reset (cleared) if needed. +As mentioned earlier in the discussion on custom scopes, individual scope caches on a container can be reset (cleared) if needed. ```swift // clear the default cached scope Container.shared.manager.reset(scope: .cached) diff --git a/Sources/Factory/Factory/Modifiers.swift b/Sources/Factory/Factory/Modifiers.swift index 112e6ba3..06099f5e 100644 --- a/Sources/Factory/Factory/Modifiers.swift +++ b/Sources/Factory/Factory/Modifiers.swift @@ -107,6 +107,16 @@ extension FactoryModifying { registration.scope(.unique) return self } + + /// Adds time to live option for scopes. If the dependency has been cached for longer than the timeToLive period the + /// cached item will be discarded and a new instance created. + @discardableResult + public func timeToLive(_ seconds: TimeInterval) -> Self { + registration.options { options in + options.ttl = seconds + } + return self + } } // FactoryModifying Decorator Functionality diff --git a/Sources/Factory/Factory/Registrations.swift b/Sources/Factory/Factory/Registrations.swift index c66169f2..a94d0f7c 100644 --- a/Sources/Factory/Factory/Registrations.swift +++ b/Sources/Factory/Factory/Registrations.swift @@ -78,6 +78,7 @@ public struct FactoryRegistration { let manager = container.manager let options = manager.options[id] let scope = options?.scope ?? manager.defaultScope + let ttl = options?.ttl var factory: (P) -> T = factoryForCurrentContext(using: options) @@ -99,7 +100,7 @@ public struct FactoryRegistration { #endif globalGraphResolutionDepth += 1 - let instance = scope?.resolve(using: manager.cache, id: id, factory: { factory(parameters) }) ?? factory(parameters) + let instance = scope?.resolve(using: manager.cache, id: id, ttl: ttl, factory: { factory(parameters) }) ?? factory(parameters) globalGraphResolutionDepth -= 1 if globalGraphResolutionDepth == 0 { @@ -328,6 +329,8 @@ public enum FactoryResetOptions { internal struct FactoryOptions { /// Managed scope for this factory instance var scope: Scope? + /// Time to live option for scopes + var ttl: TimeInterval? /// Contexts var argumentContexts: [String:AnyFactory]? /// Contexts diff --git a/Sources/Factory/Factory/Scopes.swift b/Sources/Factory/Factory/Scopes.swift index d687ee44..87560b17 100644 --- a/Sources/Factory/Factory/Scopes.swift +++ b/Sources/Factory/Factory/Scopes.swift @@ -53,31 +53,39 @@ public class Scope { fileprivate init() {} /// Internal function returns cached value if it exists. Otherwise it creates a new instance and caches that value for later reference. - internal func resolve(using cache: Cache, id: String, factory: () -> T) -> T { - if let cached: T = unboxed(box: cache.value(forKey: id)) { - return cached + internal func resolve(using cache: Cache, id: String, ttl: TimeInterval?, factory: () -> T) -> T { + if let box = cache.value(forKey: id), let cached: T = unboxed(box: box) { + if let ttl = ttl { + let now = CFAbsoluteTimeGetCurrent() + if (box.timestamp + ttl) > now { + cache.set(timestamp: now, forKey: id) + return cached + } + } else { + return cached + } } let instance = factory() if let box = box(instance) { - cache.set(value: box, forKey: id) + cache.set(value: box, forKey: id) } return instance } /// Internal function returns unboxed value if it exists fileprivate func unboxed(box: AnyBox?) -> T? { - if let box = box as? StrongBox { - return box.boxed - } - return nil + (box as? StrongBox)?.boxed } /// Internal function correctly boxes value depending upon scope type fileprivate func box(_ instance: T) -> AnyBox? { if let optional = instance as? OptionalProtocol { - return optional.hasWrappedValue ? StrongBox(scopeID: scopeID, boxed: instance) : nil + if optional.hasWrappedValue { + return StrongBox(scopeID: scopeID, timestamp: CFAbsoluteTimeGetCurrent(), boxed: instance) + } + return nil } - return StrongBox(scopeID: scopeID, boxed: instance) + return StrongBox(scopeID: scopeID, timestamp: CFAbsoluteTimeGetCurrent(), boxed: instance) } internal let scopeID: UUID = UUID() @@ -106,9 +114,9 @@ extension Scope { public override init() { super.init() } - internal override func resolve(using cache: Cache, id: String, factory: () -> T) -> T { + internal override func resolve(using cache: Cache, id: String, ttl: TimeInterval?, factory: () -> T) -> T { // ignore container's cache in favor of our own - return super.resolve(using: self.cache, id: id, factory: factory) + return super.resolve(using: self.cache, id: id, ttl: ttl, factory: factory) } /// Private shared cache internal var cache = Cache() @@ -138,10 +146,10 @@ extension Scope { fileprivate override func box(_ instance: T) -> AnyBox? { if let optional = instance as? OptionalProtocol { if let unwrapped = optional.wrappedValue, type(of: unwrapped) is AnyObject.Type { - return WeakBox(scopeID: scopeID, boxed: unwrapped as AnyObject) + return WeakBox(scopeID: scopeID, timestamp: CFAbsoluteTimeGetCurrent(), boxed: unwrapped as AnyObject) } } else if type(of: instance as Any) is AnyObject.Type { - return WeakBox(scopeID: scopeID, boxed: instance as AnyObject) + return WeakBox(scopeID: scopeID, timestamp: CFAbsoluteTimeGetCurrent(), boxed: instance as AnyObject) } return nil } @@ -154,9 +162,9 @@ extension Scope { public override init() { super.init() } - internal override func resolve(using cache: Cache, id: String, factory: () -> T) -> T { + internal override func resolve(using cache: Cache, id: String, ttl: TimeInterval?, factory: () -> T) -> T { // ignore container's cache in favor of our own - return super.resolve(using: self.cache, id: id, factory: factory) + return super.resolve(using: self.cache, id: id, ttl: ttl, factory: factory) } /// Private shared cache internal var cache = Cache() @@ -177,7 +185,7 @@ extension Scope { public override init() { super.init() } - internal override func resolve(using cache: Cache, id: String, factory: () -> T) -> T { + internal override func resolve(using cache: Cache, id: String, ttl: TimeInterval?, factory: () -> T) -> T { factory() } } @@ -197,6 +205,9 @@ extension Scope { @inlinable func set(value: AnyBox, forKey key: String) { cache[key] = value } + @inlinable func set(timestamp: Double, forKey key: String) { + cache[key]?.timestamp = timestamp + } @inlinable func removeValue(forKey key: String) { cache.removeValue(forKey: key) } @@ -245,16 +256,19 @@ extension Optional: OptionalProtocol { /// Internal box protocol for scope functionality internal protocol AnyBox { var scopeID: UUID { get } + var timestamp: Double { get set } } /// Strong box for strong references to a type internal struct StrongBox: AnyBox { let scopeID: UUID + var timestamp: Double let boxed: T } /// Weak box for shared scope internal struct WeakBox: AnyBox { let scopeID: UUID + var timestamp: Double weak var boxed: AnyObject? } diff --git a/Tests/FactoryTests/FactoryComponentTests.swift b/Tests/FactoryTests/FactoryComponentTests.swift index 4c3c171e..a23492e6 100644 --- a/Tests/FactoryTests/FactoryComponentTests.swift +++ b/Tests/FactoryTests/FactoryComponentTests.swift @@ -14,8 +14,8 @@ final class FactoryComponentTests: XCTestCase { func testScopeCache () { let cache = Scope.Cache() let scopeID = UUID() - let strongBox = StrongBox(scopeID: scopeID, boxed: { MyService() }) - let anotherBox = StrongBox(scopeID: UUID(), boxed: { MyService() }) + let strongBox = StrongBox(scopeID: scopeID, timestamp: 0, boxed: { MyService() }) + let anotherBox = StrongBox(scopeID: UUID(), timestamp: 0, boxed: { MyService() }) // Finds nothing XCTAssertNil(cache.value(forKey: key1)) XCTAssertNil(cache.value(forKey: key2)) diff --git a/Tests/FactoryTests/FactoryScopeTests.swift b/Tests/FactoryTests/FactoryScopeTests.swift index 25e4ce25..d3e7000d 100644 --- a/Tests/FactoryTests/FactoryScopeTests.swift +++ b/Tests/FactoryTests/FactoryScopeTests.swift @@ -385,6 +385,20 @@ final class FactoryScopeTests: XCTestCase { container1.manager.trace.toggle() } + func testSingletonScopeTimeToLive() throws { + let service1 = Container.shared.singletonService() + let service2 = Container.shared.singletonService() + XCTAssertTrue(service1.id == service2.id) + Container.shared.singletonService.timeToLive(-60) + let service3 = Container.shared.singletonService() + // should fail ttl test and return new instance + XCTAssertTrue(service2.id != service3.id) + Container.shared.singletonService.timeToLive(60) + let service4 = Container.shared.singletonService() + // should passs ttl test and return old instance + XCTAssertTrue(service3.id == service4.id) + } + } extension SharedContainer {