跳到主要内容

快速开始

在这个教程中,我们将实现一个简单的计数器,来展示 Move 是如何通过代码来管理资源的。 这篇文档涉及的内容包括背景知识、写代码、如何编译、如何发布到链上、如何调用。 完整的代码仓库在这里

提前准备:

  1. 需要按照如何设置本地开发网络搭建 dev 网络,并通过 Starcoin 控制台连接到 dev 网络。
  2. 按照账号管理创建一个账号或者使用已有账号,并且给账号里转一点 STC。
  3. 通过第一笔链上交易交易有基本的理解。

接下来将介绍一些必备工具和项目结构。

mpm 命令行工具以及项目结构

在开始写代码之前,我们先安装 mpm(Move Package Manager)命令行工具,并且简单介绍一下 Move 项目的结构。

mpm 简介

mpm 是由 Starcoin 开发者在 move-cli 的基础上封装并集成了一些额外的功能,方便开发、测试、部署 Starcoin 智能合约。

安装方法见设置 Move 开发环境.

现在,可以通过 mpm 创建一个新项目:

$ mpm package new my-counter

可以看到生成的目录结构:

my-counter
├── Move.toml
└── sources
  • sources - 存放 Move 模块(module)的目录。
  • Move.toml - 清单(manifest)文件:定义包的元数据、依赖以及命名地址。
Move 模块

模块是定义结构体类型和操作结构体的函数的库。(可以借助其他编程语言“类(class)”的概念来帮助理解)

模块会被发布到发布者的地址下,模块中的入口方法(entry functions)可以被大家调用执行。 (可以借助函数计算平台 —— 如 AWS Lambda —— 上发布函数、调用函数来帮助理解)

Move.toml 文件和 sources 目录组成的项目,会被认为是一个 Move 包(Move Package)

创建 MyCounter 模块

sources 目录下创建一个 MyCounter.move 文件用来存放模块的代码。

我们将要创建的模块命名为 MyCounter,在本文中,使用笔者本地 dev 网络的一个账户地址 0xcada49d6a37864931afb639203501695 来演示,我们会将 MyCounter 模块发布到这个地址上。

定义模块的语法:

module <address>::<identifier> {
// module body
}

第一版代码

MyCounter 模块中,我们定义一个结构体 Counter,包含有一个字段 value,代表这个计数器触发的次数。 value 的类型是 u64,也就是无符号64位整型。

下面是 MyCounter 模块的第一版代码:

my-counter/sources/MyCounter.move
module 0xcada49d6a37864931afb639203501695::MyCounter {
struct Counter {
value: u64,
}
}

我们可以使用 mpm package build 这条命令来构建我们的计数器程序。

$ mpm package build

BUILDING my-counter

可以看到,程序顺利通过了编译,接着往下走。

由于这个地址太长,可以在 Move.toml 文件中设置一个命名地址(named address),它可以做到在 Move 项目中全局替换。

my-counter/Move.toml
[package]
name = "my-counter"
version = "0.0.1"

[addresses]
MyCounterAddr = "0xcada49d6a37864931afb639203501695"
...
提示

高亮的代码块是有变化的或是需要注意的部分。

这样,第一版代码可以写为:

my-counter/sources/MyCounter.move
module MyCounterAddr::MyCounter {
struct Counter {
value: u64,
}
}

MyCounterAddr 是命名地址,MyCounter 是模块标识符。

接着我们编译代码:

$ mpm package build

BUILDING my-counter

没有报错,说明没有问题。

初始化方法 init

接着我们定一个初始化方法,用来创建一个 Counter 实例,并“移动(move)”到调用者账户的存储空间下。

my-counter/sources/MyCounter.move
module MyCounterAddr::MyCounter {
struct Counter {
value: u64,
}

public fun init(account: &signer) {
move_to(account, Counter { value: 0 });
}
}

这里的 move_to<T>(&signer, T) 是一个内置方法,作用是将类型为 T 的资源添加到账户 signer 的地址下的存储空间(尖括号在这里表示泛型)。

更多信息

这里的存储空间是 GlobalState,可以先简单理解为存放账户的资源模块代码的地方。 更多详细信息可以查阅概念-状态

