multi-canister

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Multi-Canister Architecture

多Canister架构

What This Is

什么是多Canister架构

Splitting an IC application across multiple canisters for scaling, separation of concerns, or independent upgrade cycles. Each canister has its own state, cycle balance, and upgrade path. Canisters communicate via async inter-canister calls.
将ICP应用拆分为多个Canister,以实现扩展、关注点分离或独立升级周期。每个Canister拥有独立的状态、Cycle余额和升级路径。Canister之间通过异步Canister间调用进行通信。

Prerequisites

前置条件

  • For Motoko:
    mops
    package manager,
    core = "2.0.0"
    in mops.toml
  • For Rust:
    ic-cdk >= 0.19
    ,
    candid
    ,
    serde
    ,
    ic-stable-structures
  • 对于Motoko:
    mops
    包管理器,mops.toml中配置
    core = "2.0.0"
  • 对于Rust:
    ic-cdk >= 0.19
    candid
    serde
    ic-stable-structures

How It Works

工作原理

A caller canister makes a call to a callee canister: the method name, arguments (payload) and attached cycles are packed into a canister request message, which is delivered to the callee after the caller blocks on
await
; the callee executes the request and produces a response; this is packed into a canister response message and delivered to the caller; the caller awakes fron the
await
and continues execution (executes the canister response message). The system may produce a reject response message if e.g. the callee is not found or some resource limit was reached.
Calls may be unbounded wait (caller MUST wait until the callee produces a response) or bounded wait (caller MAY get a
SYS_UNKNOWN
response instead of the actual response after the call timeout expires or if the subnet runs low on resources). Request delivery is best-effort: the system may decide to reject any request instead of delivering it. Unbounded wait response (including reject response) delivery is guaranteed: the caller will always learn the outcome of the call. Bounded wait response delivery is best-effort: the caller may receive a system-generated
SYS_UNKNOWN
reject response (unknown outcome) instead of the actual response if the call timed out or some system resource was exhausted, whether or not the request was delivered to the callee.
调用方Canister向被调用方Canister发起调用:方法名、参数(负载)和附加的Cycle会被打包成Canister请求消息,在调用方执行
await
阻塞后传递给被调用方;被调用方执行请求并生成响应;响应会被打包成Canister响应消息并传递给调用方;调用方从
await
唤醒并继续执行(处理Canister响应消息)。如果出现被调用方不存在或达到资源限制等情况,系统可能会生成拒绝响应消息。
调用分为无界等待(调用方必须等待被调用方生成响应)和有界等待(如果调用超时或子网资源不足,调用方可能会收到
SYS_UNKNOWN
响应而非实际响应)。请求交付是尽力而为的:系统可能会决定拒绝任何请求而不进行交付。无界等待的响应(包括拒绝响应)交付是有保障的:调用方总会得知调用结果。有界等待的响应交付是尽力而为的:如果调用超时或系统资源耗尽,无论请求是否已传递给被调用方,调用方都可能收到系统生成的
SYS_UNKNOWN
拒绝响应(结果未知)。

When to Use Multi-Canister

何时使用多Canister架构

ReasonThreshold
Storage limitsEach canister: up to hundreds of GB stable memory + 4GB heap. If your data could exceed heap limits or benefit from partitioning, split storage across canisters.
Scalable computeCanisters are single-threaded actors. Sharding load across multiple canisters, potentially across multiple subnets, can significantly improve throughput.
Separation of concernsAuth service, content service, payment service as independent units.
Independent upgradesUpgrade the payments canister without touching the user canister.
Access controlDifferent controllers for different canisters (e.g., DAO controls one, team controls another).
When NOT to use: Simple apps with <1GB data. Single-canister is simpler, faster, and avoids inter-canister call overhead. Do not over-architect.
原因阈值
存储限制每个Canister:最多数百GB稳定内存 + 4GB堆内存。如果你的数据可能超过堆内存限制或可从分区中受益,可将存储拆分到多个Canister中。
可扩展计算Canister是单线程参与者。将负载分片到多个Canister(可能跨多个子网)可显著提高吞吐量。
关注点分离认证服务、内容服务、支付服务作为独立单元。
独立升级升级支付Canister时无需改动用户Canister。
访问控制不同Canister由不同控制器管理(例如,DAO管理一个,团队管理另一个)。
何时不使用: 数据量小于1GB的简单应用。单Canister架构更简单、更快,且避免了Canister间调用的开销。不要过度设计。

Issues that May Cause Functional Bugs

可能导致功能Bug的问题

