I'm trying to wrap my head around Combine.
Here's a method I want to translate into Combine, so that it would return AnyPublisher.
func getToken(completion: @escaping (Result<String, Error>) -> Void) {
dispatchQueue.async {
do {
if let localEncryptedToken = try self.readTokenFromKeychain() {
let decryptedToken = try self.tokenCryptoHelper.decrypt(encryptedToken: localEncryptedToken)
DispatchQueue.main.async {
completion(.success(decryptedToken))
}
} else {
self.fetchToken(completion: completion)
}
} catch {
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
}
The whole thing executes on a separate dispatch queue because reading from Keychain and decryption can be slow.
My first attempt to embrace Combine
func getToken() -> AnyPublisher<String, Error> {
do {
if let localEncryptedToken = try readTokenFromKeychain() {
let decryptedToken = try tokenCryptoHelper.decrypt(encryptedToken: localEncryptedToken)
return Result.success(decryptedToken).publisher.eraseToAnyPublisher()
} else {
return fetchToken() // also rewritten to return AnyPublisher<String, Error>
}
} catch {
return Result.failure(error).publisher.eraseToAnyPublisher()
}
}
But how would I move reading from Keychain and decryption onto separate queue? It probably should look something like
func getToken() -> AnyPublisher<String, Error> {
return Future<String, Error> { promise in
self.dispatchQueue.async {
do {
if let localEncryptedToken = try self.readTokenFromKeychain() {
let decryptedToken = try self.tokenCryptoHelper.decrypt(encryptedToken: localEncryptedToken)
promise(.success(decryptedToken))
} else {
// should I fetchToken().sink here?
}
} catch {
promise(.failure(error))
}
}
}.eraseToAnyPublisher()
}
How would I return a publisher from my private method call? (see comment in code)
Are there any prettier solutions?
Before moving forward, it's important to highlight that Async/Await is not meant to replace Combine. Thus, code that operate on streams should remain using the latter. This article will be focused on code that doesn't necessarily need such capabilities.
Combine is Apple's framework to work with asynchronous events in a unified and reactive way that ensures your app is always up to date based on the latest state of its data.
What's a sink? While a complete explanation of Combine, publishers, subscribers, and sinks is beyond the scope of this article, for our purposes here it's probably enough to know that in Combine a sink is the code receives data and completion events or errors from a publisher and deals with them.
A PassthroughSubject broadcasts elements to downstream subscribers and provides a convenient way to adapt existing imperative code to Combine. As the name suggests, this type of subject only passes through values meaning that it does not capture any state and will drop values if there aren't any subscribers set.
Assuming you’ve refactored readTokenFromKeyChain
, decrypt
, and fetchToken
to return AnyPublisher<String, Error>
themselves, you can then do:
func getToken() -> AnyPublisher<String, Error> {
readTokenFromKeyChain()
.flatMap { self.tokenCryptoHelper.decrypt(encryptedToken: $0) }
.catch { _ in self.fetchToken() }
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
That will read the keychain, if it succeeded, decrypt it, and if it didn’t succeed, it will call fetchToken
. And having done all of that, it will make sure the final result is delivered on the main queue.
I think that’s the right general pattern. Now, let's talk about that dispatchQueue
: Frankly, I’m not sure I’m seeing anything here that warrants running on a background thread, but let’s imagine you wanted to kick this off in a background queue, then, you readTokenFromKeyChain
might dispatch that to a background queue:
func readTokenFromKeyChain() -> AnyPublisher<String, Error> {
dispatchQueue.publisher { promise in
let query: [CFString: Any] = [
kSecReturnData: true,
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: "token",
kSecAttrService: Bundle.main.bundleIdentifier!]
var extractedData: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &extractedData)
if
status == errSecSuccess,
let retrievedData = extractedData as? Data,
let string = String(data: retrievedData, encoding: .utf8)
{
promise(.success(string))
} else {
promise(.failure(TokenError.failure))
}
}
}
By the way, that’s using a simple little method, publisher
that I added to DispatchQueue
:
extension DispatchQueue {
/// Dispatch block asynchronously
/// - Parameter block: Block
func publisher<Output, Failure: Error>(_ block: @escaping (Future<Output, Failure>.Promise) -> Void) -> AnyPublisher<Output, Failure> {
Future<Output, Failure> { promise in
self.async { block(promise) }
}.eraseToAnyPublisher()
}
}
For the sake of completeness, this is a sample fetchToken
implementation:
func fetchToken() -> AnyPublisher<String, Error> {
let request = ...
return URLSession.shared
.dataTaskPublisher(for: request)
.map { $0.data }
.decode(type: ResponseObject.self, decoder: JSONDecoder())
.map { $0.payload.token }
.eraseToAnyPublisher()
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With