在第6行的 init 函数的参数 account: &signer 中的 signer 是 Move 的内置类型。 想要将资源放到调用者的账户地址下,需要将 &signer 参数传递给函数。 &signer 数据类型代表当前交易的发起者(函数的调用者,可以是任何账户)。

帮你理解 —— signer

signer 就像是 Linux 下的 uid 一样的东西。登陆 Linux 系统后,你输入的所有命令,都被认为是“这个已登陆的经过认证的用户”操作的。 关键来了,这个认证过程不是在运行的命令、程序中做的,而是开机后由操作系统完成的。

对应到 Move 中,这个认证过程就是和其他区块链系统类似的、我们熟知的“私钥签名,公钥验证”的过程。 在带有 &signer 参数的函数执行时,发起者的身份已经被 Starcoin 区块链认证过了。

我们试着编译一下:

$ mpm package build

BUILDING my-counter
error[E05001]: ability constraint not satisfied
┌─ ./sources/MyCounter.move:7:6

2 │ struct Counter {
│ ------- To satisfy the constraint, the 'key' ability would need to be added here
·
7 │ move_to(account, Counter { value: 0 });
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
│ │ │
│ │ The type 'MyCounterAddr::MyCounter::Counter' does not have the ability 'key'
│ Invalid call of 'move_to'

出错了!提示我们 “ability constraint not satisfied”,下面还有一句 “The type 'MyCounterAddr::MyCounter::Counter' does not have the ability 'key'”。 编译器告诉我们 MyCounterAddr::MyCounter::Counter 这个资源类型缺少 key 能力(ability),所以不能用 move_to 添加到账户地址下。

这里涉及到了 Move 的能力特性。

概念 —— ability

Move语言是面向资源的语言,核心是资源的管理。 针对资源拥有什么“能力”,Move 编程语言抽象了资源的四个属性 —— 可复制(copy)、可索引(key)、可丢弃(drop)、可储存(store)。 通过这四个属性的不同组合,用户可以方便的定义出任何能力的资源。 比如用户可以通过 key + copy + drop + store 的组合定义出一个普通的信息类型,通过 key + store 的组合定义出一个资产类型 —— 例如 NFT —— 没有 copy 属性可以保证 NFT 不能被随意的复制,提升了安全性。

Move 提供的四种能力:

  • copy:表示该值是否可以被复制
  • drop:表示该值是否可以在作用域结束时可以被丢弃
  • key:表示该值是否可以作为全局状态的键进行访问
  • store:表示该值是否可以被存储到全局状态

通过给资源赋予不同的能力,Move 虚拟机可以从根本上保证「资源」只能转移(move),至于能否拷贝、修改、丢弃,需要看资源的具体能力。 如果强行拷贝、修改或者丢弃,代码编译会出错,根本没有机会运行。

更多信息可以参考:认识 Ability 章节。

一般来说我们认为,key 能力的结构体,就是资源

我们修改代码,按照提示添加 key 能力。

my-counter/sources/MyCounter.move
module MyCounterAddr::MyCounter {
struct Counter has key {
value: u64,
}

public fun init(account: &signer) {
move_to(account, Counter { value: 0 });
}
}

此时再次编译可以通过。

计数器加一的方法 incr

现在给 Counter 资源添加一个 incr 方法。

my-counter/sources/MyCounter.move
module MyCounterAddr::MyCounter {

use StarcoinFramework::Signer;

struct Counter has key {
value: u64,
}

public fun init(account: &signer) {
move_to(account, Counter { value: 0 });
}

public fun incr(account: &signer) {
let counter = borrow_global_mut<Counter>(Signer::address_of(account));
counter.value = counter.value + 1
}
}

注意第3行我们引用了一个依赖 —— StarcoinFramwork 可以认为是 Starcoin 的 Stdlib 标准库。 我们需要使用库中的 Signer::address_of(&signer) 方法来提取 signer 的地址。

为了添加依赖到项目中,修改 Move.toml 文件

my-counter/Move.toml
[package]
name = "my-counter"
version = "0.0.1"

[addresses]
StarcoinFramework = "0x1"
MyCounterAddr = "0xcada49d6a37864931afb639203501695"

[dependencies]
StarcoinFramework = {git = "https://github.com/starcoinorg/starcoin-framework.git", rev="cf1deda180af40a8b3e26c0c7b548c4c290cd7e7"}

第16行有个新方法 borrow_global_mut,和前文的 move_to 一样,都是操作账户地址的存储空间上资源的内置方法。

加油站 —— 资源的操作方法
  1. move_to<T>(&signer, T):发布、添加类型为 T 的资源到 signer 的地址下。
  2. move_from<T>(address): T:从地址下删除类型为 T 的资源并返回这个资源。
  3. borrow_global<T>(address): &T:返回地址下类型为 T 的资源的不可变引用(immutable reference)
  4. borrow_global_mut<T>(address): &mut T:返回地址下类型为 T 的资源的可变引用(mutable reference)
  5. exists<T>(address): bool:判断地址下是否有类型为 T 的资源。

要使用这些方法,资源 T 必须定义在当前模块。 这确保了资源只能被定义资源的模块所提供的 API 方法来操作。 参数 addresssigner 代表了类型为 T 的资源存储的地址。

然后我们试着编译一下:

$ mpm package build
CACHED StarcoinFramework
BUILDING my-counter
error[E04020]: missing acquires annotation
┌─ ./sources/MyCounter.move:14:20

14 │ let counter = borrow_global_mut<Counter>(Signer::address_of(account));
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
│ │ │
│ │ The call acquires 'MyCounterAddr::MyCounter::Counter', but the 'acquires' list for the current function does not contain this type. It must be present in the calling context's acquires list
│ Invalid call to borrow_global_mut.

哦!又出错了。 报错信息提示了我们第14行调用方法获取 Counter 结构时,类型(Counter 结构)必须出现在调用上下文的 acquires 列表中,而当前函数的 acquires 列表没有包含这个类型。

这里我们引入 acquire 的概念。

概念

当一个函数用 move_from()borrow_global()borrow_global_mut() 访问资源时,函数必须要显示声明需要“获取”哪种资源。 这会被 Move 的类型系统确保对资源的引用是安全的、不存在悬空引用。

修改后的代码如下:

my-counter/sources/MyCounter.move
module MyCounterAddr::MyCounter {

use StarcoinFramework::Signer;

struct Counter has key {
value: u64,
}

public fun init(account: &signer) {
move_to(account, Counter { value: 0 });
}

public fun incr(account: &signer) acquires Counter {
let counter = borrow_global_mut<Counter>(Signer::address_of(account));
counter.value = counter.value + 1
}
}

现在可以编译通过了。

下面我们编写可以通过控制台直接调用执行的函数。

编写可调用的 script function

前面编写的 public fun initpublic fun incr 函数是不能直接在控制台中调用执行的。需要使用入口方法(entry function)来调用。

目前在 Move 中,入口方法是通过 script function 来实现的,写作 public(script) fun

这里引入了函数可见性(visibility)的概念,不同的可见性决定了函数可以从何处被调用。(下面的概念 tip 可以先跳过)

概念——函数可见性
可见性写做说明
internalfun也可以叫 private,只能在同一个模块内调用
publicpublic fun可以被任一模块内的函数调用
public scriptpublic(script) funscript function 是模块中的入口方法,可以通过控制台发起一个交易来调用,就像本地执行脚本一样(不过代码已经被存在了链上的模块地址下)。
public friendpublic(friend) fun可以被同一模块内调用,可以被加入到 friend list 的可信任模块调用

下面,我们编写对应 initincr 函数的 script function

my-counter/sources/MyCounter.move
module MyCounterAddr::MyCounter {

use StarcoinFramework::Signer;

struct Counter has key, store {
value: u64,
}

public fun init(account: &signer) {
move_to(account, Counter { value: 0 });
}

public fun incr(account: &signer) acquires Counter {
let counter = borrow_global_mut<Counter>(Signer::address_of(account));
counter.value = counter.value + 1
}

public(script) fun init_counter(account: signer) {
Self::init(&account);
}

public(script) fun incr_counter(account: signer) acquires Counter {
Self::incr(&account);
}
}

唯一需要说明的就是 Self 指代当前模块。

现在,我们将模块发布到链上,并尝试调用。

发布到链上并调用

发布到链上

运行 mpm release 命令:

$ mpm release

Packaging Modules:
0xcada49d6a37864931afb639203501695::MyCounter
Release done: release/my-counter.v0.0.1.blob, package hash: 0x31b36a1cd0fd13e84034a02e9972f68f1c9b1cde1c9dfbe7ac69f32f6fc6dafa

它将打包编译模块,获得二进制包。

前文中我们准备了地址为 0xcada49d6a37864931afb639203501695 的账户,如果没有余额,可以通过 dev get-coin 命令获取一些测试币。 现在将编译好的模块部署到这个账户地址下。

starcoin控制台
starcoin% account unlock 0xcada49d6a37864931afb639203501695 -p <MY-PASSWORD>

starcoin% dev deploy /path/to/my-counter/release/my-counter.v0.0.1.blob -s 0xcada49d6a37864931afb639203501695 -b

txn 0xf60662ba0ac3373c28f827a0ac9d9db6667c3921056905356aa5414b3bf3df09 submitted.
{
"ok": {
"dry_run_output": {
"events": [],
"explained_status": "Executed",
"gas_used": "7800",
"status": "Executed",
"write_set": [
{
"access_path": "0x00000000000000000000000000000001/1/0x00000000000000000000000000000001::TransactionFee::TransactionFee<0x00000000000000000000000000000001::STC::STC>",
"action": "Value",
"value": {
"Resource": {
"json": {
"fee": {
"value": 292331
}
},
"raw": "0xeb750400000000000000000000000000"
}
}
},
...

-s--sender 是发送者,-b--blocking,阻塞等待命令执行完成。

第5行的 txn 0xf60662... submitted 表示计数器的智能合约已经成功部署到发布者的地址下,这属于一个链上交易,链已经把这个交易状态记录下来了。

此时我们可以查看代码在链上的存储,

starcoin控制台
starcoin% state list code 0xcada49d6a37864931afb639203501695

{
"ok": {
"codes": {
"MyCounter": {
"abi": {
"module_name": {
"address": "0xcada49d6a37864931afb639203501695",
"name": "MyCounter"
},
...
}

可以看到 0xcada49d6a37864931afb639203501695 地址下只有 MyCounter 这一个合约代码。

state 命令

state 命令是用来查看账户地址下的数据的。可以在控制台中输入 state --help 查看更多帮助。

调用 init_counter 初始化资源

使用 account execute-function 命令来执行一个 script function。现在我们调用 init_counter 方法,将 Counter 资源初始化到调用者的地址下。

starcoin控制台
starcoin% account execute-function --function 0xcada49d6a37864931afb639203501695::MyCounter::init_counter -s 0xcada49d6a37864931afb639203501695 -b

txn 0x032c0eda779157e0ef3949338c3b3e4e6528c7720776d02c2cb0ddd64804f1c2 submitted.
{
"ok": {
"dry_run_output": {
"events": [],
"explained_status": "Executed",
"gas_used": "11667",
"status": "Executed",
"write_set": [
...
}

init_counter 函数中,我们初始化了一个 Counter 对象(资源),然后 move_to 到了调用者地址下。 让我们看看这个资源是否存在。使用 state list resource <ADDRESS> 命令查看给定地址下的资源列表。

starcoin控制台
starcoin% state list resource 0xcada49d6a37864931afb639203501695

{
...(输出很多,我们观察最后一部分)
"0xcada49d6a37864931afb639203501695::MyCounter::Counter": {
"json": {
"value": 0
},
"raw": "0x0000000000000000"
}
}

可以看到地址 0xcada49d6a37864931afb639203501695 下有了 0xcada49d6a37864931afb639203501695::MyCounter::Counter 这个类型的资源,内容是 "value": 0

可能有小伙伴会疑惑为什么 Counter 资源类型名要写这么长,下面先帮大家回忆一下 FQN 的概念。

概念——完整名称 FQN

Fully Qualified Name(FQN) 是一种计算机术语,是在一个调用上下文中,对一个资源(对象、函数、域名、文件)名称的无歧义定义。举例来说:

  1. Linux 的绝对路径名 /path/to/file 就是 fully qualified file name,相对的 ./to/file 是一个相对路径地址。
  2. 域名系统中,google.com. 是一个 fully qualified domain name,注意最后的 .。意味着这个域名不要继续被递归解析。

那么对应到 Move 语言中,资源类型是发布到某个地址下的,属于这个地址。 地址 0x001 可以创建一个 Counter 类型的资源,地址 0x002 也可以创建一个 Counter 类型的资源,要区分两个 Counter,就需要带上地址和模块名。

<address>::<module_identifier>::<structure>

调用 incr_counter 递增计数器

下面调用另一个函数 incr_counter 尝试对计数器加一。

starcoin控制台
starcoin% account execute-function --function 0xcada49d6a37864931afb639203501695::MyCounter::incr_counter -s 0xcada49d6a37864931afb639203501695 -b

txn 0x032c0eda779157e0ef3949338c3b3e4e6528c7720776d02c2cb0ddd64804f1c2 submitted.
...

再次查看资源,有了前面 FQN 的概念,这次我们换一个命令,用 state get resource <ADDRESS> [RESOURCE_TYPE] 查看 ADDRESS 下特定的资源类型。

starcoin控制台
starcoin% state get resource 0xcada49d6a37864931afb639203501695 0xcada49d6a37864931afb639203501695::MyCounter::Counter

{
"ok": {
"json": {
"value": 1
},
"raw": "0x0100000000000000"
}
}

可以看到计数器的值 value 变为了 1

另一个账号调用

前面的例子中,我们用了同一个地址 0xcada49d6a37864931afb639203501695 来发布模块、创建 Counter 资源类型(dev deploy),以及调用函数添加计数器(account execute-function)。

我们再换一个账号来初始化计数器和自增计数器。假设本地的一个账号为 0x012ABC

starcoin控制台
starcoin% account execute-function -s 0x012ABC  --function 0xb19b07b76f00a8df445368a91c0547cc::MyCounter::init_counter -b

starcoin% state get resource 0x012ABC 0xcada49d6a37864931afb639203501695::MyCounter::Counter

读者可以自行观察 0x012ABC 下资源的变化。

历史 —— script 和 script function

为了防止大家在别处看的教程中有 script 出现而搞迷惑,这里简单说一下历史由来。这部分内容可以跳过

我们用 Pythonpip 或者 Node.jsnpm 来辅助理解。

pipnpm 这样的中心化包管理托管平台出现之前,我们想安装一个包,需要 setup.py install /path/to/package。 这样子当然不便于包的分发传播与索引。 后来有了 pip 我们是怎么做的呢,包作者先将自己的包打包上传到 pip 仓库,pip 会存储包并建立索引。 普通用户只需要 pip install package_name 即可。 pip 工具会根据你提供的 package_name 下载源码,然后执行安装。这两种安装包的方式其实是一样的。

现在对应到 Move 中。在 script function 出现之前是只有 script 的,script 写在与 sources 目录平级的 scripts 目录下。

script 就像是本地的 Python 包,script 可以被编译为字节码,要调用 script 时,需要创建一个交易,payload 中带上编译好的字节码,script 就可以被节点上的 Move 虚拟机执行了。对应在 Starcoin 控制台中是:

starcoin% account execute-script </path/to/mv_file>

script function 作为 script 的替代,被添加到了 Move 语言中。 类比于保存在 pip 仓库中的软件包。script function 会在模块中一起发布到一个地址下(就像包作者把软件包发布在 pip 中一样)。 此时,要调用 script,需要创建一个交易,payload 中指向已经发布的代码的地址即可。对应到 Starcoin 控制台中是:

starcoin% account execute-function --function  <0x地址>::<模块>::<函数> --arg xxx

当然,Move 也是一门正在演进的语言,public(script) fun 正在被 public entry 取代,让我们拭目以待。

总结一下:

  1. script 可能会被废弃,推荐用 script function 做入口方法。
  2. 下个版本的 Move 会用 public entry fun 替代 public(script) fun

何去何从

恭喜你,你已经完成了一个简单合约的编写、部署和调用的全流程。

完整的代码仓库在这里

接下来,

或者,你可以直接进入 Dapp 的世界,