Commandable gRPC

How to create a commandable gRPC client and service.

Key takeaways

CommandSet Component that contains a set of commands and events supported by a commandable object.
CommandableGrpcService Service that receives commands via the gRPC protocol.
CommandableGrpcClient Client that calls a commandable gRPC service.

Introduction

This tutorial will help you understand how to create a commandable gRPC client and service. First, we will learn the basics of these components. Then, we will create an example where a commandable gRPC client communicates with a commandable gRPC service. After the example, we will summarize the concepts learned.

Commandable gRPC basics

gRPC is an open-source RPC framework based on HTTP/2 and originally created by Google. Pip.Services implements it in the gRPC module. The two main components in this module are CommandableGrpcService and CommandableGrprcClient.

The CommandableGrpcService class describes a service that receives commands via the gRPC protocol. These commands are then linked to commands defined in a CommandSet component.

The CommandableGrpcClient class is used to create clients that call a CommandbleGrpcService.

Commandable pattern

The example in this tutorial is structured according to the Commandable pattern. This pattern considers a CommandSet component, where all commands are registered. It also uses a controller that links to this command set and defines the specific aspects of each command.

The main advantage that this pattern offers is allowing for the use of a defined command set by commandable components using different communication methods – such as gRPC, HTTP, Azure, etc. - where the specifics for each case are declared in the controller and the common aspects in the CommandSet class.

figure 1

Example

To learn how to create a commandable gRPC client and service, we will build an example where a service uses a command set containing CRUD operations that are applied to data objects.

Project structure

To organize this example, we use the following directory structure: First, we have a directory named “clients” that contains all the files related to the client. Second, our service is organized into three layers namely, data, business logic and service layers.

The data layer is represented in the “data” directory and contains the files that define the data structure used in this example. The business logic layer contains the controller and command set, and the corresponding files are stored in the “logic” directory. And, the “services” directory contains the commandable gRPC service.

Three additional files in this project are the container used to wrap the application and stored in the “containers” directory; the “main” program, which is the application starter; and the client_creator, which is used to create an instance of the client and call the CRUD methods.

figure 2

Pre-requisites

In order to create the CommandableGrpcService, we need to import this class first. The following code shows how to do this:

import { CommandableGrpcController } from "pip-services4-grpc-node";
Not available
Not available
Not available

Not available

Similarly, we need to import the CommandableGrpcClient:

import { CommandableGrpcClient } from "pip-services4-grpc-node";
Not available
Not available
Not available

Not available

Data structure

The next thing that we need to do is to create a class representing the data to be used in the example. The code below shows the MyData class, which defines the data structure of an identifiable data object containing a key and a content field. Additionally, we define a toString() method, which transforms the data object into a dictionary.

import { IStringIdentifiable } from "pip-services4-data-node";

export class MyData implements IStringIdentifiable {
    public id: string;
    public key: string;
    public content: string;
}
Not available
Not available
Not available

Not available

We also need to create a schema for this data class, which will be used by the CommandSet component:

import { IStringIdentifiable, ObjectSchema } from "pip-services4-data-node";
import { TypeCode } from "pip-services4-commons-node";

export class MyDataSchema extends ObjectSchema {
    public constructor() {
        super();
        this.withOptionalProperty("id", TypeCode.String)
        this.withRequiredProperty("key", TypeCode.String)
        this.withOptionalProperty("content", TypeCode.String)
    }
}
Not available
Not available
Not available

Not available

And a factory, which will be used by the container:

import { Factory, Descriptor } from "pip-services4-components-node";

import { MyDataService, MyDataCommandableGrpcController } from "my-package";


export class DefaultMyDataFactory extends Factory {
    public static FactoryDescriptor = new Descriptor("service-mydata", "factory", "default", "default", "1.0");
    public static ServiceDescriptor = new Descriptor("service-mydata", "service", "default", "*", "1.0");
    public static CommandableGrpcControllerDescriptor = new Descriptor("service-mydata", "controller", "commandable-grpc", "*", "1.0");

    public constructor() {
        super();
        this.registerAsType(DefaultMyDataFactory.ServiceDescriptor, MyDataService)
        this.registerAsType(DefaultMyDataFactory.CommandableGrpcControllerDescriptor, MyDataCommandableGrpcController)
    }
}
Not available
Not available
Not available

