关于 multi-canister
面向 ICP 开发者的多 Canister 架构技能,说明如何拆分应用以扩展存储与计算、分离职责并独立升级。重点梳理跨 Canister 调用的可靠性、性能、构建和安全风险,并提供 Motoko 与 Rust 实现指引。
主要功能
- 01多 Canister 拆分与部署配置
- 02异步跨 Canister 调用模式
- 03调用限制与故障处理要点
- 04并发与安全风险防范
- 05Motoko 和 Rust 示例
适用场景
- 01按服务职责拆分 ICP 应用
- 02分片处理存储或计算负载
- 03独立升级不同 Canister
- 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 的简单应用。单容器方案更简单、速度更快,也可避免容器间调用的开销。不要过度设计。
可能导致功能错误的问题
构建多容器应用时,请以资深软件工程师的视角,仔细阅读以下可能导致隐蔽功能错误的问题,并认真避免由此引发的错误。
-
请求和响应载荷限制为 2 MB。 由于任何容器调用都可能需要跨越子网边界,而且跨子网(或 XNet)消息(与每次容器调用对应的请求和响应)会被装入(打包到)4 MB 的区块中,因此容器请求和响应载荷限制为 2 MB。请求载荷超过 2 MB 的调用会同步失败;响应载荷超过 2 MB 则会触发 trap。请将较大的载荷拆分为 1 MB 的块(以便留出编码开销),并通过多次调用传输(例如,分块上传或字节范围查询)。
-
会发起调用的更新方法不会原子执行。 更新方法发起调用时,
await之前的代码属于一次原子消息执行(即调用该更新方法的入站消息或 canister 请求);await之后的代码则属于另一次独立的原子消息执行(即对调用的响应)。特别是,如果更新方法在await之后发生 trap,那么await之前的所有更改都已持久化;而await之后的所有更改都会回滚。请围绕最终一致性进行设计,或使用 saga 模式。如需了解更多背景,可选读 ICP 上消息执行的属性。 -
使用幂等 API,或提供单独的端点来查询非幂等调用的结果。 如果对非幂等 API 的调用超时,调用方必须能够通过其他方式了解调用结果(例如,为原始调用附加一个唯一 ID,并使用该唯一 ID 查询调用结果)。如果无法了解结果,调用方收到
SYS_UNKNOWN响应时,可能无法决定是继续、重试调用还是中止。 -
跨子网调用比同一子网内的调用更慢。 在子网负载较低时,对同一子网上 canister 的调用可能在一个轮次内完成,且其响应也可能在一个轮次内由调用方处理。调用延迟仅取决于调用方和被调用方的调度频率(每轮可能会调度多次)。跨 canister 调用无论往返都需要 2-3 轮(请求传递和响应传递),此外还有调度器延迟。
-
跨子网调用的带宽相对较低。 跨子网(或 XNet)消息每轮会与入站消息和其他 XNet 消息一起打包到 4 MB 的区块中并进行处理。预计传递多个 MB 的消息需要多个轮次,还要加上 XNet 延迟。(子网内的消息会在子网内路由,因此不会受到此带宽限制。)
-
防御性实践:在 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; // 安全 } -
未处理被拒绝的调用。 canister 间调用可能失败(被调用方发生 trap、cycles 不足、canister 已停止)。在 Motoko 中使用
try/catch。在 Rust 中处理ic_cdk::call返回的Result。未处理的拒绝会导致你的 canister 发生 trap。 -
canister 工厂的 cycles 不足。 创建 canister 需要 cycles。管理 canister 会收取创建费用和初始 cycles 余额。如果附加的 cycles 不足,创建就会失败。
-
未在 Rust 中设置
#[init]和#[post_upgrade]。 如果没有post_upgrade处理程序,canister 升级可能会出现意外行为。务必同时定义这两者。
可能导致安全漏洞的问题
请以资深安全工程师的视角,仔细阅读以下可能导致安全风险的问题,并务必避免此类安全漏洞。
-
避免重入问题。 调用并非原子操作,因此可能引发重入漏洞,例如双重支出漏洞;另请参阅上文“执行调用的方法并非原子执行”。请采用锁定模式等方式避免此类问题。如需更多背景信息,可参阅安全最佳实践或论文。
-
安全处理回调中的陷阱。 回调中的陷阱(Rust 中的 panic)会导致回调不应用任何状态更改。例如,如果恶意实体能够触发陷阱,就可能跳过 DeFi 场景中的账户扣款等关键安全操作,从而导致双重支出等严重问题。为避免此类问题,请避免在可能导致此类漏洞的回调中触发陷阱,并考虑使用
call_on_cleanup和“日志记录”。如需更多背景信息,可参阅此安全最佳实践。 -
无界等待调用可能无限期阻止 Canister 升级。 无界等待调用可能需要任意长的时间才能完成:恶意或有误的被调用方可能会无限期运行,却不返回响应。等待未完成调用的响应时,无法停止 Canister。有界等待调用则可确保调用在有限时间内完成,无论被调用方是否响应。如需更多背景信息,可参阅此安全最佳实践。
-
Canister 间调用不会触发
canister_inspect_message。 它仅针对入口消息(来自外部用户)运行。请勿依赖它在 Canister 之间进行访问控制,而应使用显式的主体检查。如需更多背景信息,可参阅此安全最佳实践。
会导致构建失败的错误
-
试图用
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 部分。 -
忘记为每个后端 Canister 生成类型声明。 使用特定语言的工具(例如,用于生成 Candid 绑定的
didc)为每个后端 Canister 单独生成声明。 -
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 系统的升级策略
升级顺序
- 先部署共享依赖项(例如,先部署
user_service,再部署content_service)。 - 切勿以不兼容的方式更改 Candid 接口。新增字段应为
opt类型。 - 在主网升级前,先在本地测试升级。
安全升级检查清单
- 切勿删除或重命名 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")