For building a multi-canister application, take the perspective of an experienced senior software engineer and carefully read the following issues that may cause subtle functional bugs. Meticulously avoid bugs that could be caused by these issues.
  1. Request and response payloads are limited to 2 MB. Because any canister call may be required to cross subnet boundaries; and cross-subnet (or XNet) messages (the request and response corresponding to each canister call) are inducted in (packaged into) 4 MB blocks; canister request and response payloads are limited to 2 MB. A call with a request payload above 2 MB will fail synchronously; and a response with a payload above 2 MB will trap. Chunk larger payloads into 1 MB chunks (to allow for any encoding overhead) and deliver them over multiple calls (e.g. chunked uploads or byte range queries).
  2. Update methods that make calls are NOT executed atomically. When an update method makes a call, the code before the
    await
    is one atomic message execution (i.e. the ingress message or canister request that invoked the update method); and the code after the
    await
    is a separate atomic message execution (the response to the call). In particular, if the update method traps after the
    await
    , any mutations before the
    await
    have already been persisted; and any mutations after the
    await
    will be rolled back. Design for eventual consistency or use a saga pattern. If more context on this is needed, you can optionally refer to properties of message executions on ICP.
  3. Use idempotent APIs. Or provide a separate endpoint to query the outcome of a non-idempotent call. If a call to a non-idempotent API times out, there must be another way for the caller to learn the outcome of the call (e.g. by attaching a unique ID to the original call and querying for the outcome of the call with that unique ID). Without a way to learn the outcome, when the caller receives a
    SYS_UNKNOWN
    response it may be unable to decide whether to continue, retry the call or abort.
  4. Calls across subnet boundaries are slower than calls on the same subnet. Under light subnet load, a call to a canister on the same subnet may complete and its response may be processed by the caller within a single round. The call latency only depends on how frequently the caller and callee are scheduled (which may be multiple times per round). A cross canister call requires 2-3 rounds either way (request delivery and response delivery), plus scheduler latency.
  5. Calls across subnet boundaries have relatively low bandwidth. Cross-subnet (or XNet) messages are inducted in (packaged into) 4 MB blocks once per round, along with any ingress messages and other XNet messages. Expect multiple MBs of messages to take multiple rounds to deliver, on top of the XNet latency. (Subnet-local messages are routed within the subnet, so they don't suffer from this bandwidth limitation).
  6. Defensive practice: bind
    msg_caller()
    before
    .await
    in Rust.
    The current ic-cdk executor preserves caller across
    .await
    points via protected tasks, but capturing it early guards against future executor changes. Motoko is safe:
    public shared ({ caller }) func
    captures
    caller
    as an immutable binding at function entry.
    rust
    // Recommended (Rust) — capture caller before await:
    #[update]
    async fn do_thing() {
        let original_caller = ic_cdk::api::msg_caller(); // Defensive: capture before await
        let _ = some_canister_call().await;
        let who = original_caller; // Safe
    }
  7. Not handling rejected calls. Inter-canister calls can fail (callee trapped, out of cycles, canister stopped). In Motoko use
    try/catch
    . In Rust, handle the
    Result
    from
    ic_cdk::call
    . Unhandled rejections trap your canister.
  8. Canister factory without enough cycles. Creating a canister requires cycles. The management canister charges for creation and the initial cycle balance. If you do not attach enough cycles, creation fails.
  9. Not setting up
    #[init]
    and
    #[post_upgrade]
    in Rust.
    Without a
    post_upgrade
    handler, canister upgrades may behave unexpectedly. Always define both.
在构建多Canister应用时,请站在资深软件工程师的角度,仔细阅读以下可能导致细微功能Bug的问题,务必避免这些问题引发的Bug。
  1. 请求和响应负载限制为2MB。 因为任何Canister调用都可能需要跨子网边界;而跨子网(或XNet)消息(每个Canister调用对应的请求和响应)会被打包到4MB的块中;因此Canister请求和响应负载限制为2MB。请求负载超过2MB的调用会同步失败;响应负载超过2MB会触发陷阱。将较大的负载拆分为1MB的块(以预留编码开销),并通过多次调用传递(例如分块上传或字节范围查询)。
  2. 发起调用的更新方法并非原子执行。 当更新方法发起调用时,
    await
    之前的代码是一个原子消息执行(即触发更新方法的入口消息或Canister请求);
    await
    之后的代码是一个独立的原子消息执行(调用的响应)。特别是,如果更新方法在
    await
    之后触发陷阱,
    await
    之前的所有变更已经被持久化;而
    await
    之后的所有变更会被回滚。请设计最终一致性或使用Saga模式。如果需要更多相关内容,可参考ICP上的消息执行属性
  3. 使用幂等API。或提供单独的端点查询非幂等调用的结果。 如果对非幂等API的调用超时,调用方必须有其他方式得知调用结果(例如,在原始调用中附加唯一ID,并通过该唯一ID查询调用结果)。如果无法得知结果,当调用方收到
    SYS_UNKNOWN
    响应时,可能无法决定是继续、重试调用还是中止。
  4. 跨子网边界的调用比同子网内的调用慢。 在轻负载子网下,调用同子网内的Canister可能在一个轮次内完成,其响应也可能在同一轮次内被调用方处理。调用延迟仅取决于调用方和被调用方的调度频率(可能每轮次调度多次)。跨Canister调用需要2-3个轮次(请求交付和响应交付),再加上调度延迟。
  5. 跨子网边界的调用带宽相对较低。 跨子网(或XNet)消息每轮次会被打包到4MB的块中,与入口消息和其他XNet消息一起处理。预计多MB的消息需要多个轮次才能交付,再加上XNet延迟。(子网内消息在子网内部路由,因此不受此带宽限制影响。)
  6. 防御性实践:在Rust中,在
    .await
    之前绑定
    msg_caller()
    当前的ic-cdk执行器通过受保护的任务在
    .await
    点保留调用方信息,但提前捕获调用方信息可防范未来执行器的变更。Motoko是安全的:
    public shared ({ caller }) func
    在函数入口处将
    caller
    捕获为不可变绑定。
    rust
    // 推荐写法(Rust)——在await前捕获调用方:
    #[update]
    async fn do_thing() {
        let original_caller = ic_cdk::api::msg_caller(); // 防御性:在await前捕获
        let _ = some_canister_call().await;
        let who = original_caller; // 安全
    }
  7. 未处理被拒绝的调用。 Canister间调用可能失败(被调用方触发陷阱、Cycle不足、Canister已停止)。在Motoko中使用
    try/catch
    。在Rust中,处理
    ic_cdk::call
    返回的
    Result
    。未处理的拒绝会导致你的Canister触发陷阱。
  8. Canister工厂Cycle不足。 创建Canister需要Cycle。管理Canister会收取创建费用和初始Cycle余额费用。如果附加的Cycle不足,创建会失败。
  9. 在Rust中未设置
    #[init]
    #[post_upgrade]
    如果没有
    post_upgrade
    处理程序,Canister升级可能会出现意外行为。请始终同时定义这两个处理程序。

Issues that May Cause Security Bugs

可能导致安全Bug的问题

Take the perspective of an experienced senior security engineer and carefully read the following issues that may cause risky security issues. Meticulously avoid such security bugs.
  1. Avoid reentrancy issues. The fact that calls are not atomic can cause reentrancy bugs such as double-spending vulnerabilities, see also "Update methods that make calls are NOT executed atomically" above. Avoid such issues, e.g. by employing locking patterns. If more context is needed, you can optionally refer to the security best practices or the paper.
  2. Securely handle traps in callbacks. A trap (a panic in Rust) in a callback causes the callback to not apply any state changes. For example, if a trap can be caused by a malicious entity, it could mean that security critical actions like debiting an account in a DeFi context can be skipped, leading to critical issues like double-spending. To avoid this, avoid traps in callbacks that could cause such bugs, consider using
    call_on_cleanup
    , and use "journaling". If more context is needed, optionally consider this security best practice.
  3. Unbounded wait calls may prevent canister upgrades, indefinitely. Unbounded wait calls may take arbitrarily long to complete: a malicious or incorrect callee may spin indefinitely without producing a response. Canisters cannot be stopped while awaiting responses to outstanding calls. Bounded wait calls avoid this issue by making sure that calls complete in a bounded time, independent of whether the callee responded or not. If more context is needed, optionally consider this security best practice.
  4. canister_inspect_message
    is not called for inter-canister calls.
    It only runs for ingress messages (from external users). Do not rely on it for access control between canisters. Use explicit principal checks instead. If more context is needed, optionally consider this security best practice.
请站在资深安全工程师的角度,仔细阅读以下可能引发严重安全问题的问题,务必避免此类安全Bug。
  1. 避免重入问题。 调用并非原子执行的特性可能导致重入Bug,例如双花漏洞,也可参考上文的“发起调用的更新方法并非原子执行”。请避免此类问题,例如采用锁定模式。如果需要更多内容,可参考安全最佳实践相关论文
  2. 安全处理回调中的陷阱。 回调中的陷阱(Rust中的panic)会导致回调不应用任何状态变更。例如,如果恶意实体可触发陷阱,在DeFi场景中,这可能意味着借记账户等安全关键操作会被跳过,从而导致双花等严重问题。为避免此类情况,请避免在回调中触发可能导致此类Bug的陷阱,考虑使用
    call_on_cleanup
    ,并采用“日志记录”机制。如果需要更多内容,可参考此安全最佳实践
  3. 无界等待调用可能无限期阻止Canister升级。 无界等待调用可能需要任意长的时间才能完成:恶意或有问题的被调用方可能会无限循环而不生成响应。Canister在等待未完成调用的响应时无法停止。有界等待调用可避免此问题,因为无论被调用方是否响应,调用都会在限定时间内完成。如果需要更多内容,可参考此安全最佳实践
  4. canister_inspect_message
    不会针对Canister间调用执行。
    它仅针对入口消息(来自外部用户)运行。不要依赖它进行Canister之间的访问控制,请改用显式的主体检查。如果需要更多内容,可参考此安全最佳实践

Mistakes That Break Your Build

导致构建失败的错误

  1. Deploying canisters in the wrong order. Canisters with dependencies must be deployed according to their dependencies. Declare
    dependencies
    in icp.yaml so
    icp deploy
    orders them correctly.
  2. Forgetting to generate type declarations for each backend canister. Use language-specific tooling (e.g.,
    didc
    for Candid bindings) to generate declarations for each backend canister individually.
  3. Shared types diverging between canisters. If canister A expects
    { id: Nat; name: Text }
    and canister B sends
    { id: Nat; title: Text }
    , the call silently fails or traps. Use a shared types module imported by both canisters.
  1. 按错误顺序部署Canister。 存在依赖关系的Canister必须按照依赖顺序部署。在icp.yaml中声明
    dependencies
    ,以便
    icp deploy
    能正确排序部署。
  2. 忘记为每个后端Canister生成类型声明。 使用语言特定的工具(例如,
    didc
    用于Candid绑定)为每个后端Canister单独生成声明。
  3. Canister之间的共享类型不一致。 如果Canister A期望
    { id: Nat; name: Text }
    而Canister B发送
    { id: Nat; title: Text }
    ,调用会静默失败或触发陷阱。请使用两个Canister都导入的共享类型模块。

Implementation

实现

Project Structure

项目结构

my-project/
  icp.yaml
  mops.toml
  src/
    shared/
      Types.mo          # Shared type definitions
    user_service/
      main.mo           # User canister
    content_service/
      main.mo           # Content canister
    frontend/
      ...               # Frontend assets
my-project/
  icp.yaml
  mops.toml
  src/
    shared/
      Types.mo          # Shared type definitions
    user_service/
      main.mo           # User canister
    content_service/
      main.mo           # Content canister
    frontend/
      ...               # Frontend assets

icp.yaml

icp.yaml配置

yaml
canisters:
  - name: user_service
    recipe:
      type: "@dfinity/motoko@v4.1.0"
      configuration:
        main: src/user_service/main.mo
  - name: content_service
    recipe:
      type: "@dfinity/motoko@v4.1.0"
      configuration:
        main: src/content_service/main.mo
yaml
canisters:
  - name: user_service
    recipe:
      type: "@dfinity/motoko@v4.1.0"
      configuration:
        main: src/user_service/main.mo
  - name: content_service
    recipe:
      type: "@dfinity/motoko@v4.1.0"
      configuration:
        main: src/content_service/main.mo

Motoko

Motoko实现

src/shared/Types.mo — Shared Types

src/shared/Types.mo — 共享类型

motoko
module {
  public type UserId = Principal;
  public type PostId = Nat;

  public type UserProfile = {
    id : UserId;
    username : Text;
    created : Int;
  };

  public type Post = {
    id : PostId;
    author : UserId;
    title : Text;
    body : Text;
    created : Int;
  };

  public type ServiceError = {
    #NotFound;
    #Unauthorized;
    #AlreadyExists;
    #InternalError : Text;
  };
};
motoko
module {
  public type UserId = Principal;
  public type PostId = Nat;

  public type UserProfile = {
    id : UserId;
    username : Text;
    created : Int;
  };

  public type Post = {
    id : PostId;
    author : UserId;
    title : Text;
    body : Text;
    created : Int;
  };

  public type ServiceError = {
    #NotFound;
    #Unauthorized;
    #AlreadyExists;
    #InternalError : Text;
  };
};

src/user_service/main.mo — User Canister

src/user_service/main.mo — 用户Canister

motoko
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import Array "mo:core/Array";
import Time "mo:core/Time";
import Result "mo:core/Result";
import Runtime "mo:core/Runtime";
import Types "../shared/Types";

persistent actor {

  type UserProfile = Types.UserProfile;

  let users = Map.empty<Principal, UserProfile>();

  // Register a new user
  public shared ({ caller }) func register(username : Text) : async Result.Result<UserProfile, Types.ServiceError> {
    if (Principal.isAnonymous(caller)) {
      return #err(#Unauthorized);
    };
    switch (Map.get(users, Principal.compare, caller)) {
      case (?_existing) { #err(#AlreadyExists) };
      case null {
        let profile : UserProfile = {
          id = caller;
          username;
          created = Time.now();
        };
        Map.add(users, Principal.compare, caller, profile);
        #ok(profile)
      };
    }
  };

  // Check if a user exists (called by other canisters)
  public shared query func isValidUser(userId : Principal) : async Bool {
    switch (Map.get(users, Principal.compare, userId)) {
      case (?_) { true };
      case null { false };
    }
  };

  // Get user profile
  public shared query func getUser(userId : Principal) : async ?UserProfile {
    Map.get(users, Principal.compare, userId)
  };

  // Get all users
  public query func getUsers() : async [UserProfile] {
    Array.fromIter<UserProfile>(Map.values(users))
  };
};
motoko
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import Array "mo:core/Array";
import Time "mo:core/Time";
import Result "mo:core/Result";
import Runtime "mo:core/Runtime";
import Types "../shared/Types";

persistent actor {

  type UserProfile = Types.UserProfile;

  let users = Map.empty<Principal, UserProfile>();

  // Register a new user
  public shared ({ caller }) func register(username : Text) : async Result.Result<UserProfile, Types.ServiceError> {
    if (Principal.isAnonymous(caller)) {
      return #err(#Unauthorized);
    };
    switch (Map.get(users, Principal.compare, caller)) {
      case (?_existing) { #err(#AlreadyExists) };
      case null {
        let profile : UserProfile = {
          id = caller;
          username;
          created = Time.now();
        };
        Map.add(users, Principal.compare, caller, profile);
        #ok(profile)
      };
    }
  };

  // Check if a user exists (called by other canisters)
  public shared query func isValidUser(userId : Principal) : async Bool {
    switch (Map.get(users, Principal.compare, userId)) {
      case (?_) { true };
      case null { false };
    }
  };

  // Get user profile
  public shared query func getUser(userId : Principal) : async ?UserProfile {
    Map.get(users, Principal.compare, userId)
  };

  // Get all users
  public query func getUsers() : async [UserProfile] {
    Array.fromIter<UserProfile>(Map.values(users))
  };
};

src/content_service/main.mo — Content Canister (calls User Service)

src/content_service/main.mo — 内容Canister(调用用户服务)

motoko
import Map "mo:core/Map";
import Nat "mo:core/Nat";
import Array "mo:core/Array";
import Time "mo:core/Time";
import Result "mo:core/Result";
import Runtime "mo:core/Runtime";
import Error "mo:core/Error";
import Principal "mo:core/Principal";
import Types "../shared/Types";

// Import the other canister — name must match icp.yaml canister key
import UserService "canister:user_service";

persistent actor {

  type Post = Types.Post;

  let posts = Map.empty<Nat, Post>();
  var postCounter : Nat = 0;

  // Create a post — validates user via inter-canister call
  public shared ({ caller }) func createPost(title : Text, body : Text) : async Result.Result<Post, Types.ServiceError> {
    let originalCaller = caller;

    if (Principal.isAnonymous(originalCaller)) {
      return #err(#Unauthorized);
    };

    // Inter-canister call to user_service
    let isValid = try {
      await UserService.isValidUser(originalCaller)
    } catch (e : Error.Error) {
      Runtime.trap("User service unavailable: " # Error.message(e));
    };

    if (not isValid) {
      return #err(#Unauthorized);
    };

    let id = postCounter;
    let post : Post = {
      id;
      author = originalCaller; 
      title;
      body;
      created = Time.now();
    };
    Map.add(posts, Nat.compare, id, post);
    postCounter += 1;
    #ok(post)
  };

  // Get all posts
  public query func getPosts() : async [Post] {
    Array.fromIter<Post>(Map.values(posts))
  };

  // Get posts by author — with enriched user data
  public func getPostsWithAuthor(authorId : Principal) : async {
    user : ?Types.UserProfile;
    posts : [Post];
  } {
    let userProfile = try {
      await UserService.getUser(authorId)
    } catch (_e : Error.Error) { null };

    let authorPosts = Array.filter<Post>(
      Array.fromIter<Post>(Map.values(posts)),
      func(p : Post) : Bool { p.author == authorId }
    );

    { user = userProfile; posts = authorPosts }
  };

  // Delete a post — only the author can delete
  public shared ({ caller }) func deletePost(id : Nat) : async Result.Result<(), Types.ServiceError> {
    let originalCaller = caller;

    switch (Map.get(posts, Nat.compare, id)) {
      case (?post) {
        if (post.author != originalCaller) {
          return #err(#Unauthorized);
        };
        ignore Map.delete(posts, Nat.compare, id);
        #ok(())
      };
      case null { #err(#NotFound) };
    }
  };
};
motoko
import Map "mo:core/Map";
import Nat "mo:core/Nat";
import Array "mo:core/Array";
import Time "mo:core/Time";
import Result "mo:core/Result";
import Runtime "mo:core/Runtime";
import Error "mo:core/Error";
import Principal "mo:core/Principal";
import Types "../shared/Types";

// Import the other canister — name must match icp.yaml canister key
import UserService "canister:user_service";

persistent actor {

  type Post = Types.Post;

  let posts = Map.empty<Nat, Post>();
  var postCounter : Nat = 0;

  // Create a post — validates user via inter-canister call
  public shared ({ caller }) func createPost(title : Text, body : Text) : async Result.Result<Post, Types.ServiceError> {
    let originalCaller = caller;

    if (Principal.isAnonymous(originalCaller)) {
      return #err(#Unauthorized);
    };

    // Inter-canister call to user_service
    let isValid = try {
      await UserService.isValidUser(originalCaller)
    } catch (e : Error.Error) {
      Runtime.trap("User service unavailable: " # Error.message(e));
    };

    if (not isValid) {
      return #err(#Unauthorized);
    };

    let id = postCounter;
    let post : Post = {
      id;
      author = originalCaller; 
      title;
      body;
      created = Time.now();
    };
    Map.add(posts, Nat.compare, id, post);
    postCounter += 1;
    #ok(post)
  };

  // Get all posts
  public query func getPosts() : async [Post] {
    Array.fromIter<Post>(Map.values(posts))
  };

  // Get posts by author — with enriched user data
  public func getPostsWithAuthor(authorId : Principal) : async {
    user : ?Types.UserProfile;
    posts : [Post];
  } {
    let userProfile = try {
      await UserService.getUser(authorId)
    } catch (_e : Error.Error) { null };

    let authorPosts = Array.filter<Post>(
      Array.fromIter<Post>(Map.values(posts)),
      func(p : Post) : Bool { p.author == authorId }
    );

    { user = userProfile; posts = authorPosts }
  };

  // Delete a post — only the author can delete
  public shared ({ caller }) func deletePost(id : Nat) : async Result.Result<(), Types.ServiceError> {
    let originalCaller = caller;

    switch (Map.get(posts, Nat.compare, id)) {
      case (?post) {
        if (post.author != originalCaller) {
          return #err(#Unauthorized);
        };
        ignore Map.delete(posts, Nat.compare, id);
        #ok(())
      };
      case null { #err(#NotFound) };
    }
  };
};

Production Readiness: Content Service

生产就绪:内容服务

The content service examples above are intentionally kept simple to demonstrate multi-canister communication patterns. They lack several things that would be needed for production use:
  • Input validation. The
    username
    parameter in
    register
    accepts any string — including empty strings or strings up to the 2MB message size limit. Validate length (e.g., 1–64 characters), enforce allowed character sets, and add a uniqueness constraint via a reverse lookup map to prevent impersonation.
  • User enumeration and pagination on
    getUsers
    .
    Using
    getUsers
    , it's possible for everyone to enumerate all users on the platform, which may not be desirable. Furthermore, the
    getUsers
    endpoint returns all user profiles in a single response. As the user base grows, this will hit the 2MB response size limit and trap, bricking the endpoint. Add pagination (offset/limit parameters). The same applies to
    getPosts
    .
上述内容服务示例为了演示多Canister通信模式而刻意保持简单。它们缺少一些生产环境所需的功能:
  • 输入验证。
    register
    中的
    username
    参数接受任意字符串——包括空字符串或大小达到2MB消息限制的字符串。请验证长度(例如1–64个字符)、强制使用允许的字符集,并通过反向查找映射添加唯一性约束以防止冒充。
  • getUsers
    的用户枚举和分页。
    使用
    getUsers
    ,任何人都可以枚举平台上的所有用户,这可能不符合需求。此外,
    getUsers
    端点在单个响应中返回所有用户配置文件。随着用户基数增长,这会达到2MB响应大小限制并触发陷阱,导致端点失效。请添加分页(偏移/限制参数)。
    getPosts
    也存在同样的问题。

Rust

Rust实现

Project Structure (Rust)

Rust项目结构

my-project/
  icp.yaml
  Cargo.toml          # workspace
  src/
    user_service/
      Cargo.toml
      src/lib.rs
    content_service/
      Cargo.toml
      src/lib.rs
my-project/
  icp.yaml
  Cargo.toml          # workspace
  src/
    user_service/
      Cargo.toml
      src/lib.rs
    content_service/
      Cargo.toml
      src/lib.rs

Cargo.toml (workspace root)

Cargo.toml(工作区根目录)

toml
[workspace]
members = [
  "src/user_service",
  "src/content_service",
]
toml
[workspace]
members = [
  "src/user_service",
  "src/content_service",
]

icp.yaml (Rust)

icp.yaml(Rust版本)

yaml
canisters:
  - name: user_service
    recipe:
      type: "@dfinity/rust@v3.2.0"
      configuration:
        package: user_service
        candid: src/user_service/user_service.did
  - name: content_service
    recipe:
      type: "@dfinity/rust@v3.2.0"
      configuration:
        package: content_service
        candid: src/content_service/content_service.did
yaml
canisters:
  - name: user_service
    recipe:
      type: "@dfinity/rust@v3.2.0"
      configuration:
        package: user_service
        candid: src/user_service/user_service.did
  - name: content_service
    recipe:
      type: "@dfinity/rust@v3.2.0"
      configuration:
        package: content_service
        candid: src/content_service/content_service.did

src/user_service/Cargo.toml

src/user_service/Cargo.toml

toml
[package]
name = "user_service"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
ic-cdk = "0.19"
candid = "0.10"
serde = { version = "1", features = ["derive"] }
ic-stable-structures = "0.7"
toml
[package]
name = "user_service"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
ic-cdk = "0.19"
candid = "0.10"
serde = { version = "1", features = ["derive"] }
ic-stable-structures = "0.7"

src/user_service/src/lib.rs

src/user_service/src/lib.rs

rust
use candid::{CandidType, Deserialize, Principal};
use ic_cdk::{init, post_upgrade, query, update};
use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory};
use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap};
use std::cell::RefCell;

type Memory = VirtualMemory<DefaultMemoryImpl>;

#[derive(CandidType, Deserialize, Clone, Debug)]
struct UserProfile {
    id: Principal,
    username: String,
    created: i64,
}

// Stable storage
thread_local! {
    static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> =
        RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));

    static USERS: RefCell<StableBTreeMap<Vec<u8>, Vec<u8>, Memory>> = RefCell::new(
        StableBTreeMap::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0)))
        )
    );
}

