实现键值对存储(四):API设计


我终于为这个键值对存储项目确定了一个名字,从现在开始我将叫它FelixDB KingDB。(译注:改成这么土的名字也是醉了)

在本文中,我将会带着大家看一看四个键值对存储和数据库系统的API:LevelDB, Kyoto Cabinet, BerkekeyDB 和 SQLite3。对于其API中的每个主要功能,我将会比较他们的命名习惯和方法原型,以平衡其优缺点并为正在开发的键值对存储KingDB设计API。本文将包括:

  1. API设计的一般准则
  2. 定义KingDB公共API的功能
  3. 比较现有数据库的API 3.1 打开和关闭数据库 3.2 读写操作 3.3 遍历 3.4 参数处理 3.5 错误管理
  4. 结论
  5. 参考文献

1.API设计的一般准则

设计一个好的API很难,相当难。但我在这说的不是什么新东西,而只是在重复之前很多人告诉我的东西。到目前为止我发现的最好的资料是Joshua Bloch的演讲“How to Design a Good API & Why it Matters(如何设计一个好的API及为什么这很重要)”[1],及其摘要版本[2]。如果你还没有看过这个演讲,我强烈建议你找时间去看一下。在这个演讲中,Bloch清晰的陈述了听众需要记住的两个很重要的东西。我复制了摘要版本的要点并添加了一些评论:

  1. 不确定的时候,先放一边。当不确定某功能、类、方法或参数是否要添加在API中的时候,不要添加。
  2. 不要让用户做库可以做的事情。如果你的API让客户执行一系列函数调用的时候,需要将每个函数的输出塞到下一个函数的输入里,那你应该在API中添加一个函数来执行这一系列的函数调用。

另一个关于API设计的好资源是Joshua Bloch写的《Effective Java》4和Scott Meyers写的《Effective C++》3第四章“Designs and Declarations”。

这些资源对于当前阶段的这个键值对存储项目来说十分重要,尽管我觉得这些资源没有包含一个很重要的因素:用户期望。将API从草图上设计出来是很难的,但这个键值对存储的例子来说,是有例可循的。用户一直和他们的键值对存储或数据库系统的API打交道。因此,当面对一个新的键值对存储的时候,用户希望有一个类似的环境,而不关心这潜规则只会提高用户对新API的学习曲线,并让用户不高兴。

鉴于这个原因,即便我牢记上文列出的这些资料中的所有好建议,但我仍认为我必须尽可能多的复制已有库的API,因为这可以在用户使用我正创建的API时更简单。

2.定义KingDB公共API的功能

考虑到这只是万里长征的第一步,我打算实现一个最小且可靠的键值对存储,我当然不会包含所有的,像Kyoto Cabinet 和LevelDB那样的成熟项目提供的高级功能。我打算先让基本功能实现,然后我将逐渐增加其他功能。对于我来说,基本功能严格限制在:

  • 打开和关闭数据库
  • 读写数据库
  • 遍历数据库中所有的键值对集合
  • 提供参数调整的方法
  • 提供一个合适的错误通知接口

我意识到这些功能对于一些用例来说过于局限了,但暂时应该对付的过来。我不打算添加任何事务机制、分类查询、或原子操作。同样,现在我不打算提供快照功能。

3.比较现有数据库的API

为了比较现有数据库的C++ API,我将会比较每个功能的示例代码。 这些示例代码是修改自或直接取自于官方代码“Fundamental Specifications of Kyoto Cabinet” [5], “LevelDB’s Detailed Documentation” [6], “Getting Started with Berkeley DB” [7], 和 “SQLite in 5 minutes or less” [8]。 我同样会使用不同的颜色来标示来自不同的API。

3.1 打开和关闭数据库

下述示例代码显示出研究的系统是如何打开数据库的。为了更清晰的显示代码原理,选项设置和错误管理没有在此显示,并且会在下述各节中解释更多的细节。

/* LevelDB */
leveldb::DB* db;
leveldb::DB::Open(leveldb::Options, "/tmp/testdb", &db);
...
delete db;
/* Kyoto Cabinet */
HashDB db;
db.open("dbfile.kch", HashDB::OWRITER | HashDB::OCREATE);
...
db.close()
/* SQLite3 */
sqlite3 *db;
sqlite3_open("askyb.db", &db);
...
sqlite3_close(db);
/* Berkeley DB */
Db db(NULL, 0);
db.open(NULL, "my_db.db", NULL, DB_BTREE, DB_CREATE, 0);
...
db.close(0);

