Skip to content
Andreas Ernst edited this page Apr 15, 2024 · 26 revisions

Introduction

This library is based on spring ( core, mvc, cloud ) and tries to simplify the approach for typical architectures with distributed (micro)services that need to find and communicate with each other in a dynamic environment.

The basic design principle is to hide technical details - like concrete remoting technologies - and keep the programmer api as simple as possible. This is mainly done by heavily relying on dynamic proxies that internally take care about remoting while still keeping a clean and easy outside interface.

Service registration and discovery is based on existing spring cloud mechanisms. A first - pluggable - implementation relies on consul.

Basic concepts

Service are interfaces that describe a callable service in form of an interface.

Component are special interfaces that bundle a set of services which are exposed and can be called with a specific technology ( called channel ) . The concrete implementation takes care of registering in a central registry.

Channel All services of a component are reachable via a channel as the technical medium.

Component Registry is a central registry that keeps track of all running ( and healthy ) components including their supported channels.

Whenever a service is acquired from the framework, it will create a dynamic proxy connected via the correct channel to a remote service on basis of the current registered components in the registry. Any changes in the topology ( e.g. died services ) that are recognized by failed health checks will be delegated to the channels automatically which will react accordingly.

Let's look at the details.

Service

All services derive from a - tagging - interface Service. There are no technical restrictions on the nature of derived interfaces. It only has to be guaranteed that a client can establish a connection by an appropriate channel given the available information of the interface only.

In case of spring rest services, this for example means, that all of the mapping information is available in form of appropriate annotations.

The service interface has to be annotated with @ServiceInterface. An optional string value is used as the name of the service. If not supplied, the fully qualified name of the service is used.

Component

A component

  • is a mean to bundle similar services ( exposed by the same technical channel )
  • adds lifecycle methods to the component implementations, and
  • is responsible to register with a central component registry

Lets look a the interface declaration:

interface Component : Service {
    /**
     * any startup code that will be executed on startup
     */
    fun startup()

    /**
     * any shutdown code executed while shutting down
     */
    fun shutdown()

    /**
     * the available addresses under which the component can be called remotely
     */
    val addresses: List<ChannelAddress>

    /**
     * the component status
     */
    val status: ComponentStatus

    /**
     * the component health
     */
    val health: ComponentHealth
}

As you can see, a component is a service as well, which makes sense, because some of the methods can and should be called from the outside as well ( like the health endpoint )

The derived interface needs to be marked with the annotation @ComponentInterface that is used to reference the appropriate services.

Example:

@ComponentInterface(name="component", services = [TestService::class])
interface TestComponent : com.serious.service.Component {
   ...
}

The property name is optional. If not supplied, the fully qualified name of the interface is used.

A typical implementation derives from AbstractComponent and typically looks like this

@ComponentHost(health = "/api/test-component/health")
@RestController
@RequestMapping(value = ["/api/test-component"])
class TestComponentImpl : AbstractComponent(), TestComponent {
    // implement TestComponent

    ...

    // implement Component

    @ResponseBody
    @GetMapping("/health")
    override val health: ComponentHealth
        get() = if (healthEndpoint.health().status === Status.UP) ComponentHealth.UP else ComponentHealth.DOWN


   override val addresses: List<ChannelAddress>
        get() = java.util.List.of(
            ChannelAddress("rest", URI.create("http://$host:$port"))
        )
}

Let's look at some details.

@ComponentHost is used to mark component implementations and to specify an http endpoint that can be used to inspect the component health. In this example we simply return ComponentHealth.UP' as long as the component is running. We could also utilize other spring mechanisms such as an actuator HealthEndpoint`.

The spring annotations are used to implement or expose rest services. While this is quite handy, the framework does not make any assumptions.

The most important call is val addresses: List<ChannelAddress> where applicable addresses are published to a component registry which will be read by other clients.

Every address consists of

  • the channel name ( here "rest" )
  • a corresponding URI, used to establish a connection

In the case above, we assume that a channel called "rest" is registered and that we expose services on the currently running port on the local host.

If the servers are behind a load balancer we could as well return the cluster address.

Channel

A channel covers the technical protocol needed for a remote access of all services of a particular component.

Interface

