multi-canister
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseMulti-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: package manager,
mopsin mops.tomlcore = "2.0.0" - For Rust: ,
ic-cdk >= 0.19,candid,serdeic-stable-structures
- 对于Motoko:包管理器,mops.toml中配置
mopscore = "2.0.0" - 对于Rust:、
ic-cdk >= 0.19、candid、serdeic-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 ; 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 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.
awaitawaitCalls may be unbounded wait (caller MUST wait until the callee produces a response) or bounded wait (caller MAY get a 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 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.
SYS_UNKNOWNSYS_UNKNOWN调用方Canister向被调用方Canister发起调用:方法名、参数(负载)和附加的Cycle会被打包成Canister请求消息,在调用方执行阻塞后传递给被调用方;被调用方执行请求并生成响应;响应会被打包成Canister响应消息并传递给调用方;调用方从唤醒并继续执行(处理Canister响应消息)。如果出现被调用方不存在或达到资源限制等情况,系统可能会生成拒绝响应消息。
awaitawait调用分为无界等待(调用方必须等待被调用方生成响应)和有界等待(如果调用超时或子网资源不足,调用方可能会收到响应而非实际响应)。请求交付是尽力而为的:系统可能会决定拒绝任何请求而不进行交付。无界等待的响应(包括拒绝响应)交付是有保障的:调用方总会得知调用结果。有界等待的响应交付是尽力而为的:如果调用超时或系统资源耗尽,无论请求是否已传递给被调用方,调用方都可能收到系统生成的拒绝响应(结果未知)。
SYS_UNKNOWNSYS_UNKNOWNWhen to Use Multi-Canister
何时使用多Canister架构
| Reason | Threshold |
|---|---|
| Storage limits | Each 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 compute | Canisters are single-threaded actors. Sharding load across multiple canisters, potentially across multiple subnets, can significantly improve throughput. |
| Separation of concerns | Auth service, content service, payment service as independent units. |
| Independent upgrades | Upgrade the payments canister without touching the user canister. |
| Access control | Different 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.
-
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).
-
Update methods that make calls are NOT executed atomically. When an update method makes a call, the code before theis one atomic message execution (i.e. the ingress message or canister request that invoked the update method); and the code after the
awaitis a separate atomic message execution (the response to the call). In particular, if the update method traps after theawait, any mutations before theawaithave already been persisted; and any mutations after theawaitwill 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.await -
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 aresponse it may be unable to decide whether to continue, retry the call or abort.
SYS_UNKNOWN -
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.
-
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).
-
Defensive practice: bindbefore
msg_caller()in Rust. The current ic-cdk executor preserves caller across.awaitpoints via protected tasks, but capturing it early guards against future executor changes. Motoko is safe:.awaitcapturespublic shared ({ caller }) funcas an immutable binding at function entry.callerrust// 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 } -
Not handling rejected calls. Inter-canister calls can fail (callee trapped, out of cycles, canister stopped). In Motoko use. In Rust, handle the
try/catchfromResult. Unhandled rejections trap your canister.ic_cdk::call -
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.
-
Not setting upand
#[init]in Rust. Without a#[post_upgrade]handler, canister upgrades may behave unexpectedly. Always define both.post_upgrade
在构建多Canister应用时,请站在资深软件工程师的角度,仔细阅读以下可能导致细微功能Bug的问题,务必避免这些问题引发的Bug。
-
请求和响应负载限制为2MB。 因为任何Canister调用都可能需要跨子网边界;而跨子网(或XNet)消息(每个Canister调用对应的请求和响应)会被打包到4MB的块中;因此Canister请求和响应负载限制为2MB。请求负载超过2MB的调用会同步失败;响应负载超过2MB会触发陷阱。将较大的负载拆分为1MB的块(以预留编码开销),并通过多次调用传递(例如分块上传或字节范围查询)。
-
发起调用的更新方法并非原子执行。 当更新方法发起调用时,之前的代码是一个原子消息执行(即触发更新方法的入口消息或Canister请求);
await之后的代码是一个独立的原子消息执行(调用的响应)。特别是,如果更新方法在await之后触发陷阱,await之前的所有变更已经被持久化;而await之后的所有变更会被回滚。请设计最终一致性或使用Saga模式。如果需要更多相关内容,可参考ICP上的消息执行属性。await -
使用幂等API。或提供单独的端点查询非幂等调用的结果。 如果对非幂等API的调用超时,调用方必须有其他方式得知调用结果(例如,在原始调用中附加唯一ID,并通过该唯一ID查询调用结果)。如果无法得知结果,当调用方收到响应时,可能无法决定是继续、重试调用还是中止。
SYS_UNKNOWN -
跨子网边界的调用比同子网内的调用慢。 在轻负载子网下,调用同子网内的Canister可能在一个轮次内完成,其响应也可能在同一轮次内被调用方处理。调用延迟仅取决于调用方和被调用方的调度频率(可能每轮次调度多次)。跨Canister调用需要2-3个轮次(请求交付和响应交付),再加上调度延迟。
-
跨子网边界的调用带宽相对较低。 跨子网(或XNet)消息每轮次会被打包到4MB的块中,与入口消息和其他XNet消息一起处理。预计多MB的消息需要多个轮次才能交付,再加上XNet延迟。(子网内消息在子网内部路由,因此不受此带宽限制影响。)
-
防御性实践:在Rust中,在之前绑定
.await。 当前的ic-cdk执行器通过受保护的任务在msg_caller()点保留调用方信息,但提前捕获调用方信息可防范未来执行器的变更。Motoko是安全的:.await在函数入口处将public shared ({ caller }) func捕获为不可变绑定。callerrust// 推荐写法(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间调用可能失败(被调用方触发陷阱、Cycle不足、Canister已停止)。在Motoko中使用。在Rust中,处理
try/catch返回的ic_cdk::call。未处理的拒绝会导致你的Canister触发陷阱。Result -
Canister工厂Cycle不足。 创建Canister需要Cycle。管理Canister会收取创建费用和初始Cycle余额费用。如果附加的Cycle不足,创建会失败。
-
在Rust中未设置和
#[init]。 如果没有#[post_upgrade]处理程序,Canister升级可能会出现意外行为。请始终同时定义这两个处理程序。post_upgrade
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.
-
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.
-
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, and use "journaling". If more context is needed, optionally consider this security best practice.
call_on_cleanup -
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.
-
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.
canister_inspect_message
请站在资深安全工程师的角度,仔细阅读以下可能引发严重安全问题的问题,务必避免此类安全Bug。
-
安全处理回调中的陷阱。 回调中的陷阱(Rust中的panic)会导致回调不应用任何状态变更。例如,如果恶意实体可触发陷阱,在DeFi场景中,这可能意味着借记账户等安全关键操作会被跳过,从而导致双花等严重问题。为避免此类情况,请避免在回调中触发可能导致此类Bug的陷阱,考虑使用,并采用“日志记录”机制。如果需要更多内容,可参考此安全最佳实践。
call_on_cleanup -
无界等待调用可能无限期阻止Canister升级。 无界等待调用可能需要任意长的时间才能完成:恶意或有问题的被调用方可能会无限循环而不生成响应。Canister在等待未完成调用的响应时无法停止。有界等待调用可避免此问题,因为无论被调用方是否响应,调用都会在限定时间内完成。如果需要更多内容,可参考此安全最佳实践。
-
不会针对Canister间调用执行。 它仅针对入口消息(来自外部用户)运行。不要依赖它进行Canister之间的访问控制,请改用显式的主体检查。如果需要更多内容,可参考此安全最佳实践。
canister_inspect_message
Mistakes That Break Your Build
导致构建失败的错误
-
Deploying canisters in the wrong order. Canisters with dependencies must be deployed according to their dependencies. Declarein icp.yaml so
dependenciesorders them correctly.icp deploy -
Forgetting to generate type declarations for each backend canister. Use language-specific tooling (e.g.,for Candid bindings) to generate declarations for each backend canister individually.
didc -
Shared types diverging between canisters. If canister A expectsand canister B sends
{ id: Nat; name: Text }, the call silently fails or traps. Use a shared types module imported by both canisters.{ id: Nat; title: Text }
-
按错误顺序部署Canister。 存在依赖关系的Canister必须按照依赖顺序部署。在icp.yaml中声明,以便
dependencies能正确排序部署。icp deploy -
忘记为每个后端Canister生成类型声明。 使用语言特定的工具(例如,用于Candid绑定)为每个后端Canister单独生成声明。
didc -
Canister之间的共享类型不一致。 如果Canister A期望而Canister B发送
{ id: Nat; name: Text },调用会静默失败或触发陷阱。请使用两个Canister都导入的共享类型模块。{ id: Nat; title: Text }
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 assetsmy-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 assetsicp.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.moyaml
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.moMotoko
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 parameter in
usernameaccepts 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.register - User enumeration and pagination on . Using
getUsers, it's possible for everyone to enumerate all users on the platform, which may not be desirable. Furthermore, thegetUsersendpoint 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 togetUsers.getPosts
上述内容服务示例为了演示多Canister通信模式而刻意保持简单。它们缺少一些生产环境所需的功能:
- 输入验证。 中的
register参数接受任意字符串——包括空字符串或大小达到2MB消息限制的字符串。请验证长度(例如1–64个字符)、强制使用允许的字符集,并通过反向查找映射添加唯一性约束以防止冒充。username - 的用户枚举和分页。 使用
getUsers,任何人都可以枚举平台上的所有用户,这可能不符合需求。此外,getUsers端点在单个响应中返回所有用户配置文件。随着用户基数增长,这会达到2MB响应大小限制并触发陷阱,导致端点失效。请添加分页(偏移/限制参数)。getUsers也存在同样的问题。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.rsmy-project/
icp.yaml
Cargo.toml # workspace
src/
user_service/
Cargo.toml
src/lib.rs
content_service/
Cargo.toml
src/lib.rsCargo.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.didyaml
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.didsrc/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 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.,
createChildCanisterin Motoko,ExperimentalCycles.balance()in Rust).ic_cdk::api::canister_balance128() - 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 (, then
create_canister) with no locking. Concurrent calls from the same caller can create orphaned canisters that the factory loses track of. Add a lock (e.g., ainstall_codeof principals with in-flight calls) that prevents concurrent creation for the same caller.Set - Partial failure handling. If succeeds but
create_canisterfails, the canister exists and has cycles but is untracked by the factory. Track the canister ID immediately after creation (before attemptinginstall_code) so the factory can retry installation or clean up on failure.install_code
上述工厂示例为了演示Canister创建模式而刻意保持简单。它们缺少一些生产环境所需的功能:
- Cycle消耗防护。 任何非匿名主体都可以重复调用,每次调用都会消耗工厂的1T Cycle。请添加授权调用方的白名单、强制每个用户的创建限制,并在创建Canister前检查工厂的Cycle余额(例如,Motoko中的
createChildCanister,Rust中的ExperimentalCycles.balance())。ic_cdk::api::canister_balance128() - WASM模块验证。 WASM模块由调用方提供,这意味着任何已认证用户都可以部署任意代码。在生产环境中不要接受来自任意调用方的WASM。相反,在工厂Canister中硬编码已知的WASM模块(或其哈希),或者在安装前验证模块哈希是否在白名单中。允许通过工厂部署的主体添加白名单,以避免未授权使用。
- 重入防护。 工厂执行两个顺序的await操作(然后
create_canister),没有锁定机制。同一调用方的并发调用可能会创建工厂无法跟踪的孤立Canister。请添加锁(例如,记录正在进行调用的主体的install_code),防止同一调用方的并发创建。Set - 部分失败处理。 如果成功但
create_canister失败,Canister会存在且有Cycle,但工厂无法跟踪它。请在创建后立即(尝试install_code之前)跟踪Canister ID,以便工厂在失败时可以重试安装或清理。install_code
Upgrade Strategy for Multi-Canister Systems
多Canister系统的升级策略
Ordering
升级顺序
- Deploy shared dependencies first (e.g., before
user_service).content_service - Never change Candid interfaces in a breaking way. Add new fields as types.
opt - Test upgrades locally before mainnet.
- 先部署共享依赖(例如,先部署再部署
user_service)。content_service - 永远不要以破坏性方式更改Candid接口。将新字段添加为类型。
opt - 在主网部署前先在本地测试升级。
Safe Upgrade Checklist
安全升级检查清单
- Never remove or rename fields in existing types shared across canisters.
- Add new fields as optional (in Motoko,
?Typein Rust).Option<T> - If a canister's Candid interface changes, upgrade consumers after the provider.
- Always have both and
#[init]in Rust canisters.#[post_upgrade] - In Motoko, handles stable storage automatically.
persistent actor
- 永远不要删除或重命名跨Canister共享的现有类型中的字段。
- 将新字段添加为可选类型(Motoko中的,Rust中的
?Type)。Option<T> - 如果Canister的Candid接口发生变化,先升级提供方再升级消费方。
- Rust Canister中始终同时拥有和
#[init]。#[post_upgrade] - 在Motoko中,会自动处理稳定存储。
persistent actor
Upgrade Commands
升级命令
bash
undefinedbash
undefinedUpgrade 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
undefinedUSER_SERVICE_ID=$(icp canister id user_service)
icp deploy content_service --argument "(principal "$USER_SERVICE_ID")"
npm run build
icp deploy frontend
undefinedDeploy & Test
部署与测试
Local Development
本地开发
bash
undefinedbash
undefinedStart 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
undefinednpm run build
icp deploy frontend
undefinedTest Inter-Canister Calls (Motoko)
测试Canister间调用(Motoko)
bash
undefinedbash
undefinedRegister 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; ... } })
undefinedundefinedTest 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; ... } })
undefinedundefinedVerify 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 = ... } })
undefinedundefinedVerify Inter-Canister Call
验证Canister间调用
bash
undefinedbash
undefinedThis 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
undefinedicp identity use default
undefinedVerify 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 { ... } })
undefinedundefinedVerify Canister Factory
验证Canister工厂
bash
undefinedbash
undefinedRead 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")
undefinedundefined