Using tokens to handle async Swift code
Discover page available: ConcurrencyHandling asynchronous code in a way that is predictable, readable and easy to debug can be really challenging. It's also something that is very common, almost all modern codebases have parts that are highly asynchronous - whether that's loading data over the network, processing big datasets locally or any other computationally intensive operations.
It's therefore not surprising that in most languages, several abstractions have been created to try to make dealing with asynchronous tasks easier. We've already taken a look at a few such techniques and abstractions in previous posts - including using GCD and Futures & Promises. This week, let's take a look at another technique that can make managing asynchronous calls a bit simpler and less error prone - using tokens.
Token-based APIs
The core idea of token-based APIs is that a function that starts an asynchronous or delayed operation returns a token. That token can then be used to manage and cancel that operation. The signature of such a function can look like this:
func loadData(from url: URL, completion: @escaping (Result) -> Void) -> Token
Tokens are much more lightweight than, for example, Futures & Promises - since the token simply acts as a way to keep track of a request, rather than containing the entire request itself. This also makes them a lot easier to add to an existing code base, rather than having to rewrite a lot of asynchronous code using a different pattern, or having to expose implementation details.
So what's the benefit of having a function like the above return a token? Let's take a look at an example, in which we are building a view controller to allow a user to search for other users in our app:
class UserSearchViewController: UIViewController, UISearchBarDelegate {
private let userLoader: UserLoader
private lazy var searchBar = UISearchBar()
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
userLoader.loadUsers(matching: searchText) { [weak self] result in
switch result {
case .success(let users):
self?.reloadResultsView(with: users)
case .error(let error):
self?.handle(error)
}
}
}
}
As you can see above, this all looks pretty standard, but if you have ever implemented this kind of search-as-you-type feature before - you probably know there's a bug hidden in there. The problem is that the speed of which characters are typed into the search field won't necessarily match how quickly requests are completed. This leads to a very common problem of accidentally rendering old results when a previous request will finish after a subsequent one.
Let's take a look at how tokens can help us solve the above problem 👍
A token for every request
Let's start by taking a closer look at UserLoader
. It's currently implemented like this:
class UserLoader {
private let urlSession: URLSession
func loadUsers(matching query: String,
completionHandler: @escaping (Result<[User]>) -> Void) {
let url = requestURL(forQuery: query)
let task = urlSession.dataTask(with: url) { (data, _, error) in
// Pattern match on the returned data and error to determine
// the outcome of the operation
switch (data, error) {
case (_, let error?):
completionHandler(.error(error))
case (let data?, _):
do {
let users: [User] = try unbox(data: data)
completionHandler(.success(users))
} catch {
completionHandler(.error(error))
}
case (nil, nil):
completionHandler(.error(Error.missingData))
}
}
task.resume()
}
}
What we need to do in order to avoid bugs in our UserSearchViewController
, is to enable any ongoing task to be cancelled. There are a couple of ways that we could achieve that. One option is to simply have UserLoader
itself keep track of its current data task, and cancel it whenever a new one is started:
class UserLoader {
private let urlSession: URLSession
private weak var currentTask: URLSessionDataTask?
func loadUsers(matching query: String,
completionHandler: @escaping (Result<[User]>) -> Void) {
currentTask?.cancel()
let url = requestURL(forQuery: query)
let task = urlSession.dataTask(with: url) { (data, _, error) in
...
}
task.resume()
currentTask = task
}
}
The above works, and is a totally valid approach. However, it does make the API a bit less obvious - since cancellation is now mandatory and baked into the implementation. That could potentially cause bugs in the future if UserLoader
is used in another context in which previous requests shouldn't be cancelled.
Another option, is to return the data task itself when loadUsers
is called:
class UserLoader {
private let urlSession: URLSession
func loadUsers(matching query: String,
completionHandler: @escaping (Result<[User]>) -> Void) -> URLSessionDataTask {
let url = requestURL(forQuery: query)
let task = urlSession.dataTask(with: url) { (data, _, error) in
...
}
task.resume()
return task
}
}
Also a valid approach, but the downside here is that we are leaking implementation details as part of the API. There's no reason (other than allowing cancellation) that other code using UserLoader
should have to know that it uses URLSessionDataTask
under the hood - that should ideally be kept a private implementation detail to make things like testing & refactoring easier.
Finally, let's try solving this using a token-based API instead. To do that, we're first going to create a token type that we'll later return when a request is performed:
class RequestToken {
private weak var task: URLSessionDataTask?
init(task: URLSessionDataTask) {
self.task = task
}
func cancel() {
task?.cancel()
}
}
As you can see above, our RequestToken
is super simple, and really just contains a reference to the task that can be cancelled. There's really no reason for it to be more complicated, and it's totally fine for it to hold a reference to the task itself, as long as we don't expose it publicly.
Let's go ahead and use RequestToken
as the return type in UserLoader
:
class UserLoader {
private let urlSession: URLSession
@discardableResult
func loadUsers(matching query: String,
completionHandler: @escaping (Result<[User]>) -> Void) -> RequestToken {
let url = requestURL(forQuery: query)
let task = urlSession.dataTask(with: url) { (data, _, error) in
...
}
task.resume()
return RequestToken(task: task)
}
}
Note that we also add the @discardableResult
attribute to the method, so that we don't force the API user to use the token in case it's not needed.
The result is a neatly abstracted API that enables cancellation without introducing additional state - pretty sweet! 🍭
Using a token to cancel an ongoing task
Time for the final piece of the puzzle - actually using the token to cancel any previously unfinished request as soon as the user types a new character in our view controller's search bar.
To do that, we're going to add a property for storing the current request token, and before we start a new request we simply call cancel()
on it. Finally, since we have introduced cancellation, we might want to update our result handling code to deal with URLError.cancelled
specifically, so that we don't end up displaying an error view whenever we cancel an old request.
Here's what the final implementation looks like:
class UserSearchViewController: UIViewController, UISearchBarDelegate {
private let userLoader: UserLoader
private lazy var searchBar = UISearchBar()
private var requestToken: RequestToken?
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
requestToken?.cancel()
requestToken = userLoader.loadUsers(matching: searchText) { [weak self] result in
switch result {
case .success(let users):
self?.reloadResultsView(with: users)
case .error(URLError.cancelled):
// Request was cancelled, no need to do any handling
break
case .error(let error):
self?.handle(error)
}
}
}
}
Conclusion
Adding tokens to your existing asynchronous APIs can be a great way to easily add cancellation support, without having to completely rewrite your implementation. While other, more sophisticated async abstractions (such as Futures/Promises, RxSwift, Operations, etc) provide a lot more powerful features than simple tokens can offer - if all you need is cancellation, tokens can be a great option.
We could also go one step further, and make our RequestToken
more generalized, by having it take an object conforming to a Cancellable
protocol instead of a concrete URLSessionDataTask
object. But in general I'd suggest keeping tokens super simple, to avoid having them grow into something that looks more like a Future/Promise.
What do you think? Have you used tokens to manage async operations in the past, or is it something you'd like to try out in the future? Let me know, along with any questions, comments or feedback that you might have - on Twitter @johnsundell.
Thanks for reading! 🚀