mcpField
M
multi-canister

multi-canister

github.com/dfinity开发39294 次安装今天更新
代码仓库

关于 multi-canister

面向 ICP 开发者的多 Canister 架构技能,说明如何拆分应用以扩展存储与计算、分离职责并独立升级。重点梳理跨 Canister 调用的可靠性、性能、构建和安全风险,并提供 Motoko 与 Rust 实现指引。

主要功能

  1. 01多 Canister 拆分与部署配置
  2. 02异步跨 Canister 调用模式
  3. 03调用限制与故障处理要点
  4. 04并发与安全风险防范
  5. 05Motoko 和 Rust 示例

适用场景

  1. 01按服务职责拆分 ICP 应用
  2. 02分片处理存储或计算负载
  3. 03独立升级不同 Canister
  4. 04排查跨 Canister 调用问题

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dfinity/icskills --skill multi-canister

安装后在 Claude Code 中输入/multi-canister确认已加载。

详细介绍

多容器架构

这是什么

将 IC 应用拆分到多个容器中,以便扩展、分离关注点或独立安排升级周期。每个容器都有自己的状态、Cycles 余额和升级路径。容器通过异步容器间调用进行通信。

前提条件

  • 对于 Motoko:mops 包管理器,在 mops.toml 中设置 core = "2.1.0"——下文用于发现同级容器 ID 的 Runtime.envVar 是在 motoko-core 2.1.0 中添加的
  • 对于 Rust:ic-cdk >= 0.19、candid、serde、ic-stable-structures

工作原理

调用方容器向被调用方容器发起调用:方法名称、参数(载荷)和附带的 Cycles 会打包到容器请求消息中;调用方在 await 处阻塞后,请求消息会送达被调用方;被调用方执行请求并生成响应;响应会打包到容器响应消息中并送达调用方;调用方从 await 唤醒后继续执行(执行容器响应消息)。例如,如果找不到被调用方,或者达到某项资源限制,系统可能会生成拒绝响应消息。

调用可以是无限等待(调用方必须等待被调用方生成响应),也可以是有限等待(调用超时后,或子网资源不足时,调用方可能会收到 SYS_UNKNOWN 响应,而不是实际响应)。请求送达采用尽力而为策略:系统可能会选择拒绝任何请求,而不进行送达。无限等待的响应(包括拒绝响应)保证送达:调用方始终会获知调用结果。有限等待的响应送达采用尽力而为策略:如果调用超时或耗尽了某些系统资源,无论请求是否已送达被调用方,调用方都可能收到系统生成的 SYS_UNKNOWN 拒绝响应(结果未知),而不是实际响应。

何时使用多容器

原因阈值
存储限制每个容器:最多可使用数百 GB 的稳定内存和 4GB 堆内存。如果数据可能超过堆内存限制,或能从分区中受益,请将存储拆分到多个容器中。
可扩展计算容器是单线程的 Actor。将负载分片到多个容器中(可能跨多个子网),可以显著提高吞吐量。
分离关注点将身份验证服务、内容服务和支付服务作为独立单元。
独立升级升级支付容器,而不影响用户容器。
访问控制为不同容器设置不同的控制器(例如,一个由 DAO 控制,另一个由团队控制)。

不应使用的情况: 数据量小于 1GB 的简单应用。单容器方案更简单、速度更快,也可避免容器间调用的开销。不要过度设计。

可能导致功能错误的问题