Not available

Command set

Now, we need to create a command set component. We do this by extending the CommandSet class and defining our CRUD commands.

import { 
    IStringIdentifiable, ObjectSchema, FilterParams, 
    PagingParams, FilterParamsSchema, PagingParamsSchema 
} from "pip-services4-data-node";
import { TypeCode } from "pip-services4-commons-node";
import { IContext, Parameters } from "pip-services4-components-node";
import { CommandSet, ICommand, Command } from "pip-services4-rpc-node";

import { MyDataService, MyDataCommandableGrpcController } from "my-package";

export class MyDataCommandSet extends CommandSet {
    private _controller: MyDataService;

    public constructor(controller: MyDataService) {
        super();
        this._controller = controller;

        this.addCommand(this._makePageByFilterCommand());
        this.addCommand(this._makeGetOneByIdCommand());
        this.addCommand(this._makeCreateCommand());
        this.addCommand(this._makeUpdateCommand());
        this.addCommand(this._makeDeleteByIdCommand());
    }

    private _makePageByFilterCommand(): ICommand {
        return new Command(
            'get_my_datas',
            new ObjectSchema()
                .withOptionalProperty('filter', new FilterParamsSchema())
                .withOptionalProperty('paging', new PagingParamsSchema()),
            async (ctx: IContext, args: Parameters) => {
                let filter = FilterParams.fromValue(args.get("filter"));
                let paging = PagingParams.fromValue(args.get("paging"));
                return await this._controller.getPageByFilter(ctx, filter, paging);
            }
        );
    }

    private _makeGetOneByIdCommand(): ICommand {
        return new Command(
            'get_my_data_by_id',
            new ObjectSchema()
                .withOptionalProperty('my_data_id', TypeCode.String),
            async (ctx: IContext, args: Parameters) => {
                let id = args.getAsString("my_data_id")
                return await this._controller.getOneById(ctx, id);
            }
        );
    }

    private _makeCreateCommand(): ICommand {
        return new Command(
            'create_my_data',
            new ObjectSchema()
                .withOptionalProperty('my_data', new MyDataSchema()),
            async (ctx: IContext, args: Parameters) => {
                let entity: MyData = args.get("my_data");
                return await this._controller.create(ctx, entity);
            }
        );
    }

    private _makeUpdateCommand(): ICommand {
        return new Command(
            'update_my_data',
            new ObjectSchema()
                .withOptionalProperty('my_data', new MyDataSchema()),
            async (ctx: IContext, args: Parameters) => {
                let entity: MyData = args.get("my_data");
                return await this._controller.update(ctx, entity);
            }
        );
    }

    private _makeDeleteByIdCommand(): ICommand {
        return new Command(
            'delete_my_data',
            new ObjectSchema()
                .withOptionalProperty('my_data_id', TypeCode.String),
            async (ctx: IContext, args: Parameters) => {
                let id = args.getAsString("my_data_id")
                return await this._controller.deleteById(ctx, id);
            }
        );
    }
}
Not available
Not available
Not available

Not available

Controller

Next, we create a controller to manage the logic of our example. This controller extends an interface where we declare the CRUD methods used. It also links to our command set from where it obtains the collection of commands used and to the gRPC service that receives data from the client. For each of the commands defined in the CommandSet, it defines a method with the operations that are particular to the considered commandable class. The code below shows the controller and its interface:

import { FilterParams, PagingParams, DataPage } from "pip-services4-data-node";

import { MyData } from "my-package";


export interface IMyDataController {
    getPageByFilter(correlationId: string, filter: FilterParams, paging: PagingParams): Promise<DataPage<MyData>>;
    getOneById(correlationId: string, id: string): Promise<MyData>;
    create(correlationId: string, entity: MyData): Promise<MyData>;
    update(correlationId: string, entity: MyData): Promise<MyData>;
    deleteById(correlationId: string, id: string): Promise<MyData>;
}
Not available
Not available
Not available

Not available
import { FilterParams, PagingParams, DataPage, IdGenerator } from "pip-services4-data-node";
import { CommandSet, ICommandable } from "pip-services4-rpc-node";
import { MyData, IMyDataService, MyDataCommandSet } from "my-package";

