使用 STLdb4 简单访问 Berkeley DB

作者:Ben Martin

Berkeley DB 库为 B 树和哈希文件结构提供了可靠的实现。该实现包括对事务、从多个进程并发访问数据库文件以及二级索引以及日志记录和恢复的支持。

在本文中,我使用术语数据库来指代由 Berkeley DB 维护的 B 树或哈希。这些数据库允许快速的键值查找。

Berkeley DB 的标准发行版附带 C 和 C++ API。不幸的是,标准的 Berkeley DB C++ API 只是一个非常薄的包装器,忽略了现代 C++ 设计,例如智能指针、标准 C++ I/O 流、迭代器、默认参数、运算符重载等等。作为缺少引用计数智能指针的一个具体示例,清单 1 中所示的 Berkeley DB API Db::get() 包括两个 Dbt 指针,并且这些指针的内存所有权并不立即显而易见。

清单 1. 标准 Berkeley DB C++ API Db::get()

#include <db_cxx.h>
int Db::get(DbTxn *txnid, Dbt *key, Dbt *data,
            u_int32_t flags);

创建 STLdb4 项目是为了使从 C++ 更容易地使用 Berkeley DB。STLdb4 API 旨在使简单的数据库交互变得微不足道,同时仍然保持更高级的用法简单。Berkeley DB 对象行为类似于 STL 集合,允许使用重载的数组运算符查找和设置元素。清单 2 中显示了一个完整的示例程序。执行后,以 argv[1] 命名的文件将包含一个包含 foo-bar 数据对的 Berkeley DB B 树文件。

主类是 Database,此类的引用计数智能指针称为 fh_database。这种趋势贯穿整个 STLdb4,其中 Foo 的智能指针称为 fh_foo。数据库可以直接在构造函数中打开(如清单 2 所示),也可以稍后使用空构造函数和 open() 或 create() 方法打开。open 和 create 之间的主要区别在于 create 需要数据库类型(例如 B 树或哈希),并且如果给定路径中尚不存在数据库,则将在给定路径中创建一个新数据库。

在清单 2 的示例中,我不必显式关闭数据库,因为 Database 对象的智能指针会为我处理此事。

清单 2. STLdb4 设置和获取值

#include <iostream>
#include <STLdb4/stldb4.hh>

using namespace STLdb4;
using namespace std;

int main( int argc, char** argv )
{
 fh_database db = new Database(DB_BTREE, argv[1]);
 db["foo"] = "bar";
 cerr << "foo is set to:" << db["foo"] << endl;
 return 0;
}

标准的 STL 集合方法,例如 empty()、size()、insert()、erase()、count()、begin()、end()、find()、upper_bound() 和 lower_bound(),都存在于 Database 类中。后三种方法也有部分版本。部分版本允许在 B 树文件中查找具有部分键的条目。双向迭代器对象由上述许多方法返回。

当在数据库中存储大值时,使用标准 I/O 流可能比使用 get() 方法或重载的数组运算符更有效。这是因为标准 I/O 流在底层 Berkeley DB 文件上使用部分读取和写入操作。标准 I/O 流是使用 Database 类的 getIStream() 和 getIOStream() 方法获得的。

清单 3 中的示例显示了 STLdb4 的标准 C++ I/O 流接口。执行到 Berkeley DB 文件的部分 I/O 的内务处理由 STLdb4 处理。通过此 API 访问大数据块可保持较低的内存消耗。API 显示其中一个使用的 getIOStream() 调用具有 ferris_ios 第一个参数。由于 STLdb4 使用的 libferrisstreams 库提供通用 I/O 流支持,因此 ferris_ios 是 std::ios 位域的向后兼容扩展。该扩展允许指定内存映射后备和顺序流访问等内容以在支持的情况下使用。运行此示例的输出如清单 4 所示。

清单 3. Berkeley DB 文件的标准 C++ I/O 流

#include <iostream>
#include <STLdb4/stldb4.hh>

using namespace STLdb4;
using namespace Ferris;
using namespace std;

