RPC Service

Remote Procedure Call services (RPCs) are used to execute resource-intensive or different language code in a separate process. This is done to prevent the main server (node.js backend) from being blocked by the execution of such code. Therefore we introduce a standardized interface and protocol for RPCs to allow seamless switching and addition of new RPCs regardless of functionality and underlying platforms. Communication between the main server and the RPCs is done via a websocket connection, to ensure persistent communication and low latency. The external code is executed in a Docker container, and usually started with the rest of CARE.

Implementing a new RPC service

To implement a new RPC service, you can orient yourself on the existing test RPC service example. Here you can find a simple python script that provides a scalable python-socketio server implementation for answering RPC calls.

The example consists of the following components:

  • Docker Container

  • Backend Service

  • Environment

Docker Container

The Docker container is responsible for executing the external code.

utils/rpcs/test/
├── Dockerfile
├── main.py
└── requirements.txt

The Dockerfile is used to build the Docker container and install the necessary packages. The requirements.txt file contains the necessary packages that are installed in the Docker container. The main.py file contains the websocket interface and the code that is executed when the RPC is called.

For a new RPC service, you can copy the utils/rpcs/test/ folder and adjust the files to your needs. If you want to extend the RPC service with more complex features, you can just add a listening socket to the main.py file like:

@sio.on("my custom event")
def event(sid, data):
    logger.info(f"Received call: {data} from {sid}")
    # Do something with the data
    return <response>

Warning

To be able to use the RPC service, the Docker container must be started with the rest of CARE. Therefore, you have to add the Docker container to the docker-compose.yml and docker-dev.yml file in the root folder. See rpc_test as an example or read the Environment section.

Tip

To build the Docker container, you can use the docker compose -f docker-compose.yml build <rpc_service_name> command. To run the Docker container, you can use the docker compose -f docker-compose.yml run <rpc_service_name> command.

Backend Service

The backend service is responsible for managing the RPC services and executing the RPC calls. All RPC services need to be added to the backend/webserver/rpcs/ folder. The Example RPC service file is called test.js. It holds a class that is extended from the RPC class and implements additional methods, but at least the connection url with the environment variables (see Environment section).

Note

The class name of your RPC service will be the name for calling your RPC service in the backend later.

The RPC class usually handles all the communication with the RPC service and provides a standard method to call the RPC service. Please look at the backend/webserver/RPC.js file for a detailed implementation. To provide extended functionality, you can just add more methods to the RPC service class.

const RPC = require("../RPC.js");

module.exports = class RPC<rpc_service_name> extends RPC {
    constructor(server) {
        const url = "ws://" + process.env.RPC_<rpc_service_name>_HOST + ":" + process.env.RPC_<rpc_service_name>_PORT;
        super(server, url);
    }

    /**
    * Simplest method to call your RPC service function and return a promise with the response
    * @param {Object} data - The data that is sent to the RPC service
    * @returns {Promise} - The response from the RPC service
    */
    async <rpc_service_function_name>(data) {
        try {
            return this.emit("<rpc_service_function_event_in_docker>", data);
        } catch (err) {
            throw err
        }
    }
}

Environment

The environment variables are used to configure the RPC service. With the environment variables, we make sure that the RPC service could run easily on different machines without changing the code. Therefore, you have to add the environment variables to all *.env files in the root folder.

.env

# RPCs
RPC_TEST_HOST=127.0.0.1
RPC_TEST_PORT=3010

Note

The .env file is used for development, so the IP address is set to localhost and docker-dev.yaml publishes the ports to the host machine. The *.env files are used to build the fully docker environment, so the IP address is set to the docker network (i.e. the name of the machine defined in the docker-compose.yml file).

Furthermore, we want to make sure that the RPC service is started with the CARE environment. Here we need to add the environment variables to the docker-compose.yml and docker-dev.yml file in the root folder.

docker-compose.yml

rpc_test:
  build:
    context: ./utils/rpcs/test
    dockerfile: Dockerfile
  command: command: gunicorn --workers 1 --threads 100 --bind 0.0.0.0:8080 'main:create_app()'
  restart: unless-stopped

docker-dev.yml

rpc_test:
  ports:
    - ${RPC_TEST_PORT}:8080

Lastly, we need to adapt the Makefile to build the Docker container. By adding the docker machine name to the make docker command, the Docker container is built and started with the CARE environment.

How to call the RPC service

To call the RPC service, you have to use the RPC service class that you implemented in the backend. The RPC service class provides a method to call the RPC service and return a promise with the response. The RPC service class name is the identifier for the RPC service.

try {
    const response = await this.server.rpcs['<rpc_service_name>'].<rpc_service_function_name>(data);
    // Do something with the response
} catch (err) {
    // Handle the error
}

Unit Tests

It is always a good idea to write unit tests during the implementation of a new RPC service. The unit tests should cover the most important parts of the RPC service. The tests are located in the backend/tests/rpcs folder. Each RPC service has its own test file, which is named after the RPC service class. Here you can add simple tests to check if the RPC service is working as expected.

const Server = require("../../webserver/Server.js");

describe('<RPC service name>', () => {

    /**
     * Test your RPC function
     */
    test('<your rpc function name>', async () => {
        let server = new Server();

        // wait until RPC service is connected
        await server.rpcs["<RPC service name>"].wait();

        // check status
        expect(await server.rpcs["<RPC service name>"].isOnline()).toEqual(true);

        // call rpc and check response
        const response = await server.rpcs["<RPC service name>"].<your rpc function name>(<test object>);
        expect(response).toEqual(<response object>)

    })

})

Now you can run the unit tests with make test-rpc.

Warning

When running the unit tests locally, make sure your RPC service is running (e.g., make docker or docker compose -f docker-compose.yml -f docker-dev.yml up <rpc_service_docker_name>).