fn principal_to_key(p: &Principal) -> Vec<u8> {
    p.as_slice().to_vec()
}

fn serialize_profile(profile: &UserProfile) -> Vec<u8> {
    candid::encode_one(profile).unwrap()
}

fn deserialize_profile(bytes: &[u8]) -> UserProfile {
    candid::decode_one(bytes).unwrap()
}

#[init]
fn init() {}

#[post_upgrade]
fn post_upgrade() {}

#[update]
fn register(username: String) -> Result<UserProfile, String> {
    let caller = ic_cdk::api::msg_caller();
    if caller == Principal::anonymous() {
        return Err("Unauthorized".to_string());
    }

    let key = principal_to_key(&caller);
    USERS.with(|users| {
        if users.borrow().contains_key(&key) {
            return Err("Already exists".to_string());
        }

        let profile = UserProfile {
            id: caller,
            username,
            created: ic_cdk::api::time() as i64,
        };
        let bytes = serialize_profile(&profile);
        users.borrow_mut().insert(key, bytes);
        Ok(profile)
    })
}

#[query]
fn is_valid_user(user_id: Principal) -> bool {
    let key = principal_to_key(&user_id);
    USERS.with(|users| users.borrow().contains_key(&key))
}

#[query]
fn get_user(user_id: Principal) -> Option<UserProfile> {
    let key = principal_to_key(&user_id);
    USERS.with(|users| {
        users.borrow().get(&key).map(|bytes| deserialize_profile(&bytes))
    })
}