int main( int, char** )
{
 fh_database db = new Database( DB_BTREE,
                                "/tmp/play.db" );

 string data = "1234567890";
 db[ "fred" ] = data;
 cerr << "Initial value:" << db["fred"] << endl;

 {
   fh_iostream ss = db->getIOStream( "fred" );
   ss << "54321";
 }
 cerr << "Second value:" << db["fred"] << endl;


 {
   fh_iostream ss = db->getIOStream( "fred" );
   ss.seekp( 3 );
   ss << "AAAA";
 }
 cerr << "post seekp value:" << db["fred"] << endl;

 // truncate the iostream and write
 {
  Database::iterator di = db->find( "fred" );
  fh_iostream oss = di.getIOStream(ios::trunc, 0);
  oss << "sm";
 }
 cerr << "Trunc and write:" << db["fred"] << endl;

 // append some more data to end of iostream
 {
  fh_iostream oss = db->find( "fred" )
      .getIOStream( ios::ate, 0 );
  oss << "AndMore";
 }
 cerr << "at end write value:"
      << db["fred"] << endl;

 return 0;
}

清单 4. 标准 C++ I/O 流示例的输出

Initial value:1234567890
Second value:5432167890
post seekp value:543AAAA890
Trunc and write:sm
at end write value:smAndMore
存储对象

Database 类与像 std::map<> 这样的 STL 集合之间的一个主要区别是,键和值在 Database 中未参数化。其主要原因是 Database 对象中的项通常不在 RAM 中,而是根据需要从磁盘读取。此外,为了不限制 Berkeley DB 提供的功能,Database 类必须支持存储任意数据,而不是对象的异构集合。

可以使用隐式构造函数和类型转换薄对象包装器来创建存储对象的错觉。如清单 5 所示,Person 类存储有关人员的一些信息。隐式构造函数接受 DatabaseMutableValueRef,它是 Database 中数组运算符返回的类。Person 对象可以隐式转换为 std::string,以使其可以序列化到磁盘。正如 main 函数所示,这种薄包装器使其看起来 Database 正在存储 Person 对象。

清单 5. 使用 STLdb4 存储和读取对象

#include <iostream>
#include <STLdb4/stldb4.hh>

using namespace STLdb4;
using namespace std;

class Person
{
public:
    string email;
    string name;
    string phoneNum;
    explicit Person( const string& name,
                     const string& email,
                     const string& ph = "" )
        :
        email( email ), name( name ), phoneNum( ph )
        {}


    Person( const DatabaseMutableValueRef& r )
        {
            stringstream ss;
            ss << (string)r;
            getline( ss, name, '\0' );
            getline( ss, email, '\0' );
            getline( ss, phoneNum, '\0' );
        }
    operator string() const
        {
            stringstream ret;
            ret << name << '\0' << email << '\0'
                << phoneNum << '\0';
            return ret.str();
        }
};


int main( int, char** )
{
 fh_database db = new Database( DB_BTREE,
                                "/tmp/play.db" );

 db->insert(
   make_pair(
     "alex", Person("Alex",  "alex@foo.com")));
 db->insert(
   make_pair(
     "barry",  Person("Barry", "barry@bar.com")));

 Person p = db["barry"];
 cerr << "Barry has email address:"
      << p.email << endl;

 return 0;
}
二级索引

有时,您存储的信息具有多个键,您希望能够通过这些键快速查找给定的项。例如,如果您存储联系信息,您希望能够根据姓名或电子邮件地址查找人员。

您可以通过手动存储每个人的信息来实现上述目的,使用姓名作为键并维护从电子邮件地址到姓名的第二个数据库。要通过电子邮件地址查找人员,您将使用电子邮件键控数据库来查找姓名,然后使用姓名数据库来查找实际信息。像这样手动维护索引非常容易出错,而且,Berkeley DB 中的二级索引可以自动为您完成此内务处理。

