Bringing Drools rules into the cloud with Kogito: a step by step path

The goal of this article is to demonstrate how to expose a Drools stateless rules evaluation in a Quarkus REST endpoint and then how to migrate it to Kogito in order to fully leverage Quarkus features and finally how to embrace the Kogito’s programming model based on rule units.

Overview

In Drools a stateless rules evaluation is a one-off execution of a rule set against a provided set of facts. Under this point of view this kind of rules evaluation can be seen as a pure function invocation, where the return value is only determined by its input values, without observable side effects, in which the arguments passed to the function are actually the facts to be inserted into the session and the result is the outcome of your rules set applied on those facts. From a consumer perspective of this service the fact that the invoked function uses a rule engine to perform its job could be only an internal implementation detail.

In this situation it is natural to expose such a function through a REST endpoint, thus turning it into a microservice. At this point it can be eventually deployed into a Function as a Service environment, possibly after having compiled it into a native image, to avoid paying the cost of relatively high JVM startup time. This document is focused on this stateless scenario because at the moment it is the only use case also supported in Kogito.

The sample project

Let’s try to put this idea in practice by taking an existing Drools project and migrating it in steps to Kogito and Quarkus. The domain model of the sample project that we will is use to demonstrate this migration is made only by two classes, a loan application

public class LoanApplication {
   private String id;
   private Applicant applicant;
   private int amount;
   private int deposit;
   private boolean approved = false;

   public LoanApplication(String id, Applicant applicant, int amount, int deposit) {
       this.id = id;
       this.applicant = applicant;
       this.amount = amount;
       this.deposit = deposit;
   }
}

and the applicant who requested it

public class Applicant {
   private int age;

   public Applicant(String name, int age) {
       this.name = name;
       this.age = age;
   }
}

The rules set is made of business decisions to approve or reject an application plus one last rule collecting all the approved applications into a list.

The rules set is made of business decisions to approve or reject an application plus one last rule collecting all the approved applications into a list.

global Integer maxAmount;
global java.util.List approvedApplications;

rule LargeDepositApprove when
   $l: LoanApplication( applicant.age >= 20, deposit >= 1000, amount <= maxAmount )
then
   modify($l) { setApproved(true) }; // loan is approved
end

rule LargeDepositReject when
   $l: LoanApplication( applicant.age >= 20, deposit >= 1000, amount > maxAmount )
then
   modify($l) { setApproved(false) }; // loan is rejected
end

// ... more loans approval/rejections business rules ...

rule CollectApprovedApplication when
   $l: LoanApplication( approved )
then
   approvedApplications.add($l); // collect all approved loan applications
end

Step 1: Exposing rules evaluation with a REST endpoint through Quarkus

The first goal that we want to achieve is providing a REST endpoint for this service using Quarkus. The easiest way to do this is creating a new module depending on the one containing the rules plus a few basic Quarkus libraries providing the REST support.

<dependencies>

 <dependency>
   <groupId>io.quarkus</groupId>
   <artifactId>quarkus-resteasy</artifactId>
 </dependency>

 <dependency>
   <groupId>io.quarkus</groupId>
   <artifactId>quarkus-resteasy-jackson</artifactId>
 </dependency>

 <dependency>
   <groupId>org.example</groupId>
   <artifactId>drools-project</artifactId>
   <version>1.0-SNAPSHOT</version>
 </dependency>

<dependencies>

With this setup it’s easy to create a REST endpoint as it follows.

@Path("/find-approved")
public class FindApprovedLoansEndpoint {

   private static final KieContainer kContainer = KieServices.Factory.get().newKieClasspathContainer();

   @POST()
   @Produces(MediaType.APPLICATION_JSON)
   @Consumes(MediaType.APPLICATION_JSON)
   public List<LoanApplication> executeQuery(LoanAppDto loanAppDto) {
       KieSession session = kContainer.newKieSession();
       List<LoanApplication> approvedApplications = new ArrayList<>();

       session.setGlobal("approvedApplications", approvedApplications);
       session.setGlobal("maxAmount", loanAppDto.getMaxAmount());
       loanAppDto.getLoanApplications().forEach(session::insert);

       session.fireAllRules();
       session.dispose();
       return approvedApplications;
   }
}

Here a KieContainer containing the rules taken from the other module in the classpath is created and put into a static field. In this way it will be possible to reuse the same KieContainer for all subsequent invocations of this endpoint without having to recompile the rules. When the endpoint is invoked it creates a new KieSession from the container, populates it with the objects coming from a DTO resulting from the unmarshalling of the JSON request.

public class LoanAppDto {
   private int maxAmount;
   private List<LoanApplication> loanApplications;

   public int getMaxAmount() {
       return maxAmount;
   }

   public void setMaxAmount(int maxAmount) {
       this.maxAmount = maxAmount;
   }

   public List<LoanApplication> getLoanApplications() {
       return loanApplications;
   }

   public void setLoanApplications(List<LoanApplication> loanApplications) {
       this.loanApplications = loanApplications;
   }
}

When we call fireAllRules() the session is fired and all our business logic is evaluated against the provided input data. Then the last rule collects all the approved applications into a global list and this list is returned as the result of the computation.

