visit
interface EmailServiceInterface {
RequestResponse:
send(SendRequest)(void),
listEmails(ListRequest)(EmailInfoList),
downloadEmail(DownloadEmailRequest)(Email)
}
Suppose that the e-mail service is available to us as emailService
. We want to write a decorator with this logic: whenever send
is called, we check if we have sent an “important” e-mail (e.g., the subject contains a specific keyword telling us that the e-mail is important, or the addressee is in a special list); if an important e-mail has been sent successfully, we backup its content by calling another service, for indexing and safe-keeping. Conceptually, we want the following architecture.
One way of writing our decorator is to code a service that reimplements all operations offered in the interface EmailServiceInterface
, as in the next snippet.
service EmailServiceDecorator {
// Output ports to access the target e-mail service, the backup service, and a library to check if e-mails are important
outputPort emailService { ... }
outputPort backupService { ... }
outputPort important { ... }
// Access point for clients
inputPort EmailServiceDecorator {
location: "socket://localhost:8080" protocol: MyProtocol // e.g., http
interfaces: EmailServiceInterface // exposed API
}
// Implementation
main {
// Offer three operations: send, listEmails, and downloadEmail
[ send( request )( response ) {
send@emailService( request )()
check@important( { subject = request.subject, to = request.to } )( important )
if( important ) {
backup@backupService( request )()
}
} ]
[ listEmails( request )( response ) {
listEmails@emailService( request )( response )
} ]
[ downloadEmail( request )( response ) {
downloadEmail@emailService( request )( response )
} ]
}
}
The code for send
does what we have previously described. The code for listEmails
and downloadEmail
is, however, a pure boilerplate: we’re just forwarding requests and responses between the client and the target e-mail service. Not only is this a bit annoying, but it also means that this approach wouldn't scale well if the target service had many operations.
A particularly convenient primitive for creating a decorator is aggregation. In our example, we can rewrite the last code snippet as follows.
service EmailServiceDecorator {
// Output ports to access the target e-mail service, the backup service, and a library to check if e-mails are important
outputPort emailService { ... }
outputPort backupService { ... }
outputPort important { ... }
// Access point for clients
inputPort EmailServiceDecorator {
location: "socket://localhost:8080" protocol: MyProtocol // e.g., http
aggregates: emailServiceInterface // Aggregates instead of interfaces!
}
main {
[ send( request )( response ) {
send@emailService( request )()
check@important( { subject = request.subject, to = request.to } )( important )
if( important ) {
backup@backupService( request )()
}
} ]
}
}
Notice the usage of the aggregates
instruction in the input port. It means that our service is a proxy to the e-mail service now: all client calls to operations offered by the e-mail service are now automatically forwarded to it. Furthermore, we can refine the behavior of specific operations. Here, we defined a custom behavior for the send
operation, with our logic for backups. No boilerplate anymore!
service EmailServiceDecorator {
// Output ports to access the target e-mail service and a logger service
outputPort emailService { ... }
outputPort logger { ... }
// Access point for clients
inputPort EmailServiceDecorator {
location: "socket://localhost:8080" protocol: MyProtocol // e.g., http
interfaces: EmailServiceInterface // exposed API
}
// Implementation
main {
// Offer three operations: send, listEmails, and downloadEmail
[ send( request )( response ) {
send@emailService( request )()
log@logger( request )()
} ]
[ listEmails( request )( response ) {
listEmails@emailService( request )( response )
log@logger( request )()
} ]
[ downloadEmail( request )( response ) {
downloadEmail@emailService( request )( response )
log@logger( request )()
} ]
}
}
Ouch, boilerplate again! Since we're modifying the behavior of every operation, we have to reimplement all of them. However, as already mentioned, the change is uniform: it is the same for all operations.
What we need is the capability of writing that logging code just once, and applying it to the entire API of the e-mail service. Jolie offers courier processes that can be applied to entire interfaces to achieve this. Here is what our code can look like by using a courier.
service EmailServiceDecorator {
// Output ports to access the target e-mail service and a logger service
outputPort emailService { ... }
outputPort logger { ... }
// Access point for clients
inputPort EmailServiceDecorator {
location: "socket://localhost:8080" protocol: MyProtocol // e.g., http
aggregates: emailService // aggregate emailService
}
// A courier for input port EmailServiceDecorator
courier EmailServiceDecorator {
[ interface EmailServiceInterface( request )( response ) ] { // Apply to all operations offered by the e-mail service
forward( request )( response ) // forward is a primitive: it forwards the message to the aggreated (decorated) service
log@logger( request )()
}
}
}
No boilerplate again! In a courier, we can receive a message for any operation in a given interface, and then use the forward
primitive to forward the message to the target service that we are decorating. We then invoke the logger.
The e-mail example is quite simple. In general, a decorator might modify the API by adding or hiding data fields to its types. Jolie supports adding data fields which can then be used by the decorator and are automatically removed by the forward
primitive when calls are forwarded to the target service (hiding can be done manually, but a dedicated primitive is planned for future release).