When using services we work with:
- service APIs
- service consumers
- service providers
- service transports
Service APIs describe the communication between a service consumer and a service provider.
To write one:
- create an interface
- annotate it with
@ServiceApi
- define suspend functions the service provides
@ServiceApi
interface CounterApi {
suspend fun incrementAndGet() : Int
}
The getService
function:
- returns with a service consumer instance
- the compiler plugin generates all the code for the consumer side
- you simply call the functions
val counterService = getService<CounterApi>()
val counter = counterService.incrementAndGet()
println(counter)
Service providers:
- implement a service API
- extend
ServiceImpl
- added with
service
to the backend fragment tree - are instantiated for each call (see below)
class CounterService : CounterApi, ServiceImpl<CounterService> {
companion object {
val counter = AtomicInteger(0)
}
override suspend fun incrementAndGet(): Int {
return counter.incrementAndGet()
}
}
Register the service during application startup, so it is known.
fun main() {
backend(wait = true) {
service { CounterService() }
}
}
Important
A new service provider instance is created for each service call. This might seem a bit of an overkill, but it makes the handling of the service context very straightforward.
Caution
IMPORTANT This DOES NOT WORK. As each call gets a new instance, counter
will be 0 all the time.
class CounterService : CounterApi, ServiceImpl<CounterService> {
val counter = AtomicInteger(0)
override suspend fun incrementAndGet(): Int {
TODO("WRONG, THIS CODE DOES NOT WORK, RTFM")
return counter.incrementAndGet()
}
}
If you don't use a worker, use a companion object or other static structure. Don't forget to pay attention to synchronization.
class CounterService : CounterApi, ServiceImpl<CounterService> {
companion object {
val clicked = AtomicInteger(0)
}
override suspend fun click(): Int {
return clicked.incrementAndGet()
}
}
Each service provider call gets the service context which is reachable in the serviceContext
property and
is an instance of ServiceContext
.
ServiceContext
instances
-
store session data on the server side
- ID of the session
- owner of the session, is known (user ID)
- roles of the owner
-
provide functions for publish/subscribe patterns
send
- sends a message on the connectionconnectionCleanup
- register a connection cleanup functionsessionCleanup
- register a session cleanup function
class HelloService : HelloServiceApi, ServiceImpl<HelloService> {
override suspend fun hello(myName: String): String {
publicAccess()
if (serviceContext.isLoggedIn) {
return "Sorry, I can talk only with clients I know."
} else {
return "Hello $myName! Your user id is: ${serviceContext.owner}."
}
}
}
Service transports:
- move the call arguments from the consumer to the provider
- move the return value from the provider to the consumer
- use WireFormat
Built-in transports:
WebSocket
- check Ktor for examples
Stream (JVM only)
- StreamServiceCallTransport
- uses Java input and output streams
Test
- TestServiceTransport
- calls services of a supplied template or from an implementation factory
- DirectServiceTransport
The service transports may close the connection at specific events such as login and logout. When this
happens, all pending calls fail with DisconnectException
.
You can call client side functions from the server if you have a ServiceContext
:
getService<DuplexApi>(serviceContext.transport).process(value)
Note
Be careful, it is easy to create infinite loops by calling a service from a service implementation.
Whatever WireFormat supports.
Exception handling depends on the transport implementation, see Service Transports.
With the Ktor implementation, provider side exceptions result in:
- for Adat exception instances, instance of the same class is thrown with the same data as on the server (also see note)
- for non-Adat classes
ServiceCallException
is thrown
Note
If there is no wireformat registered for the given Adat class on the client side, ServiceCallException
is thrown.
@Adat
class OddNumberException : Exception()
@ServiceApi
interface NumberApi {
suspend fun ensureEven(i : Int, illegal : Boolean)
}
// ---- SERVER SIDE --------
class NumberService : NumberApi, ServiceImpl<NumberService> {
override suspend fun ensureEven(i : Int, illegal : Boolean) {
publicAccess()
if (i % 2 == 1) {
if (illegal) throw IllegalArgumentException() else throw OddNumberException()
}
}
}
// ---- CLIENT SIDE --------
suspend fun checkNumber(i : Int, illegal : Boolean) : String {
try {
getService<NumberApi>(clientBackend.transport).ensureEven(i, illegal)
return "this is an even number"
} catch (ex : OddNumberException) {
return "this is an odd number"
} catch (ex : ServiceCallException) {
return "ServiceCallException"
}
}
The default server side transport implementation logs service access and service errors. These can be configured
in logback.xml
as the example shows below. For a full example, check logback.xml.
Exceptions that extend ReturnException
are logged as INFO rather than WARN or ERROR.
These are not actual software errors but more like out-of-order return values.
An example use case is AccessDenied
which does not indicate an actual software error and does not need
investigation. In contrast, NullPointerException
should be investigated.
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright © 2020, Simplexion, Hungary and contributors. Use of this source code is governed by the Apache 2.0 license.
-->
<configuration
xmlns="http://ch.qos.logback/xml/ns/logback"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://ch.qos.logback/xml/ns/logback xsd/logback.xsd">
<appender name="ErrorLogFile" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- appender configuration -->
</appender>
<appender name="AccessLogFile" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- appender configuration -->
</appender>
<root level="WARN">
<appender-ref ref="ErrorLogFile"/>
</root>
<logger name="fun.adaptive.service.ServiceAccessLog" level="INFO" additivity="false">
<appender-ref ref="AccessLogFile"/>
</logger>
</configuration>