在打开数据库部分出现了两种清晰的模式。一方面,LevelDB 和SQLite3的API请求创建一个数据库对象的指针(句柄)。然后调用打开函数的时候将这个指针的引用作为参数,以定位对象的内存空间,然后设置这个数据库对象。另一方面,Kyoto Cabinet 和Berkeley DB的API以实例化一个数据库对象为开始,然后调对象的用open()方法来设置这个数据库对象。

说到关闭数据库部分,LevelDB只需要请求删除指针就行了,但SQLite3必须调用关闭函数。Kyoto Cabinet 和BerkeleyDB的数据库对象自身有一个close()方法。

我相信像LevelDB 和SQLite3那样强制使用数据库对象的指针,然后将指针传递给打开函数是很“C风格”的。另外,我认为LevelDB处理关闭的方法—通过删除指针—是一个设计缺陷。因为这会导致API的不对称。在API中,函数的对称应该尽可能的对称,因为这样更加直观和逻辑。“如果我调用了open() 那我就应该调用close()”的想法比“如果我调用了open() 那我就应该删除指针”的想法合乎逻辑一万倍。

设计决策

因此我决定使用在KingDB上的是类似于Kyoto Cabinet 和Berkeley DB的,先实例化一个数据库对象,然后调用对象的Open() 和Close()方法。至于命名,我仍使用传统的Open() 和Close()。

3.2 读写

在本节,我比较他们读写功能的API。

/* LevelDB */
std::string value;
db->Get(leveldb::ReadOptions(), "key1", &value);
db->Put(leveldb::WriteOptions(), "key2", value);
/* Kyoto Cabinet */
string value;
db.get("key1", &value);
db.set("key2", "value");
/* SQLite3 */
int szErrMsg;
char *query = INSERT INTO table col1, col2 VALUES (value1, value2);
sqlite3_exec(db, query, NULL, 0, &szErrMsg);
/* Berkeley DB */
/* reading */
Dbt key, data;

key.set_data(&money);
key.set_size(sizeof(float));

data.set_data(description);
data.set_ulen(DESCRIPTION_SIZE + 1);
data.set_flags(DB_DBT_USERMEM);

db.get(NULL, &key, &data, 0);


/* writing */
char *description = "Grocery bill.";
float money = 122.45;

Dbt key(&money, sizeof(float));
Dbt data(description, strlen(description) + 1);

db.put(NULL, &key, &data, DB_NOOVERWRITE);

int const DESCRIPTION_SIZE = 199;
float money = 122.45;
char description[DESCRIPTION_SIZE + 1];

我不会考虑SQLite3的设计,因为其是基于SQL的,因此其读写是通过SQL请求进行的,而非方法调用。Berkeley DB请求Dbt类对象的创建,并在上面进行一大堆设置,因此我也不会考虑这个设计。剩下的只有LevelDB 和Kyoto Cabinet,而他们有很漂亮的getter/setter对称接口。LevelDB 有Get() 和Put(), 而Kyoto Cabinet 有get() 和set()。Setter方法的原型——Put() 和set()十分相似:键名是值传递,而键值是传递的指针使得调用时可以更改。键值并不通过调用返回,返回值是给错误管理使用的。

设计决策

对于KingDB,我打算使用和LevelDB 及Kyoto Cabinet相似的方法,对于setter方法使用一个相似的原型,即用值传递键值而用指针传递键值。至于命名,一开始我觉得Get() 和Set()是最好的选择,但仔细思考之后我更倾向于LevelDB那样,使用Get() 和Put()。其原因是Get/Set 和Get/Put都很对称,但“Get” 和 “Set”两个词太相似,只差了一个字母。因此阅读代码的时候使用“Get” 和“Put”会更加清晰且更易辨认,因此我会使用Get/Put。

3.3 遍历

/* LevelDB */
leveldb::Iterator* it = db->NewIterator(leveldb::ReadOptions());
for (it->SeekToFirst(); it->Valid(); it->Next()) {
  cout << it->key().ToString() << ": "  << it->value().ToString() << endl;
}
delete it;
/* Kyoto Cabinet */
DB::Cursor* cur = db.cursor();
cur->jump();
string ckey, cvalue;
while (cur->get(&ckey, &cvalue, true)) {
  cout << ckey << ":" << cvalue << endl;
}
delete cur;
/* SQLite3 */
static int callback(void *NotUsed, int argc, char **argv, char **szColName) {
  for(int i = 0; i < argc; i++) {
    printf("%s = %s\n", szColName[i], argv[i] ? argv[i] : "NULL");
  }
  printf("\n");
  return 0;
}

