-
Notifications
You must be signed in to change notification settings - Fork 1
Home
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.
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.
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.
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.
A channel covers the technical protocol needed for a remote access of all services of a particular component.
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
URI
s that they expose.
As in the section above service instances and URI
s 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 ) .
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.
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 firstURI
-
RoundRobinURIProvider
will iterate through the list
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!
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>
}
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>)
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.
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.
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
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!
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 likeUndeclaredThrowableException
-
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.
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.
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 URI
s.
The situation that the set of URI
s 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!
}