-
Notifications
You must be signed in to change notification settings - Fork 9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Use kotlin coroutine return types in query extensions instead of CompletableFuture & Optional #139
Comments
It seems like a reasonable addition to me @matthewadams and straightforward enough too. |
@smcvb I would, however, I consider myself a Kotlin noob right now. I'm coming along quickly. Perhaps I can enlist the help of the commenters on my SO question related to this issue. I've asked them if they'd be willing to help me out. We'll see what they say. Meantime, I'll fork and take a first stab. |
Perhaps we should expand the scope of this issue to be the overhaul of the entire module to use current Kotlin idioms & conventions. What do you think, @smcvb? If so, I propose renaming the issue |
I would be happy to review the PR or even help with the code. Don't hesitate to ping me! |
Ok. I’ve already forked the repo. I’m a little busy, but I’ll take a
crack at it by the middle of next week. If you want to fork and do it,
that’s cool too. Just let me know so we’re not duplicating effort! 😊
…On Wed, Jul 28, 2021 at 11:22 AM Joffrey Bion ***@***.***> wrote:
I would be happy to review the PR or even help with the code. Don't
hesitate to ping me!
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#139 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AADN3IDXAF2BRH5GHQG45ZDT2A4FPANCNFSM5BCM7BUA>
.
|
@matthewadams Same here, a bit busy until next week. I'll let you know if I start anything before you ;) |
@smcvb what are the current backwards compatibility constraints for these extensions? I see the version is 0.1.0 so I'm expecting it's ok to just change the signature directly from Also, is it ok for you if we bring in the dependency on |
As it stands, the Kotlin Extension is free to change its API. Just for my understanding, though, any chance either of you guys could hook me up with a decent article/description on why we would move away entirely from the current format? As far as the dependency goes, if it's experimental, I'd have to do some internal debate. Apart from all of that, happy to see some people jumping on this! |
Ah, I see it's already finalized since Kotlin 1.3, whilst we're on 1.5. For my understanding, though, all you'd be looking at is transforming the types, correct? |
@smcvb sorry for the delay, and sorry for the confusion. As you noticed, Kotlin coroutines are quite stable now, they have been for a while. They are still a "kotlinx" library, which means it's separate from Kotlin's stdlib. There were basically 2 issues in one here:
This second point is not necessary per se, but is a welcome change for coroutine users. That being said, after such change, the library becomes opinionated on using Kotlin coroutines, and the users of the lib will (sort of) have to use coroutines to call the lib (regardless of whether we use Note that if we keep
I was talking about changing the methods to be |
I don't see a reason to break backwards compatibility with 0.1.0. We could leave everything as it is, and simply add the |
With regard to dependencies, I propose that we make the dependencies on the coroutine libraries be |
@matthewadams I agree it is possible, but it doubles the API surface of the library and requires deciding on a naming convention. Since it's a 0.1.0 and it's ok, changing it instead saves some maintenance burden, but I agree keeping both could be interesting as well, I just don't like having 2 different names for the same thing. If we want to keep both APIs, what would you suggest as a naming convention for the future-based VS suspending equivalent? The usual convention would be
I mentioned using suspend functions instead of
What exactly would be the benefit of using provided dependencies in this case? Since this library is distributed with maven, the dependencies are not embedded anyway so they don't take up any extra space.
|
I'm trying to have a stab at it before the weekend, starting with @smcvb I realized with great surprise by reading the tests that To illustrate my point, here is an example: val queryResult: CompletableFuture<String> = gateway.query<String, ExampleQuery>(queryName, exampleQuery) This return type is lying, because the string inside the completable future could be null if the underlying Java call returned a null inside the future. Is there any way in the regular Java Axon framework to specify queries that forbid null values? More specifically, are there query methods that generate validation/runtime exceptions in case of null responses? From what I can see, if you specify If that is the case, then we should make the return types nullable for non-optional query methods, or we should fail with exceptions in case of nulls inside those methods. One option is to define both |
Fair enough, @joffrey-bion. We can just stick with Kotlin idioms moving forward. Less to maintain.
I also agree.
Since |
Sounds good. I'm kind of on vacation right now, anyway, so have at it, @joffrey-bion! |
Hey, @joffrey-bion and @matthewadams thanks for taking this on! Maybe I can help clear out a few things as well. In the case of Optional discussion, as you have already noticed, you can use both nullable and non-nullable references and types in regular query methods. However if you do expect nulls as a possible response from that query, you can use: In the end, it all boils down to requesting the proper type based on the Query handler's responses. We can have pairs of methods like Having the above points in mind, that is what you were trying to convert the
For About breaking backwards compatibility, as @smcvb already mentioned, the extension is still experimental and the API can freely change. |
@sandjelkovic Thanks a lot for all these precisions and links, I will definitely take a look at the related issues!
@sandjelkovic No it won't blow up here. This is why I believe it's a problem. This is an unchecked cast, and will only fail when the value retrieved from the @Test
fun `Query should handle nullable responses`() {
val nullInstanceReturnValue: CompletableFuture<String?> = CompletableFuture.completedFuture(null)
val nullableQueryGateway = mockk<QueryGateway> {
every { query(queryName, exampleQuery, matchInstanceResponseType<String?>() ) } returns nullInstanceReturnValue
}
val queryResult = nullableQueryGateway.query<String, ExampleQuery>(queryName = queryName, query = exampleQuery)
val result = queryResult.get() // the inferred type of result is String (non-nullable)
assertSame(result, nullInstanceReturnValue.get()) // passes!
assertEquals(nullInstanceReturnValue.get(), null)
verify(exactly = 1) { nullableQueryGateway.query(queryName, exampleQuery, matchExpectedResponseType(String::class.java)) }
} Specifying a non-nullable type doesn't add any checks inside the extension function, and nulls can still escape from Java. We don't have to double the API surface, though. If it's enough in your opinion to add a check depending on the generic type, we can take advantage of the
Yes, but if the developer expects a non-nullable value and null is returned by the handler, I believe it would be much better to fail right on the
Agreed, that's actually my question to @matthewadams. When you asked on stackoverflow for how to convert the
I think this will depend on @matthewadams's answer. Having 2 APIs might help inform the user that there is a strict and a nullable API, and avoid confusion. Currently having
Agreed. I guess to replace Java Streams in |
No, I guess I wasn't aware. I think I was viewing the Kotlin API as strict with regards to nullable types. |
I would personally also vote for having a separate method for querying nullable types. Here are my reasons:
To give a bit more substance to reason 1, one puzzling scenario would be the following:
class State
class Test(val gateway: QueryGateway) {
private var someState: State? = null
suspend fun initializeSomething() {
// this assumes query() is now a suspend function returning the result directly (not wrapped in CompletableFuture)
// the type is inferred from the type of someState
someState = gateway.query("MyQueryName", query = "query")
}
} Here it would really not be obvious why it makes any difference to specify types explicitly. With 2 methods, it's clear. With one method driven by type parameters, it would lead to very strange situations (almost undetectable). If someone with more experience comes across this code, they may replace @sandjelkovic / @smcvb what do you think? Another question, do you believe it's useful to keep the |
@sandjelkovic please let me backtrack on what I said about
What about |
Thinking about @joffrey-bion's comments above, I really think it would make the interface much more obvious IMHO with regard to nullability & multiplicity if we refactored to the following (plus their overloads, of course):
Lastly, I feel like @joffrey-bion: Is my omission of |
@matthewadams it is correct to not use the However, my suggestion of using Regarding naming, I am actually fine with the current |
Could we give the illusion of asynchrony by having How does the notion of hot v. cold |
I think it really depends on the contract of the method. If the behaviour is really to suspend and then get all elements at once (like an API call that gets a collection), then I don't believe there is any reason to use a
Hot vs cold flow depends on the source of the data. Returning a cold flow means that nothing happens as long as no-one applies a terminal operation on the flow (basically as long as no-one collects the flow). Returning a hot flow means that something is going on regardless of whether a collector is collecting the flow or not. It's the case for instance if you have an already open websocket connection and you return a flow of events: events are coming regardless of collectors. In both cases, it's still always the client that calls terminal operators, but the source of the flow behaves differently. |
You are correct, I didn't make myself clear enough. Yes, it will blow up when the value is received from the server. However, before the value is received there is no way to know if that's going to be null or not. To really support non-nullable types on that level, the core framework components for query handling must differentiate between the two with some sort of mechanism. Right now I'm not sure if there is a difference in compiled JVM bytecode between nullable and non-nullable types. Even if we require non-nullable generic, I don't see a way how to assert non-nullability on the result before it is retrieved from the server in the completable future. If you have an idea on how to do it, sure, I'm all for type safety.
I would actually expect the return type to be nullable if it's assigned to a nullable value unless a non-nullable one is specifically requested. Keep in mind that the Kotlin stdlib, at least the collections, also allow for val list: List<String?> = listOf<String?>() and val first: String? = list.first() Would also get you a nullable reference even though there is
To give some perspective,
Honestly, There are 2 very important differences between
I don't see why would these need to be removed, they provide utility to users by bypassing the I would also like to mention that the discussion is starting to blur two APIs and concepts together. These extension methods are intended to make the original Gateway as much Kotlin and user friendly as possible and are usually simple utilities. As such they are always going to be limited by the underlying Gateway interface and what they can do to wrap those regular Java friendly method calls. So while Java's For a true Kotlin asynchronous API these extensions on the regular Gateway probably won't work. I say probably as there might be a good way to integrate those API, but either extensions to ReactorQueryGateway or a new interface based on coroutines suspending functions and types make more sense for true asynchronous API. |
@sandjelkovic thanks again for taking the time to respond. Please let me address the nullability typing problem, as it's sort-of independent from the The problem
No it won't blow up when we receive the value from the server (at least not with the current code), and it will not even blow up when we access the value from the completable future. People can call The only moment it fails is when we assign the result of // test gateway returning null inside the completed future despite the non-null R==String
val queryResult = gateway.query<String, ExampleQuery>(queryName = queryName, query = exampleQuery)
val result = queryResult.get() // doesn't fail, the inferred type of result is String (non-nullable) but holds a null
val result2: String = queryResult.get() // correctly fails, but users are unlikely to declare types explicitly
someFunExpectingNonNullString(result) // correctly fails, but that's too late The solutions
I'm sorry if it sounded like I was suggesting this was possible. You're right, we can't have an error before we get the result from the server of course, just like you won't get a compile-time error when calling What I was suggesting is to add My previous messages might have been unclear, so please let me clarify the 2 solutions I had talked about with actual code (leaving aside the whole coroutine stuff for now), and the 3rd solution just for completeness. Solution 1 (with 2 separate methods): // strict query method with non-nullable generic type param, and non-nullable `R` in the returned future
inline fun <reified R : Any, reified Q> QueryGateway.query(queryName: String, query: Q): CompletableFuture<R> {
return query(queryName, query, ResponseTypes.instanceOf(R::class.java))
.thenApply { it ?: error("Expected non-null value in query '$queryName'") }
}
// lenient query method (possibly with a better name) that accepts nullable type param and returns nullable `R`
inline fun <reified R, reified Q> QueryGateway.queryNullable(queryName: String, query: Q): CompletableFuture<R?> {
return query(queryName, query, ResponseTypes.instanceOf(R::class.java))
} (Note that if the strict Solution 2 (all-in-one): inline fun <reified R, reified Q> QueryGateway.query(queryName: String, query: Q): CompletableFuture<R> {
val result = query(queryName, query, ResponseTypes.instanceOf(R::class.java))
return if (null is R) result else result.thenApply { it ?: error("Expected non-null value in query '$queryName'") }
} This second solution would be strict if the type parameter is non-null, but allow nulls if the type parameter allows it. In a previous message, I detailed why I would prefer 2 separate methods (as a user) instead of this "combo" one. Solution 3 (just fix the type): // exactly the same as the current query method that accepts nullable type param but properly returns nullable R?
// no matter the nullability of the type parameter R
inline fun <reified R, reified Q> QueryGateway.query(queryName: String, query: Q): CompletableFuture<R?> {
return query(queryName, query, ResponseTypes.instanceOf(R::class.java))
} If nothing else is changed, at least this has to be done in order to match the current code, because calling About why I find solution 2 undesirable
Note that while
I'm not sure what you're getting at here. The list is properly declared as The example I had constructed here was an argument against solution 2, and in favor of solution 1. Note that the A closer analogy here would be the following: if the stdlib was designed with solution 2, we would only have a single I'm really sorry for the long message, I just wanted to be very clear about what I meant in all previous messages. |
Apart from the problem discussed above, which I'm thinking I should open as a separate issue, I agree with @sandjelkovic on almost everything else.
I don't have a strong opinion against
These are pretty weak arguments to be honest, and these extensions could indeed be useful to some users using mixed Java/Kotlin projects. So honestly, no strong opinion from me 😄 We can keep them.
It's very easy to adapt future-based APIs to coroutines by just using |
Enhancement Description
The current query extension methods return values using Java's
CompletableFuture
andOptional
types. The usage of these types seems awkward to me in modern Kotlin that uses coroutines. This enhancement request is to encapsulate the use of Java types in favor of pure Kotlin idioms.Current Behaviour
Note the use of Java types here, for example:
This requires the developer to adapt
CompletableFuture
to Kotlin'sDeferred
orFlow
types (depending on single vs multiple response types), as well as adaptingOptional
to Kotlin's nullable type system.Wanted Behaviour
I've been trying to create new extension methods that only expose Kotlin types.
Consider the following:
Now, notice how I use them in the extension method
queryNullableAsDeferred
below, whereSchedule
is a Spring Data MongoDB persistent entity on the read side:I'm no Kotlin expert yet, so maybe this needs some fine-tuning, but this seems more natural to me. I think this issue could be expanded to other parts of the codebase as well (like
CommandGateway.send(..)
, as they also returnCompletableFuture
.Possible Workarounds
The text was updated successfully, but these errors were encountered: