Explore Backend Multi-Canister Development on IC

blockpunk
ICP League
Published in
6 min readMay 5, 2022

The essence of the ICP platform is in the canister, which can not only run the back-end business logic, but also host the front-end pages. Canister having the ability to do so is mainly because of the wasm virtual machine.

On the ICP platform, everything can be stuffed into a canister (temporarily, except for the ICP), but there are certain restrictions. Currently, only 4G stable storage is supported, and it may be expanded to T level in the future. No discussion here for now.

If one canister is not enough, use two, and if two are not enough, use three… As long as you have enough cycles, you can add canisters unlimitedly. It is best to consider the expansion problem before the business is launched. Otherwise, after the business is up and running, it is not easy to expand, and you will encounter some problems more or less.

So this article mainly starts with the basics of multi-container development. Two points are involved:

  • communication between canisters and dynamic creation of canisters;
  • and an example: simple multi-container structure

1. Communication between canisters

Because canister is divided into front-end and back-end, the communication between canisters involves front-end and front-end communication, front-end and back-end communication, back-end and back-end communication. Here we mainly talk about front-end and back-end communication and back-end and back-end communication. The front-end and the front-end communication is currently unable. The main reason is that the front-end can only make requests and cannot passively accept requests.

1.1 Front-end container

The main functions of the front-end container are: store static resources on the web side, call the back-end interface, and then display it according to the content of the response.

Therefore, there are roughly three scenarios for the usage of front-end containers:

  • Deploy a static website;
  • Deploy a website and call the interface from a third party, a centralized server or the interface of other blockchains;
  • Deploy a website and call the interface provided by another canister.

Containers accessed directly from the browser are usually front-end containers.

1.1.1 The front-end calls the back-end container interface

For a front-end to call a back-end container, two things are required:

(1) Back-end container ID

(2) Interface description file of back-end container

The project created by default couples the front-end container and the back-end container. After we understand the nature of the interaction, we can easily decouple the front-end container and the back-end container, just extract the candid file and the back-end canisterid.

The separated front-end js code is like this, and the authentication situation is not considered here.

// import agent js sdk
import { Actor, HttpAgent } from "@dfinity/agent";
// import did definition file of the backend, and the backend container will generate it in the .dfx directory when it executes dfx deploy
import { idlFactory } from "./server.did.js";
// Specifies the canisterid of the backend container
const canisterId = "rrkah-fqaaa-aaaaa-aaaaq-cai";
// create agent object,which is to create actor
const agent = new HttpAgent({});
// create an actor object,directly use this object to call the backend container interface
let actor = Actor.createActor(idlFactory, {agent, canisterId: canisterId});
actor.test();

For a complete front-end and back-end decoupling example, see https://github.com/motokocc/helloworld

1.2 Back-end container

The main functions of the back-end container are: data storage, business logic, and providing access interfaces.

Back-end containers can both accept and send messages.

Accepting a message is done by the actor defined. The content encapsulated in the actor will generate an interface file candid during build. The format of all message interaction is defined by candid uniformly. As long as the candid file of a canister is obtained, anyone can interact with this canister. Both front-end and back-end can call the interface defined in the canister’s candid.

For sending a message, back-end containers directly call the public interface defined in the actor of another canister (should be set to public, private by default).

1.2.1 Define the actor interface

Actor of back-end canister provides two types of interface modes:

  • The default interface. All interfaces are read-write interfaces by default. That is, the data needs to be modified, and the data modification will wait for a consensus cycle. Generally, the delay is relatively high, theoretically in 2s.
  • The query interface, which is qulified with the query keyword when the interface is defined. It is a read-only interface, and no data can be modified in the query interface (including but not limited to stable type of data, which means any data is not allowed to be modified), and the latency is relatively low, theoretically 500ms.

Therefore, when designing the back-end interface, we must remember that the interface for writing data and reading data must be decoupled, so that when the front-end is called, we can achieve a better experience.

The callee defines an interface in the actor for other canisters to call. Here we define two interfaces, a read-write interface create_server, and a read-only interface server_list.

public shared(msg) func create_server(_controller: Principal, _name: Text): async Principal{ 
};
public query(msg) func server_list(): async [ServerInfo]{
};

1.2.2 Call the actor interface

To call another canister’s interface, first declare the actor, then create the actor, and finally call the specified interface

ⅰ. declare the actor

Declare the actor interface of the canister that needs to be called,

The following code declares a ManagerActor with two interfaces create_server and server_list.

public type ManagerActor = actor {
create_server: (_principal: Principal, _name: Text) -> async Principal;
server_list: () -> async [SeviceInfo];
};

ⅱ. Create an actor object

Directly use the actor function to create an actor object. The parameter Principal is of type string.

let manager : ManagerActor = actor(Principal.toText(_manager));

ⅲ. Calling the interface

The interface name, parameters, and parameter types of the call must be consistent with those in the declaration.

let server = await manager.create_server(_controller, _name);

Reference to the official documentation.

2. Dynamically create canister

This is really a magic. Many businesses need this function.

Because it is impossible to create all canisters when deploy, large-scale applications need to dynamically create canisters, such as openchat, dstar-note, token issuance, etc.

To dynamically create a canister, just define an actor class on the IC, and dynamically call the actor class in a canister.

2.1 Define actor class

In the following code, we define an actor class named Server, specify two parameters, and define two interfaces.

We save this as a server.mo file for other modules to call.

shared(msg) actor class Server(_owner: Principal, _name: Text) {
private stable var s_name : Text = _name;
private stable var s_owner : Principal = _owner;
public shared(msg) func start(): async Text{
};
public query(msg) func get_some():async {
};
}

2.2 Introduce actor class

Generally, the actor class is stored as a sub-module in a project that containing an actor.

When we need to use it, we can import a file to import this actor class.

import Server "server";

2.3 Generate an actor object

Pass the required parameters to create an actor object. This actor object is a general actor, which is the same as the actor we directly defined.

We can also get the Principal of this actor by calling Principal.fromActor.

let server = await Server.Server(_controller, _name);
let principal = Principal.fromActor(server);

2.4 Call the interface in the actor

The calling method is the same as calling the directly defined actor interface.

let test = await server.start();

Reference to the official tutorial.

3. Simple multi-container structure

A relatively simple example, mainly to verify the interaction between multiple containers, and initially explore the combination of multiple containers and the feasibility of business splitting

At present, only the sub-module processing of the back-end container has been completed, which is mainly composed of the following modules:

  • Central service module for managing users and business management services: a)store core data: user information, manager information — stable storage is required; b) provide functions of user authentication, service registration and service query
  • Business service module, which consists of two parts: manager and business service. a) Manager: responsible for creating business service canisters and maintaining business service canister information, the association between controllers and specific business services — stable storage is optional. b)Business services: specific business logic services — stable storage is not required

(3) Business controllers for saving and controlling business data:Store user business data, a business controller can correspond to one or more specific business services — stable storage is optional

Last

This simple multi-container structure template program is just to experiment with the experimental code for the interaction between containers. In practice, the interaction and business division of multiple containers may be more complex and challenging.

Welcome for discussion, reference to the implementation code: https://github.com/motokocc/mccsc

--

--

blockpunk
ICP League

Co-founder of ICP League & Ourea Group, obsessed with Social Tokens, DAO & NFT.