上面的示例可以通过将主键值数据与人的姓名作为键和电子邮件地址(或多个电子邮件地址)上的二级索引一起存储来实现。此设置如图 1 所示。我将具有姓名到人员数据映射的数据库称为主数据库,并将电子邮件查找数据库称为二级索引。

Simple Access Berkeley DB Using STLdb4

图 1. 用于通过电子邮件地址快速查找的二级索引

使用 STLdb4 进行二级索引时,主要关注点是如何从数据中提取二级键。STLdb4 中有一些模板函数可以帮助您完成此操作。getOffsetSecIdx() 模板以偏移量作为其模板参数,并将返回从该偏移量到项结尾的所有数据作为二级键。getOffsetLengthSecIdx() 类似,但它允许您同时指定二级键数据的偏移量和长度。最后,getOffsetNullTerminatedSecIdx() 采用偏移量和字符串跳过计数,以允许您在给定偏移量后提取第 n 个空终止字符串。例如,如果您的持久格式有五个(32 位)整数值,后跟四个空终止字符串,则可以使用偏移量 20 和跳过 2 来提取第三个空终止字符串作为二级索引键。

假设使用清单 5 中的 Person 类,清单 6 中的代码在您的 Person 对象的电子邮件地址上创建并使用二级索引。由于磁盘格式以我们的字符串数据开头,因此在使用 getOffsetNullTerminatedSecIdx() 创建提取函数时,我使用偏移量零并跳过一个空终止字符串(姓名)以提取电子邮件地址空终止字符串。

然后,我使用二级索引执行部分查找。equal_range_partial() 方法查找部分键材料的下限和上限。在本例中,我查找任何以 al 开头的电子邮件地址。程序的输出如清单 7 所示。请注意,迭代器的第一个元素是来自二级索引的键,第二个元素是来自主数据库的数据。此查找的主数据库的键可通过迭代器对象上的 getPrimaryKey() 获得。

清单 6. 二级索引

unlink( "/tmp/play.db" );
unlink( "/tmp/play.sec.db" );

fh_database db = new Database( DB_BTREE,
                               "/tmp/play.db" );
Database::sec_idx_callback f
    = getOffsetNullTerminatedSecIdx<0,1>();
fh_database secdb = Database::makeSecondaryIndex(
    db, f, DB_BTREE, "/tmp/play.sec.db" );

db->insert(
  make_pair(
    "alex", Person("Alex",  "alex@foo.com")));
db->insert(
  make_pair(
    "alfred", Person("Alfred","alfred@bar.com")));
db->insert(
  make_pair(
    "andrew", Person("Andrew","andy@foo.com")));
db->insert(
  make_pair(
    "barry",  Person("Barry", "barry@bar.com")));

pair< Database::iterator, Database::iterator > p
    = secdb->equal_range_partial( (string)"al" );
for( Database::iterator di = p.first;
     di != p.second; ++di )
{
    string prim;
    di.getPrimaryKey( prim );
    cerr << "di... "
         << " primary:" << prim
         << " first:" << di->first
         << " second:" << di->second << endl;
    Person p = di->second;
    cerr << "Person has name:" << p.name
         << " email:" << p.email << endl;
}

清单 7. 二级索引程序输出

di...
  primary:alex
  first:alex@foo.com
  second:Alexalex@foo.com
Person has name:Alex email:alex@foo.com
di...
  primary:alfred
  first:alfred@bar.com
  second:Alfredalfred@bar.com
Person has name:Alfred email:alfred@bar.com
事务

事务通过将显式事务对象传递给每个方法或通过在 Database 对象上设置隐式事务来支持。后一种样式在重载的数组运算符被使用的情况下非常方便,这不允许传入事务对象(只有一个参数可以传递给数组运算符)。

当为每个方法调用显式将事务对象传递给数据库时,Transaction 类具有 commit() 和 abort() 方法,以确保数据安全地存储在磁盘上或完全回滚整个事务。当对事务对象的最后一个引用超出范围时,如果尚未提交或中止事务,它将在其析构函数中调用 commit()。

