怎样在ultrain的智能合约中持久化存储数据,是很多写过solidity合约的同学都会觉得疑惑的地方,以及为什么我们要这样设计?首先我们需要澄清的一个事实是,每次调用一个合约的action时,共识节点都会启动一个VM实例(事实上并没有新进程产生,但是可以这样理解),来执行目标代码,当一个action执行完成、或者超时、或者异常时,这个VM实例就被回收了,所有VM堆栈上的数据,也都被回收掉。所以,如果数据没有在action完成之前存储到数据库中,那么它们就丢失了。
  其次,由于每次action都由不同的VM实例来执行,所以不同action之间不能直接共享状态变量(action是顺序执行的)。可行的方法是在一个action A中先把变量存储到数据库,在action B中再将变量读取出来。
  再次,存储到数据库的数据,可以通过rpc接口,从数据库中直接读出来。使用rpc接口读数据库比发起一个action、获得返回值/事件回调的效率高很多倍,在实际编程中,如果只想了解当前合约状态,推荐的方式是在Dapp中直接使用rpc接口读数据库,而不是使用action。
  在ultrain中,数据并不会自动的保存、恢复,而是需要在合约代码中使用DBManager(https://developer.ultrain.io/documents)辅助完成(由于typescript的源码开放性,有同学会发现存在更底层的DB api可以使用,但是强烈不建议直接使用底层api)。接下来我们通过一个例子来详细描述如何使用DBManager存取数据。

步骤1. 定义一个支持Serializable的class
  数据库中的数据是以k-v形式保存的,k通过class的primaryKey()方法确定, v通过serialize()方法将class中的成员序列化获得。定义一个支持
Serializable的class非常简单,如:

class Address implements Serializable {
	street: string;
	post: string;
}

class Person implements Serializable {
	@primaryid   
	id  : u64;
    name: string;
    age: u32;
    sex: string;
	address: Address;
	@ignore
	salary: u32;

	constructor(_id: u64 = 0, _name: string = "unknown") {
		// do something
	}
}

在这个例子中,我们定义了一个class Person,它支持Serializable。同时,我们在id上加了一个注解@primaryid,表示使用id这个变量的值作为primaryKey。注解@ignore则表示salary这个变量不会被序列化,也不会存储到数据库中,它类似于一个临时变量。
到这里一个简单的支持Serializable的class就定义好了。
很简单,对吧?但是仍然有几下几点需要特别注意:

  • 1)能够被注解为@primaryid的变量,必须是u64类型。如果没有注解@primaryid或者重写primaryKey()方法,编译器默认使用'0'做为priamry key。

  • 2)constructor方法必须支持无参数输入时可调用,即支持let p: Person = new Person();这种语法。(可以通过提供默认参数来实现,如例子中)

  • 3)如果Person的成员变量中有除基本数据类型(bool, i8,u8,i16,u16,i32,u32,i64,u64,string等)之外的其它class类型,那这个class也必须支持Serializable接口,如例子中的Address。

    通过上面的操作,我们可以使用编译器默认生成的Serializable实现来帮我们简化操作。但是如果我们真有需要定制Serializable方法呢?又应该如何做?其实是有方法的,即重写(override) Serializable中的方法。
      Serializable是一个Interface, 它的定义是这样的:

export interface Serializable {
    deserialize(ds: DataStream): void;
    serialize(ds : DataStream) : void;
    primaryKey(): u64;
}

接下来我们可以重写Person如下:

class Person implements Serializable {
	@primaryid   
	id  : u64;
    name: string;
    age: u32;
    sex: string;
	address: Address;
	@ignore
	salary: u32;

	constructor(_id: u64 = 0, _name: string = "unknown") {
		// do something
	}

	primarykey(): u64 {
		// 使用一种特定的方法产生primary key
		return this.id + NAME(this.name);
	}

	serialize(ds: datastream): void {
		// 只将id和address保存到数据流
		ds.write<u64>(this.id);
		this.address.serialize(ds);
	}

	deserialize(ds: datastream): void {
		// 从数据流中读出id和address
		this.id = ds.read<u64>();
		this.address.deserialize(ds);
	}
}

重写serializable方法,也有几点是需要注意的:

  • 1)可以选择重写primarykey()、serialize()、deserialize()方法中的一个或几个。
  • 2)使用了@primaryid、@ignore注解的同时,又重写了serializable的方法,编译器优先使用重写过的方法。如果重写了primarykey()方法,@primaryid注解失效;如果重写了serialize()和deserialize()方法,@ignore注解会失效。
  • 3)serialize()和deserialize()中读、写数据的顺序必须严格一致。

步骤2.在合约中声明k-v数据表

在上面的步骤中,我们已经定义一个支持serializable的class person,就可以在合约中使用它了。

const salestable = "tb.sales";
const marketingtable = "tb.marketing";

@database(Person, "tb.sales")
@database(Person, "tb.marketing")
class HumanResource extends Contract {

    salesdb: DBManager<Person>;
	marketingdb: DBManager<Person>;

    constructor(code: u64) {
        super(code);
        this.salesdb = new DBManager<Person>(NAME(salestable), this.receiver, NAME(salestable));
        this.marketingdb = new DBManager<Person>(NAME(marketingtable), this.receiver, NAME(marketingtable));
    }
	// .......
}