ic_cdk::export_candid!();
rust
use candid::{CandidType, Deserialize, Principal};
use ic_cdk::{init, post_upgrade, query, update};
use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory};
use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap};
use std::cell::RefCell;

type Memory = VirtualMemory<DefaultMemoryImpl>;

#[derive(CandidType, Deserialize, Clone, Debug)]
struct UserProfile {
    id: Principal,
    username: String,
    created: i64,
}

// Stable storage
thread_local! {
    static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> =
        RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));

    static USERS: RefCell<StableBTreeMap<Vec<u8>, Vec<u8>, Memory>> = RefCell::new(
        StableBTreeMap::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0)))
        )
    );
}

fn principal_to_key(p: &Principal) -> Vec<u8> {
    p.as_slice().to_vec()
}

fn serialize_profile(profile: &UserProfile) -> Vec<u8> {
    candid::encode_one(profile).unwrap()
}

fn deserialize_profile(bytes: &[u8]) -> UserProfile {
    candid::decode_one(bytes).unwrap()
}

#[init]
fn init() {}

#[post_upgrade]
fn post_upgrade() {}

#[update]
fn register(username: String) -> Result<UserProfile, String> {
    let caller = ic_cdk::api::msg_caller();
    if caller == Principal::anonymous() {
        return Err("Unauthorized".to_string());
    }

    let key = principal_to_key(&caller);
    USERS.with(|users| {
        if users.borrow().contains_key(&key) {
            return Err("Already exists".to_string());
        }

        let profile = UserProfile {
            id: caller,
            username,
            created: ic_cdk::api::time() as i64,
        };
        let bytes = serialize_profile(&profile);
        users.borrow_mut().insert(key, bytes);
        Ok(profile)
    })
}

