1.3. DApp 简介
通过上一节的操作,你已经初步掌握了一链一应用的最小子集操作,回顾一下:
- 编译一个区块链应用(
DApp
) - 创建一个创世块(
genesis
) - 运行一个挖矿节点(
miner
) - 运行一个同步节点(
peer
) - 运行一个钱包节点(
wallet
)
本节,我们详细介绍DApp的细节。你将掌握:
- chainDemo的工程目录结构
- DApp的目录结构、核心概念、最小代码编写点
- chainSDK的内置命令行,这是把BlockChain运行起来的重要入口
整体目录介绍
完整运行BlockChain后,目录结构如下:
.
├── data
│ ├── coin
│ │ ├── genesis
│ │ │ ├── ...
│ │ ├── miner
│ │ │ ├── ...
│ │ └── peer
│ │ ├── ...
│ └── coin2
│ ├── genesis
│ │ ├── ...
│ ├── miner
│ │ ├── ...
│ └── peer
│ ├── ...
├── dist
│ ├── coin
│ │ ├── ...
│ ├── coin2
│ │ ├── ...
│ └── util
│ └── util.js
├── doc
│ ├── 1.QuickStart
│ │ ├── ...
│ ├── 2.Learn\ ChainSDK
│ │ └── ...
│ ├── 3.Examples
│ │ ├── ...
│ ├── README.md
│ ├── SUMMARY.md
│ └── index.md
├── src
│ ├── coin
│ │ ├── ...
│ ├── coin2
│ │ ├── ...
│ └── util
│ └── util.ts
├── gulpfile.js
├── package-lock.json
├── package.json
├── run.js
└── tsconfig.json
首先,介绍下根目录下的几个配置文件:
package.json
: 配置了依赖的node库,包括chainSDK库:blockchain-sdk
等,见上一节的说明。gulpfile.js
: 由于src/
目录下的DApp使用TypeScript编写,我们需要讲源代码从src/
目录编译到dist/
目录,gulpfile.js里配置了这些过程。代码变动后,请执行gulp build
重新编译。tsconfig
: TypeScript编译相关的配置,包括输出目录,目标JavaScript版本等配置。run.js
: 执行在src/coin/chain.json
里配置的chainSDK命令行的封装脚本。- 通过
node run.js -chain ${chainName} -session ${sessionName}
来自执行。 - 例如,上一节我们执行的
node run.js -chain coin -session create
等。 - 可以打开
src/coin/chain.json
查看相关命令及其参数。 - 如果只是想看下最终执行的命令文本,而不真正执行,可以添加
-show
选项,例如:node run.js -chain coin -session create -show
,可以看到命令文本是:chain_host create --package "./dist/coin/chain" --externalHandler --dataDir "./data/coin/genesis" --loggerConsole --loggerLevel "debug" --genesisConfig "./dist/coin/chain/genesis.json"
- 通过
其次,介绍下几个目录:
- DApp的源码目录:
src/
src/util
是几个DApp公共依赖的工具代码。src/coin
,src/coin2
等是DApp的源码目录。
- DApp的编译目录:
dist/
dist/coin
,dist/coin2
等是DApp源码编译后的目录。
- DApp的数据目录:
data/
data/coin
,data/coin2
等是DApp运行期间的数据目录。- 例如
data/coin/genesis
是coin这条链的创世块目录 - 例如
data/coin/miner
是coin这条链挖矿节点的数据。 - 例如
data/coin/peer
是coin这条链同步节点的数据。
- 例如
- 文档目录:
doc/
注意:
data/
和dist/
目录被添加到.gitignore,不会被提交到git仓库。
DApp目录介绍
现在,我们来学习下一个完整的DApp目录结构。进入src/coin
目录,该目录结构如下:
.
├── chain
│ ├── config.json
│ ├── genesis.json
│ └── handler.ts
├── test
│ └── test.ts
├── wallet
│ └── wallet.ts
└── chain.json
coin由四个部分构成:
chain/
目录是一个DApp的合约(Contract)
部分代码。test/
目录是使用mochajs编写的测试代码wallet/
目录是DApp的“客户端”部分,在这里是一个交互式的命令行钱包客户端。chain.json
里配置了运行BlockChain各个节点的命令行和对应的参数。
接下来,我们就分别展开这四个部分,源码之前,了无秘密。
合约(Contract
)
一个DApp,最核心的逻辑就是合约代码。一个合约包含三个重要文件:
config.json: 配置了合约执行所使用的共识算法以及BlockChain出块相关的全局配置。
{
"handler":"./handler.js",
"type": {
"consensus":"pow",
"features":[]
},
"global": {
"retargetInterval":10,
"targetTimespan":60,
"basicBits":520159231,
"limit":"0000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
}
}
其中,consensus
用来配置共识算法。可选的共识算法有:
pow
dpos
dbft
其中,global
节点配置出块相关的参数,参考host。
genesis.json:配置了创世块信息
{
"coinbase": "12LKjfgQW26dQZMxcJdkj2iVP2rtJSzT88"
}
我们知道创建一个Block,会获得一定的coin奖励,创建创世块也不例外。genesis.json里的coinbase
指定了创造创世块时,给谁发奖励。更多genesis.json
的配置,参考host里的genesis config字段
一节。
handler.ts:合约代码。
import { ErrorCode, BigNumber, ValueViewContext, ValueTransactionContext, ValueHandler } from 'blockchain-sdk';
export function registerHandler(handler: ValueHandler) {
// 注册合约的只读接口
handler.addViewMethod('getBalance', async (context: ValueViewContext, params: any): Promise<any> => {
return await context.getBalance(params.address);
});
// 注册合约的交易接口
handler.addTX('transferTo', async (context: ValueTransactionContext, params: any): Promise<ErrorCode> => {
return await context.transferTo(params.to, context.value);
});
// 注册合约的挖矿奖励回调
handler.onMinerWage(async (): Promise<BigNumber> => {
return new BigNumber(10000);
});
}
第1行从blockchain-sdk
库引导入了chainSDK的重要接口。包括:
ErrorCode
:全局错误码定义BigNumber
:数值运算ValueViewContext
:合约状态(storage
)的只读上下文,通过该接口只允许调用合约的只读接口。参考context/ValueViewContext。ValueTransactionContext
:合约状态(storage
)的可读可写上下文,通过该接口允许读写合约的相关状态。参考context/ValueTransactionContext。ValueHandler
:合约的回调注册接口。
紧接着,handler.ts
必须导出export function registerHandler(handler: ValueHandler)
接口。而registerHandler
内部,就是合约代码的实现。
registerHandler
内部通过参数handler: ValueHandler
的方法完成以下3种接口注册:
- 通过
handler.addViewMethod
注册合约的只读方法。- 例如
handler.addViewMethod('getBalance'...
注册了getBalance
方法,这些方法提供给钱包等客户端调用。 - 该方法内只能通过类型为
ValueViewContext
的参数调用合约的只读方法,不会更改合约的状态。
- 例如
- 通过
handler.addTX
注册合约的写状态方法,- 例如
handler.addTX('transferTo',...
注册了transferTo
方法,这些方法同样提供给钱包等客户端调用。 - 该方法内可通过类型为
ValueTransactionContext
的参数调用合约的读写方法,可修改合约的状态。
- 例如
- 通过
handler.onMinerWage
注册挖矿的奖励金额。
通过这一小节,你已经初步理解并掌握了开发一个DApp的的最核心概念。
调试(Test
)
执行测试用例
进入chainDemo根目录,我们执行下命令测试下coin这个DApp的测试用例:
node run.js -chain coin -session test
可以看到输出日志:
2018-11-15T15:11:45.311Z - info: will execute view method getBalance, params {"address":"18dAKNdNYCnoRBxzKhgnSYtFdbTg9bquNA"} view.js:58
✓ wage
2018-11-15T15:11:45.323Z - info: [transaction: 269527184e10759f5f7b558e9453d06362245af0e2a4419c04dd8dd2d7ea3a59] will execute tx 269527184e10759f5f7b558e9453d06362245af0e2a4419c04dd8dd2
d7ea3a59: transferTo,from 18dAKNdNYCnoRBxzKhgnSYtFdbTg9bquNA, params {"to":"18cVpJQXvFa23pLDoaysu2NA3NXsnqqu2E"} transaction.js:99
2018-11-15T15:11:45.325Z - info: will execute view method getBalance, params {"address":"18dAKNdNYCnoRBxzKhgnSYtFdbTg9bquNA"} view.js:58
2018-11-15T15:11:45.325Z - info: will execute view method getBalance, params {"address":"18cVpJQXvFa23pLDoaysu2NA3NXsnqqu2E"} view.js:58
✓ transferTo
2 passing (72ms)
两个测试用例都成功了。
mocha测试用例
我们打开coin/test/test.js
看下测试用例代码。首先是导入的接口:
import {valueChainDebuger, initLogger, stringifyErrorCode, ValueIndependDebugSession, BigNumber} from 'blockchain-sdk';
关键的新接口是valueChainDebuger
。该接口下有两个方法:
const {createIndependSession,createChainSession} = valueChainDebuger;
其中,createIndependSession
会创建一个调试用的Session,该Session可以直接触发块相关的事件,便于调试。
测试用例用的是mocha
框架,通过我们的测试用例可以快速看到该框架写测试用例是很简单的:
// 创建名为coin的测试用例
describe('coin', () => {
const logger = initLogger({loggerOptions: {console: true}});
let session: ValueIndependDebugSession;
// 测试初始化环境安装
before((done) => {
});
// 挖矿奖励金额测试
it('wage', (done) => {
// 转账测试
it('transferTo', (done) => {
});
});
可见用mocha
写测试用例的核心步骤就是:
- 在
before((done) => {...}
里初始化全局对象 - 在
it('xxx', (done) => {...}
里添加测试用例 - 多个测试用例之间是顺序执行的。
测试初始化环境安装
测试用例初始化的地方,我们创建了一个全局对象ValueIndependDebugSession,通过该对象可以便利的测试合约代码。见下面的注释:
describe('coin', () => {
const logger = initLogger({loggerOptions: {console: true}});
let session: ValueIndependDebugSession;
// 测试初始化环境安装
before((done) => {
async function __test() {
const dataDir = path.join(__dirname, '../chain');
//
// 创建一个调试用的Session,该Session可以直接触发块相关的事件,便于调试。
// 例如:你不需要真正创建一个挖矿节点就可以调用给矿机发工资的接口session!.wage()
// 然后查询账户余额,确认下是否收到了在合约里注册的挖矿奖励等额的钱。
//
// ChainSDK充分考虑了开发者开发DApp中的调试困难,从而设计了便于调试的工具和组件。
//
const r = await createIndependSession({logger},dataDir);
const err = r.err;
session = r.session!;
assert(!err, 'createIndependSession failed', stringifyErrorCode(err));
assert(!(await session!.init({
height: 0, // 初始化块高度为0
accounts: 2, // 创建两个默认账户,可分别通过session!.getAccount(i)获得
coinbase: 0, // 挖矿奖励给index为0的账户
interval: 10 // 出块时间
})), 'init session failed');
}
__test().then(done);
});
...
});
测试handler.onMinerWage
和handler.addViewMethod('getBalance'
接着,添加一个测试用例,测试挖矿的金额奖励,以及账户余额接口,见代码注释:
describe('coin', () => {
...
// 挖矿奖励金额测试
it('wage', (done) => {
async function __test() {
// 触发一次发工资调用session!.wage()
assert(!(await session!.wage()).err, 'wage error');
// 调用getBalance查询余额,挖矿奖励给index为0的账户,所以查询该账户
const gbr = await session.view({method: 'getBalance', params: {address: session!.getAccount(0)}});
// 此时,账户0里的金额应该等于:
// 1. session初始化时,调用合约里handler.onMinerWage给coinbase=0的账户发奖励,也就是10000
// 2. 调用session!.wage()时,调用合约里handler.onMinerWage发奖励,也就是10000
// 所以一共是 10000*2
assert(!gbr.err, 'getBalance failed error');
assert((gbr.value! as BigNumber).eq(10000*2), 'wage value error', gbr);
// 牛到小试,激动中..
}
__test().then(done);
});
...
});
测试转账transferTo
测试用例是按顺序执行的,前面执行的会影响到后面的状态。我们接着测试转账接口,见代码注释:
describe('coin', () => {
...
// 转账测试
it('transferTo', (done) => {
async function __test() {
// 触发一次转账操作:transferTo,转出10给账户1
assert(!(await session.transaction({caller: 0, method: 'transferTo', input: {to: session.getAccount(1)}, value: new BigNumber(10), fee: new BigNumber(0)})).err, 'transferTo failed');
// 调用getBalance查询余额,此时账户0的金额应该比上一步少了10
let gbr = await session.view({method: 'getBalance', params: {address: session!.getAccount(0)}});
assert(gbr.value!.eq(10000*2 - 10), '0 balance value err');
// 而账户1的余额应该是10
gbr = await session.view({method: 'getBalance', params: {address: session!.getAccount(1)}});
assert(gbr.value!.eq(10), '1 balance value err');
// 一切都对,一链尽在掌握中!
}
__test().then(done);
});
...
});
执行一个失败的测试:
我们可以把wage
这个测试用例的assert修改一下:
assert((gbr.value! as BigNumber).eq(10000), 'wage value error', gbr);// 把10000*2修改为10000
重新编译一下代码:
gulp build
再次执行测试命令:
node run.js -chain coin -session test
可以看到测试失败了:
2018-11-15T15:36:13.864Z - info: will execute view method getBalance, params {"address":"15mAmUvKXyvsufBBQGuNBvBugXgwwEGTfV"} view.js:58
未处理的 rejection: Promise {
<rejected> { AssertionError [ERR_ASSERTION]: wage value error
at __test (/Users/feilong/Desktop/dev/chainDemo/dist/coin/test/test.js:51:13)
at <anonymous>
...
那么,如何调试呢?请看第四节:如何在VSCode里断点调试DApp。
钱包(walleet)客户端
写了合约代码,测试了合约接口,最后我们写一个交互式命令行的钱包客户端。这个客户端代码就在coin/wallet/wallet.js
。先看下导入的接口:
import {ChainClient, BigNumber, ErrorCode, addressFromSecretKey, ValueTransaction, initLogger } from 'blockchain-sdk';
首先,重点看客户端的接口ChainClient
, 我们在客户端代码里通过该接口监听出块事件:
chainClient.on('tipBlock', async (tipBlock) => {
for (let tx of watchingTx.slice()) {
let {err, block, receipt} = await chainClient.getTransactionReceipt({tx});
if (!err) {
if (receipt.returnCode !== 0) {
console.error(`tx:${tx} failed for ${receipt.returnCode}`);
watchingTx.splice(watchingTx.indexOf(tx), 1);
} else {
// 等待6个块确认
let confirm = block.number - tipBlock.number + 1;
if (confirm < 6) {
console.log(`tx:${tx} ${confirm} confirm`);
} else {
console.log(`tx:${tx} confirmed`);
watchingTx.splice(watchingTx.indexOf(tx), 1);
}
}
}
}
});
其次,交互式命令行的逻辑部分:
function runCmd(cmd: string) {
let chain = runEnv;
try {
eval(cmd);
} catch (e) {
console.error(e.message);
}
}
let c = command.options.get('run');
if (c) {
console.log('');
console.log(c);
runCmd(c);
}
let rl = readline.createInterface({input: process.stdin, output:process.stdout, prompt:'>'});
rl.on('line', (cmd: string) => {
runCmd(cmd);
});
基本上就是用户输入一个脚本,就通过eval
执行一下这个脚本。既然是通过JavaScript
来解释脚本的,eval
环境里可以调用闭包里的chain
对象,也就是runEvn对象的接口。
function runCmd(cmd: string) {
let chain = runEnv; // eval里可以通过闭包调用chain的接口。
try {
eval(cmd);
} catch (e) {
console.error(e.message);
}
}
可以看到runEnv
提供了这几个方法:
let runEnv = {
getAddress: () => {
console.log(address);
},
getBalance: async (_address: string) => {
if (!_address) {
_address = address;
}
let ret = await chainClient.view({
method: 'getBalance',
params: {address: _address}
});
if (ret.err) {
console.error(`get balance failed for ${ret.err};`);
return ;
}
console.log(`${ret.value!}`);
},
transferTo: async (to: string, amount: string, fee: string) => {
let tx = new ValueTransaction();
tx.method = 'transferTo',
tx.value = new BigNumber(amount);
tx.fee = new BigNumber(fee);
tx.input = {to};
let {err, nonce} = await chainClient.getNonce({address});
if (err) {
console.error(`transferTo failed for ${err}`);
return ;
}
tx.nonce = nonce! + 1;
tx.sign(secret);
let sendRet = await chainClient.sendTransaction({tx});
if (sendRet.err) {
console.error(`transferTo failed for ${err}`);
return ;
}
watchingTx.push(tx.hash!);
console.log(`send transferTo tx: ${tx.hash}`);
},
};
这些方法内部都是转调用了ChainSDK导出的chainClient
接口。该接口的更多方法参考chainClient手册
命令配置chain.json
最后,我们打开chain.json
,这个文件里配置了通过根目录下的run.js
执行的命令。例如:
"create":{
"program": "chain_host",
"args":[
"create",
"--package", "./dist/coin/chain",
"--externalHandler",
"--dataDir", "./data/coin/genesis",
"--loggerConsole",
"--loggerLevel", "debug",
"--genesisConfig", "./dist/coin/chain/genesis.json"
]
},
可以看到program
指定要执行的程序,chain_host
是ChainSDK内置的命令,所有参数的信息都可以在host里找到,而peerId和secret的生成,可以参考address手册。
chain_host
命令主要用来:
- 创建创世块
- 启动挖矿节点
- 启动同步节点
此外,钱包客户端编译后的代码在dist/
目录,因此,我们配置了对钱包客户端的调用:
"wallet":{
"program": "./dist/coin/wallet/wallet.js",
"args":[
"--secret", "21a744ac011e0457d67327f0361237e73181d4d21e25b1ca99a0a05e84533359",
"--host", "localhost",
"--port", "18089",
"--run", "chain.getBalance()"
]
},
当然,也包括mocha
测试用例的执行配置,注意下windows和非windows下启动脚本的位置不同:
"test":{
"type": "test",
"program": {
"windows": "./node_modules/bin/mocha",
"linux": "./node_modules/.bin/mocha"
},
"args":[
"./dist/coin/test/test.js",
"--timeout", "600000"
]
}
自己动手添加新功能
通过本节Step by Step的分析,你不仅理解了一个完整的DApp包含哪些部分,还掌握了编写和调试的技术。那么,现在可以进一步:
- 自己阅读
src/conn2
的代码,通过查阅参考手册辅助理解完整的逻辑。 - 使用目前为止学到的命令操作和调试技术跑通。
最后,是时候自己编写一个新的DApp,开启一链一应用的世界。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论