我们可以使用@database来声明一个k-v数据表。@database有两个参数,第一个参数为Serializable的class,第二个参数是table名字(需符合account name限制)。如果有多个k-v表,可以使用多个@database声明。如果没有使用@database声明,代码并不会出错,但是不能使用rpc方法get_table_records来读取数据库中的数据。
  接下来在代码中,我们就可以使用DBManager来关联一个k-v数据表了。DBManager的原型是一个泛型类:DBManager<T>(table_name: u64, code: u64, scope: u64)。table_name是数据表名;code必须使用this.receiver;scope是一个区分数据分类的关键字。

步骤3.使用DBManager来操作数据

DBManager提供了一系列的方法,方便操作数据。
DBManager.exists(key: u64): boolean; 判断数据表中是否存在一个primary key的数据。
DBManager.get(key: u64, obj: T): boolean; 从数据表中读取一个primary key,并将数据存入obj中。 如果数据存在,返回true, 否则返回false。
DBManager.emplace(payer: u64, obj: T): void;往数据表中加入一条记录, payer表示谁为存储付费,并且只有payer能够修改数据。
DBManager.modify(payer: u64, obj: T): void; 修改一条记录,payer需要与emplace中使用的payer一致,和obj.primaryKey()一致的数据将会被修改。
DBManager.erase(key: u64): void; 删除key代表的记录。
DBManager.dropAll(): i32; 删除一个k-v数据表中的所有数据。 返回0表示删除成功,-1表示这个表中没有数据。
DBManager.cursor(): Cursor<T>; 获得一个迭代器,迭代数据表中所有数据。

步骤4.使用DBManager的api

实干兴邦、空谈误国,下面我们使用一个完整的demo来展示如何使用DB。

import "allocator/arena";
import { Contract } from "ultrain-ts-lib/src/contract";
import { Log } from "ultrain-ts-lib/src/log";
import { NAME } from "ultrain-ts-lib/src/account";

class Address implements Serializable {
    street: string;
    post: string;
}

class Person implements Serializable {
    @primaryid
    id  : u64;
    name: string;
    age: u32;
    sex: string;
    address: Address;
    @ignore
    salary: u32;

    constructor(_id: u64 = 0, _name: string = "unknown") {
        // do something
    }

    prints(): void {
        Log.s("id = ").i(this.id).s(", name = ").s(this.name).flush();
    }
}

const salestable = "tb.sales";
const marketingtable = "tb.marketing";

@database(Person, "tb.sales")
@database(Person, "tb.marketing")
class HumanResource extends Contract {

    salesdb: DBManager<Person>;
	marketingdb: DBManager<Person>;

    constructor(code: u64) {
        super(code);
        this.salesdb = new DBManager<Person>(NAME(salestable), this.receiver, NAME(salestable));
        this.marketingdb = new DBManager<Person>(NAME(marketingtable), this.receiver, NAME(marketingtable));
    }

    @action
    addSales(id: u64, name: string, age: u32, sex: string, street: string, post: string, salary: u32): void {
        let p = new Person();
        p.id = id;
        p.name = name;
        p.age = age;
        p.address.street = street;
        p.address.post = post;
        p.salary = salary;

        let existing = this.salesdb.exists(id);
        ultrain_assert(!existing, "this person has existed in db yet.");
        this.salesdb.emplace(this.receiver, p);
    }

    @action
    addMarketing(id: u64, name: string, age: u32, sex: string, street: string, post: string, salary: u32): void {
        let p = new Person();
        p.id = id;
        p.name = name;
        p.age = age;
        p.address.street = street;
        p.address.post = post;
        p.salary = salary;

        let existing = this.marketingdb.exists(id);
        ultrain_assert(!existing, "this person has existed in db yet.");
        this.marketingdb.emplace(this.receiver, p);
    }

    @action
    modify(id: u64, name: string, salary: u32): void {
        let p = new Person();
        let existing = this.salesdb.get(id, p);
        ultrain_assert(existing, "the person does not exist.");

        p.name   = name;
        p.salary = salary;

        this.salesdb.modify(this.receiver, p);
    }

    @action
    remove(id: u64): void {
        Log.s("start to remove: ").i(id).flush();
        let existing = this.salesdb.exists(id);
        ultrain_assert(existing, "this id is not exist.");
        this.salesdb.erase(id);
    }

    @action
    enumrate(dbname: string): void {
        let cursor: Cursor<Person>;
        if (dbname == "sales") {
            cursor = this.salesdb.cursor();
        } else if (dbname == "marketing") {
            cursor = this.marketingdb.cursor();
        } else {
            ultrain_assert(false, "unknown db name.");
        }
        Log.s("cursor.count =").i(cursor.count).flush();

        while(cursor.hasNext()) {
            let p = cursor.get();
            p.prints();
            cursor.next();
        }
    }

    @action
    drop(dbname: string): void {
        if (dbname == "sales") {
            this.salesdb.dropAll();
        } else if (dbname == "marketing") {
            this.marketingdb.dropAll();
        } else {
            ultrain_assert(false, "unknown db name.");
        }
    }
}