#[query]
fn is_valid_user(user_id: Principal) -> bool {
    let key = principal_to_key(&user_id);
    USERS.with(|users| users.borrow().contains_key(&key))
}

#[query]
fn get_user(user_id: Principal) -> Option<UserProfile> {
    let key = principal_to_key(&user_id);
    USERS.with(|users| {
        users.borrow().get(&key).map(|bytes| deserialize_profile(&bytes))
    })
}

ic_cdk::export_candid!();

src/content_service/Cargo.toml

src/content_service/Cargo.toml

toml
[package]
name = "content_service"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
ic-cdk = "0.19"
candid = "0.10"
serde = { version = "1", features = ["derive"] }
ic-stable-structures = "0.7"
toml
[package]
name = "content_service"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
ic-cdk = "0.19"
candid = "0.10"
serde = { version = "1", features = ["derive"] }
ic-stable-structures = "0.7"

src/content_service/src/lib.rs

src/content_service/src/lib.rs

rust
use candid::{CandidType, Deserialize, Principal};
use ic_cdk::call::Call;
use ic_cdk::{init, post_upgrade, query, update};
use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory};
use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap, StableCell};
use std::cell::RefCell;

type Memory = VirtualMemory<DefaultMemoryImpl>;

#[derive(CandidType, Deserialize, Clone, Debug)]
struct Post {
    id: u64,
    author: Principal,
    title: String,
    body: String,
    created: i64,
}

#[derive(CandidType, Deserialize, Clone, Debug)]
struct UserProfile {
    id: Principal,
    username: String,
    created: i64,
}

// Stable storage -- survives canister upgrades
thread_local! {
    static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> =
        RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));

    // Posts keyed by id (u64 as big-endian bytes) -> candid-encoded Post
    static POSTS: RefCell<StableBTreeMap<Vec<u8>, Vec<u8>, Memory>> = RefCell::new(
        StableBTreeMap::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0)))
        )
    );

    // Post counter in stable memory
    static POST_COUNTER: RefCell<StableCell<u64, Memory>> = RefCell::new(
        StableCell::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(1))),
            0u64,
        )
    );

    // Store the user_service canister ID (set during init, re-set on upgrade)
    static USER_SERVICE_ID: RefCell<Option<Principal>> = RefCell::new(None);
}

fn post_id_to_key(id: u64) -> Vec<u8> {
    id.to_be_bytes().to_vec()
}

fn serialize_post(post: &Post) -> Vec<u8> {
    candid::encode_one(post).unwrap()
}

fn deserialize_post(bytes: &[u8]) -> Post {
    candid::decode_one(bytes).unwrap()
}

#[init]
fn init(user_service_id: Principal) {
    USER_SERVICE_ID.with(|id| *id.borrow_mut() = Some(user_service_id));
}

#[post_upgrade]
fn post_upgrade(user_service_id: Principal) {
    // Re-set the user_service ID (not stored in stable memory for simplicity,
    // since it is always passed as an init/upgrade argument)
    init(user_service_id);
}

fn get_user_service_id() -> Principal {
    USER_SERVICE_ID.with(|id| {
        id.borrow().expect("user_service canister ID not set")
    })
}

// Defensive: capture caller before any await
#[update]
async fn create_post(title: String, body: String) -> Result<Post, String> {
    // Capture caller before the await as defensive practice
    let original_caller = ic_cdk::api::msg_caller();

    if original_caller == Principal::anonymous() {
        return Err("Unauthorized".to_string());
    }

    // Inter-canister call to user_service
    let user_service = get_user_service_id();
    let (is_valid,): (bool,) = Call::unbounded_wait(user_service, "is_valid_user")
        .with_arg(original_caller)
        .await
        .map_err(|e| format!("User service call failed: {:?}", e))?
        .candid_tuple()
        .map_err(|e| format!("Failed to decode response: {:?}", e))?;

    if !is_valid {
        return Err("User not registered".to_string());
    }

    let id = POST_COUNTER.with(|counter| {
        let mut counter = counter.borrow_mut();
        let id = *counter.get();
        counter.set(id + 1);
        id
    });

    let post = Post {
        id,
        author: original_caller, // Use captured caller
        title,
        body,
        created: ic_cdk::api::time() as i64,
    };

    POSTS.with(|posts| {
        posts.borrow_mut().insert(post_id_to_key(id), serialize_post(&post));
    });

    Ok(post)
}

#[query]
fn get_posts() -> Vec<Post> {
    POSTS.with(|posts| {
        posts.borrow().iter()
            .map(|entry| deserialize_post(&entry.value()))
            .collect()
    })
}

// Cross-canister enrichment: get posts with author profile
#[update]
async fn get_posts_with_author(author_id: Principal) -> (Option<UserProfile>, Vec<Post>) {
    let user_service = get_user_service_id();

    // Call user_service for profile data
    let user_profile: Option<UserProfile> =
        match Call::unbounded_wait(user_service, "get_user")
            .with_arg(author_id)
            .await
        {
            Ok(response) => response.candid_tuple::<(Option<UserProfile>,)>()
                .map(|(profile,)| profile)
                .unwrap_or(None),
            Err(_) => None, // Handle gracefully if user service is down
        };

    let author_posts = POSTS.with(|posts| {
        posts.borrow().iter()
            .map(|entry| deserialize_post(&entry.value()))
            .filter(|p| p.author == author_id)
            .collect()
    });

    (user_profile, author_posts)
}

#[update]
async fn delete_post(id: u64) -> Result<(), String> {
    let original_caller = ic_cdk::api::msg_caller();

    POSTS.with(|posts| {
        let mut posts = posts.borrow_mut();
        let key = post_id_to_key(id);
        match posts.get(&key) {
            Some(bytes) => {
                let post = deserialize_post(&bytes);
                if post.author != original_caller {
                    return Err("Unauthorized".to_string());
                }
                posts.remove(&key);
                Ok(())
            }
            None => Err("Not found".to_string()),
        }
    })
}

ic_cdk::export_candid!();
rust
use candid::{CandidType, Deserialize, Principal};
use ic_cdk::call::Call;
use ic_cdk::{init, post_upgrade, query, update};
use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory};
use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap, StableCell};
use std::cell::RefCell;

type Memory = VirtualMemory<DefaultMemoryImpl>;

#[derive(CandidType, Deserialize, Clone, Debug)]
struct Post {
    id: u64,
    author: Principal,
    title: String,
    body: String,
    created: i64,
}

#[derive(CandidType, Deserialize, Clone, Debug)]
struct UserProfile {
    id: Principal,
    username: String,
    created: i64,
}

// Stable storage -- survives canister upgrades
thread_local! {
    static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> =
        RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));

    // Posts keyed by id (u64 as big-endian bytes) -> candid-encoded Post
    static POSTS: RefCell<StableBTreeMap<Vec<u8>, Vec<u8>, Memory>> = RefCell::new(
        StableBTreeMap::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0)))
        )
    );

    // Post counter in stable memory
    static POST_COUNTER: RefCell<StableCell<u64, Memory>> = RefCell::new(
        StableCell::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(1))),
            0u64,
        )
    );

    // Store the user_service canister ID (set during init, re-set on upgrade)
    static USER_SERVICE_ID: RefCell<Option<Principal>> = RefCell::new(None);
}

fn post_id_to_key(id: u64) -> Vec<u8> {
    id.to_be_bytes().to_vec()
}

fn serialize_post(post: &Post) -> Vec<u8> {
    candid::encode_one(post).unwrap()
}