export class MyDataService implements IMyDataService, ICommandable {
    private _entities: MyData[] = [];
    private _commandSet: CommandSet; 

    public getCommandSet(): CommandSet {
        if (this._commandSet == null)
            this._commandSet = new MyDataCommandSet(this);
        return this._commandSet;
    }

    public async getPageByFilter(correlationId: string, filter: FilterParams, paging: PagingParams): Promise<DataPage<MyData>> {
        filter = filter != null ? filter : new FilterParams();
        let key: string = filter.getAsNullableString("key");

        paging = paging != null ? paging : new PagingParams();
        let skip: number = paging.getSkip(0);
        let take: number = paging.getTake(100);

        let result: MyData[] = [];
        for (let i = 0; i < this._entities.length; i++) {
            let entity: MyData = this._entities[i];
            if (key != null && key != entity.key)
                continue;

            skip--;
            if (skip >= 0) continue;

            take--;
            if (take < 0) break;

            result.push(entity);
        }

        return new DataPage<MyData>(result);
    }
    
    public async getOneById(correlationId: string, id: string): Promise<MyData> {
        for (let i = 0; i < this._entities.length; i++) {
            let entity: MyData = this._entities[i];
            if (id == entity.id) {
                return entity;
            }
        }
        return null;
    }

    public async create(correlationId: string, entity: MyData): Promise<MyData> {
        if (entity.id == null || entity.id == "")
            entity.id = IdGenerator.nextLong();
            
        this._entities.push(entity);
        return entity;
    }

    public async update(correlationId: string, newEntity: MyData): Promise<MyData> {
        for (let index = 0; index < this._entities.length; index++) {
            let entity: MyData = this._entities[index];
            if (entity.id == newEntity.id) {
                this._entities[index] = newEntity;
                return newEntity;
            }
        }
        return null;
    }

    public async deleteById(correlationId: string, id: string): Promise<MyData> {
        for (let index = 0; index < this._entities.length; index++) {
            let entity: MyData = this._entities[index];
            if (entity.id == id) {
                this._entities.splice(index, 1);
                return entity;
            }
        }
        return null;
    }
}
Not available
Not available
Not available

Not available

Service

Next, we define the service that provides an endpoint to our application. The following code shows what this service should look like:

import { Descriptor } from "pip-services4-components-node";
import { CommandableGrpcController } from "pip-services4-grpc-node";

export class MyDataCommandableGrpcService extends CommandableGrpcController {
    public constructor() {
        super('mydata');
        this._dependencyResolver.put('controller', new Descriptor('service-mydata', 'controller', '*', '*', '*'))
    }
}
Not available
Not available
Not available

Not available

Container

To run our service, we define a container that calls the data factory previously defined and the DefaultGrpcFactory component. These classes will create our data objects and gRPC service respectively.

import { DefaultGrpcFactory } from "pip-services4-grpc-node";
import { ProcessContainer } from "pip-services4-container-node";

class MyDataProcess extends ProcessContainer {
    constructor() {
        super("my_data", "simple my data microservice");
        this._factories.add(new DefaultMyDataFactory());
        this._factories.add(new DefaultGrpcFactory());
    }
}
Not available
Not available
Not available

Not available

Configuration

Our next step is to create a config file that contains information about our components and can be used by our container to find them. In this example, we don’t specify the _config_path variable in the container but we use its default value ("./config/config.yml"). The code below shows the content of this file:

---
# Container descriptor
- descriptor: "pip-services:context-info:default:default:1.0"
  name: "mydata"
  description: "MyData microservice"

# Console logger
- descriptor: "pip-services:logger:console:default:1.0"
  level: "trace"

# Service
- descriptor: "service-mydata:service:default:default:1.0"

# Common GRPC endpoint
- descriptor: "pip-services:endpoint:grpc:default:1.0"
  connection:
    protocol: "http"
    host: "0.0.0.0"
    port: 8090

# Commandable GRPC endpoint version 1.0
- descriptor: "service-mydata:controller:commandable-grpc:default:1.0"

Proto files

When using the commandable gRPC classes, we don’t need to worry about the proto files. This is because these classes rely on a universal proto file defined in the gRPC module that is automatically called by them.

Client