Here is the interface definition.

interface Channel : MethodInterceptor, InvocationHandler {
     /**
      * return the supported [ComponentDescriptor]
      */
    val componentDescriptor: ComponentDescriptor<out Component>

     /**
      * return the channel name
      */
    val name : String

     /**
      * return the associated [ServiceAddress]
      */
    val address : ServiceAddress

     /**
      * setup the channel details based on the supplied address
      */
    fun setup()

     /**
      * react to topology updates
      *
      * @param newAddress the new [ServiceAddress]
      */
    fun topologyUpdate(newAddress: ServiceAddress)
}

Technically it is used as an invocation handler as part of the dynamic proxy logic.

All channels are aware of a ServiceAddress which covers

  • the currently applicable service instances, and
  • the URIs that they expose.

As in the section above service instances and URIs could deviate if we expose cluster addresses.

It is up to a channel how to deal with multiple possible addresses. Implementations could allow client side load-balancing mechanisms or simply pick a random ( or first ) address.

In any case channels are actively informed about topology changes, providing the newly computed service address, which gives them the chance to react accordingly ( e.g. picking a new address from the list if the old address has disappeared ) .

Customization

Every channel usually offers possibilities for customizations while being constructed. Use-Cases are:

  • adding authorization aspects
  • changing load-balancing mechanisms

For this purpose, an interface ChannelCustomizer is available.

interface ChannelCustomizer<T : Channel> {
    /**
     * return the corresponding channel class that this builder is responsible for
     */
    val channelClass: Class<out Channel>

    /**
     * return true if this builder is responsible for a particular [Component]
     */
    fun isApplicable(component: Class<out Component>): Boolean

    /**
     * apply any inital customizations
     *
     * @param channel the [Channel]
     * @return
     */
    fun apply(channel: T):Unit
}
  • isApplicable may be used to add different logic for different components ( think of authorization aspects )
  • apply is called with the concrete channel that typically offers public methods for customization purposes.

Different channel types add the corresponding abstract base-classes.

The customizers are registered by annotating an implementation with @RegisterChannelCustomizer

Example:

@RegisterChannelCustomizer(channel = RestChannel::class)
class RestChannelCustomizer @Autowired constructor(channelManager: ChannelManager) : AbstractRestChannelCustomizer(channelManager) {
    // implement AbstractRestChannelBuilder

     override fun apply(channel: RestChannel) {
         channel.roundRobin()
     }

    override fun customize(builder: WebClient.Builder): WebClient.Builder {
        return builder.filter { clientRequest: ClientRequest, nextFilter: ExchangeFunction ->
            nextFilter.exchange(clientRequest)
        }
    }
}

Here we can see, that a rest-specific method roundRobin() is used to influence the load balancing method.

The second rest-specific method customize(builder: WebClient.Builder): WebClient.Builder is used to influence the WebClient construction.

Implementations

RestChannel

