Implementation details
In this chapter we are addressing the Angular 5 implementation and the Backend For Frontend as a nodejs app used to implement authentication, service orchestration, data mapping and to serve the Angular App. The BFF is also using Hystrix JS for circuit breaker and fault tolerance.
Update 08/2108 - Author Jerome Boyer
Web Application
Code explanation
Most of the end user's interactions are supported by Angular 5 single page javascript library, with its router
mechanism and the DOM rendering capabilities via directives and components. When there is a need to access data to the on-premise server for persistence, an AJAX call is done to server, and the server will respond asynchronously later on. The components involved are presented in the figure below in a generic way:
From an implementation point of view we are interested by the router, the controller and the services.
To clearly separate the codebase for front-end and back-end the src/client
folder includes Angular 5 code while src/server
folder includes the REST api and BFF implemented with expressjs.
Angular app
The application code follows the standard best practices for Angularjs development: unique index.html to support single page application use of modules to organize features use of component, html and css per feature page encapsulate calls to back end for front end server via service components.
We recommend Angular beginners to follow the product "tour of heroes" tutorial. We also recommend to read our last work on Angular 5 app using a test driven development approach in this project.
Main Components
As traditional Angular 5 app, you need:
a main.ts
script to declare and bootstrap your application.
a app.module.ts
to declare all the components of the application and the URL routes declaration. Those routes are internal to the web browser. They are protected by a guard mechanism to avoid unlogged person to access some private pages. The following code declares four routes for the four main features of this application: display the main top navigation page, the customer page to access account, and the itSupport to access the chat bot user interface. The AuthGard component assesses if the user is known and logged, if not he/she is routed to the login page.
const routes: Routes = [
{ path: 'home', component: HomeComponent,canActivate: [AuthGuard]},
{ path: 'log', component: LoginComponent },
//canActivate: [AuthGuard]
{ path: 'itSupport', component: ConversationComponent,canActivate: [AuthGuard]},
{ path: 'customer', component: CustomersComponent,canActivate: [AuthGuard]},
// otherwise redirect to home
{ path: '**', redirectTo: 'home' }
]
* an app.component
to support the main page template where routing is done. This component has the header and footer of the HTML page and the placeholder directly to support sub page routing:
<router-outlet></router-outlet>
Home page
The home page is just a front end to navigate to the different features. It persists the user information in a local storage and uses the Angular router capability to map widget button action to method and route.
For example the following HTML page uses angular construct to link the button to the itSupport()
method of the Home.component.ts
<div class="col-md-6 roundRect" style="box-shadow: 3px 3px 1px #05870b; border-color: #05870b;">
<h2>Support Help</h2>
<p>Get help</p>
<p><button (click)="itSupport()" class="btn btn-primary">Ask me</button></p>
</div>
the method delegates to the angular router with the 'itSupport' url
itSupport(){
this.router.navigate(['itSupport']);
}
Conversation bot
For the conversation front end we are re-using the code approach of the conversation broker of the Cognitive reference architecture implementation. The same approach, service and component are used to control the user interface and to call the back end. The service does an HTTP POST of the newly entered message:
export class ConversationService {
private convUrl ='/api/c/conversation/';
constructor(private http: Http) {
};
submitMessage(msg:string,ctx:any): Observable<any>{
let user = JSON.parse(sessionStorage.getItem('currentUser'));
let bodyString = JSON.stringify( { text:msg,context:ctx,user:user });
let headers = new Headers({ 'Content-Type': 'application/json' });
let options = new RequestOptions({ headers: headers })
return this.http.post(this.convUrl,bodyString,options)
.map((res:Response) => res.json())
}
}
So it is interesting to see the message as the watson conversation context and the user basic information.
Account component
When the user selects to access the account information, the routing is going to the account component in client/app/account
folder use a service to call the nodejs / expressjs REST services as illustrated in the code below:
export class CustomerService {
private invUrl ='/api/c';
constructor(private http: Http) {
};
getItems(): Observable<any>{
return this.http.get(this.invUrl+'/customer')
.map((res:Response) => res.json())
}
}
The http component is injected at service creation, and the promise returned object is map so the response can be processed as json document.
An example of code using those service is the account.component.ts
, which loads the account during component initialization phase.
export class AccountComponent implements OnInit {
constructor(private router: Router, private cService : CustomerService){
}
// Uses in init to load data and not the constructor.
ngOnInit(): void {
this.user = JSON.parse(localStorage.getItem('currentUser'));
if(this.user && 'email' in this.user) {
cService.getCustomerByEmail(this.user.email).subscribe(
data => {
this.customer=data;
},
error => {
console.log(error);
});
}
}
}
Server code
The application is using nodejs and expressjs standard code structure. The code is under server
folder.
Conversation back end
The script is in server/route/features/chatBot.js
and uses the Watson developer cloud library to connect to the remote service. This library encapsulates HTTP calls and simplifies the interactions with the public service. The only thing that needs to be done for each chat bot is to add the logic to process the response, for example to get data from a backend, presents user choices in a form of buttons, or call remote service like a rule engine / decision service.
This module exports one function to be called by the API used by the front end. This API is defined in api.js
as:
app.post('/api/c/conversation',isLoggedIn,(req,res) => {
chatBot.chat(config,req,res)
});
The chatBot.chat()
method gets the message and connection parameters and uses the Watson API to transfer the call. The set of if statements are used to perform actions, call services, using the variables set in the Watson Conversation Context. One example is to use the Operational Decision Management rule engine to compute the best product for a given customer situation.
chat : function(config,req,res){
req.body.context.predefinedResponses="";
console.log("text "+req.body.text+".")
if (req.body.context.toneAnalyzer && req.body.text !== "" ) {
analyzeTone(config,req,res)
}
if (req.body.context.action === "search" && req.body.context.item ==="UserRequests") {
getSupportTicket(config,req,res);
}
if (req.body.context.action === "recommend") {
odmclient.recommend(config,req.body.context,res, function(contextWithRecommendation){
req.body.context = contextWithRecommendation;
sendToWCSAndBackToUser(config,req,res);
});
}
if (req.body.context.action === "transfer") {
console.log("Transfer to "+ req.body.context.item)
}
if (req.body.context.action === undefined) {
sendToWCSAndBackToUser(config,req,res);
}
} // chat
The send message uses the Watson developer library:
conversation = watson.conversation({
username: config.conversation.username,
password: config.conversation.password,
version: config.conversation.version,
version_date: config.conversation.versionDate});
conversation.message(
{
workspace_id: wkid,
input: {'text': message.text},
context: message.context
},
function(err, response) {
// add logic here to process the conversation response
}
)
It uses content of the conversation context to drive some of the routing mechanism. This code supports the following sequencing:
-
As the user is enquiring about an existing ticket support, the conversation set the action variable to "search", and return a message in "A" that the system is searching for existing records. The web interface send back an empty message on behave of the user so the flow can continue.
-
If the conversation context has a variable action set to "search", it calls the corresponding backend to get other data. Like a ticket management app. We did not implement the ticket management app, but just a mockup.
javascript
if (req.body.context.action === "search" && req.body.context.item ==="UserRequests") {
ticketing.getUserTicket(config,req.body.user.email,function(ticket){
if (config.debug) {
console.log('Ticket response: ' + JSON.stringify(ticket));
}
req.body.context["Ticket"]=ticket
sendToWCSAndBackToUser(config,req,res);
})}
The ticket information is returned to the conversation directly and the message response is built there.
if the action is "recommend", the code can call a decision service deployed on IBM Cloud and execute business rules to compute the best recommendations/ actions. See example of such approach in the project "ODM and Watson conversation"
If in the conversation context the boolean toneAnalyzer
is set to true, then any new sentence sent by the end user will be sent to Watson Tone Analyzer.
if (req.body.context.toneAnalyzer && req.body.text !== "" ) {
analyzeTone(config,req,res)
}
- When the result to the tone analyzer returns a tone as
Sad or Frustrated
then a call to a churn scoring service is performed.
function analyzeTone(config,req,res){
toneAnalyzer.analyzeSentence(config,req.body.text).then(function(toneArep) {
if (config.debug) {console.log('Tone Analyzer '+ JSON.stringify(toneArep));}
req.body.context["ToneAnalysisResponse"]=toneArep.utterances_tone[0].tones[0];
if (req.body.context["ToneAnalysisResponse"].tone_name === "Frustrated") {
churnScoring.scoreCustomer(config,req,function(score){
req.body.context["ChurnScore"]=score;
sendToWCSAndBackToUser(config,req,res);
})
}
}).catch(function(error){
console.error(error);
res.status(500).send({'msg':error.Error});
});
} // analyzeTone
- when the churn score is greater than a value the call is routed to a human. This is done in the conversation dialog and the context action is set to Transfer
if (req.body.context.action === "transfer") {
console.log("Transfer to "+ req.body.context.item)
}
See also how the IBM Watson conversation is built to support this logic, in this note.
Finally this code can persist the conversation to a remote document oriented database. The code is in persist.js
and a complete detailed explanation to setup this service is in this note.
Customer back end
The customer API is defined in the server/routes/feature folder and uses the request and hystrix libraries to perform the call to the customer micro service API. The config.json
file specifies the end point URL.
The Hystrixjs is interesting to use to protect the remote call with timeout, circuit breaker, fails quickly.... modern pattern to support resiliency and fault tolerance.
var run = function(config,email){
return new Promise(function(resolve, reject){
var opts = buildOptions('GET','/customers/email/'+email,config);
opts.headers['Content-Type']='multipart/form-data';
request(opts,function (error, response, body) {
if (error) {reject(error)}
resolve(body);
});
});
}
// times out calls that take longer, than the configured threshold.
var serviceCommand =CommandsFactory.getOrCreate("getCustomerDetail")
.run(run)
.timeout(5000)
.requestVolumeRejectionThreshold(2)
.build();
getCustomerDetail : function(config,email) {
return serviceCommand.execute(config,email);
}
Churn risk scoring
The scoring is done by deploying a trained model as a service. We have two clients, one for Watson Data Platform and one for Spark cluster on ICP. The interface is the same so it is easy to change implementation.
Helm chart
We created a new helm chart for this application with
helm create greencompute-telco-app
.
Then we update the following:
Add a configMap.yaml file under the templates to defined the parameters used to configure the service end point metadata used by the BFF code.
Add a volume in the deployment.yaml to use the parameters from the configMap. and mount this volume into the file. And modify the port mapping
spec:
volumes:
- name: config
configMap:
name: {{ template "greencompute-telco-app.fullname" . }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: {{ .Values.service.internalPort }}
volumeMounts:
- name: config
mountPath: /greenapp/server/config/config.json
subPath: config.json
- In values.yaml set the docker image name
- Modify the value to use service.port to be externalPort and set it to match the one exposed in dockerfile:
service:
type: ClusterIP
externalPort: 3001
internalPort: 3001
- enable ingress and set a hostname (telcoapp.green.case)
- In the ingress use the servicePort to map the externalPort.
{{- $servicePort := .Values.service.externalPort -}}
- In the service.yaml be sure to define the ports well using the one set in values.yaml
ports:
- port: {{ .Values.service.externalPort }}
targetPort: {{ .Values.service.internalPort }}
protocol: TCP
name: {{ .Values.service.name }}
ICP deployment
For this web application we are following the same steps introduced within the Brown Case Web app application and can be summarized as:
Compile the app: ng build
Create docker images: docker build -t ibmcase/greencompute-telco-app .
We are now using public dockerhub so just we are doing a docker push ibmcase/greencompute-telco-app
When deploying to a private registry as the one internal to ICP, tag the image with the docker repository name, version: docker tag ibmcase/greencompute-telco-app greencluster.icp:8500/greencompute/greencompute-telco-app
, then push the docker images to the docker repository running on the Master node of ICP:
$ docker login greencluster.icp:8500
$ docker push greencluster.icp:8500/greencompute/greenapp:v0.0.2
- Be sure to be connected to the kubernetes server with commands like:
bx pr login -u admin -a https://greencluster.icp:8443 --skip-ssl-validation
bx pr cluster-config greencluster.icp
- Install the Helm release with the greencompute namespace:
helm install greencompute-telco-app/ --name green-telco-app --namespace greencompute
-
Be sure you have name resolution from the hostname you set in values.yaml and IP address of the ICP proxy. Use your local '/etc/hosts' file for that. In production, set your local DNS with this name resolution.
-
Test by accessing the URL:
http://http://greenapp.green.case/
Customer Microservice
The back end customer management function is a micro service in its separate repository, and the code implementation explanation can be read here.
More readings
- Angular io
- Hystrixjs the latency an fault tolerance library
- Javascript Promise chaining article
- [Case Portal app using Angular 5 app using a test driven development approach in this project