Skip to content

C++ 高级接口

Qiuwen-chen edited this page Mar 28, 2023 · 2 revisions

本文将介绍 WCDB 的一些高级接口,包括链式调用与联表查询、查询重定向、Handle、Statement。

链式调用

增删查改一章中,已经介绍了通过 WCDB::DatabaseWCDB::Table 操作数据库的方式。它们是经过封装的便捷接口,其实质都是通过调用链式接口完成的。

WCDB::Select<Sample> select = database.prepareSelect<Sample>().fromTable("sampleTable");
WCDB::OptionalValueArray<Sample> allObjects = select.where(WCDB_FIELD(Sample::identifier) > 1).limit(10).allObjects();
    
WCDB::Delete delete_ = database.prepareDelete().where(WCDB_FIELD(Sample::identifier) != 0);
ret = delete_.execute();

链式接口都以 prepareXXX 开始,根据不同的调用返回 WCDB::InsertWCDB::UpdateWCDB::DeleteWCDB::SelectWCDB::MultiSelect 对象。 这些对象的基本接口都返回其引用类型,因此可以链式连续调用。 最后调用对应的函数使其操作生效,如 allObjectsexecute 等。通过链式接口,可以更灵活的控制数据库操作。

联表查询

在链式类中,WCDB::InsertWCDB::UpdateWCDB::DeleteWCDB::Select 都有其对应的增删查改接口。而 WCDB::MultiSelect 则不同。 WCDB::MultiSelect 用于联表查询,在某些场景下可提供性能,获取更多关联数据等。以下是联表查询的示例代码:

ret = database.createTable<Sample>("sampleTable");
ret &= database.createTable<SampleMulti>("sampleTableMulti");

// 相当于执行 SQL: SELECT sampleTable.identifier, sampleTable.content, sampleTableMulti.identifier, sampleTableMulti.content FROM sampleTable, sampleTableMulti WHERE sampleTable.identifier = sampleTableMulti.identifier
WCDB::MultiSelect multiSelect = database.prepareMultiSelect().onResultFields({
    WCDB_FIELD(Sample::identifier).table("sampleTable"),
    WCDB_FIELD(Sample::content).table("sampleTable"),
    WCDB_FIELD(SampleMulti::identifier).table("sampleTableMulti"),
    WCDB_FIELD(SampleMulti::content).table("sampleTableMulti")
}).fromTables({"sampleTable", "sampleTableMulti"})
.where(WCDB_FIELD(Sample::identifier).table("sampleTable") ==  WCDB_FIELD(SampleMulti::identifier).table("sampleTableMulti"));

//读取两个表格的结果
WCDB::OptionalMultiObject multiObject = multiSelect.firstMultiObject();
Sample sample = multiObject.value().objectAtTable<Sample>("sampleTable").value();
SampleMulti multiSample = multiObject.value().objectAtTable<SampleMulti>("sampleTableMulti").value();

该查询将 "sampleTable" 和 "sampleTableMulti" 表联合起来,取出它们中具有相等 identifier 值的数据。 多表查询时,所有同名字段都需要通过 table() 接口指定表名,否则会因为无法确定其属于哪个表从而出错。 查询到的 multiObject 是表名到对象的映射,取出后进行类型转换即可。

Handle

在之前的所有教程中,WCDB 通过其封装的各种接口,简化了最常用的增删查改操作。但 SQL 的操作千变万化,仍会有部分功能无法通过便捷接口满足。此时则可以直接操作WCDB::HandleWCDB::PreparedStatement,来做更精细的控制。

WCDB::Handle是单个数据库连接(具体见:Database Connection Handle)的包装,WCDB::Database则是相当于一个数据库连接的池子。WCDB::Handle可以通过Database::getHandle()宏来获取。WCDB::Handle具备WCDB::Database的全部建表和CRUD接口,还可以精细控制SQL的执行过程。下面是一个示例:

// 获取handle
WCDB::Handle handle = database.getHandle();
bool ret = handle.createTable<Sample>("sampleTable");
// 先 prepare statement, 其实是sqlite3_prepare函数的封装。
ret &= handle.prepare(WCDB::StatementInsert()
                      .insertIntoTable("sampleTable")
                      .columns(Sample::allFields())
                      .values(WCDB::BindParameter::bindParameters(Sample::allFields().size())));
for(int i = 0; i < 100000; i++) {
    // 先 reset,其实是sqlite3_reset函数的封装。
    handle.reset();
    
    //1. 可以直接使用对象来bind,会逐个属性调用sqlite3_bind系列接口
    Sample obj;
    obj.identifier = i;
    handle.bindObject(obj, Sample::allFields());
    
    //2. 也可以逐个字段bind,更高效一点
    handle.bindInteger(i, 1);
    handle.bindNull(2);
    
    // step 写入数据
    ret &= handle.step();
}

//一个statement用完之后需要调用finalize,底下会调用sqlite3_finalize函数
handle.finalize();

std::list<Sample> objects;
ret &= handle.prepare(WCDB::StatementSelect()
                      .select(Sample::allFields())
                      .from("sampleTable"));

while (handle.step() && !handle.done()) {
    //1. 可以直接读取对象,会逐个属性来调用sqlite3_column系列接口来读取数据,并赋值给对象
    Sample obj = handle.extractOneObject<Sample>(Sample::allFields());
    
    //2. 也可以逐个字段读取,更高效一点
    obj = Sample();
    obj.identifier = handle.getInteger(0);
    obj.content = handle.getText(1);
    
    objects.push_back(obj);
}