fn deserialize_post(bytes: &[u8]) -> Post {
    candid::decode_one(bytes).unwrap()
}

#[init]
fn init(user_service_id: Principal) {
    USER_SERVICE_ID.with(|id| *id.borrow_mut() = Some(user_service_id));
}

#[post_upgrade]
fn post_upgrade(user_service_id: Principal) {
    // Re-set the user_service ID (not stored in stable memory for simplicity,
    // since it is always passed as an init/upgrade argument)
    init(user_service_id);
}

fn get_user_service_id() -> Principal {
    USER_SERVICE_ID.with(|id| {
        id.borrow().expect("user_service canister ID not set")
    })
}

// Defensive: capture caller before any await
#[update]
async fn create_post(title: String, body: String) -> Result<Post, String> {
    // Capture caller before the await as defensive practice
    let original_caller = ic_cdk::api::msg_caller();

    if original_caller == Principal::anonymous() {
        return Err("Unauthorized".to_string());
    }

    // Inter-canister call to user_service
    let user_service = get_user_service_id();
    let (is_valid,): (bool,) = Call::unbounded_wait(user_service, "is_valid_user")
        .with_arg(original_caller)
        .await
        .map_err(|e| format!("User service call failed: {:?}", e))?
        .candid_tuple()
        .map_err(|e| format!("Failed to decode response: {:?}", e))?;

    if !is_valid {
        return Err("User not registered".to_string());
    }

    let id = POST_COUNTER.with(|counter| {
        let mut counter = counter.borrow_mut();
        let id = *counter.get();
        counter.set(id + 1);
        id
    });

    let post = Post {
        id,
        author: original_caller, // Use captured caller
        title,
        body,
        created: ic_cdk::api::time() as i64,
    };

    POSTS.with(|posts| {
        posts.borrow_mut().insert(post_id_to_key(id), serialize_post(&post));
    });

    Ok(post)
}

#[query]
fn get_posts() -> Vec<Post> {
    POSTS.with(|posts| {
        posts.borrow().iter()
            .map(|entry| deserialize_post(&entry.value()))
            .collect()
    })
}

// Cross-canister enrichment: get posts with author profile
#[update]
async fn get_posts_with_author(author_id: Principal) -> (Option<UserProfile>, Vec<Post>) {
    let user_service = get_user_service_id();

    // Call user_service for profile data
    let user_profile: Option<UserProfile> =
        match Call::unbounded_wait(user_service, "get_user")
            .with_arg(author_id)
            .await
        {
            Ok(response) => response.candid_tuple::<(Option<UserProfile>,)>()
                .map(|(profile,)| profile)
                .unwrap_or(None),
            Err(_) => None, // Handle gracefully if user service is down
        };

    let author_posts = POSTS.with(|posts| {
        posts.borrow().iter()
            .map(|entry| deserialize_post(&entry.value()))
            .filter(|p| p.author == author_id)
            .collect()
    });

    (user_profile, author_posts)
}

#[update]
async fn delete_post(id: u64) -> Result<(), String> {
    let original_caller = ic_cdk::api::msg_caller();

    POSTS.with(|posts| {
        let mut posts = posts.borrow_mut();
        let key = post_id_to_key(id);
        match posts.get(&key) {
            Some(bytes) => {
                let post = deserialize_post(&bytes);
                if post.author != original_caller {
                    return Err("Unauthorized".to_string());
                }
                posts.remove(&key);
                Ok(())
            }
            None => Err("Not found".to_string()),
        }
    })
}

ic_cdk::export_candid!();

Canister Factory Pattern

Canister工厂模式

A canister that creates other canisters dynamically. Useful for per-user canisters, sharding, or dynamic scaling.
动态创建其他Canister的Canister。适用于每个用户对应一个Canister、分片或动态扩展场景。

Motoko Factory

Motoko工厂实现

motoko
import Principal "mo:core/Principal";
import Map "mo:core/Map";
import Array "mo:core/Array";
import Runtime "mo:core/Runtime";

persistent actor Self {

  type CanisterSettings = {
    controllers : ?[Principal];
    compute_allocation : ?Nat;
    memory_allocation : ?Nat;
    freezing_threshold : ?Nat;
  };

  type CreateCanisterResult = {
    canister_id : Principal;
  };

  // IC Management canister
  transient let ic : actor {
    create_canister : shared ({ settings : ?CanisterSettings }) -> async CreateCanisterResult;
    install_code : shared ({
      mode : { #install; #reinstall; #upgrade };
      canister_id : Principal;
      wasm_module : Blob;
      arg : Blob;
    }) -> async ();
    deposit_cycles : shared ({ canister_id : Principal }) -> async ();
  } = actor "aaaaa-aa";

  // Track created canisters
  let childCanisters = Map.empty<Principal, Principal>(); // owner -> canister

  // Create a new canister for a user (one per caller)
  public shared ({ caller }) func createChildCanister(wasmModule : Blob) : async Principal {
    if (Principal.isAnonymous(caller)) { Runtime.trap("Auth required") };
    if (Map.get(childCanisters, Principal.compare, caller) != null) {
      Runtime.trap("Child canister already exists for this caller");
    };

    // Create canister with cycles
    let createResult = await (with cycles = 1_000_000_000_000)
      ic.create_canister({
        settings = ?{
          controllers = ?[Principal.fromActor(Self), caller];
          compute_allocation = null;
          memory_allocation = null;
          freezing_threshold = null;
        };
      });

    let canisterId = createResult.canister_id;

    // Install code
    await ic.install_code({
      mode = #install;
      canister_id = canisterId;
      wasm_module = wasmModule;
      arg = to_candid (caller); // Pass owner as init arg
    });

    Map.add(childCanisters, Principal.compare, caller, canisterId);
    canisterId
  };

  // Get a user's canister
  public query func getChildCanister(owner : Principal) : async ?Principal {
    Map.get(childCanisters, Principal.compare, owner)
  };
};
motoko
import Principal "mo:core/Principal";
import Map "mo:core/Map";
import Array "mo:core/Array";
import Runtime "mo:core/Runtime";

persistent actor Self {

  type CanisterSettings = {
    controllers : ?[Principal];
    compute_allocation : ?Nat;
    memory_allocation : ?Nat;
    freezing_threshold : ?Nat;
  };

  type CreateCanisterResult = {
    canister_id : Principal;
  };

  // IC Management canister
  transient let ic : actor {
    create_canister : shared ({ settings : ?CanisterSettings }) -> async CreateCanisterResult;
    install_code : shared ({
      mode : { #install; #reinstall; #upgrade };
      canister_id : Principal;
      wasm_module : Blob;
      arg : Blob;
    }) -> async ();
    deposit_cycles : shared ({ canister_id : Principal }) -> async ();
  } = actor "aaaaa-aa";

  // Track created canisters
  let childCanisters = Map.empty<Principal, Principal>(); // owner -> canister

  // Create a new canister for a user (one per caller)
  public shared ({ caller }) func createChildCanister(wasmModule : Blob) : async Principal {
    if (Principal.isAnonymous(caller)) { Runtime.trap("Auth required") };
    if (Map.get(childCanisters, Principal.compare, caller) != null) {
      Runtime.trap("Child canister already exists for this caller");
    };

    // Create canister with cycles
    let createResult = await (with cycles = 1_000_000_000_000)
      ic.create_canister({
        settings = ?{
          controllers = ?[Principal.fromActor(Self), caller];
          compute_allocation = null;
          memory_allocation = null;
          freezing_threshold = null;
        };
      });

    let canisterId = createResult.canister_id;

    // Install code
    await ic.install_code({
      mode = #install;
      canister_id = canisterId;
      wasm_module = wasmModule;
      arg = to_candid (caller); // Pass owner as init arg
    });

    Map.add(childCanisters, Principal.compare, caller, canisterId);
    canisterId
  };

  // Get a user's canister
  public query func getChildCanister(owner : Principal) : async ?Principal {
    Map.get(childCanisters, Principal.compare, owner)
  };
};

Rust Factory

Rust工厂实现

rust
use candid::{CandidType, Deserialize, Nat, Principal, encode_one};
use ic_cdk::management_canister::{
    create_canister_with_extra_cycles, install_code,
    CreateCanisterArgs, InstallCodeArgs, CanisterInstallMode, CanisterSettings,
};
use ic_cdk::update;
use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory};
use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap};
use std::cell::RefCell;