The class RestChannel defines a channel named "rest" which is based on the spring annotations and a WebClient as the technical building block. It offers both synchronous and asynchronous message handling depending on the return values of methods ( e.g. Flux,Mono vs. literal types such as String.

An appropriate class AbstractRestChannelCustomizer is available that can be used to influence the channel construction.

The following methods are exposed and can be called within the an apply method

fun uriProvider(factory : URIProviderFactory)

where a URIProviderFactory is defined as:

interface URIProviderFactory {
        /**
         * Create and return a [URIProvider] for the given address
         *
         * @param address a [ServiceAddress]
         * @return the provider
         */
        fun create(address :ServiceAddress) : URIProvider 
}

 abstract class URIProvider(var address :ServiceAddress) {
        open fun update(newAddress :ServiceAddress) {
            address = newAddress
        }
        abstract fun provide() : URI
    }

Concrete classes are:

  • FirstMatchURIProvider will always pick the first URI
  • RoundRobinURIProvider will iterate through the list

Dispatch Channel

A channel named "dispatch" is a special rest channel - -it actually services from the class RestChannel that communicates with a single rest method that executes every single method call by serializing the request information ( service, method, arguments ) with an object stream and encoding it base64. The big advantage is that no additional annotations are required on interface level!

Component Registry

Interface

The interface ComponentRegistry describes the required protocol for a registry that keeps track of components.

/**
 * A `ComponentRegistry` is a registry for components.
 */
interface ComponentRegistry {
    /**
     * register the specified component which has just started up
     */
    fun register(descriptor: ComponentDescriptor<Component>)

    /**
     * deregister the specified component which has just shut down
     */
    fun deregister(descriptor: ComponentDescriptor<Component>)

    /**
     * return the list of registered service names
     */
    fun getServices() : List<String>

    /**
     * return the list of alive service instances 
     */
    fun getInstances(service: String): List<ServiceInstance>
}

Implementations

ConsulComponentRegistry

A concrete registry has been implemented based on consul and the respective spring support.

Since consul requires specific rest health endpoints, the according health property needs to annotated with an appropriate @GetMapping including the identical health property of the @ComponentHost.

If you take a look at the consul ui, you will notice two things:

  • every component has a tag named "component"
  • the meta-data "channels" defines a comma-separated list of available channels in the form <channel-name>(<uri>)

ServiceManager

The class ServiceManager is the central class in order to access services.

While starting up it will scan all beans in the package referenced by the configuration value service.root which has to be set accordingly ( e.g. application.yaml ).

Once the configured web server has started it will begin by publishing local components with the registry.

API

The main functions are:

  /**
     * create a service proxy which will call the local implementation
     *
     * @param T the service type
     * @param serviceClass the service class
     * @return the proxy
     */
    fun <T : Service> acquireLocalService(serviceClass: Class<T>): T { ... }
    
   /**
     * create a service proxy for a remote service. A proxy will be cerated even if no channels are registered.
     *
     * @param T the service type
     * @param serviceClass the service class
     * @param preferredChannel optional preferred channel name
     * @return the proxy
     */
    fun <T : Service> acquireService(serviceClass: Class<T>, preferredChannel: String? = null): T { ... }
  • acquireLocalService is used to fetch an appropriate service proxy for services that have the appropriate implementation in the class path. It will lead to an exception if this is not the case.
  • acquireService is used to fetch proxies for remote services. By adding the name of a preferred channel, the use can pick between different possible implementations.

Injection

Another way how to fetch service proxies is by injection by applying the annotation

@InjectService(val preferLocal: Boolean = false, val channel: String = "")

  • preferLocal can be set to true to prefer local implementations, if available
  • 'channel' can be set to describe the required channel type

Exception handling

As all things that can go wrong, usually do :-) we have to take care of exception handling.

First idea is that we have to differentiate between two types of exceptions:

  • fatal exceptions, and
  • expected exceptions

Expected exceptions are part of the normal method contract and have to be dealt with on a functional level.

Example:

@ServiceInterface
internal interface TestService : Service {
    @Throws(ValidationException::class)
    fun saveFoo: Foo()

    ...
}

Saving a Foo could lead to validation errors. In order to tell the system that this is a valid flow, we simply mark the method with a @Throws annotation.

Fatal exceptions are exceptions which we cannot foresee and deal with appropriately.

For fatal exceptions a hierarchy of classes has been provided:

FatalException
   ServerException
   CommunicationException 
  • Fatal exception are all exceptions thrown by service calls in the same VM.
  • Server exceptions originate from a remote service call.
  • Communication exceptions originate from a failed remote call.

The exception handling logic is integrated in the dynamic proxies that are responsible for executing method calls.

The following logic applies for all caught exceptions:

  • if the exception is part of the signature, it is simply rethrown
  • all other exceptions are wrapped in the correct fatal exception class and rethrown. Before that a central exception manager is asked to handle them appropriately ( see next chapter )
  • fatal exceptions are rethrown since the were already handled

It is up the the individual channel implementations to implement this logic!

Exception Manager

The purpose of an exception manager is to handle caught exceptions appropriately. The idea is to have a flexible mechanism that doesn't force you to decide early ( and hardcoded ) how to deal with exceptions but to leave it up to dynamically scanned handlers that contribute logic.

An exception manager is able to register any number of so called handlers that define specific methods that deal with specific exception classes. Based on the inheritance hierarchy the most specific method ( or all registered ordered by their applicability ) is picked.

