通过上一节的操作,你已经初步掌握了一链一应用的最小子集操作,回顾一下:

  • 编译一个区块链应用(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/coinsrc/coin2等是DApp的源码目录。
  • DApp的编译目录:dist/
    • dist/coindist/coin2等是DApp源码编译后的目录。
  • DApp的数据目录:data/
    • data/coindata/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由四个部分构成:

  1. chain/目录是一个DApp的合约(Contract)部分代码。
  2. test/目录是使用mochajs编写的测试代码
  3. wallet/目录是DApp的“客户端”部分,在这里是一个交互式的命令行钱包客户端。
  4. 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.onMinerWagehandler.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包含哪些部分,还掌握了编写和调试的技术。那么,现在可以进一步:

  1. 自己阅读src/conn2的代码,通过查阅参考手册辅助理解完整的逻辑。
  2. 使用目前为止学到的命令操作和调试技术跑通。

最后,是时候自己编写一个新的DApp,开启一链一应用的世界。

results matching ""

    No results matching ""