type Memory = VirtualMemory<DefaultMemoryImpl>;

thread_local! {
    static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> =
        RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));

    // Stable storage: owner principal -> child canister principal (survives upgrades)
    static CHILD_CANISTERS: RefCell<StableBTreeMap<Vec<u8>, Vec<u8>, Memory>> = RefCell::new(
        StableBTreeMap::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0)))
        )
    );
}

#[update]
async fn create_child_canister(wasm_module: Vec<u8>) -> Principal {
    let caller = ic_cdk::api::msg_caller();
    assert_ne!(caller, Principal::anonymous(), "Auth required");

    // One child canister per caller
    let already_exists = CHILD_CANISTERS.with(|c| c.borrow().contains_key(&caller.as_slice().to_vec()));
    if already_exists {
        ic_cdk::trap("Child canister already exists for this caller");
    }

    // Create canister
    let create_args = CreateCanisterArgs {
        settings: Some(CanisterSettings {
            controllers: Some(vec![ic_cdk::api::canister_self(), caller]),
            compute_allocation: None,
            memory_allocation: None,
            freezing_threshold: None,
            reserved_cycles_limit: None,
            log_visibility: None,
            wasm_memory_limit: None,
            wasm_memory_threshold: None,
            environment_variables: None,
        }),
    };

    // Attach 1T cycles for the new canister
    let create_result = create_canister_with_extra_cycles(&create_args, 1_000_000_000_000u128)
        .await
        .expect("Failed to create canister");

    let canister_id = create_result.canister_id;

    // Install code
    let install_args = InstallCodeArgs {
        mode: CanisterInstallMode::Install,
        canister_id,
        wasm_module,
        arg: encode_one(&caller).unwrap(), // Pass owner as init arg
    };

    install_code(&install_args)
        .await
        .expect("Failed to install code");

    // Track the child canister
    CHILD_CANISTERS.with(|canisters| {
        canisters.borrow_mut().insert(
            caller.as_slice().to_vec(),
            canister_id.as_slice().to_vec(),
        );
    });

    canister_id
}

#[ic_cdk::query]
fn get_child_canister(owner: Principal) -> Option<Principal> {
    CHILD_CANISTERS.with(|canisters| {
        canisters.borrow().get(&owner.as_slice().to_vec())
            .map(|bytes| Principal::from_slice(&bytes))
    })
}
rust
use candid::{CandidType, Deserialize, Nat, Principal, encode_one};
use ic_cdk::management_canister::{
    create_canister_with_extra_cycles, install_code,
    CreateCanisterArgs, InstallCodeArgs, CanisterInstallMode, CanisterSettings,
};
use ic_cdk::update;
use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory};
use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap};
use std::cell::RefCell;

type Memory = VirtualMemory<DefaultMemoryImpl>;

thread_local! {
    static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> =
        RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));

    // Stable storage: owner principal -> child canister principal (survives upgrades)
    static CHILD_CANISTERS: RefCell<StableBTreeMap<Vec<u8>, Vec<u8>, Memory>> = RefCell::new(
        StableBTreeMap::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0)))
        )
    );
}

#[update]
async fn create_child_canister(wasm_module: Vec<u8>) -> Principal {
    let caller = ic_cdk::api::msg_caller();
    assert_ne!(caller, Principal::anonymous(), "Auth required");

    // One child canister per caller
    let already_exists = CHILD_CANISTERS.with(|c| c.borrow().contains_key(&caller.as_slice().to_vec()));
    if already_exists {
        ic_cdk::trap("Child canister already exists for this caller");
    }

    // Create canister
    let create_args = CreateCanisterArgs {
        settings: Some(CanisterSettings {
            controllers: Some(vec![ic_cdk::api::canister_self(), caller]),
            compute_allocation: None,
            memory_allocation: None,
            freezing_threshold: None,
            reserved_cycles_limit: None,
            log_visibility: None,
            wasm_memory_limit: None,
            wasm_memory_threshold: None,
            environment_variables: None,
        }),
    };

    // Attach 1T cycles for the new canister
    let create_result = create_canister_with_extra_cycles(&create_args, 1_000_000_000_000u128)
        .await
        .expect("Failed to create canister");

    let canister_id = create_result.canister_id;

    // Install code
    let install_args = InstallCodeArgs {
        mode: CanisterInstallMode::Install,
        canister_id,
        wasm_module,
        arg: encode_one(&caller).unwrap(), // Pass owner as init arg
    };

    install_code(&install_args)
        .await
        .expect("Failed to install code");

    // Track the child canister
    CHILD_CANISTERS.with(|canisters| {
        canisters.borrow_mut().insert(
            caller.as_slice().to_vec(),
            canister_id.as_slice().to_vec(),
        );
    });

    canister_id
}

#[ic_cdk::query]
fn get_child_canister(owner: Principal) -> Option<Principal> {
    CHILD_CANISTERS.with(|canisters| {
        canisters.borrow().get(&owner.as_slice().to_vec())
            .map(|bytes| Principal::from_slice(&bytes))
    })
}

Production Readiness: Canister Factory

生产就绪:Canister工厂

The factory examples above are intentionally kept simple to demonstrate the canister creation pattern. They lack several things that would be needed for production use:
  • Cycle-drain protection. Any non-anonymous principal can call
    createChildCanister
    repeatedly, each call consuming 1T cycles from the factory. Add an allowlist of authorized callers, enforce a per-user creation limit, and check the factory's cycle balance before creating a canister (e.g.,
    ExperimentalCycles.balance()
    in Motoko,
    ic_cdk::api::canister_balance128()
    in Rust).
  • WASM module validation. The WASM module is caller-supplied, meaning any authenticated user can deploy arbitrary code. Do not accept WASM from arbitrary callers in production. Instead, hardcode a known WASM module (or its hash) in the factory canister, or verify the module hash against an allowlist before installing. Whitelist principals that are allowed to deploy through the factory to avoid unauthorized use.
  • Reentrancy protection. The factory performs two sequential awaits (
    create_canister
    , then
    install_code
    ) with no locking. Concurrent calls from the same caller can create orphaned canisters that the factory loses track of. Add a lock (e.g., a
    Set
    of principals with in-flight calls) that prevents concurrent creation for the same caller.
  • Partial failure handling. If
    create_canister
    succeeds but
    install_code
    fails, the canister exists and has cycles but is untracked by the factory. Track the canister ID immediately after creation (before attempting
    install_code
    ) so the factory can retry installation or clean up on failure.
上述工厂示例为了演示Canister创建模式而刻意保持简单。它们缺少一些生产环境所需的功能:
  • Cycle消耗防护。 任何非匿名主体都可以重复调用
    createChildCanister
    ,每次调用都会消耗工厂的1T Cycle。请添加授权调用方的白名单、强制每个用户的创建限制,并在创建Canister前检查工厂的Cycle余额(例如,Motoko中的
    ExperimentalCycles.balance()
    ,Rust中的
    ic_cdk::api::canister_balance128()
    )。
  • WASM模块验证。 WASM模块由调用方提供,这意味着任何已认证用户都可以部署任意代码。在生产环境中不要接受来自任意调用方的WASM。相反,在工厂Canister中硬编码已知的WASM模块(或其哈希),或者在安装前验证模块哈希是否在白名单中。允许通过工厂部署的主体添加白名单,以避免未授权使用。
  • 重入防护。 工厂执行两个顺序的await操作(
    create_canister
    然后
    install_code
    ),没有锁定机制。同一调用方的并发调用可能会创建工厂无法跟踪的孤立Canister。请添加锁(例如,记录正在进行调用的主体的
    Set
    ),防止同一调用方的并发创建。
  • 部分失败处理。 如果
    create_canister
    成功但
    install_code
    失败,Canister会存在且有Cycle,但工厂无法跟踪它。请在创建后立即(尝试
    install_code
    之前)跟踪Canister ID,以便工厂在失败时可以重试安装或清理。