After having started Quarkus you can already put this at work invoking the REST endpoint with a JSON request containing the loan applications to be checked and the value for the maxAmount to be used in the rules, like in the the following example

curl -X POST -H 'Accept: application/json' -H 'Content-Type: application/json' -d 
'{"maxAmount":5000,"loanApplications":[
{"id":"ABC10001","amount":2000,"deposit":1000,"applicant":{"age":45,"name":"John"}},
{"id":"ABC10002","amount":5000,"deposit":100,"applicant":{"age":25,"name":"Paul"}},
{"id":"ABC10015","amount":1000,"deposit":100,"applicant":{"age":12,"name":"George"}}
]}'
http://localhost:8080/find-approved

and you will be returned with a list of the approved applications.

[{"id":"ABC10001","applicant":{"name":"John","age":45},"amount":2000,"deposit":1000,"approved":true}]

This straightforward approach has the major drawback of not allowing to use some of the most interesting features of Quarkus like the hot reload and the possibility of creating a native image of the project. Those features, as we will see in the next step, are indeed provided by the Kogito extension to Quarkus that in essence makes Quarkus aware of the existence of the drl files,  implementing their hot reload in a similar way to what Quarkus provides out-of-the-box for the java sources.

The integration demonstrated up to this point between Drools and Quakus has to be considered no more than an introduction to the next migration step. Since Kogito supports the use of Drools API, and given the advantages it provides in terms of fully functional out-of-the-box Quarkus integration, we strongly suggest to not stop the development of your REST service at this point. 

Step 2: From Drools to Kogito without changing (almost) anything using the Drools Legacy API

As anticipated, Kogito can provide those missing features, so let’s try to migrate our project to Kogito with the minimal amount of effort. To do this we can use the Quarkus extension for Kogito in conjunction with the kogito-legacy-api allowing us to use the same API of Drools 7. This approach also makes it possible to consolidate the former two modules into a single one.

<dependencies>

 <dependency>
  <groupId>org.kie.kogito</groupId>
  <artifactId>kogito-quarkus-rules</artifactId>
 </dependency>

 <dependency>
  <groupId>org.kie.kogito</groupId>
  <artifactId>kogito-legacy-api</artifactId>
 </dependency>

</dependencies>

In this way no changes at all are required to  the DRL file containing the rules while the former REST endpoint implementation can be rewritten as follows.

@Path("/find-approved")
public class FindApprovedLoansEndpoint {

   @Inject
   KieRuntimeBuilder kieRuntimeBuilder;

   @POST()
   @Produces(MediaType.APPLICATION_JSON)
   @Consumes(MediaType.APPLICATION_JSON)
   public List<LoanApplication> executeQuery(LoanAppDto loanAppDto) {
       KieSession session = kieRuntimeBuilder.newKieSession();
       List<LoanApplication> approvedApplications = new ArrayList<>();

       session.setGlobal("approvedApplications", approvedApplications);
       session.setGlobal("maxAmount", loanAppDto.getMaxAmount());
       loanAppDto.getLoanApplications().forEach(session::insert);

       session.fireAllRules();
       session.dispose();
       return approvedApplications;
   }
}

Here the only difference with the former implementation is that the KieSession instead of being created from the KieContainer is created from an automatically injected KieRuntimeBuilder. 

The KieRuntimeBuilder is the interface provided by the kogito-legacy-api module that replace the KieContainer and from which it is now possible to create the KieBases and KieSessions exactly as you did with the KieContainer itself. An implementation of the KieRuntimeBuilder interface is automatically generated at compile time by Kogito and injected into the class implementing the REST endpoint.

With this change it is possible both to launch quarkus in dev mode thus leveraging its hot reload to make on-the-fly changes also to the rules files that are immediately applied to the running application and to create a native image of your rule based application.  

Step 3: Embracing rule units and automatic REST endpoint generation

A rule unit is a new concept introduced in Kogito encapsulating both a set of rules and the facts against which those rules will be matched. It comes with a second abstraction called data source, defining the sources through which the facts are inserted, acting in practice as typed entry-points. There are two types of data sources:

  • DataStream: an append-only data source
    • subscribers only receive new (and possibly past) messages
    • cannot update/remove
    • stream may also be hot/cold in “reactive streams” terminology
  • DataStore: data source for modifiable data
    • subscribers may act upon the data store, by acting upon the fact handle

In essence a rule unit is made of 2 strictly related parts: the definition of the fact to be evaluated and the set of rules evaluating them. The first part is implemented with a POJO, that for our loan applications could be something like the following:

package org.kie.kogito.queries;

import org.kie.kogito.rules.DataSource;
import org.kie.kogito.rules.DataStore;
import org.kie.kogito.rules.RuleUnitData;

public class LoanUnit implements RuleUnitData {

   private int maxAmount;
   private DataStore<LoanApplication> loanApplications;

   public LoanUnit() {
       this(DataSource.createStore(), 0);
   }

   public LoanUnit(DataStore<LoanApplication> loanApplications, int maxAmount) {
       this.loanApplications = loanApplications;
       this.maxAmount = maxAmount;
   }