After defining our service, we need to create a client that calls its methods. For this, we create an interface that declares the used CRUD methods. Then, we construct a class that calls this interface and defines the inherited methods. The code below shows both programs:

import { FilterParams, PagingParams, DataPage } from "pip-services4-data-node";
import { MyData } from "my-package";

export interface IMyDataClient {
    getMyDatas(correlationId: string, filter: FilterParams, paging: PagingParams): Promise<DataPage<MyData>>;
    getMyDataById(correlationId: string, id: string): Promise<MyData>;
    createMyData(correlationId: string, entity: MyData): Promise<MyData>;
    updateMyData(correlationId: string, entity: MyData): Promise<MyData>;
    deleteMyData(correlationId: string, id: string): Promise<MyData>;
}
Not available
Not available
Not available

Not available
import { CommandableGrpcClient } from "pip-services4-grpc-node";
import { Context } from "pip-services4-components-node";
import { FilterParams, PagingParams, DataPage } from "pip-services4-data-node";
import { MyData, IMyDataClient } from "my-package";


export class MyCommandableGrpcClient extends CommandableGrpcClient implements IMyDataClient {
    public constructor() {
        super('mydata');
    }

    public async getMyDatas(ctx: Context, filter: FilterParams, paging: PagingParams): Promise<DataPage<MyData>> {
        return await this.callCommand('get_my_datas', ctx, { filter: filter, paging: paging });
    }

    public async getMyDataById(ctx: Context, id: string): Promise<MyData> {
        return await this.callCommand('get_my_data_by_id', ctx, { my_data_id: id });
    }

    public async createMyData(ctx: Context, entity: MyData): Promise<MyData> {
        return await this.callCommand('create_my_data', ctx, { my_data: entity });
    }

    public async updateMyData(ctx: Context, entity: MyData): Promise<MyData> {
        return this.callCommand('update_my_data', ctx, { my_data: entity })
    }
    
    public async deleteMyData(ctx: Context, id: string): Promise<MyData> {
        return this.callCommand('delete_my_data', ctx, { my_data_id: id })
    }

}
Not available
Not available
Not available

Not available

Running the application

Now, we start the service. To do this, we run the following code:

export async function main() { 
    try {
        let proc = new MyDataProcess();
        proc.run(process.argv);
    } catch (ex) {
        console.error(ex);
    }
}
Not available
Not available
Not available

Not available

Which, after execution, produces the following output:

figure 3

Once our service is running, we run a program that creates an instance of the client and instances of the MyData class, and calls the CRUD operations available from the service. The following code shows how to do this:

import { ConfigParams, References } from "pip-services4-components-node";
import { MyData, MyCommandableGrpcClient, IMyDataClient } from "my-package";

export async function main() {
    const assert = require('assert');

    let correlationId = 'example';

    // create client
    let grpcConfig = ConfigParams.fromTuples(
        'connection.protocol', 'http',
        'connection.host', 'localhost',
        'connection.port', 8090
    );

    let grpcClient = new MyCommandableGrpcClient();
    grpcClient.configure(grpcConfig);
    grpcClient.setReferences(new References());
    await grpcClient.open(correlationId);

    // simple data
    let data1: MyData = {id: '1', key: '0005', content: 'any content 1'};
    let data2: MyData = {id: '2', key: '0010', content: 'any content 2'};

    // using the client
    let res = await grpcClient.createMyData(correlationId, data1);
    assert(res.id == data1.id);

    res = await grpcClient.createMyData(correlationId, data2);
    assert(res.id == data2.id);

    let resPage = await grpcClient.getMyDatas(correlationId, null, null);
    assert(resPage.data.length == 2); 

    res = await grpcClient.deleteMyData(correlationId, data2.id);
    assert(res.id == data2.id);

    res = await grpcClient.getMyDataById(correlationId, data2.id);
    assert(res == null);
}
Not available
Not available
Not available

Not available

Which, after running will produce the following output from our service:

figure 4

Wrapping up

In this tutorial, we have learned how to create a simple system that includes a command set, together with a service and a client that communicate via the gRPC protocol. In order to do this, we created a system that contains a CommandSet, a CommandableGrpcService and a CommandableGrpcClient. Then, we encapsulated our service in a container and created a program that calls the different CRUD methods available from the command set.