如果您仅在一个数据库上操作,则可以很大程度上避免使用 Transaction 类,而可以使用 Database 类的 start() 方法来启动隐式事务。使用隐式事务时,Database 类的 commit() 和 abort() 方法执行事务最终确定操作。

使用事务的最简单方法如清单 8 所示。示例中需要注意的事项包括数据库环境的使用,在本例中,它将包括 Berkeley DB 事务子系统的初始化。事务对象必须在创建时传递给 Database 对象。在 Database 构造函数中,我传递一个新的 Transaction,它将作为 fh_trans 智能指针传入,这将在构造 Database 对象后为我清理 Transaction 对象。执行后,“初始值”和“最终值”行将在 cerr 中打印相同的信息。

清单 8. 使用 STLdb4 的隐式事务

#include <iostream>
#include <STLdb4/stldb4.hh>

using namespace STLdb4;
using namespace std;

int main( int,char** )
{
Environment::setDefault(new Environment( "/tmp" ));

 fh_database db = new Database(
     new Transaction(), DB_BTREE, "/tmp/play.db" );

 db->start();
 db[ "foo" ] = "bar";
 cerr << "Initial value:" << db[ "foo" ] << endl;
 db->commit();

 db->start();
 db[ "foo" ] = "newbar";
 cerr << "Middle value:" << db[ "foo" ] << endl;
 db->abort();


 cerr << "Final value:" << db[ "foo" ] << endl;
 return 0;
}

相同的事务可以与多个数据库一起使用,方法是持有事务对象智能指针并将其与每个数据库关联。这如清单 9 所示。示例的第二部分使用 setImplicitTransaction() 将数据库与当前事务关联。

清单 9. 使用 STLdb4 的显式事务

fh_trans trans = new Transaction();
fh_database db1 = new Database(
    trans, DB_BTREE, "/tmp/play1.db" );
fh_database db2 = new Database(
    trans, DB_BTREE, "/tmp/play2.db" );
db1[ "foo1" ] = "bar1";
db2[ "foo2" ] = "bar2";
trans->commit();

// create a new implicit transaction and go again
trans = new Transaction();
db1->setImplicitTransaction( trans );
db2->setImplicitTransaction( trans );
db1[ "foo1" ] = "bar111";
db2->set( "foo2", "bar222", 0, trans );
// we'd rather not put these changes in after all
trans->abort();

可以通过在事务对象上设置 setDefaultDestructionIsAbort(true) 来将 Transaction 对象的默认操作更改为调用 abort()。这对于使用资源获取即初始化 (RAII) 编程风格以在代码块中发生任何异常时自动回滚事务非常方便。此 RAII 风格如清单 10 所示。以注释 (AA) 开头的代码块将事务的默认销毁操作设置为调用 abort(),然后使用此事务修改数据库。显式抛出一个异常,这将导致 Transaction 对象被销毁(其最后一个引用是堆栈上 tr 持有的引用)。这将为事务调用 abort(),我们最终将在示例末尾打印旧的“bar”值。

Database::iterator 类在其实现中使用 Berkeley DB 游标,因此我们传递给 Database::find() 的事务将用于对数据库迭代器执行的任何操作。例如,如果在 diter 上调用 getIOStream(),则 STLdb4 将使用 API 后面的 Berkeley DB 文件上的事务 tr 执行部分 I/O。

对于想要一次性更改数据库但可能在此过程中抛出异常的代码,使用 RAII 非常方便。

在执行 RAII 时,应避免调用 setImplicitTransaction(),因为它将使 Database 保留 Transaction 的智能指针,如果抛出异常,这将延长对 abort() 的调用。

清单 10. 使用 STLdb4 的 RAII 事务