Every exception will pass 4 phases ( which are also the name of the methods ):

  • unwrap unwrapping of exception wrappers like UndeclaredThrowableException
  • log do any kind of logging
  • handle any additional logic how to deal with an exception
  • wrap wrapping an exception

Let's look at an example:

class TestHandler : ExceptionManager.Handler {
    // unwrap

    fun unwrap(e : Throwable) : Throwable {
        return e
    }
    fun unwrap(e : UndeclaredThrowableException) : Throwable  {
        return ExceptionManager.proceed(e.undeclaredThrowable)
    }

    fun unwrap(e : InvocationTargetException) : Throwable {
        return ExceptionManager.proceed(e.targetException)
    }

    // log

    fun log(e : Throwable) {
        println("log"   e.message)
    }

    fun log(e : Exception) {
        ExceptionManager.proceed(e)
    }

    fun log(e : NullPointerException) {
        ExceptionManager.proceed(e)
    }
}


val manager = ExceptionManager()

manager.register(TestHandler())

val handledException = manager.handleException(UndeclaredThrowableException(NullPointerException()))

In this case, the null pointer is unwrapped and logged by three different methods. The resulting exception is the null pointer!

The infrastructure already declares a central exception manager instance as a component.

Handlers can be added by declaring subclasses of AbstractExceptionHandler and adding a @RegisterExceptionHandler annotation.

Example:

@RegisterExceptionHandler
class DefaultExceptionHandler : AbstractExceptionHandler() {
   fun log(e: Throwable) {
     ...
   }
}

Inside a handler the static method

ExceptionManager.proceed()

can be called to call the next applicable method in the current phase according to the class hierarchy.

Implementation details

ComponentRegistry Implementations

Take a look at the existing consul implementation as a starting point. A crucial part is to add a watchdog for any changes reported by the external server. Whenever this happens, we need to inform the ServiceRegistry to deal with the changes by calling

fun update(newMap: MutableMap<String, List<ServiceInstance>>)

with the new map of service names and the corresponding list of available instances.

Channel Implementation

Let's take a look at some implementation details of the "rest" channel as a blueprint for other channels.

Example:

@RegisterChannel("rest")
open class RestChannel(channelManager: ChannelManager, componentDescriptor: ComponentDescriptor<out Component>, address: ServiceAddress)
    : AbstractChannel(channelManager, componentDescriptor, address) {
    // instance data

    private var webClient: WebClient? = null
    ...

    // public

    // customization methods, etc.

    // implement MethodInterceptor

    override fun invoke(invocation: MethodInvocation): Any {
        return ... // internal logic here
    }

    override fun setup() {
        // fetch customizers

        val channelCustomizers = channelManager.getChannelCustomizers<AbstractRestChannelCustomizer>(this)

        // apply

        for (channelCustomizer in channelCustomizers)
            channelCustomizer.apply(this)

        // add some defaults

        var builder = WebClient.builder()
            .baseUrl(...)
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
            // custom filters
            .filter(...)

        // webclient customization

        for (channelCustomizer in channelCustomizers)
            builder = channelCustomizer.customize(builder)

        // done

        webClient = builder.build()
    }

    override fun topologyUpdate(newAddress :ServiceAddress) {
        ...

        // super

        super.topologyUpdate(newAddress) // for now...
    }
}

Channel implementations need to derive from AbstractChannel and are marked by the annotation @RegisterChannel supplying the channel name.

setup

setup is used to implement any internal logic based on the - already set - ServiceAddress. If the channel accepts customization options, the should be applied here based on another channel specific ChannelCustomizer

topologyUpdate

Any changes to a previously computed ServiceAddress will call this method in order to adapt to a new topology. A change is any change in the set of URIs. The situation that the set of URIs is empty has been already covered by the framework, which will replace the specific channel with a MissingChannel implementation that will always throw an exception on being called!

invoke

invoke is the final method that will execute a call. The parameter MethodInvocation has all the required information, which is

  • the interface
  • the method
  • the arguments

Make sure that all technical exceptions are caught in a try-catch and delegated to the ServiceManager

Example:

 try {
    return ...
 }
 catch(exception : Throwable) {
    return manager.handleException(invocation.method, exception) // will throw actually!
 }