构建多容器应用时,请以资深软件工程师的视角,仔细阅读以下可能导致隐蔽功能错误的问题,并认真避免由此引发的错误。

  1. 请求和响应载荷限制为 2 MB。 由于任何容器调用都可能需要跨越子网边界,而且跨子网(或 XNet)消息(与每次容器调用对应的请求和响应)会被装入(打包到)4 MB 的区块中,因此容器请求和响应载荷限制为 2 MB。请求载荷超过 2 MB 的调用会同步失败;响应载荷超过 2 MB 则会触发 trap。请将较大的载荷拆分为 1 MB 的块(以便留出编码开销),并通过多次调用传输(例如,分块上传或字节范围查询)。

  2. 会发起调用的更新方法不会原子执行。 更新方法发起调用时,await 之前的代码属于一次原子消息执行(即调用该更新方法的入站消息或 canister 请求);await 之后的代码则属于另一次独立的原子消息执行(即对调用的响应)。特别是,如果更新方法在 await 之后发生 trap,那么 await 之前的所有更改都已持久化;而 await 之后的所有更改都会回滚。请围绕最终一致性进行设计,或使用 saga 模式。如需了解更多背景,可选读 ICP 上消息执行的属性。

  3. 使用幂等 API,或提供单独的端点来查询非幂等调用的结果。 如果对非幂等 API 的调用超时,调用方必须能够通过其他方式了解调用结果(例如,为原始调用附加一个唯一 ID,并使用该唯一 ID 查询调用结果)。如果无法了解结果,调用方收到 SYS_UNKNOWN 响应时,可能无法决定是继续、重试调用还是中止。

  4. 跨子网调用比同一子网内的调用更慢。 在子网负载较低时,对同一子网上 canister 的调用可能在一个轮次内完成,且其响应也可能在一个轮次内由调用方处理。调用延迟仅取决于调用方和被调用方的调度频率(每轮可能会调度多次)。跨 canister 调用无论往返都需要 2-3 轮(请求传递和响应传递),此外还有调度器延迟。

  5. 跨子网调用的带宽相对较低。 跨子网(或 XNet)消息每轮会与入站消息和其他 XNet 消息一起打包到 4 MB 的区块中并进行处理。预计传递多个 MB 的消息需要多个轮次,还要加上 XNet 延迟。(子网内的消息会在子网内路由,因此不会受到此带宽限制。)

  6. 防御性实践:在 Rust 中,于 .await 之前绑定 msg_caller()。 当前的 ic-cdk 执行器通过受保护任务在 .await 之间保留调用方信息,但提前捕获调用方信息可防范未来执行器变更带来的影响。Motoko 是安全的:public shared ({ caller }) func 会在函数入口处将 caller 捕获为不可变绑定。

    // 推荐(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 间调用可能失败(被调用方发生 trap、cycles 不足、canister 已停止)。在 Motoko 中使用 try/catch。在 Rust 中处理 ic_cdk::call 返回的 Result。未处理的拒绝会导致你的 canister 发生 trap。

  8. canister 工厂的 cycles 不足。 创建 canister 需要 cycles。管理 canister 会收取创建费用和初始 cycles 余额。如果附加的 cycles 不足,创建就会失败。

  9. 未在 Rust 中设置 #[init] 和 #[post_upgrade]。 如果没有 post_upgrade 处理程序,canister 升级可能会出现意外行为。务必同时定义这两者。

可能导致安全漏洞的问题

请以资深安全工程师的视角,仔细阅读以下可能导致安全风险的问题,并务必避免此类安全漏洞。

  1. 避免重入问题。 调用并非原子操作,因此可能引发重入漏洞,例如双重支出漏洞;另请参阅上文“执行调用的方法并非原子执行”。请采用锁定模式等方式避免此类问题。如需更多背景信息,可参阅安全最佳实践或论文。

  2. 安全处理回调中的陷阱。 回调中的陷阱(Rust 中的 panic)会导致回调不应用任何状态更改。例如,如果恶意实体能够触发陷阱,就可能跳过 DeFi 场景中的账户扣款等关键安全操作,从而导致双重支出等严重问题。为避免此类问题,请避免在可能导致此类漏洞的回调中触发陷阱,并考虑使用 call_on_cleanup 和“日志记录”。如需更多背景信息,可参阅此安全最佳实践。

  3. 无界等待调用可能无限期阻止 Canister 升级。 无界等待调用可能需要任意长的时间才能完成:恶意或有误的被调用方可能会无限期运行,却不返回响应。等待未完成调用的响应时,无法停止 Canister。有界等待调用则可确保调用在有限时间内完成,无论被调用方是否响应。如需更多背景信息,可参阅此安全最佳实践。

  4. Canister 间调用不会触发 canister_inspect_message。 它仅针对入口消息(来自外部用户)运行。请勿依赖它在 Canister 之间进行访问控制,而应使用显式的主体检查。如需更多背景信息,可参阅此安全最佳实践。

会导致构建失败的错误

  1. 试图用 dependencies 字段控制部署顺序。 icp.yaml 有一个 dependencies 字段,但它不会用于控制部署顺序——icp-cli 会并行构建 Canister,而 icp deploy 会将每个 Canister 的 ID 注入所有 Canister,作为 PUBLIC_CANISTER_ID:<canister-name> 环境变量,因此 Canister 无论部署顺序如何,都可以在运行时发现同级 Canister 的 ID。在 Motoko 中使用 Runtime.envVar<system>("PUBLIC_CANISTER_ID:user_service")(motoko-core >= 2.1.0),或在 Rust 中使用 ic_cdk::api::env_var_value("PUBLIC_CANISTER_ID:user_service") 读取。请参阅 icp-cli 技能中的 Canister Environment Variables 部分。

  2. 忘记为每个后端 Canister 生成类型声明。 使用特定语言的工具(例如,用于生成 Candid 绑定的 didc)为每个后端 Canister 单独生成声明。

  3. Canister 之间的共享类型不一致。 如果 Canister A 期望 { id: Nat; name: Text },而 Canister B 发送 { id: Nat; title: Text },调用会静默失败或触发陷阱。请使用由两个 Canister 导入的共享类型模块。