   public DataStore<LoanApplication> getLoanApplications() { 
      return loanApplications; 
   }

   public void setLoanApplications(DataStore<LoanApplication> loanApplications) {
       this.loanApplications = loanApplications;
   }

   public int getMaxAmount() { 
        return maxAmount; 
   }

   public void setMaxAmount(int maxAmount) { 
        this.maxAmount = maxAmount; 
   }
}

Here instead of using the LoanAppDto that we introduced to marshall/unmarshall the JSON requests we are binding directly the class representing the rule unit. The two relevant differences are that it implements the org.kie.kogito.rules.RuleUnitData interface and uses a DataStore instead of a plain List to contain the loan applications to be approved. The first is just a marker interface to notify the engine that this class is part of a rule unit definition. The use of a DataStore is necessary to let the rule engine to react to changes of processed fact, this allows the rule engine to react accordingly to the changes by firing new rules and triggering other rules. In the example, the consequences of the rules modify the approved property of the loan applications. Conversely the maxAmount value can be considered a configuration parameter of the rule unit and left as it is: it will automatically be processed during the rules evaluation with the same semantic of a global, and automatically set from the value passed by the JSON request as in the first example, so you will still be allowed to use it in your rules.

The second part of the rule unit is the drl file containing the rules belonging to this unit.

package org.kie.kogito.queries;

unit LoanUnit; // no need to using globals, all variables and facts are stored in the rule unit 

rule LargeDepositApprove when
   $l: /loanApplications[ applicant.age >= 20, deposit >= 1000, amount <= maxAmount ] // oopath style
then
   modify($l) { setApproved(true) };
end

rule LargeDepositReject when
   $l: /loanApplications[ applicant.age >= 20, deposit >= 1000, amount > maxAmount ]
then
   modify($l) { setApproved(false) };
end

// ... more loans approval/rejections business rules ...

// approved loan applications are now retrieved through a query
query FindApproved
   $l: /loanApplications[ approved ]
end

This rules file must declare the same package and a unit with the same name of the java class implementing the RuleUnitData interface in order to state that they belong to the same rule unit.

This file has also been rewritten using the new OOPath notation: as anticipated, here the data source acts as a typed entry-point and the oopath expression has its name as root while the constraints are in square brackets, like in the following example.

$l: /loanApplications[ applicant.age >= 20, deposit >= 1000, amount <= maxAmount ]

Alternatively you can still use the old DRL syntax, specifying the name of the data source as an entry-point, with the drawback that in this case you need to specify again the type of the matched object, even if the engine can infer it from the type of the datasource, as it follows. 

$l: LoanApplication( applicant.age >= 20, deposit >= 1000, amount <= maxAmount ) from entry-point loanApplications

Finally note that the last rule collecting all the approved loan applications into a global List has been replaced by a query simply retrieving them. One of the advantages in using a rule unit is that it clearly defines the context of computation, in other terms the facts to be passed in input to the rule evaluation. Similarly the query defines what is the output expected by this evaluation.     

This clear definition of the computation boundaries allows Kogito to also automatically generate a class executing the query and returning its results

public class LoanUnitQueryFindApproved implements org.kie.kogito.rules.RuleUnitQuery<List<org.kie.kogito.queries.LoanApplication>> {
   private final RuleUnitInstance<org.kie.kogito.queries.LoanUnit> instance;

   public LoanUnitQueryFindApproved(RuleUnitInstance<org.kie.kogito.queries.LoanUnit> instance) {
       this.instance = instance;
   }

   @Override
   public List<org.kie.kogito.queries.LoanApplication> execute() {
       return instance.executeQuery("FindApproved").stream().map(this::toResult).collect(toList());
   }

   private org.kie.kogito.queries.LoanApplication toResult(Map<String, Object> tuple) {
       return (org.kie.kogito.queries.LoanApplication) tuple.get("$l");
   }
}

together with a REST endpoint taking the rule unit as input, passing it to the former query executor and returning its as output.

@Path("/find-approved")
public class LoanUnitQueryFindApprovedEndpoint {

   @javax.inject.Inject
   RuleUnit<org.kie.kogito.queries.LoanUnit> ruleUnit;

   public LoanUnitQueryFindApprovedEndpoint() {
   }

   public LoanUnitQueryFindApprovedEndpoint(RuleUnit<org.kie.kogito.queries.LoanUnit> ruleUnit) {
       this.ruleUnit = ruleUnit;
   }

   @POST()
   @Produces(MediaType.APPLICATION_JSON)
   @Consumes(MediaType.APPLICATION_JSON)
   public List<org.kie.kogito.queries.LoanApplication> executeQuery(org.kie.kogito.queries.LoanUnit unit) {
       RuleUnitInstance<org.kie.kogito.queries.LoanUnit> instance = ruleUnit.createInstance(unit);
       return instance.executeQuery(LoanUnitQueryFindApproved.class);
   }
}

You can have as many query as you want and for each of them it will be generated a different REST endpoint with the same name of the query transformed from camel case (like FindApproved) to dash separated (like find-approved).

5 2 votes
Article Rating
Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments