1. Introduction

Promises are a fantastic way to manage asynchronous code, like where we need a response but are willing to wait for it to be available.

In this tutorial, we’re going to see how Kovenant introduces promises to Kotlin.

2. What Are Promises?

At their most basic, a Promise is a representation of a result that has yet to happen. For example, a piece of code might return a Promise for some complicated computation, or for the retrieval of some network resource. The code is literally Promising that the result will be available, but that it might not be available just yet.

In many ways, Promises are similar to Futures that are already part of the core Java language. However, as we’ll see, Promises are a lot more flexible and powerful, allowing for failure cases, for chains, and other combinations.

3. Maven Dependencies

Kovenant is a standard Kotlin component, and then adapter modules for working alongside various other libraries.

Before we can use Kovenant in our project, we need to add correct dependencies. Kovenant makes this easy with a pom artifact:

<dependency>
    <groupId>nl.komponents.kovenant</groupId>
    <artifactId>kovenant</artifactId>
    <type>pom</type>
    <version>3.3.0</version>
</dependency>

In this POM file, Kovenant includes several different components that work in combination.

There are also modules for working alongside other libraries or on other platforms, such as RxKotlin or on Android. The full list of components is on the Kovenant website.

4. Creating Promises

The first thing we’ll want to do is create a promise. There are a number of ways we can achieve this, but the end result is always the same: A value that represents the promise of a result that might or might not have happened yet.

4.1. Manually Creating a Deferred Action

One way to engage the Kovenant’s Promise API is by deferring an action.

We can manually defer an action using the deferred<V, E> function. This returns an object of type Deferred<V, E> where V is the expected success type and E the expected error type:

val def = deferred<Long, Exception>()

Once we’ve created a Deferred<V, E>, we can choose to resolve or reject it as needed:

try {
    def.resolve(someOperation())
} catch (e: Exception) {
    def.reject(e)
}

But, we can only to do one of these on a single Deferred, and trying to call either again will result in an error.

4.2. Extracting a Promise from a Deferred Action

Once we’ve created a deferred action, we can extract a Promise<V, E> from it:

val promise = def.promise

This promise is the actual result of the deferred action, and it won’t have a value until the deferred is either resolved or rejected:

val def = deferred<Long, Exception>()
try {
    def.resolve(someOperation())
} catch (e: Exception) {
    def.reject(e)
}
return def.promise

When this method returns, it will return a promise that is either resolved or rejected*,* depending on how the execution of someOperation went.

Note that a Deferred<V, E> wraps a single Promise<V, E>, and we can extract this promise as many times as we need to. Every call to Deferred.promise will return the same Promise with the same state.

4.3. Simple Task Execution

Most of the time, we want to create a Promise by a simple execution of some long-running task, similar to how we want to create a Future by executing a long-running task in a Thread.

Kovenant has a very easy way to do this, with the task function.

We call this by providing a block of code to execute, and Kovenant will execute this asynchronously and immediately return a Promise<V, Exception> for the result:

val result = task {
    updateDatabase()
}

Note that we don’t need to actually specify the generic bounds since Kotlin can infer these automatically from the return type of our block.

4.4. Lazy Promise Delegates

We can also use Promises as an alternative to the standard lazy() delegate. This works exactly the same, but the property type is a Promise<V, Exception>.

As with the task handler, these are evaluated on a background thread and made available when appropriate:

val webpage: Promise<String, Exception> by lazyPromise { getWebPage("http://www.example.com") }

5. Reacting to Promises

Once we’ve got a Promise in our hands, we need to be able to do something with it, preferably in a reactive or event-driven fashion.

A promise is fulfilled successfully when we complete a Deferred<V, E> with the resolve method, or when our task finishes successfully.

Alternatively, it is fulfilled unsuccessfully when a Deferred<V, E> is completed with the reject method, or when a task finishes by throwing an exception.

Promises can only ever be fulfilled once in their life, and attempts to do so a second time are an error.

5.1. Promise Callbacks

We can register callbacks against promises for Kovenant to trigger once the promise is either resolved or rejected.

If we want something to happen when our deferred action succeeds, we can use the success function on the Promise to register callbacks:

val promise = task { 
    fetchData("http://www.example.com") 
}

promise.success { response -> println(response) }

And if we want something to happen when our deferred action fails, we can use fail in the same way:

val promise = task { 
    fetchData("http://www.example.com") 
}

promise.fail { error -> println(error) }

Alternatively, we can register a callback to be triggered whether the promise was successful or not, using Promise.always:

val promise = task {
    fetchData("http://www.example.com")
}
promise.always { println("Finished fetching data") }

Kovenant lets us chain these together as well, meaning that we can write our code a bit more succinctly if we wish:

task {
    fetchData("http://www.example.com")
} success { response ->
    println(response)
} fail { error ->
    println(error)
} always {
    println("Finished fetching data")
}

Sometimes, there are multiple things we want to happen based on the state of a promise, and we can register each one individually.

We can, of course, chain these in the same way as above, though this is less common as we’d likely just have a single callback that does all of the work.

val promise = task {
    fetchData("http://www.example.com")
}

promise.success { response ->
    logResponse(response)
} success { response ->
    renderData(response)
} success { response ->
    updateStatusBar(response)
}

And all appropriate callbacks are executed sequentially in the order that we listed them.

This includes interleaving between different kinds of callbacks:

task {
    fetchData("http://www.example.com")
} success { response ->
    // always called first on success
} fail { error ->
    // always called first on failure
} always {
    // always called second regardless
} success { response ->
    // always called third on success
} fail { error ->
    // always called third on failure
}

5.2. Chaining Promises

Once we’ve got a promise, we can chain it with other promises, triggering additional pieces of work based on the result.

This allows us to take the output of one promise, adapt it–possibly as another long-running process–and return another promise:

task {
    fetchData("http://www.example.com")
} then { response -> 
    response.data
} then { responseBody ->
    sendData("http://archive.example.com/savePage", responseBody)
}

If any of the steps in this chain fail then the entire chain fails. This allows us to shortcut pointless steps in the chain and still have clean, easy to understand code:

task {
    fetchData("http://bad.url") // fails
} then { response -> 
    response.data // skipped, due to failure
} then { body -> 
    sendData("http://good.url", body) // skipped, due to failure
} fail { error ->
    println(error) // called, due to failure
}

This code attempts to load data from a bad URL, fails, and immediately drops through to the fail callback.

This acts similarly to if we had wrapped it in a try/catch block, except that we can register multiple different handlers for the same error conditions.

5.3. Blocking on the Promise Result

Occasionally, we’ll need to get the value out of a promise synchronously.

Kovenant makes this possible using the get method, which will either return the value if the promise has been fulfilled successfully or throw an exception if it has been resolved unsuccessfully.

Or, in the case that the promise is not yet fulfilled, this will block until it has:

val promise = task { getWebPage() }

try {
    println(promise.get())
} catch (e: Exception) {
    println("Failed to get the web page")
}

There is a risk here that the promise is never fulfilled, and thus the call to get() will never return.

If this is a concern then we can be a bit more cautious and inspect the promise’s state using isDoneisSuccess and isFailure instead:

val promise = doSomething()
println("Promise is done? " + promise.isDone())
println("Promise is successful? " + promise.isSuccess())
println("Promise failed? " + promise.isFailure())

5.4. Blocking With a Timeout

At the moment, Kovenant has no support for timeouts when waiting on promises like this. This feature is expected in a future release though.

However, we can achieve this ourselves with a bit of elbow grease:

fun <T> timedTask(millis: Long, body: () -> T) : Promise<T?, List<Exception>> {
    val timeoutTask = task {
        Thread.sleep(millis)
        null     
    }
    val activeTask = task(body = body)
    return any(activeTask, timeoutTask)
}

(Note that this uses the any() call, which we’ll discuss later on.)

We can then call this code to create a task and provide it with a timeout. If the timeout expires then the promise will immediately resolve to null:

timedTask(5000) {
    getWebpage("http://slowsite.com")
}

6. Canceling Promises

Promises typically represent code that is running asynchronously and will eventually produce a resolved or rejected result.

And sometimes we decide that we don’t need the result after all.

In that case, we might want to cancel the promise instead of letting it continue to use resources.

Whenever we use task or then to produce a Promise, these are cancellable by default. But you still need to cast it to a CancelablePromise to do it since the API returns a supertype that doesn’t have the cancel method:

val promise = task { downloadLargeFile() }
(promise as CancelablePromise).cancel(UserGotBoredException())

Or, if we use deferred to create a promise then these aren’t cancellable unless we first provide an “on-cancel” callback:

val deferred = deferred<Long, String> { e ->
    println("Deferred was cancelled by $e")
}
deferred.promise.cancel(UserGotBoredException())

When we call cancel, the result of this is very similar to if the promise was rejected by any other means, like by calling Deferred.reject or by the task block throwing an exception.

The main difference is that cancel will actively abort the thread running the promise if there is one, raising an InterruptedException inside that thread.

The value passed in to cancel is the rejected value of the promise. This is provided to any fail handlers that you might have set up, in exactly the same way as any other form of rejecting the promise.

Now, Kovenant states that cancel is a best-effort request. It might mean that the work never gets scheduled in the first place. Or, if it is already executing, then it will attempt to interrupt the thread.