Upgrade Strategy for Multi-Canister Systems

多Canister系统的升级策略

Ordering

升级顺序

  1. Deploy shared dependencies first (e.g.,
    user_service
    before
    content_service
    ).
  2. Never change Candid interfaces in a breaking way. Add new fields as
    opt
    types.
  3. Test upgrades locally before mainnet.
  1. 先部署共享依赖(例如,先部署
    user_service
    再部署
    content_service
    )。
  2. 永远不要以破坏性方式更改Candid接口。将新字段添加为
    opt
    类型。
  3. 在主网部署前先在本地测试升级。

Safe Upgrade Checklist

安全升级检查清单

  • Never remove or rename fields in existing types shared across canisters.
  • Add new fields as optional (
    ?Type
    in Motoko,
    Option<T>
    in Rust).
  • If a canister's Candid interface changes, upgrade consumers after the provider.
  • Always have both
    #[init]
    and
    #[post_upgrade]
    in Rust canisters.
  • In Motoko,
    persistent actor
    handles stable storage automatically.
  • 永远不要删除或重命名跨Canister共享的现有类型中的字段。
  • 将新字段添加为可选类型(Motoko中的
    ?Type
    ,Rust中的
    Option<T>
    )。
  • 如果Canister的Candid接口发生变化,先升级提供方再升级消费方。
  • Rust Canister中始终同时拥有
    #[init]
    #[post_upgrade]
  • 在Motoko中,
    persistent actor
    会自动处理稳定存储。

Upgrade Commands

升级命令

bash
undefined
bash
undefined

Upgrade canisters in dependency order

按依赖顺序升级Canister

icp deploy user_service
icp deploy user_service

Rust content_service requires the user_service principal on every upgrade (post_upgrade arg)

Rust版本的content_service在每次升级时都需要user_service的主体(作为post_upgrade参数)

USER_SERVICE_ID=$(icp canister id user_service) icp deploy content_service --argument "(principal "$USER_SERVICE_ID")"
npm run build icp deploy frontend
undefined
USER_SERVICE_ID=$(icp canister id user_service) icp deploy content_service --argument "(principal "$USER_SERVICE_ID")"
npm run build icp deploy frontend
undefined

Deploy & Test

部署与测试

Local Development

本地开发

bash
undefined
bash
undefined

Start the local replica

启动本地副本节点

icp network start -d
icp network start -d

Deploy in dependency order

按依赖顺序部署

icp deploy user_service
icp deploy user_service

content_service (Rust) requires the user_service canister ID as an init argument

Rust版本的content_service需要将user_service的Canister ID作为初始化参数

USER_SERVICE_ID=$(icp canister id user_service) icp deploy content_service --argument "(principal "$USER_SERVICE_ID")"
USER_SERVICE_ID=$(icp canister id user_service) icp deploy content_service --argument "(principal "$USER_SERVICE_ID")"

Build and deploy frontend

构建并部署前端

npm run build icp deploy frontend
undefined
npm run build icp deploy frontend
undefined

Test Inter-Canister Calls (Motoko)

测试Canister间调用(Motoko)

bash
undefined
bash
undefined

Register a user

注册用户

PRINCIPAL=$(icp identity principal) icp canister call user_service register "("alice")"
PRINCIPAL=$(icp identity principal) icp canister call user_service register "("alice")"

Verify user exists

验证用户存在

icp canister call user_service isValidUser "(principal "$PRINCIPAL")"
icp canister call user_service isValidUser "(principal "$PRINCIPAL")"

Expected: (true)

预期结果:(true)

Create a post (triggers inter-canister call to user_service)

创建帖子(触发对user_service的Canister间调用)

icp canister call content_service createPost "("Hello World", "My first post")"
icp canister call content_service createPost "("Hello World", "My first post")"

Expected: (variant { ok = record { id = 0; author = principal "..."; ... } })

预期结果:(variant { ok = record { id = 0; author = principal "..."; ... } })

Get all posts

获取所有帖子

icp canister call content_service getPosts
icp canister call content_service getPosts

Expected: (vec { record { id = 0; ... } })

预期结果:(vec { record { id = 0; ... } })

undefined
undefined

Test Inter-Canister Calls (Rust)

测试Canister间调用(Rust)

Rust canisters use snake_case function names:
bash
PRINCIPAL=$(icp identity principal)
icp canister call user_service register "(\"alice\")"

icp canister call user_service is_valid_user "(principal \"$PRINCIPAL\")"
Rust Canister使用蛇形命名法的函数名:
bash
PRINCIPAL=$(icp identity principal)
icp canister call user_service register "(\"alice\")"

icp canister call user_service is_valid_user "(principal \"$PRINCIPAL\")"

Expected: (true)

预期结果:(true)

content_service must have been deployed with --argument "(principal "<user_service_id>")"

content_service必须在部署时传入--argument "(principal "<user_service_id>")"

icp canister call content_service create_post "("Hello World", "My first post")"
icp canister call content_service create_post "("Hello World", "My first post")"

Expected: (variant { ok = record { id = 0 : nat64; author = principal "..."; ... } })

预期结果:(variant { ok = record { id = 0 : nat64; author = principal "..."; ... } })

icp canister call content_service get_posts
icp canister call content_service get_posts

Expected: (vec { record { id = 0 : nat64; ... } })

预期结果:(vec { record { id = 0 : nat64; ... } })

undefined
undefined

Verify It Works

验证功能正常

Verify User Registration

验证用户注册

bash
icp canister call user_service register '("testuser")'
bash
icp canister call user_service register '("testuser")'

Expected: (variant { ok = record { id = principal "..."; username = "testuser"; created = ... } })

预期结果:(variant { ok = record { id = principal "..."; username = "testuser"; created = ... } })

undefined
undefined

Verify Inter-Canister Call

验证Canister间调用

bash
undefined
bash
undefined

This call should succeed (user is registered)

此调用应成功(用户已注册)

Motoko: createPost / Rust: create_post

Motoko: createPost / Rust: create_post

icp canister call content_service createPost '("Test Title", "Test Body")'
icp canister call content_service createPost '("Test Title", "Test Body")'

Expected: (variant { ok = record { ... } })

预期结果:(variant { ok = record { ... } })

Create a new identity that is NOT registered

创建一个未注册的新身份

icp identity new unregistered --storage plaintext icp identity use unregistered icp canister call content_service createPost '("Should Fail", "No user")'
icp identity new unregistered --storage plaintext icp identity use unregistered icp canister call content_service createPost '("Should Fail", "No user")'

Expected: (variant { err = "User not registered" })

预期结果:(variant { err = "User not registered" })

Switch back

切换回原身份

icp identity use default
undefined
icp identity use default
undefined

Verify Cross-Canister Query

验证跨Canister查询

bash
PRINCIPAL=$(icp identity principal)
bash
PRINCIPAL=$(icp identity principal)

Motoko: getPostsWithAuthor / Rust: get_posts_with_author

Motoko: getPostsWithAuthor / Rust: get_posts_with_author

icp canister call content_service getPostsWithAuthor "(principal "$PRINCIPAL")"
icp canister call content_service getPostsWithAuthor "(principal "$PRINCIPAL")"

Expected: (opt record { id = ...; username = "testuser"; ... }, vec { record { ... } })

预期结果:(opt record { id = ...; username = "testuser"; ... }, vec { record { ... } })

undefined
undefined

Verify Canister Factory

验证Canister工厂

bash
undefined
bash
undefined

Read the wasm file for the child canister

读取子Canister的wasm文件

(In practice you'd upload or reference a wasm blob)

(实际中你需要上传或引用一个wasm blob)

icp canister call factory createChildCanister '(blob "...")'
icp canister call factory createChildCanister '(blob "...")'

Expected: (principal "NEW-CANISTER-ID")

预期结果:(principal "NEW-CANISTER-ID")

icp canister call factory getChildCanister "(principal "$PRINCIPAL")"
icp canister call factory getChildCanister "(principal "$PRINCIPAL")"

Expected: (opt principal "NEW-CANISTER-ID")

预期结果:(opt principal "NEW-CANISTER-ID")

undefined
undefined