实现

项目结构
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
canisters:
  - name: user_service
    recipe:
      type: "@dfinity/[email protected]"
  - name: content_service
    recipe:
      type: "@dfinity/[email protected]"
mops.toml

@dfinity/motoko@v5+ 配方通过 mops build <canister-name> 进行编译,因此每个 canister 的 main 都定义在 mops.toml 中,而不是 icp.yaml 的 recipe.configuration 中。icp.yaml 中每个 canister 的 name 都必须与此处的 [canisters.<name>] 键完全匹配,否则构建会失败并显示 No Motoko canisters found in mops.toml configuration。

[toolchain]
moc = "1.9.0"

[dependencies]
core = "2.1.0"

[canisters.user_service]
main = "src/user_service/main.mo"

[canisters.content_service]
main = "src/content_service/main.mo"
Motoko
src/shared/Types.mo — 共享类型
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 — 用户 Canister
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 — 内容 Canister(调用用户服务)
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) };
    }
  };
};
生产就绪性:内容服务

上面的内容服务示例特意保持简单,以展示多 canister 通信模式。它们缺少生产环境所需的若干功能:

  • 输入验证。 register 中的 username 参数可接受任意字符串,包括空字符串或长度达到 2MB 消息大小上限的字符串。应验证长度(例如,1–64 个字符)、限制允许使用的字符集,并通过反向查找映射添加唯一性约束,以防止冒充他人。
  • getUsers 中的用户枚举和分页。 使用 getUsers,任何人都可以枚举平台上的所有用户,这可能并不理想。此外,getUsers 端点会在单个响应中返回所有用户资料。随着用户群体增长,这会达到 2MB 响应大小上限并触发 trap,导致端点无法使用。请添加分页(offset/limit 参数)。getPosts 也存在同样的问题。
Rust
项目结构(Rust)
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]
members = [
  "src/user_service",
  "src/content_service",
]
icp.yaml(Rust)
canisters:
  - name: user_service
    recipe:
      type: "@dfinity/[email protected]"
      configuration:
        candid: src/user_service/user_service.did
  - name: content_service
    recipe:
      type: "@dfinity/[email protected]"
      configuration:
        candid: src/content_service/content_service.did

此处省略了 package:recipe >= v3.3.0 时,默认将其设为 canister 的 name,而每个工作区成员的 [package] name 已与之匹配。仅当 crate 名称与 canister 名称不同时,才需要显式设置 package。

src/user_service/Cargo.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
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
[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
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 工厂模式

一种可动态创建其他 canister 的 canister。适用于按用户创建 canister、分片或动态扩展。

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 工厂
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))
    })
}
生产就绪性:Canister 工厂

上面的工厂示例特意保持简单,以展示 canister 创建模式。它们缺少生产环境所需的若干功能:

  • 防止 cycles 耗尽。 任何非匿名 principal 都可以反复调用 createChildCanister,每次调用都会消耗工厂 1T cycles。应添加授权调用者允许列表、限制每位用户可创建的 canister 数量,并在创建 canister 前检查工厂的 cycles 余额(例如,Motoko 中使用 mo:core/Cycles 的 Cycles.balance(),Rust 中使用 ic_cdk::api::canister_cycle_balance())。
  • WASM 模块验证。 WASM 模块由调用者提供,这意味着任何通过身份验证的用户都可以部署任意代码。生产环境中不要接受任意调用者提供的 WASM。应在工厂 canister 中硬编码已知的 WASM 模块(或其哈希),或者在安装前将模块哈希与允许列表进行比对。请将获准通过工厂部署的 principal 加入允许列表,以防止未经授权的使用。
  • 重入保护。 工厂连续执行两次 await(create_canister,然后是 install_code),期间没有锁定机制。同一调用者的并发调用可能创建孤立的 canister,导致工厂无法跟踪它们。请添加锁(例如,使用一个记录正在处理的 principal 的 Set),以防止同一调用者并发创建。
  • 部分失败处理。 如果 create_canister 成功而 install_code 失败,该 canister 仍然存在并持有 cycles,但工厂无法跟踪它。应在创建后立即记录 canister ID(在尝试 install_code 之前),以便工厂能够重试安装或在失败时清理。