7. Combining Promises

Now, let’s say that we have many asynchronous tasks running and we want to wait for them all to finish. Or we want to react to whichever is the first to finish.

Kovenant supports working with multiple promises and combining them in various ways.

7.1. Waiting for All to Succeed

When we need to wait for all the promises to finish before reacting, we can use Kovenant’s all:

all(
    task { getWebsite("http://www.example.com/page/1") },
    task { getWebsite("http://www.example.com/page/2") },
    task { getWebsite("http://www.example.com/page/3") }
) success { websites: List<String> ->
    println(websites)
} fail { error: Exception ->
    println("Failed to get website: $error")
}

all will combine several promises together and produce a new promise. This new promise resolves to the list of all successful values or fails with the very first error that is thrown by any of them.

This means that all the provided promises must have the exact same typePromise<V, E> and that the combination takes the type Promise<List, E>.

7.2. Waiting for Any to Succeed

Or, maybe we only care about the first one that finishes, and for that, we have any:

any(
    task { getWebsite("http://www.example.com/page/1") },
    task { getWebsite("http://www.example.com/page/2") },
    task { getWebsite("http://www.example.com/page/3") }
) success { result ->
    println("First web page loaded: $result")
} fail { errors ->
    println("All web pages failed to load: $errors)
}

The resulting promise is the inverse of what we saw with all. It’s successful if any single provided promise resolves successfully, and it fails if every provided promise fails.

Also, this means that success takes a single Promise<V, E> and fail takes a Promise<V, List>.

If a successful result is returned by any of the promises, Kovenant will attempt to cancel the remaining unresolved promises.

7.3. Combining Promises of Different Types

Now, let’s say that we have an all situation, but each promise is of a different type. This is a more general case of the one supported by all, but Kovenant has support for this as well.

This functionality is provided by the kovenant-combine library instead of kovenant-core that we’ve been using so far. Though, because we added the pom dependency, both are available to us.

In order to combine an arbitrary number of promises of different types, we can use combine:

combine(
    task { getMessages(userId) },
    task { getUnreadCount(userId) },
    task { getFriends(userId) }
) success { 
  messages: List<Message>, 
  unreadCount: Int, 
  friends: List<User> ->
    println("Messages in inbox: $messages")
    println("Number of unread messages: $unreadCount")
    println("List of users friends: $friends")
}

The successful result of this is a tuple of the combined results. However, the promises must all have the same failure type as these are not merged together.

kovenant-combine also provides special support for combining exactly two promises together via the and extension method. The end result is exactly the same as using combine on exactly two promises, but the code can be more readable.

As before, the types don’t need to match in this case:

val promise = 
  task { computePi() } and 
  task { getWebsite("http://www.example.com") }

promise.success { pi, website ->
    println("Pi is: $pi") 
    println("The website was: $website") 
}

8. Testing Promises

Kovenant is deliberately designed to be an asynchronous library. It runs tasks in background threads and makes results available as and when the tasks finish.

This is fantastic for our production code but can make testing more complicated. If we are testing some code that itself uses promises, the asynchronous nature of those promises can make the tests complicated at best and unreliable at worst.

For example, let’s say we want to test a method whose return type contains some asynchronously-populated properties:

@Test
fun testLoadUser() {
    val user = userService.loadUserDetails("user-123")
    Assert.assertEquals("Test User", user.syncName)
    Assert.assertEquals(5, user.asyncMessageCount) 
}

This is a problem since asyncMessageCount might not be populated by the time that the assert is invoked.

To that end, we can configure Kovenant to use a testing mode. This will cause everything to be synchronous instead.

It also gives us a callback that is triggered if anything goes wrong, where we can handle this unexpected error:

@Before 
fun setupKovenant() {
    Kovenant.testMode { error ->
        Assert.fail(error.message)
    }
}

This test mode is a global setting. Once we invoke it, it affects all Kovenant promises created by the suite of tests. Typically, then, we’d call this from a @Before-annotated method to ensure that all tests are running in the same way.

Note that currently there’s no way to turn the test mode off, and it affects Kovenant globally. As such, we need to be careful using this when we want to also test the asynchronous nature of promises.

9. Conclusion

In this article, we showed the Kovenant basics as well as some fundamentals about promise architectures. Specifically, we talked about deferred and task, registering callbacks, and chaining, canceling, and combining promises.

Then, we wrapped up with talking about testing asynchronous code.

There’s a lot more this library can do for us, including more complicated core functionality as well as interactions with other libraries.

And, as always, check out the examples of all this functionality over on GitHub.


« 上一篇: Kotlin的Fuel HTTP库