I'm trying to come up with a good multiplatform pu...
# multiplatform
e
I'm trying to come up with a good multiplatform public API for socket communication. Example of an
expect
class method:
Copy code
/**
 * Connects to the host via a TCP socket.
 */
fun connect(host: String, port: Int, listener: Result<ServerProperties>)
This is then implemented both for JS and JVM. The problem with this approach is the method accepts a parameter which isn't really part of what the method does. It clutters it. Ideally it would be a return value, however that complicates things. In JS you could return a
Promise
, in JVM you could return a
CompletableFuture
. Anyone had the same kind of "problem" while designing an API? In which direction did you go? A note: I can't use suspending functions, I need to guarantee good interoperability with JS code and Java code.
a
I’d return Deferred from the common code, and provide JVM and JS specific extension functions (in
src/jvmMain
and
src/jsMain
) on the
expect class
, and those extension functions will call
connect()
and convert the resulting Deferred to a suitable platform type
e
Thanks @Adam S. The thing with extension function is they will be translated to static methods on the JVM, which will make working with the code a bit weird (imo)
a
yes, that’s true (nitpick: from Java/Groovy/Scala, but not Kotlin/JVM :) )
the problem, basically, is that you want to have an
expect class Foo
, but in the
actual class Foo
have some platform specific member functions - but that’s not possible.
Instead you could try this: 1. in commonMain, define a
interface Foo
instead of an
expect class Foo
(possibly use
sealed interface Foo
instead) 2. add
fun connect(...): Deferred<String>
as a member function of Foo 3. in commonMain, create
expect fun Foo(): Foo
4. and then in each platform you can create an implementing class, with platform specific functions, e.g.
Copy code
// src/jvmMain/FooJvm.kt

actual fun Foo(): Foo = FooJvm()

class FooJvm : Foo {

  fun connect(...): Deferred<String>

  fun connectJvm(...): CompletableFuture<String> {
    val result = connect()
    return result().convertToFuture() // (or whatever actual the Coroutines extension function is)
  }
}
the downside is that it’s harder to share code between platforms, so that’s why in commonMain you create
Copy code
class FooCommon: Foo {
  fun connect(...): Deferred<String> { /* actual impl */ }
}
And then in the platform implementations you can use interface delegation to re-use the FooCommon implementation
Copy code
class FooJvm : Foo by FooCommon() {

  // fun connect(...): Deferred<String> // no need to implement connect(), it's provided by FooCommon

  fun connectJvm(...): CompletableFuture<String> {
    val result = connect()
    return result().convertToFuture() // (or whatever actual the Coroutines extension function is)
  }
}
e
Valid suggestion! Thanks. I need to think about it a bit, I really need to decide if it's worth the additional implementation and maintenance cost over time
a
you’re welcome! Yeah, have a think about it because there’s not one clear answer. It depends on your situation. Okio does the ‘delegate to common’ approach using extension functions on the ‘expect class’ type https://github.com/square/okio/blob/81bce1a30af244550b0324597720e4799281da7b/okio/src/commonMain/kotlin/okio/internal/-Utf8.kt#L27
e
@Adam S probably the best thing is to start by cleaning up method's parameters, by moving the listener to a return value. Since internally I'm using coroutines (e.g., GlobalScope in JS), I could come up with my own class that use the Deferred object + invokeOnCompletion?
I can probably re-use Deferred.asPromise and Deferred.asCompletableFuture, wrapping them in my own common class/interface
a
so you’d create a custom ‘deferred result’ type in commonMain, and map them to platform types as required? That could work too!
e
Yup I've already done that, will share the code after lunch!
Basically I've wrapped the promise and the future. This also means I can reuse their function chaining capabilities
New expect function signature (Z is a common prefix for the project). I've kept the promise name as it overall suites better.
Copy code
fun connect(host: String, port: Int): ZPromise<ZServerProperties>
The return type:
Copy code
interface ZPromise<T> {
  fun <S> then(thenFn: ((T) -> S)?): ZPromise<S>
  fun <S> catch(catchFn: (Throwable) -> S): ZPromise<S>
}
JS impl:
Copy code
class JsZPromise<T>(private val promise: Promise<T>) : ZPromise<T> {
  override fun <S> then(thenFn: ((T) -> S)?): ZPromise<S> =
    JsZPromise(promise.then(thenFn))

  override fun <S> catch(catchFn: (Throwable) -> S): ZPromise<S> =
    JsZPromise(promise.catch(catchFn))
}
JVM impl:
Copy code
class JvmZPromise<T>(private val future: CompletableFuture<T>) : ZPromise<T> {
  override fun <S> then(thenFn: ((T) -> S)?): ZPromise<S> =
    JvmZPromise(future.thenApply(thenFn))

  override fun <S> catch(catchFn: (Throwable) -> S): ZPromise<S> =
    JvmZPromise(future.handle { _, u -> catchFn(u) })
}
JS usage site:
Copy code
actual fun connect(host: String, port: Int): ZPromise<ZServerProperties> {
  val jsPromise = GlobalScope.promise {
    connect(port, host)
    readZObject(::ZServerProperties)
  }

  return JsZPromise(jsPromise)
}
Can also simplify further with an extension function on both sides:
Copy code
fun <T> CoroutineScope.deferred(block: suspend CoroutineScope.() -> T): ZPromise<T> =
  JsZPromise(promise(block = block))
The problem is how do I chain
ZPromise
calls
On the JVM it's actually straightforward.
Copy code
override fun <S> thenPromise(thenFn: ((T) -> ZPromise<S>)): ZPromise<S> =
  JvmZPromise(future.thenCompose {
    val thenFn = thenFn(it) as JvmZPromise<S>
    thenFn.future // This is a private field pointing to the CompletableFuture
  })
a
e
@andylamax interesting, thanks!