char *query = SELECT * FROM table;
sqlite3_exec(db, query, callback, 0, &szErrMsg);
/* Berkeley DB */
Dbc *cursorp;
db.cursor(NULL, &cursorp, 0);
Dbt key, data;
while (cursorp->get(&key, &data, DB_NEXT) == 0) {
  // do things
}
cursorp->close();

在上一节中,SQLite3不被考虑是因为其不满足键值对存储的需求。但看看它是如何将一个SELECT请求发送到数据库,然后在取回来的每一行上调用回调函数是比较有趣的。大多数MySQL 和 PostgreSQL的API用循环并调用一个能够填充本地变量的函数来做到,而非这样使用一个回调函数。我发现这种回调函数比较棘手,因为这对于那些想执行合计操作或对取回来的行进行计算的用户来说,会让事情变得复杂。但这是另一方面的讨论,现在回到我们的键值对存储上来!

这里有两种方法:使用游标或者使用遍历器。Kyoto Cabinet 和BerkeleyDB使用游标,一开始创建一个指向游标对象的指针并实例化对象,然后在while循环中重复调用游标的get()方法来获取数据库中所有的值。LevelDB使用遍历器设计模式,一开始创建一个指向遍历器对象的指针并实例化对象(这部分和游标一样),但是使用一个for循环来遍历集合中的项目。注意这里的while和for循环只是习惯:游标可以使用for循环而遍历器也可以使用while循环。其主要的不同是,在游标中,键和值是指针传递然后在游标的get()方法中填充内容,但在迭代器中,键和值是通过迭代器方法的返回值来访问的。

设计决策

同样,游标和其while循环是相当“C风格”的。我发现迭代器的方法更加清晰并更符合“C++风格”,因为这正是C++中STL的集合的访问方式。因此对于KingDB来说,我选择使用LevelDB那样的遍历器。至于命名,我简单的复制了LevelDB中的方法名。

3.4 参数处理

参数在IKVS系列文章中第三部分3.4节已经简要叙述了,但我还想在这提一下。

/* LevelDB */
leveldb::DB* db;
leveldb::Options options;
options.create_if_missing = true;
options.compression = leveldb::kNoCompression;
leveldb::DB::Open(options, "/tmp/testdb", &db);
...
leveldb::WriteOptions write_options;
write_options.sync = true;
db->Put(write_options, "key", "value");
/* Kyoto Cabinet */
db.tune_options(GrassDB::TCCOMPESS);
db.tune_buckets(500LL * 1000);
db.tune_page(32768);
db.tune_page_cache(1LL << 20);
db.open(...);
/* SQLite3 */ 
sqlite3_config(SQLITE_CONFIG_MULTITHREAD);
sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 1);
sqlite3_config(SQLITE_CONFIG_LOG, SqliteLogger, NULL);
sqlite3_initialize();
sqlite3_open(...);
/* Berkeley DB */
db.set_flags(DB_DUPSORT);
db.set_bt_compare(compare_fct);
db.open(NULL, file_name, NULL, DB_BTREE, DB_CREATE, 0);

SQLite3是通过sqlite3_config()修改全局参数,然后在所有后续连接建立的时候应用。Kyoto Cabinet 和Berkeley DB中,选项是在调用open()之前通过调用数据库对象的方法来设置选项的,和SQlite3的做法比较相似。在这些方法之上,更通用的选项是通过open()方法的参数来设置的(见上文3.1节)。这表示选项被分为两部分,一些通过方法的调用来设置,而另一些是通过open()的调用来设置。

LevelDB的做法不大一样。选项是在自己的类中一起定义,而参数是通过这些类的属性来更改。之后这些设置类的对象以方法参数的形式传递,并总是第一个参数。例如LevelDB数据对象的open()方法的第一个参数是leveldb::Options类的对象,而Get()和Put()方法的第一个参数分别是leveldb::ReadOptions 和leveldb::WriteOptions。这种设计的一个好处是在同时创建多个数据库的情况下可以很简单的共享设置,尽管在Kyoto Cabinet 和 Berkeley DB的例子中可以为一组设置创建一个方法,然后通过调用这个方法来设置这组设定。像LevelDB那样把设置放到一个特定的类中真正的优势在于,其接口更稳定,因为扩展设置只需要修改这个选项类,而不用修改数据库对象的任何方法。

尽管我想用这种选项类,但我必须说的是LevelDB这种总是将选项作为第一个参数在各个方法中传递的方式我不是很习惯。如果没有需要修改的选项,这导致代码中需要使用默认选项,就像这样:

db.Put(leveldb::WriteOptions, "key", "value");

这可能导致代码膨胀,而另一种可能是将选项作为最后一个参数,然后为这个参数设定一个缺省值,使得不需要设置选项的时候可以省掉这项。而另一种源自于C++的解决方式是函数的重载,有数个带有原型的方法使其可以省略掉选项的对象。把选项放到参数的最后对于我来说看上去更符合逻辑,因为其是可能省略的。但我相信LevelDB的作者把选项作为第一个参数是有很好的原因的。

设计决策

对于参数处理,我觉得将选项作为类是最简洁的方式,同时其符合面向对象设计。

对于KingDB来说,我会像LevelDB那样使用独立的类来处理选项,不过我会将作为方法的最后一个参数。我或许以后能明白将选项作为最后一个参数是真正正确的方法——或者有谁能帮我解释下——但现在我坚持将其放到最后。最后,命名子啊这儿不是很重要,因此Options, ReadOption 和WriteOption都可以。

3.5 错误管理

在IKVS系列第三部分3.6节,有关于错误管理的一些讨论,基本上是说用户看不到的代码是如何管理错误的。本节再次讨论这个话题但稍有不同,不讨论库中错误的细节,而是关于错误发生后是怎么报告给使用公共接口的用户的。

/* LevelDB */
leveldb::Status s = db->Put(leveldb::WriteOptions(), "key", "value");
if (!s.ok()) {
  cerr << s.ToString() << endl;
}
/* Kyoto Cabinet */
if (!db.set("baz", "jump")) {
  cerr << "set error: " << db.error().name() << endl;
}
/* SQLite3 */
int rc = sqlite3_exec(db, query, callback, 0, &zErrMsg);
if (rc != SQLITE_OK) {
  fprintf(stderr, "SQL error: %s\n", zErrMsg);
  sqlite3_free(zErrMsg);
}
/* Berkeley DB */
int ret = my_database.put(NULL, &key, &data, DB_NOOVERWRITE);
if (ret == DB_KEYEXIST) {
  my_database.err(ret, "Put failed because key %f already exists", money);
}

Kyoto Cabinet, Berkeley DB 和SQLite3使用相同的方法处理错误,即其方法返回一个整型的错误代码。如在IKVS系列第三部分3.6节所述,Kyoto Cabinet内部将值设置在数据库对象中,这就是为何上述示例代码中,错误信息是从db.error().name()取出的。

LevelDB有个一特别的Status类,包含错误类型和提供了关于此错误更多信息的消息。LevelDB库中的所有方法都返回了此类的一个对象,这使错误测试和将错误传递给系统各部分以进行进一步的检查更加简单。

设计决策

返回错误代码而避免使用C++的异常处理机制是十分正确的,然而整形并不足以携带有意义的信息。Kyoto Cabinet, Berkeley DB 和SQLite3都有其自己的存储错误信息的方法,然而即便是在在Kyoto Cabinet 和Berkeley例子中,创建了错误管理和数据库类的强耦合,,仍然会为取得信息添加额外的步骤。像LevelDB那样使用一个Status类可以避免使用C++异常处理,同时也避免了和架构其他部分的耦合。

4.结论

API的预设比较有意思,因为去看不同的工程师如何解决相同的问题总是很有意思的。这同样让我意识到Kyoto Cabinet 和Berkeley DB的API有多么相似。Kyoto Cabinet 的作者Mikio Hirabayashi清楚地声明了他的键值对存储是基于Berkeley DB的,而在看完API相似性之后这一点更加清晰了。

LevelDB的设计相当好,但我还是对于一些我认为可以以其他方式实现的细节有些意见。例如数据库打开和关闭以及方法原型。

我吸取了每个系统的一点长处,而我现在对于KingDB的API设计的各个选择感觉更加自信了。

5.参考文献

[1] http://www.infoq.com/presentations/effective-api-design [2] http://www.infoq.com/articles/API-Design-Joshua-Bloch [3] http://www.amazon.com/Effective-Specific-Improve-Programs-Designs/dp/0321334876 [4] http://www.amazon.com/Effective-Java-Edition-Joshua-Bloch/dp/0321356683 [5] http://fallabs.com/kyotocabinet/spex.html [6] http://leveldb.googlecode.com/svn/trunk/doc/index.html [7] http://docs.oracle.com/cd/E17076_02/html/gsg/CXX/index.html [8] http://www.sqlite.org/quickstart.html

评论!

社交