多 Canister 系统的升级策略

升级顺序
  1. 先部署共享依赖项(例如,先部署 user_service,再部署 content_service)。
  2. 切勿以不兼容的方式更改 Candid 接口。新增字段应为 opt 类型。
  3. 在主网升级前,先在本地测试升级。
安全升级检查清单
  • 切勿删除或重命名 Canister 之间共享的现有类型中的字段。
  • 新增字段时应设为可选(Motoko 中使用 ?Type,Rust 中使用 Option<T>)。
  • 如果某个 Canister 的 Candid 接口发生变化,应在升级提供方之后升级使用方。
  • Rust Canister 始终应同时包含 #[init] 和 #[post_upgrade]。
  • 在 Motoko 中,persistent actor 会自动处理稳定存储。
升级命令
# Upgrade canisters in dependency order
icp deploy user_service

# Rust content_service requires the user_service principal on every upgrade (post_upgrade arg)
USER_SERVICE_ID=$(icp canister status user_service --id-only)
icp deploy content_service --argument "(principal \"$USER_SERVICE_ID\")"

npm run build
icp deploy frontend

部署与测试

本地开发
# Start the local replica
icp network start -d

# Deploy in dependency order
icp deploy user_service

# content_service (Rust) requires the user_service canister ID as an init argument
USER_SERVICE_ID=$(icp canister status user_service --id-only)
icp deploy content_service --argument "(principal \"$USER_SERVICE_ID\")"

# Build and deploy frontend
npm run build
icp deploy frontend
测试 Canister 间调用(Motoko)
# Register a user
PRINCIPAL=$(icp identity principal)
icp canister call user_service register "(\"alice\")"

# Verify user exists
icp canister call user_service isValidUser "(principal \"$PRINCIPAL\")"
# Expected: (true)

# Create a post (triggers inter-canister call to user_service)
icp canister call content_service createPost "(\"Hello World\", \"My first post\")"
# Expected: (variant { ok = record { id = 0; author = principal "..."; ... } })

# Get all posts
icp canister call content_service getPosts
# Expected: (vec { record { id = 0; ... } })
测试 Canister 间调用(Rust)

Rust Canister 使用 snake_case 函数名:

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

icp canister call user_service is_valid_user "(principal \"$PRINCIPAL\")"
# Expected: (true)

# content_service must have been deployed with --argument "(principal \"<user_service_id>\")"
icp canister call content_service create_post "(\"Hello World\", \"My first post\")"
# Expected: (variant { ok = record { id = 0 : nat64; author = principal "..."; ... } })

icp canister call content_service get_posts
# Expected: (vec { record { id = 0 : nat64; ... } })

验证是否正常运行

验证用户注册
icp canister call user_service register '("testuser")'
# Expected: (variant { ok = record { id = principal "..."; username = "testuser"; created = ... } })
验证 Canister 间调用
# This call should succeed (user is registered)
# Motoko: createPost / Rust: create_post
icp canister call content_service createPost '("Test Title", "Test Body")'
# Expected: (variant { ok = record { ... } })

# Remember the identity you registered with. There is no identity named
# `default` -- `icp identity default` names the *role*, and a fresh icp-cli
# install has only `anonymous`. (Notices go to stderr, so this captures cleanly.)
ORIGINAL_IDENTITY=$(icp identity default)

# Create a new identity that is NOT registered
icp identity new unregistered --storage plaintext
icp identity default unregistered
icp canister call content_service createPost '("Should Fail", "No user")'
# Expected: (variant { err = "User not registered" })

# Switch back
icp identity default "$ORIGINAL_IDENTITY"
验证跨 Canister 查询
PRINCIPAL=$(icp identity principal)
# Motoko: getPostsWithAuthor / Rust: get_posts_with_author
icp canister call content_service getPostsWithAuthor "(principal \"$PRINCIPAL\")"
# Expected: (opt record { id = ...; username = "testuser"; ... }, vec { record { ... } })
验证 Canister Factory
# Read the wasm file for the child canister
# (In practice you'd upload or reference a wasm blob)
icp canister call factory createChildCanister '(blob "...")'
# Expected: (principal "NEW-CANISTER-ID")

icp canister call factory getChildCanister "(principal \"$PRINCIPAL\")"
# Expected: (opt principal "NEW-CANISTER-ID")

评论

支持 Markdown