//一个statement用完之后需要调用finalize,底下会调用sqlite3_finalize函数
handle.finalize();

一个值得注意的点是,handle是用到的时候才获取,不能长时间持有它

PreparedStatement

使用WCDB::Handle虽然可以精细控制 SQL 的执行过程,但是一次只能执行一个 SQL,如果需要同时执行多个,就没办法了。这时候就需要用到WCDB::PreparedStatementWCDB::PreparedStatementsqlite3_stmt(具体见Prepared Statement Object)的封装,它保存了 SQL 的语法解析结果,用它来重复执行SQL语句的话,就可以节省SQL语句的解析耗时,提高性能,下面是示例代码:

// 获取handle
WCDB::Handle handle = database.getHandle();

ret &= handle.createTable<Sample>("sampleTable");
ret &= handle.createTable<Sample>("newSampleTable");
// prepare statement
WCDB::OptionalPreparedStatement selectSTMT =
    handle.getOrCreatePreparedStatement(WCDB::StatementSelect()
                                        .select(Sample::allFields())
                                        .from("sampleTable"));
WCDB::OptionalPreparedStatement insertSTMT =
    handle.getOrCreatePreparedStatement(WCDB::StatementInsert()
                                        .insertIntoTable("newSampleTable")
                                        .columns(Sample::allFields())
                                        .values(WCDB::BindParameter::bindParameters(Sample::allFields().size())));

while (selectSTMT->step() && !selectSTMT->done()) {
    //1. 可以直接读取对象,会逐个属性来调用sqlite3_column系列接口来读取数据,并赋值给对象
    Sample obj = selectSTMT->extractOneObject<Sample>(Sample::allFields());

    //2. 也可以逐个字段读取,更高效一点
    obj = Sample();
    obj.identifier = selectSTMT->getInteger(0);
    obj.content = selectSTMT->getText(1);

    // 先 reset,其实是sqlite3_reset函数的封装。
    insertSTMT->reset();
    obj.identifier += 10000;

    //1. 可以直接使用对象来bind,会逐个属性调用sqlite3_bind系列接口
    insertSTMT->bindObject(obj, Sample::allFields());

    //2. 也可以逐个字段bind,更高效一点
    insertSTMT->bindInteger(obj.identifier, 1);
    insertSTMT->bindText(obj.content, 2);

    // step 写入数据
    ret &= insertSTMT->step();
}

//statement用完之后可以一次性finalize,底下会调用sqlite3_finalize函数逐个释放preparedStatement的资源
//不调用的话,handle 析构之后也会自动finalize它所创建的所有preparedStatement
handle.finalizeAllStatement();

可中断事务

在需要对数据库进行大量数据更新的场景,我们的开发习惯一般是将这些更新操作统一到子线程处理,这样可以避免阻塞主线程,影响用户体验。

对于这类场景,如果只是将数据更新操作放到子线程执行,是不能完整解决问题的。因为 SQLite 的同个DB不支持并行写入,如果子线程的数据更新操作耗时太久,而主线程又有数据写入操作,比如用户在收消息的同时还会发消息,这样也会造成主线程阻塞。一种可行的做法是,将子线程的数据更新操作拆成一个个耗时很小的独立操作分别执行。但这样又会导致磁盘 IO 量大和增加子线程耗时的问题。

因为SQLite读写数据库时以一个数据页为单位的,一个数据页的大小在 WCDB 中是4kb,单个数据页一般可以存多条数据,逐条数据更新容易导致同一个数据页被读写多次。为了减少磁盘写入量,只能将所有的数据更新操作放到一个事务中执行,这样又会造成主线程阻塞的问题。

为了解决大事务会阻塞主线程的问题,我们在 WCDB 中开发了一种可中断事务。可中断事务把一个流程很长的事务过程看成一个循环逻辑,每次循环执行一次短时间的DB操作。操作之后根据外部传入的参数判断当前事务是否可以结束,如果可以结束的话,就直接Commit Transaction,将事务修改内容写入磁盘。如果事务还不可以结束,再判断主线程是否因为当前事务阻塞,没有的话就回调外部逻辑,继续执行后面的循环,直到外部逻辑处理完毕。如果检测到主线程因为当前事务阻塞,则会立即 Commit Transaction,先将部分修改内容写入磁盘,并唤醒主线程执行DB操作。等到主线程的DB操作执行完成之后,在重新开一个新事务,让外部可以继续执行之前中断的逻辑。可中断事务的整体逻辑如下图所示:

下面是可中断事务的使用示例:

std::vector<Sample> objects;
for(int i = 0; i < 100000; i++) {
    Sample obj;
    obj.identifier = i;
    objects.push_back(obj);
}

std::async(std::launch::async, [&](){
    int index = 0;
    database.runPauseableTransactionWithOneLoop([&](WCDB::Handle &handle, bool &stop, bool isNewTransaction){
        bool ret = false;
        // isNewTransaction表示第一次执行,或者事务在上次循环结束之后被中断提交了
        if(isNewTransaction) {
            //新事务先建一下表,避免事务被中断之后,表已经被其他逻辑删除
            ret = handle.createTable<Sample>("sampleTable");
        }
        //写入一个对象,这里还可以用WCDB::PreparedStatement来减少SQL解析的耗时
        ret &= handle.insertObjects<Sample>(objects[index], "sampleTable");
        
        index++;
        //给stop赋值成true表示事务结束,根据返回值ret提交或者回滚事务。
        stop = index >= objects.size();
        return ret;
    });
});
Clone this wiki locally