Services
Services are Toit's way of communicating between different containers. They use an RPC mechanism that allows containers to call predefined methods across container boundaries. As such they are a natural way of separating out complex drivers (like the ones for cellular modems), so they can run in the own address spaces.
Architecture
When working with services, there are four main concepts:
- a "service interface"
- a "service provider"
- a "service client"
- a "service manager"
The service interface describes the API the service provides.
A provider class then implements the service interface and registers itself as a service provider.
A client class implements the actual RPC calls so that users of the service can interact with the provider as if it was a local object.
The service manager is responsible for connecting clients and providers, and for dispatching requests between them. It is part of the system.
Service interfaces
A service interface describes the functionality that a provider offers to clients. The interface is identified by a UUID, together with a major and minor version number. Each method furthermore gets a unique integer.
For example:
// service.toit. import system.services show ServiceSelector interface RandomGeneratorService: static SELECTOR ::= ServiceSelector --uuid="dd9e5fd1-a5e9-464e-b2ef-92bf15ea02ca" --major=0 --minor=1 generate limit/int -> int static GENERATE-INDEX ::= 1
The RandomGeneratorService.SELECTOR
constant will bind a
client of the service and the service implementation together. We typically
just generate random UUIDs, because all we need is some notion of identity.
The dd9e5fd1-a5e9-464e-b2ef-92bf15ea02ca
constant was generated via
https://www.uuidgenerator.net/.
The RandomGeneratorService.SELECTOR.major
and
RandomGeneratorService.SELECTOR.minor
values
represent the current version of the interface. The version is used during
service discovery, because it is possible that a client will be trying to
access a newer or older implementation of the service due to the fact that
clients and implementations are decoupled and are likely to be updated
independently of each other.
The interface furthermore adds information on what the method for index
1 (GENERATE-INDEX
) should look like. The underlying RPC mechanism doesn't
take advantage of this information. Provider and client implementations,
on the other hand, should use this information to implement the interface,
so they are compatible.
We currently manually assign indexes to methods, but you could imagine generating the interface definition from something like a protocol buffer service and have the indices automatically assigned. If you change the index of any existing method you should bump the major version, but if you only add new methods with previously unused indices, you can get away with just bumping the minor version.
In a similar vein, we are investigating ways to automatically generate the service provider and client code from the interface definition.
Service providers
A service provider is an object that provides the functionality that the service interface promises.
It registers itself with the service manager, and then listens for incoming requests. The service manager connects clients and providers, and dispatches requests between them.
For example, the aforementioned RandomGeneratorService
could be
implemented as follows:
import system.services show ServiceProvider ServiceHandler import .service class RandomGeneratorServiceProvider extends ServiceProvider implements ServiceHandler: constructor: super "test/random-generator" --major=7 --minor=9 provides RandomGeneratorService.SELECTOR --handler=this handle index/int arguments/any --gid/int --client/int -> any: if index == RandomGeneratorService.GENERATE-INDEX: return generate arguments unreachable generate limit/int -> int: print "got request to generate a random number with limit $limit" return random limit
The RandomGeneratorServiceProvider
class extends the ServiceProvider
base clase which implements the communication with the service manager.
The arguments to the super
call are the name of the service, and the
version of the service interface. These are not used by the service
manager, and are there mostly for debugging purposes. They are
fundamentally a description of the provider.
The provides
call (invoking a method on the super class) declares the
selector this provider implements. There can be multiple calls to
provides
if the provider implements multiple service interfaces.
The provides
invocation takes a
ServiceHandler
object as --handler
argument. This object is
responsible for handling incoming requests. In most cases, the provider
itself will implement the ServiceHandler
interface. However,
especially for providers that implement multiple service interfaces, it
can be useful, or even necessary, to have a separate handlers for each
interface. For example, a temperature, humidity, and pressure sensor
could implement three different service interfaces, and have a separate
handler for each.
Note that the ServiceHandler
interface is just describing
a callback:
The handle
method implements the actual service. It switches on
the index of the incoming request, and calls the appropriate method.
In this example the provider class actually implements the
RandomGeneratorService
interface, and we could explicitly state
that by adding implements RandomGeneratorService
to the class.
However, to give the developer more flexibility in implementing
the service, we generally don't add the implements
clause.
Before a provider can be used, it must be registered with the service
manager by calling install
on the provider object.
Using a service provider
Typically, a service provider is installed in its own container where
the main
function simply installs the provider:
For testing (or on a desktop machine), it can also be useful to spawn the provider process from the client process:
... main: spawn:: provider := RandomGeneratorServiceProvider provider.install // Do client stuff here.
Service provider lifecycle
A service provider is a long-lived object. It is usually installed
when the container starts, and stays alive until the container is
forcefully stopped. In Artemis containers
that run providers should be marked as background
so they don't
prevent the system to go to deep sleep.
For each client that connects to a provider, the on-opened
method
is called. Similarly, when a client disconnects, the on-closed
method is called. These methods can be used to keep track of the
number of connected clients, and to perform setup actions or cleanups.
For example, a driver that requires an external peripheral to be
powered on could use the on-opened
method to power on the peripheral,
and the on-closed
method to power it off again.
Service clients
A service client is an object that provides the service functionality to the client. It transparently forwards calls to the service provider.
Once the client has connected to a provider (with the help of the service manager), its main job consists of serializing the arguments and return values, and to map the method calls to the appropriate indexes.
For example, the aforementioned RandomGeneratorService
could be
implemented as follows:
import system.services show ServiceClient ServiceSelector import .service class RandomGeneratorServiceClient extends ServiceClient: static SELECTOR ::= RandomGeneratorService.SELECTOR constructor selector=SELECTOR: assert: selector.matches SELECTOR super selector generate limit/int -> int: return invoke_ RandomGeneratorService.GENERATE-INDEX limit
The RandomGeneratorServiceClient
class extends the ServiceClient
base clase which implements the communication with the service manager.
The super
call takes a ServiceSelector
object as argument. This
object describes the service that the client wants to connect to. By
default the selector from the RandomGeneratorService
interface is
used, but users can also provide their own selector as long as its
compatible (the assert
call checks this).
The generate
method then implements the actual service. It calls the
invoke_
method on the super class, passing the index of the method
to call, and the arguments to the method. The invoke_
method then
serializes the arguments, sends them to the service provider, and
waits for the response. It then deserializes the response and returns
it.
Using a service client
A client needs the a provider to be available. See how to use providers for more information.
The common way to use a service is to construct it and to open
it. The
open
call contacts the service manager and finds a matching provider.
main: client := RandomGeneratorServiceClient client.open 10.repeat: print "random = $(client.generate 100)"
Often, the service can also be in a lazy-initialized constant:
The --if-absent
block is invoked when we cannot find the requested service.
You can provide a timeout if you're willing to
wait a bit for the service to appear:
service_/RandomGeneratorServiceClient? ::= (RandomGeneratorServiceClient).open --timeout=(Duration --s=2) --if-absent=: null
Serialization
As part of the communication between clients and providers, messages
are serialized in an efficient way. Messages must be either null
,
integers, booleans, floats, strings, byte arrays, lists or maps.
Any type that is not one of these must be converted
before calling invoke_
. In that case, the provider and client must agree
on how to encode the type.
For example, if we want to send more than one argument as an RPC message we must encode them first. By convention, we encode them as a list of the individual arguments.
For a more efficient message passing, byte arrays may be transferred directly, without copying. In that case, the original byte array is "neutered" and reset to an empty byte array. This means that the byte array cannot be used anymore after it has been sent.
Service resources and proxies
Sometimes it is useful for a service to let clients refer to resources allocated on their behalf. The resource lives with the service provider, and the client receives a handle to it. The system will then take care of cleaning up the resource when the client goes away.
Say, the provider can allocate a Die
resource that the client can
interact with, by calling roll
on it. Let's assume, that the
object must live on the provider. The provider gives the client
a way to create the object, and then sends a "handle" to the client.
Handles are simply integers, and the service is thus changed as follows:
interface RandomGeneratorService: ... create-die -> int static CREATE-DIE-INDEX /int ::= 2 roll-die handle/int -> int static ROLL-DIE-INDEX /int ::= 3
The Die
's roll
method is integrated into the service interface,
but the provider and client should abstract them away and move them
onto the resource objects.
Resources on the provider
On the provider side a resource object should extend the ServiceResource
base class:
import system.services show ServiceResource ServiceProvider class DieResource extends ServiceResource: sides_/int ::= ? constructor .sides_ provider/ServiceProvider client/int: super provider client roll -> int: return 1 + (random sides_) on-closed -> none: // Handle cleanup.
The on-closed
method is automatically called when the client closes
the resource or if the client happens to go away. Instances of
ServiceResource
are automatically serializable, so it is possible to
return them from the handle
method in the service provider and
the ServiceResource
constructor takes care of registering them
correctly, so they can be found later on future client method calls.
Let's add the new methods to the RandomGeneratorServiceProvider
:
handle index/int arguments/any --gid/int --client/int -> any: ... if index == RandomGeneratorService.CREATE-DIE-INDEX: // The 'arguments' parameter is equal to the number of sides. return DieResource arguments this client if index == RandomGeneratorService.ROLL-DIE-INDEX: // The 'arguments' parameter is set to the handle of the resource. die := (resource client arguments) as DieResource return die.roll
Note that we implement the create-die
and roll-die
functionality
directly in the handle
method. That's not necessary, but often
convenient if it's as simple as in this case.
The provider also handles the ROLL-DIE-INDEX
method by forwarding it to
the corresponding DieResource
instance.
Resources on the client
Clients receive resources as integers, representing the handle to the resource. They need to convert the handle to a resource proxy that can be used to call methods on the resource.
There is currently nothing in the service interface that defines how the proxy should look like. It's up to the implementation to create a good API for it.
A proxy should always extend
the ServiceResourceProxy
base class, which automatically closes
the resources if it is garbage-collected:
import system.services show ServiceResourceProxy class DieProxy extends ServiceResourceProxy: constructor client/ServiceClient handle/int: super client handle roll -> int: return (client_ as RandomGeneratorServiceClient).roll-die_ handle_
The proxy takes the corresponding client and handle as arguments. As can be
seen, a handle is serialized as an int
, but for the most part this can
be ignored.
The proxy simply forwards its 'roll' method to the client which needs to
add create-die
and roll-die_
methods:
class RandomGeneratorServiceClient extends ServiceClient: ... create-die sides/int -> DieProxy: handle := invoke_ RandomGeneratorService.CREATE-DIE-INDEX sides proxy := DieProxy this handle return proxy roll-die_ handle/int -> int: return invoke_ RandomGeneratorService.ROLL-DIE-INDEX handle
Using a resource
With all that machinery in place, we can now use create resources through our client and call methods on them:
main: spawn:: service := RandomGeneratorServiceProvider service.install client := RandomGeneratorServiceClient client.open die := client.create-die 6 10.repeat: print "die roll = $(die.roll)" die.close
Resource notifications
While most interactions with resources follow a simple request-response
pattern, it can be useful to be able to asynchronously notify users of
a resource of certain events. The ServiceResource
and
ServiceResourceProxy
classes have built-in support for this through
notifications. A notification is any kind of serializable object sent
from the resource to the proxy. The resources that take part in this
must be marked notifiable at construction time:
class DieResource extends ServiceResource: ... constructor .sides_ provider/ServiceProvider client/int: super provider client --notifiable ...
Even though you probably wouldn't expect dice to ping you periodically, we can now experiment with the behavior by adding periodic notifications like this:
class DieResource extends ServiceResource: sides_/int ::= ? task_ := null constructor .sides_ provider/ServiceProvider client/int: super provider client --notifiable task_ = task:: notify-periodically roll -> int: return random sides_ notify-periodically -> none: while not Task.current.is-canceled: sleep --ms=(random 500) + 500 notify_ 87 on-closed -> none: task_.cancel
The notifications will show up on the proxy side through calls to on-notified_
:
class DieProxy extends ServiceResourceProxy: ... on-notified_ notification/any -> none: print "got notified: $notification" ...
You'll need to wait a bit in main
for the notifications to start showing up:
main: ... die := client.create-die 6 10.repeat: print "die roll = $(die.roll)" sleep (Duration --s=10) die.close
Example: Network by proxy
We have used the service framework to allow providing a full network
implementation from a separate container. The core of this is the
NetworkService
interface and the associated NetworkServiceClient
:
https://github.com/toitlang/toit/blob/master/lib/system/api/network.toit
We use them to build proxying sockets like SocketResourceProxy_
that forward reads and writes to the network service:
class SocketResourceProxy_ extends ServiceResourceProxy: static WRITE-DATA-SIZE-MAX_ /int ::= 2048 ... write data from=0 to=data.size -> int: to = min to (from + WRITE-DATA-SIZE-MAX_) return (client_ as NetworkServiceClient).socket-write handle_ data[from..to] ...
You can find all the helpers in the main repository:
https://github.com/toitlang/toit/blob/master/lib/system/base/network.toit
All in all, this allows a cellular driver to provide a network to all other apps that a blissfully unaware that their data flows through a good old-fashioned sequence of AT commands.