int main( int,char** )
{
 Environment::setDefault(new Environment( "/tmp" ));

 fh_database db = new Database(
     new Transaction(), DB_BTREE, "/tmp/play.db" );

 try
 {
  {
   fh_trans tr = new Transaction();
   tr->setDefaultDestructionIsAbort( true );
   db->setImplicitTransaction( tr );
   db["foo"] = "bar";
   tr->commit();
   tr = 0;
  }

 // (AA) RAII with transactions
 // Don't use setImplicitTransaction() in this block
  {
   fh_trans tr = new Transaction();

   tr->setDefaultDestructionIsAbort( true );
   db->set( "foo", "First setting", 0, tr );
   Database::iterator diter = db->find("foo",tr);
   diter->second = "this is something evil";
   throw exception();
   tr->commit();
  }
 }
 catch( exception& e )
 {
     cerr << e.what() << endl;
 }

 cerr << db["foo"] << endl;
 return 0;
}
数据库环境

数据库环境对于配置将一起使用的一组 Berkeley 数据库非常方便。将数据库环境与具有多个数据库文件的并发数据存储模式结合使用,允许多个应用程序都读取和写入数据库文件,并且 Berkeley DB 负责锁定以确保文件不会损坏。

STLdb4 中的默认数据库环境实际上是一个空环境。新的数据库环境是使用 Environment 类创建的。静态 Environment::setDefault() 方法可用于使用单个数据库环境的应用程序,以避免必须将数据库环境对象传递给 Database 构造函数。

清单 11 中显示的代码使用数据库环境来保护两个数据库文件免受多个正在运行的进程的同步更新。首先,创建一个新的数据库环境并设置为使用并发数据存储模式。此数据库环境设置为默认的 STLdb4 环境。第一个 Database 对象是使用默认数据库环境创建的;第二个 Database 对象是通过显式指定数据库环境并作为单独的调用打开数据库文件来创建的。

清单 11. STLdb4 和数据库环境

#include <iostream>
#include <STLdb4/stldb4.hh>

using namespace STLdb4;
using namespace std;

int main( int argc, char** argv )
{
 string dbenvpath = argv[1];
 fh_env dbenv = new Environment( dbenvpath );
 dbenv->setDefaultOpenFlags(
     DB_CREATE | DB_INIT_CDB | DB_INIT_MPOOL );
 Environment::setDefault( dbenv );

 fh_database db = new Database(
     DB_BTREE, dbenvpath + "/foo.db" );
 db["bar"] = argv[2];

 fh_database db2 = new Database( dbenv );
 db2->create( DB_BTREE, dbenvpath + "/foo2.db" );
 db2["key"] = (string)"value_" + argv[2];

 return 0;
}
其他感兴趣的事项

可以使用函数指针或 Loki 函子对象,通过 Database::set_bt_compare() 更改数据库中元素的排序。有关 Loki 函子的详细信息,请参阅 Modern C++ Design 书籍(请参阅在线资源)。由于比较函数是一个相对底层的操作,因此不会发生隐式转换,您必须比较两个 Dbt 值。STLdb4 中提供了一系列用于数值比较的函数,例如 getInt32Compare() 和使用 getCISCompare() 的区分大小写和不区分大小写的字符串比较。可以通过将其传递给 makeReverseCompare() 以创建新的函子来反转比较函子的排序。这些操作必须在数据库打开之前执行,因此您必须使用 open() 或 create() 调用以及非打开的 Database 构造函数,如清单 12 所示。

使用 Database::set_cachesize() 增加默认的 Berkeley DB 缓存大小可以显着提高只读数据库的性能。

清单 12. STLdb4 和数据库环境

fh_database db = new Database();
Database::m_bt_compare_functor_t tmpf
  = getInt32Compare();
db->set_bt_compare( makeReverseCompare( tmpf ) );
db->create( DB_BTREE, "/tmp/play.db" );
未来方向

采用类似于 std::map<> 的参数的 Database 的模板子类会很好。可能需要进行一些额外的工作,以允许按需(反)序列化键和值,也许可以假设两者都可以进行 Boost 序列化。

本文资源: /article/9512

Ben Martin 从事文件系统工作已有十多年。他目前正在澳大利亚卧龙岗大学攻读博士学位,将语义文件系统与形式概念分析相结合,以改善人与文件系统的交互。

加载 Disqus 评论