LevelDB-入门指南-全-

LevelDB 入门指南(全)

原文:zh.annas-archive.org/md5/1c696c9ecfc7191723eec29a358f2707

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

LevelDB 是跨平台最简单且潜在上最强大的数据库技术,适用于移动设备使用。本书通过 iOS 和 OS X 示例教你所有关于 LevelDB 的知识。

如果你需要将数据存储在持久字典中,构建难以用 SQL 建模的复杂数据结构,或者只是推动性能边界,LevelDB 提供了构建块。要超越基础,你需要理解允许在键值存储中进行更复杂数据建模的编程模式。

本书提供了理解和编码工具,以充分利用 LevelDB。它所教授的原则也可以应用于其他排序键值存储。本书适合程序员阅读,但其中包含足够的理论,对数据库管理员或软件架构师也很有趣。

本书涵盖的内容

第一章,下载 LevelDB 和用 OS X 构建,详细介绍了开源 LevelDB 的下载和构建过程,包括处理 Unix 惯用语。它包括构建一个简单的 OS X 应用程序来证明数据库正在工作。

第二章,安装 LevelDB 并为 iOS 构建,重复了库构建过程,以获取用于模拟器和 iOS 设备的 LevelDB 库。它包括构建一个简单的 iOS 应用程序来证明数据库正在工作。

第三章,基本键值操作 – 创建和删除数据,在你学习如何在 LevelDB 数据库中存储、检索和删除数据的同时,教你一点 C++。

第四章,迭代和搜索键,不仅展示了如何读取精确的键,还展示了如何有效地按顺序迭代数据库的所有或部分,并引入了存储键值以引用其他记录的概念。

第五章,使用 Objective-C,介绍了为 LevelDB 发布的三个主要 Objective-C 包装器,并比较了它们之间的使用以及与之前章节中使用的传统 C++ API 的对比。

第六章,与 Cocoa UI 集成,展示了如何将基于表单的应用程序中的记录列表从数据库中加载,并使用简单的 JSON 数组将文本输入字段映射到存储数据库记录。

第七章,使用 REPL 和命令行进行调试,提供了一些调试工具来帮助检查你的数据库。它包括一个通用目的的 Web 服务器库,可以集成到任何 iOS 应用程序中,以提供调试接口。

第八章, 更丰富的键和数据结构,通过完整的记录编辑和不同的列表排序扩展了我们的简单 GUI 示例。它基于该示例讨论了与 SQL 和自定义键比较器的键设计。

第九章, 文档数据库,构建了一个不同的示例程序,展示了使用 APLevelDB Objective-C 包装器的扩展进行模式设计。它还涵盖了如何将 LevelDB 文件夹视为单个包,在查找器中作为一个项目出现。

第十章, 调优和键策略,介绍了 LevelDB 实现的基础知识,作为讨论如何根据您的应用需求对其进行调优的依据。

附录 A, 脚本语言,简要介绍了如何从 Node.js、Python 和 Ruby 脚本语言中使用 LevelDB。

您需要本书

要构建本书中的示例,您需要 Apple Xcode 编译器的当前版本。开源软件的剩余下载将在每章中按需描述。要测试 iOS 设备上的示例程序,您需要成为 Apple 开发者计划的付费成员。

本书面向对象

本书主要面向需要灵活的数据库引擎的程序员,这种引擎易于上手,但能够扩展到数亿条记录。对于想要了解更多关于排序键值存储的数据库设计者、架构师和技术经理来说,本书将很有价值。

习惯用法

在本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词如下所示:“您可以在您的 LevelDB 库上进行的进一步检查是运行db_bench命令。”

代码块设置如下:

#include <iostream>
#include <cassert>
#include "leveldb/db.h"

int main(intargc, const char * argv[])
{
    leveldb::DB* db;
leveldb::Options options;
options.create_if_missing = true;

任何命令行输入或输出都如下所示:

$./configure
$make
$make install

新术语重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“在 Xcode 中,导航到文件 | 新建 | 工作区,并在您可以使用作为开发基础的地方创建一个工作区。”

注意

警告或重要注意事项如下所示。

小贴士

小技巧和窍门如下所示。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者的反馈对我们开发您真正能从中获得最大价值的标题至关重要。

要向我们发送一般反馈,只需发送电子邮件到<feedback@packtpub.com>,并在邮件主题中提及书名。

如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载示例代码

您可以从www.packtpub.com的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

错误清单

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误清单,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误清单提交表单链接,并输入您的错误清单详情。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站,或添加到该标题的错误清单部分。任何现有的错误清单都可以通过从www.packtpub.com/support选择您的标题来查看。

盗版

互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何我们作品的非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过<copyright@packtpub.com>与我们联系,并提供疑似盗版材料的链接。

我们感谢您在保护我们的作者以及为我们提供有价值内容的能力方面提供的帮助。

问题

如果您在本书的任何方面遇到问题,可以通过<questions@packtpub.com>联系我们,我们将尽力解决。

第一章。下载 LevelDB 和用 OS X 构建

本章将指导你下载 LevelDB 并使用针对 OS X 构建的特定命令行技术构建它。然后,它将展示如何设置一个简单的 OS X 应用程序的 Xcode 项目,iOS 的详细信息请参阅第二章,安装 LevelDB 和为 iOS 构建

构建错误消息以及我们如何处理它们将对任何使用开源项目的基于 Mac 的开发者都有用。这些通常假设开发者熟悉 Unix 开发工具和安装惯例。我们将从高度详细的内容开始,以便让那些只在其他平台上的 Xcode 或类似 IDE 上使用过的人更容易理解。后面的章节将总结步骤,因此你可能需要回来复习。

本章中的说明将假设你正在使用 OS X 的终端。我们将用作终端提示符的$将根据你的本地终端设置而变化,通常显示当前工作目录。

本章中的示例使用了最小限度的 C++(使用 C++11 的更易用的风格)。安装步骤和源代码的完整日志文件可在 Packt Publishing 网站上找到,后面的章节有更大的示例作为完整的应用程序。

小贴士

本章中的说明与通用 Unix 命令类似,但你可能会发现命令、目录结构和权限略有不同。大多数 Linux 发行版具有类似的目录布局,但 OS X 与通用 Unix 实践甚至与早期的 OS X 标准有所不同。

安装 LevelDB

想要积极使用最新源代码的人可以使用 Git 克隆仓库,从以下说明开始:code.google.com/p/leveldb/source/checkout。项目维护者通常在少量更改后更新发布存档,因此除非你计划积极贡献,否则很少有动力与仓库一起工作。一个基于本书中使用的源代码,面向为 Apple 构建的 Git 克隆是:code.google.com/r/dentaroo-appleflavouredleveldb/

要决定你是否想更新你的 LevelDB 副本,你可以查看变更历史记录在code.google.com/p/leveldb/source/list。以下的大部分截图和示例来自 2013 年 5 月 14 日发布的版本 1.10.1。对后续版本的任何依赖都将进行讨论。至少有一个针对 LevelDB 的补丁是直接由于这本书的贡献,问题 177,在后续编译器上为 iOS 构建。

LevelDB、其他库和我们的示例主要使用 Xcode 版本 4.6.3 编译,并使用 Xcode 5 的开发者预览版进行验证,因为它们是可用的。

稳定的 LevelDB 版本始终可在下载页面获得:code.google.com/p/leveldb/downloads/list

打开该页面,然后点击 1.10.1,这将带您到一个特定的页面,允许您点击.tar.gz文件并下载它。

使用标准的 Unix 工具tar,将解压缩.gz步骤,然后在一个命令中解包.tar存档。如果您想了解更多信息,请查看tar --help

$ tar xvf leveldb-1.10.0.tar.gz
x leveldb-1.10.0/
…
x leveldb-1.10.0/util/coding.cc

现在文件已解压,将目录更改到其中:

$ cd leveldb-1.10.0
$ pwd
/Users/andydent/dev/openSourceDev/leveldb-1.10.0

您可以清理这里的.tar文件,因为它不再需要,但我建议存档一份您的zip文件副本,以供以后比较和恢复。

构建 LevelDB 库

与许多开源项目不同,LevelDB 没有附带配置脚本。要构建我们的第一个版本,只需在命令行中输入make(见make.txt日志)。理解makefile非常重要,它是一个纯文本文件,您可以用任何编辑器打开它。在顶部有一个注释部分,允许您设置 OPT 以指定调试或生产构建(默认)。

目标是出现在行左侧的标签,以冒号结尾,例如db_bench。大多数 makefile 至少有allclean目标。clean目标会删除所有之前的构建产品,这样您就可以保证使用更改后的设置进行构建。LevelDB 源代码附带了一系列测试,通过make check调用(见make check.txt日志)。在make check的输出中,您将看到:

==== Test TableTest.ApproximateOffsetOfCompressed
skipping compression tests

由于 LevelDB 的默认安装缺少用于快速压缩表中值的 snappy 压缩库,因此跳过了压缩测试。

您可以对 LevelDB 库进行的进一步检查是运行db_bench命令,这是一个由 makefile 构建的时间工具。它作为make check的一部分构建,也可以通过命令build db_bench在任何时候构建。如果您现在运行db_bench并保存输出,您可以在包含 snappy 前后比较基准测试结果。我们还将查看使用针对您应用程序特定的数据使用 snappy 的影响,在第十章 调整和关键策略 中,我们将探讨调整。

安装 snappy

如果您的数据库具有非常大的值,例如存储在单个记录中的完整文档,snappy 压缩库非常有用。您经常在 LevelDB 的讨论中看到它被提及。

为了完整性,我们将介绍如何安装 snappy 以及使用默认选项构建它。不幸的是,在撰写本文时,它无法使用我们在后续章节中将使用的 C++11 和 libc++选项进行构建。因此,在您在此处对 snappy 进行任何实验后,请使用以下说明来删除它,以避免与 libc++相关的编译错误。

要安装 snappy,我们通过一个类似的过程从code.google.com/p/snappy/downloads/list下载存档,然后解压,使用第二个终端窗口以便更容易跟踪不同的库。这次,有一个配置脚本。我们使用以下命令构建和安装:

$./configure
$make
$make install

经过这三个过程(见日志)后,你将在/usr的标准位置找到 snappy 的包含文件和构建库,LevelDB makefile 会在这里查找它们。在 LevelDB 目录的终端窗口中,使用以下命令重新构建你的 LevelDB 库:

$make clean
$make

你会在make命令的日志中看到–DSNAPPY,这表示它检测到了 snappy 的安装并更改了选项以匹配。如果你重复执行make check,你会看到压缩测试正在工作。

删除 snappy

如果你已经安装了 snappy 进行这些测试,如上所述,你可能想将其删除。makefile 中内置了一个卸载目标,可以从标准位置将其删除,这是 LevelDB makefile 检查的位置。

在一个工作目录设置为你的 snappy 目录的终端中:

$ make uninstall
( cd '/usr/local/share/doc/snappy' &&rm -f ChangeLog COPYINGINSTALL NEWS README format_description.txt framing_format.txt )
( cd '/usr/local/include' &&rm -f snappy.h snappy-sinksource.h snappy-stubs-public.h snappy-c.h )
 /bin/sh ./libtool   --mode=uninstall rm -f '/usr/local/lib/libsnappy.la'
libtool: uninstall: rm -f /usr/local/lib/libsnappy.la /usr/local/lib/libsnappy.1.dylib /usr/local/lib/libsnappy.dylib /usr/local/lib/libsnappy.a

现在将目录切换回你的 LevelDB 源目录,执行make clean,然后重复原始的make命令以重新构建你的库。

小贴士

在构建之前清理是一个好习惯。几乎所有的 makefile 都会在源文件被污染后重新构建,但不会对环境变化做出响应,因此需要通过清理强制进行全面重建。

移动到 Xcode

现在构建过程已经成功构建了库、实用程序和测试程序,你可以继续以纯 Unix 方式编程命令行工具,通过编辑cpp文件并使用 make 命令构建它们。对于 OS X GUI 和所有 iOS 应用,我们必须使用 Xcode 进行构建。

小贴士

我们将首先创建一个工作空间。使用工作空间来封装项目是一个好习惯,因为新的CocoaPods标准用于交付开源模块,它依赖于它们。在这个阶段,我们没有技术上的理由必须使用工作空间,只是养成好习惯。

在 Xcode 中,导航到文件 | 新建 | 工作空间,在你可以用作开发基础的地方创建一个工作空间。

小贴士

我建议避免在路径名称中使用空格,因为有时这会导致脚本或实用程序执行一些意外的操作。这也是给 Windows 开发者的一个很好的建议,即使他们使用的是最新的 Visual Studio。不是核心工具让你陷入困境,而是相关的脚本、命令行或环境变量。

现在导航到文件 | 新建 | 项目,这将显示一个模板选择器。在左侧面板中选择 OS X 应用程序,然后点击提供的图标中的命令行工具,然后点击下一步

移动到 Xcode

选择命令行工具模板

选择一个 C++ 项目,并取消选择 使用自动引用计数 复选框。确保您指定了 产品名称公司标识符。当您输入这些条目时,您会看到 捆绑标识符 正在从它们生成:Packt.LevelDB-OSX-Sample01。如下面的截图所示:

移动到 Xcode

输入选项并查看捆绑标识符

下一步 按钮将您带到保存对话框,您可以在其中指定项目将被创建的位置。保留 控制 选项,并选择 添加到:我们称为 levelDB_OSX 的工作区。

您将看到 Xcode 中出现一个项目窗口,显示 构建设置。在左上角是 运行 按钮。只需点击它,以证明您的命令行工具可以编译和运行。在底部,您应该看到嵌入的终端窗口的 所有输出 显示 Hello, World!

如果这是您第一次使用 Xcode,恭喜!您刚刚编译并运行了一个简单的 C++ 程序。现在我们将从文档 doc/index.html 中复制一段代码,并使用它来证明我们的简单 Hello World 是一个 Hello LevelDB

我们将从以下行开始:

#include <assert>
#include "leveldb/db.h"

注意,一个红色的警告图标迅速出现在 <assert> 行的左侧。点击它告诉我们 assert 文件未找到,并且在 导航器 的左侧面板中可以看到类似的消息。将 <assert> 改为 <cassert>,消息就会消失(这会查找标准的 C++ 头文件而不是传统的 Unix assert 头文件)。

小贴士

下载示例代码

您可以从您在 www.packtpub.com 的账户中下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册以将文件直接通过电子邮件发送给您。

移动到 Xcode

由于找不到 db.h 头文件导致的错误

现在红色图标位于 leveldb/db.h 包含旁边,并警告我们它不知道该文件。我们将在一分钟内修复它,Xcode 不知道在哪里可以找到 LevelDB 头文件。现在,只需将 index.html 中的其他行复制到创建数据库,然后是最终的 delete db; 来再次关闭它。

最终的代码看起来像:

#include <iostream>
#include <cassert>
#include "leveldb/db.h"

int main(intargc, const char * argv[])
{
    leveldb::DB* db;
leveldb::Options options;
options.create_if_missing = true;
leveldb::Status status = leveldb::DB::Open(options,
"/tmp/testdb", &db);
assert(status.ok());
std::cout<< "Hello, World with leveldb in it!\n";
delete db;
return 0;
}

我们需要将 Xcode 指向头文件的位置,这意味着在设置中设置一个路径,同时也需要决定文件应该放在哪里。这很大程度上是一个个人喜好问题。您可以将它们留在您解压和构建的地方,或者将副本放在一个标准位置。我将将它们复制到 Unix 头文件的标准位置:/usr/local/include。只需将 LevelDB 目录从我们的 LevelDB 安装中的包含目录(记得我们之前解压了它)拖到 /usr/local/include。我们复制的目录包含 db.henv.h 以及一些其他的 .h 文件:

移动到 Xcode

用户头文件搜索路径

复制这些文件仍然没有解决我们的编译警告。我们需要修改我们的项目,让它知道在哪里查找包含文件。在导航器(树的顶部)中点击 Xcode 目标LevelDB_OSX_Sample01,然后在出现的目标面板中点击其名称,以便看到构建设置选项卡。向下滚动大约一半到达搜索路径部分,并在用户头文件搜索路径中添加一个条目/usr/local/include,递归设置为关闭。它将直接显示为/usr/local/include/

现在,位于leveldb/db.h文本旁边的红色图标应该消失,但我们仍然无法构建,我们需要添加库。点击构建****阶段选项卡,打开链接二进制与库部分。将libleveldb.a文件拖动到这个部分(从你放在/usr/local/lib中的副本),如图所示:

移动到 Xcode

静态库添加到构建阶段

你可能会认为这已经足够构建了,但尝试会导致错误:

Undefined symbols for architecture x86_64:
  "leveldb::DB::Open(leveldb::Options const&, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char>> const&, leveldb::DB**)", referenced from:
      _main in main.o
ld: symbol(s) not found for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

问题在于默认的构建版本链接了 libstdc++,而默认的模板使用了 libc++。LevelDB 库在其接口中使用了std::string对象,因此你必须确保库和应用程序都使用相同的标准库,以避免崩溃和不可预测的运行时错误:

移动到 Xcode

选择 libstdc++库

返回到构建设置选项卡,并滚动到Apple LLVM 编译器 4.2 – 语言面板。C++标准库允许你选择libstdc++ (GNU C++标准库)

选择它后,你应该能够通过点击大型的运行图标最终构建并运行你的小测试程序。然后去查看/tmp/testdb文件夹,看看创建的数据库文件。

摘要

在本章中,我们经历了一个典型的使用以 Unix 为导向的开源软件的过程,通过命令行下载并构建了 LevelDB。我们克服了一些常见的构建错误,并了解了 C++库模型之间的差异。

在库构建和安装后,我们学习了如何将它们包含到 Xcode 项目中并构建一个简单的 OS X 命令行程序。接下来,我们将学习如何为 iOS 应用程序调整这个过程。

第二章。安装 LevelDB 并为 iOS 构建

下载并解压 LevelDB 的基本步骤与 第一章 中所述的相同,即 下载 LevelDB 并在 OS X 上构建,但我们将重新构建库,并且需要根据 iOS 进行一些构建步骤的调整。为 iOS 构建被称为 交叉编译,因为生成的代码是为与编译器运行的处理器架构不同的架构。

为 iOS 构建静态 LevelDB 库

首先,我们将重新构建 libleveldb.a 文件。这次,我们将不带 snappy 进行构建,并为多个架构构建:模拟器的 32 位 x86 和 iOS 设备的 armv6 和 armv7。最后,我们将将其重命名为 iOS。

首先,在 snappy 目录的终端中移除 snappy 压缩:

$ make uninstall
 ( cd '/usr/local/share/doc/snappy' && rm -f ChangeLog COPYING INSTALL NEWS README format_description.txt framing_format.txt )
 ( cd '/usr/local/include' && rm -f snappy.h snappy-sinksource.h snappy-stubs-public.h snappy-c.h )
 /bin/sh ./libtool   --mode=uninstall rm -f '/usr/local/lib/libsnappy.la'
libtool: uninstall: rm -f /usr/local/lib/libsnappy.la /usr/local/lib/libsnappy.1.dylib /usr/local/lib/libsnappy.dylib /usr/local/lib/libsnappy.a

现在,将目录切换回您的 LevelDB 源目录,并清理,然后尝试为 iOS 构建。如果您使用的是 LevelDB 1.10,这将因 makefile 中的错误而失败:

$ make clean
…
$ CXXFLAGS="-std=c++11 -stdlib=libc++" make PLATFORM=IOS
…
make: /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/bin/c++: No such file or directory
make: *** [db/builder.o] Error 1

注意

LevelDB 1.10 中出现的错误已在 LevelDB 1.14.0 中修复

LevelDB v1.10 版本中包含的 makefile 存在一个错误(记录为问题 177 code.google.com/p/leveldb/issues/detail?id=177),因为命令行编译器在 Xcode 4.3 之后移动了。苹果将这些编译器移动了多次,因此不同的开源项目版本会遇到这个问题。您可以从 Packt Publishing 网站获取相关的 MakefileASD 或按照以下描述编辑 makefile 的副本:

  1. 在文本编辑器中打开 makefile,并向下滚动到以下部分:

    ifeq ($(PLATFORM), IOS)
    # For iOS, create universal object files
    
  2. 您需要更改以下开始的两个行:

    $(DEVICEROOT)/usr/bin/$(C
    
  3. 并且只需移除 $(DEVICEROOT)/usr/bin/,使它们从 $(CC)$(CXX) 开始。

  4. 假设您将此文件保存为 MakefileASD,您可以重复 make:

    $ CXXFLAGS="-std=c++11 -stdlib=libc++" make –f MakefileASD PLATFORM=IOS
    $ mv libleveldb.a /usr/local/lib/libleveldb_IOS.a
    

这将生成并复制静态库 libleveldb_IOS.a,使用标准的 C++11 libc++ 库,而不是默认库,因此它与默认的 Xcode 项目相匹配。如果您查看日志,您将看到使用了 lipo 命令来组合不同处理器类型的二进制文件。您也可以使用 lipo 检查库:

$ lipo -info /usr/local/lib/libleveldb_IOS.a
Architectures in the fat file: libleveldb_IOS.a are: armv6 armv7 i386

Lipo 可以检查或操作单个目标文件或编译库。

创建最小的 iOS 测试平台

首先,我们将创建一个尽可能简单的 iOS 程序,该程序可以在设备上运行并显示反馈。它将仅显示一个警报,表示我们已创建数据库。

如果您像前一章中推荐的那样使用工作区来组织项目,请在 Xcode 中打开该工作区。

现在,导航到文件 | 新建 | 项目…,这将显示模板选择器。在左侧面板中选择 iOS 应用程序,然后点击提供的图标中的空应用程序,然后点击下一步。确保勾选使用自动引用计数复选框,并取消勾选包含单元测试。确保指定产品名称公司标识符。当你输入这些条目时,你会看到捆绑标识符是从它们生成的,例如,Packt.LevelDB-iOS-Sample02

在项目导航器中点击以选择AppDelegate(例如,GSwLDBAppDelegate.m)源文件。这是我们为这次测试将要定制的唯一程序源文件。

对于这样一个简单的测试,我们只需要定制方法 application: didFinishLaunchingWithOptions,它最初包含:

- (BOOL)application:(UIApplication *)application
    didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  self.window = [[UIWindow alloc] 
    initWithFrame:[[UIScreen mainScreen] bounds]];
  self.window.backgroundColor = [UIColor whiteColor];
  [self.window makeKeyAndVisible];
  return YES;
}

return 语句之前,添加以下行:

UIAlertView* helloWorldAlert = [[UIAlertView alloc]
    initWithTitle:@"Getting Started with LevelDB"
    message:@"Hello, LevelDB World!"
    delegate:nil
    cancelButtonTitle:@"OK" 
    otherButtonTitles:nil];
  [helloWorldAlert show];
  return YES;

运行按钮附近的弹出菜单中选择一个模拟器方案,然后点击运行以编译并看到应用程序运行并显示小警报。

将 LevelDB 添加到 iOS 测试平台

我们基本上重复了为 OS X 项目所做的步骤:

  • 将查找 LevelDB 标头的包含路径 /usr/local/include 添加到用户头文件搜索路径

  • 将库 /usr/local/lib/libleveldb_IOS.a 添加到链接二进制与库面板

  • /usr/local/lib 添加到库搜索路径

要将一些 C++ 代码包含到我们的 Objective-C 文件中,我们需要告诉编译器将其视为 Objective-C++,这可以通过将文件扩展名从 .m 更改为 .mm 来实现。在项目导航器中点击文件名,然后按 Enter 键以能够编辑名称。

现在,你可以像在 Sample01 中那样添加 #include 语句:

#include <cassert>
#include "leveldb/db.h"

添加与我们在 OS X 中所做的相同的数据库语句,这样整个方法看起来就像以下代码:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    self.window = [[UIWindow alloc] initWithFrame:[
     [UIScreen mainScreen] bounds]];
    // Override point for customization after application launch.
    self.window.backgroundColor = [UIColor whiteColor];
    [self.window makeKeyAndVisible];
    leveldb::DB* db;
    leveldb::Options options;
    options.create_if_missing = true;
    leveldb::Status status = leveldb::DB::Open(
    options, "/tmp/testdbios", &db);
    assert(status.ok());

    UIAlertView* helloWorldAlert = [[UIAlertView alloc]
      initWithTitle:@"Getting Started with LevelDB"
      message:@"Hello, LevelDB World!"
      delegate:nil
      cancelButtonTitle:@"OK" otherButtonTitles:nil];
    [helloWorldAlert show];
    delete db;    
    return YES;
}

你可以再次点击运行并看到它在模拟器中运行。运行后,去查看目录 /tmp/testdbios,你将看到那里创建的文件。然而,这之所以可行,是因为模拟器是以对 OS X 文件系统完全访问的方式运行的。你无法以这种方式在 iOS 设备上的任意目录中创建文件。

连接一个已注册的 iOS 设备,并将方案更改为指向该设备,以便可以在设备上运行应用程序。现在运行应用程序。你会看到应用程序出现,但没有预期的警报。Xcode 的输出窗口显示一个错误消息,例如:

Assertion failed: (status.ok()), function -[GSwLDBAppDelegate application:didFinishLaunchingWithOptions:], file /Users/andydent/dev/learning/leveldb_OSX/LevelDB_IOS_Sample02/LevelDB_IOS_Sample02/GSwLDBAppDelegate.mm, line 27.

这是因为应用程序无法访问路径 /tmp。iOS 文件系统为每个应用程序都配备了沙盒,其中包含一些对操作系统具有不同意义的目录。其中一些是缓存,当空间不足时可以清除,而无需通知你的应用程序。还有一些是同步到 iCloud 的。

现在,我们将数据放入标准缓存区域 NSCachesDirectory(当前映射到 Library/Caches),这是用于临时数据,可能会被清除。

在你的 GSwLDBAppDelegate.mm 源文件中尽早添加以下辅助方法:

- (NSString*)pathForCachedDir:(NSString*)dirname {
  staticNSString* cachePath = nil;
  if (cachePath == nil) {  // save path first time per-runNSArray* paths =
      NSSearchPathForDirectoriesInDomains(NSCachesDirectory,NSUserDomainMask, YES);
    cachePath = [paths objectAtIndex:0];
    BOOL isDir = NO;
    NSError *error;if (! [[NSFileManagerdefaultManager] 
      fileExistsAtPath:cachePath isDirectory:&isDir] && 
      isDir == NO) { // create cache – first run on this device[[NSFileManagerdefaultManager] 
          createDirectoryAtPath:cachePath
          withIntermediateDirectories:NO
          attributes:nilerror:&error];}  }
  return [cachePath stringByAppendingPathComponent:dirname];
}

现在修复我们的数据库打开代码,替换以下行:

leveldb::Status status = leveldb::DB::Open(options, 
    "/tmp/testdbios", &db);

使用这两行代码,这些代码使用我们的辅助方法来查找缓存目录:

NSString* tempPath = [self pathForCachedDir:
   @"testdbios"];
leveldb::Status status = leveldb::DB::Open(options, 
  [tempPath UTF8String], &db);

现在在设备上运行将正常工作并显示警报。在模拟器上运行仍然有效,但会将文件放在一个会变化的位置。如果你在最后一行设置断点,你可以在设备上的调试器中看到 tempPath 的值,它将显示为:

/var/mobile/Applications/37742F10-B3D6-4945-A4CA-5F7764D4E33A/Library/Caches/testdbios 

但在模拟器中,它是在你的桌面上的绝对路径,例如:

/Users/andydent/Library/Application Support/iPhone Simulator/6.1/Applications/1017B648-DA52-4909-A1B2-18343722686A/Library/Caches/testdbios

在这个路径中,模拟器类型会变化,下一个目录是 iOS 版本,然后最终是库目录,这与设备上的相同。

摘要

再次构建 LevelDB 静态库,并学习了如何选择一个不同的标准 C++ 库以匹配 Xcode 的默认设置。创建一个简单的 iOS 应用程序,我们能够将其与适用于模拟器和 iOS 设备的 LevelDB 库链接起来。然后我们看到了它在模拟器中的运行情况与设备上沙盒文件系统的对比。

第三章. 基本键值操作 – 创建和删除数据

LevelDB 和任何键值存储的核心是能够通过键来 PutGetDelete 值。这三个操作就是我们存储和检索特定数据片段所需的所有操作,将我们的数据库视为持久字典。

本章将介绍如何以最简单的方式使用 PutGetDelete 操作来存储和检索数据。我们还将讨论何时以及如何将操作组合成批处理。

本章中的代码将开始比迄今为止的简单示例更具 C++ 习惯用法。LevelDB 的基本接口是 C++,而不仅仅是 C,这引入了一些您需要了解以进行安全编码的问题。许多其他 LevelDB 的语言绑定都建立在 C++ API 之上,尽管在大多数情况下它是不可见的。为了纯 Objective-C 程序员的好处,一些 C++ 习惯用法将详细解释。

用于本章和下一章的示例代码被编写为 OS X 控制台程序,以拥有最简单的测试环境。它关于数据库概念,而不是平台。在 Xcode 中复制项目是尴尬的,所以我们每次都会创建新的项目(当然,您可以在配套代码中下载)。示例代码在许多不同操作周围使用了大量的 assert 语句。通常,您会编写更健壮的错误处理代码。

以下截图显示了运行 Sample03 代码的整个控制台输出,包括由于一个键中嵌入的空字符而绘制的奇怪字符。

与我们的大多数示例一样,代码从零开始创建数据库,使用与我们在 第一章 中相同的逻辑将其放入临时目录,即 下载 LevelDB 并在 OS X 上构建。下载完整的示例代码以编译并亲自尝试,或者只需参考此控制台输出以查看以下代码片段的结果:

基本键值操作 – 创建和删除数据

Sample03 的控制台输出,显示数据插入和检索的消息

在 LevelDbHelper.h 中理解 C++ 习惯用法

LevelDbHelper.h 的几个 C++ 习惯用法是关于能够写入标准 I/O。

小贴士

永远不要将未限定的 using 语句放入头文件中,因为这会传播到任何 #includes 该头文件的源代码,导致难以理解的错误。然而,将 using 语句放在其中是完全可以接受的,就像在 testRead(以下代码片段中)中看到的那样简化函数。

template 开头并使用模板类型在其声明中使用的函数,如 operator<<,将自动为不同数据类型创建代码。以下是如何添加一个 operator<< 函数,以便您的自定义数据类型可以写入流以进行输出:

// little helper so any Slice can be written to a stream
template<class streamT>
streamT& operator<<(
  streamT& stream,
  const leveldb::Slice& sliceValue)
{
  stream << sliceValue.ToString();
  return stream;
}

LevelDB 的 Get 函数将 std::string 作为其返回值的唯一方式。与经典的 const char* C 字符串不同,C++ std::string 可以包含二进制数据。它基本上是一个大小和一个指向字节的指针,它通常像字符字符串一样行为,但也可以用作任意容器。你可以看到通过调用 Get 返回的 leveldb::Status 对象,以及我们如何通过 ToString() 检查其 ok() 函数和可读的错误消息:

template<class keyT>
void testRead(keyT key)
{ // templated so we take a key of any datatype accepted by Get
  using std::cout; using std::endl;
  std::string value;
  leveldb::Status s = db->Get(
    leveldb::ReadOptions(), key, &value);
  if (s.ok())
    cout << key << " => " << value << endl;
  else
    cout << "failed: " << s.ToString() << " " << key << endl;
}

大多数 LevelDB 函数将 options 对象作为它们的第一个参数。仅为此测试,你看到了如何使用 leveldb::ReadOptions() 创建默认值。main03.cpp 中的 roptwopt 变量用于一组单一的连续选项。

使用字符串进行 Get 和 Put 操作

来自 main03.cpptestString 函数存储和检索字符串值。我们首先尝试读取一个不存在的键。请注意,可以传递一个字面量引号字符串或 std::string 对象作为键。testRead 中的 Get 调用会失败,并显示消息 NotFound,你可以通过 if (s.IsNotFound()) 来测试这一点。参见 leveldb/status.h 中的 leveldb::Status 类声明,以获取所有这些辅助函数。

在使用 Put 调用来为给定键添加值之后,只能通过再次使用确切的键来读取它——参见以下代码——不同的案例是如何失败的。更新值只需再次使用 Put 并提供不同的值但相同的键即可:

testRead("Packt");  // should fail until we add this key
testRead( std::string("Packt") ); // should still fail
cout << "putting Packt key in" << endl;
assert( db->Put(wopt, "Packt", "Getting Started").ok() );
assert( db->Put(wopt, "Packt2", "with Leveldb").ok() );
testRead("Packt");  // succeeds now we have put that record
testRead("packt");  // fails, keys are case-sensitive

// change value for existing key
assert( db->Put(wopt "Packt", "Is Started").ok() );
testRead("Packt");  // succeeds now we have put that record

理解切片 - 高效的 LevelDB 参数对象

testRead 函数被模板化,允许它接受任何数据类型作为键。在实践中,这可以是任何可以创建 leveldb::Slice 对象的数据类型。这些对象在 LevelDB 接口中用作参数。Slice 对象包含一个长度和一个数据指针。它不拥有其数据,因此复制起来非常高效,但也存在风险。如果你保留了一个 Slice 对象,那么请确保初始化它的数据也被保留,并且上下文是线程安全的。这也是你永远不应该在线程之间共享 LevelDB 对象的一个原因——它们有通过 Slices 引用的内部存储,而这些缓冲区可能会被其他线程的操作损坏。

查看 slice.h 以获取完整的类声明。Slice 构造函数可以单独接受一个 const char* 参数作为 C 字符串,或者接受一个 std::stringconst char*size_t 长度来定义一个有大小值。Slice 方法 data()size() 返回该数据指针和大小。GetPut 的输入键和值是 Slice 对象的 const 引用。这意味着独特的 C++ 构造转换习语可以从任何单个值创建一个临时的 Slice 对象,该对象可以用作 Slice 构造函数参数,例如 const char*std::string&

使用二进制值进行 Get 和 Put 操作

现在你已经了解了如何使用Slice对象,让我们回到简单的PutGet操作。二进制值可以像字符串值一样存储,记住一个std::string值可以被视为,例如Slice,只包含数据字节和长度。区别在于std::string保留了其字节的拥有权,因此是二进制数据的安全容器。在下面的testBin()示例中,我们使用一个指针和长度创建一个Slice对象,任意二进制struct,并执行相反操作从Get返回的std::string值中获取二进制struct

  struct binValues {
    int intVal;
    double realVal;
  };    
  binValues b = {-99, 3.14};
  Slice binSlice((const char*)&b, sizeof(binValues) );
  assert( db->Put(WriteOptions(), "BinSample", binSlice).ok() );
  std::string binRead;
  assert( db->Get(ReadOptions(), "BinSample", &binRead).ok() );
// treat the std::string as a container for arbitary binary data
  binValues* b2 = (binValues*)binRead.data();     
  cout << "Read back binary structure " << b2->intVal << "  "
  << b2->realVal << " binary size=" << binRead.size() << endl;

使用 Delete – 键值操作的最后一部分

在本章前面,我们看到了如何仅通过再次使用相同键的Put操作来更改给定键的值。如果你想有效地重命名一个键,或者完全删除它,你将使用Delete。如果键不存在,它将处理这种情况,就像在Packt的第二个Delete中看到的那样:

WriteOptions syncWopt;
syncWopt.sync = true;
assert( db->Delete(syncWopt, "Packt").ok() );
testRead("Packt");  // should fail now we have deleted the key
testRead("Packt2"); // still here, delete only removed exact match
assert( db->Delete(syncWopt, "Packt").ok() );  // safe failure

Delete操作从存储中删除一个键值对。如前所述,我们还需要将其作为重命名的一部分使用,以确保原始键不再存在。与其它数据库架构不同,LevelDB 基于日志的存储没有可以更新的索引表。因此,rename操作意味着创建一个新的键,并且整个相关值被重写:

std::string value;
if (db->Get(readOpt, fromKey, &value).ok()) {
  if (db->Put(writeOpt, toKey, value).ok())
    db->Delete(writeOpt, fromKey);
}    

这种通过复制重命名的操作首先检索我们要添加回的值,所以我们从使用旧键的Get操作开始。

使用 WriteBatch 包装操作以增加安全性

之前重命名键的例子,就像 LevelDB 的任何其他操作序列一样,如果程序在中间终止,则存在使数据库处于不一致状态的风险。我们可以将任何PutDelete操作序列包装在WriteBatch对象中。使用同步将保证我们等待磁盘 I/O 完成,并允许批次优化其写入序列。重命名的更安全版本是:

leveldb::WriteOptions syncW;
syncW.sync = true;  // sync writes slower but even safer
std::string value;
if (db->Get(readOpt, fromKey, &value).ok()) {
  leveldb::WriteBatch wb;
  wb.Put(toKey, value);
  wb.Delete(fromKey);
  db->Write(syncW, &wb);
}

如果你熟悉其他数据库概念,WriteBatch类似于执行事务和提交。这意味着可以放弃操作,这也是WriteBatch的一部分,使用其Clear操作可以实现。或者,你也可以简单地释放一个WriteBatch对象,并且永远不使用db->Write()来应用它。

摘要

这章非常专注于 C++,展示了用于不同类型内容的模板函数以及将数据写入控制台。详细讨论了至关重要的Slice类,其高效但危险的仅引用行为以及它如何使存储二进制数据成为可能。我们涵盖了PutGetDelete操作,以及使用WriteBatch安全地包装数据更改组合的方法。接下来,我们将学习如何搜索键。

第四章. 遍历和搜索键

第三章, 基本键值操作 创建和删除数据,发现我们只需要通过键来PutGetDelete值,但有时一次获取多个值的方式通常非常有用。如果你不知道你的键中可能包含什么数据,那么你需要一种方法来搜索部分匹配或者从数据库的起始位置开始搜索。这种按顺序搜索和遍历键的能力,使得 LevelDB 能够成为数据库基础的能力得以完善。默认的排序顺序是BytewiseComparator,实际上是 ASCII 排序。

介绍 Sample04 以展示循环和搜索

Sample04使用与之前相同的LevelDbHelpers.h。请下载整个示例,查看main04.cpp以了解代码的上下文。运行Sample04首先会打印出整个数据库的输出,如下面的截图所示:

介绍 Sample04 以展示循环和搜索

列出键的控制台输出

使用循环创建测试记录

这里使用的测试数据是通过一个简单的循环创建的,形成了一个链表。在简单关系型风格部分有更详细的解释。创建测试数据的循环使用了新的 C++11 基于范围的循环风格:

vector<string> words {"Packt", "Packer", "Unpack", "packing",
"Packt2", "Alpacca"};
stringprevKey;
WriteOptionssyncW; syncW.sync = true;
WriteBatchwb;
for (auto key : words) {
  wb.Put(key, prevKey + "\tsome more content");
  prevKey = key;
}
assert(db->Write(syncW, &wb).ok() );

注意我们是如何使用一个string来保留prevKey的。这里可能会有使用Slice来引用key的前一个值的诱惑,但请记住关于Slice只有数据指针的警告。这将是一个由Slice指向其下可以更改的值的经典错误!

我们正在使用WriteBatch添加所有键,这不仅是为了保持一致性,而且为了让存储引擎知道它正在一次性接收大量的更新,并可以优化文件写入。从现在开始,我将经常使用术语记录。它比键值对更容易说,也表明了我们正在存储的更丰富、多值数据。

使用迭代器遍历所有记录

LevelDB 中多记录读取的模型是一个简单的迭代。找到一个起点,然后向前或向后移动。

这是通过一个管理你遍历键和值的顺序和起点的Iterator对象来完成的。你可以在Iterator上调用方法来选择开始的位置,移动,并获取键和值。每个Iterator都会得到数据库的一致快照,忽略迭代过程中的更新。创建一个新的Iterator来查看变化。

如果你使用过基于 SQL 的声明性数据库 API,你会习惯于执行查询然后操作结果。许多这些 API 和较老的、以记录为中心的数据库有一个游标的概念,它维护结果中的当前位置,你只能向前移动。其中一些允许你将游标移动到之前的记录。如果你习惯于从服务器获取集合,那么逐个记录迭代可能会显得笨拙且过时。然而,记住 LevelDB 是一个本地数据库。每一步都不代表一个网络操作!

可迭代游标的方法是 LevelDB 提供的所有方法,称为Iterator。如果你想以某种方式将收集到的结果集直接映射到列表框或其他容器,你将不得不在Iterator之上实现它,就像我们稍后将要看到的那样。

向前迭代时,我们只需从数据库中获取一个Iterator并使用SeekToFirst()跳转到第一个记录:

Iterator* idb = db->NewIterator(ropt);
for (idb->SeekToFirst(); idb->Valid(); idb->Next())
cout<<idb->key() <<endl;

向后搜索非常相似,但作为存储权衡,本质上效率较低:

for (idb->SeekToLast(); idb->Valid(); idb->Prev())
cout<<idb->key() <<endl;

如果你想同时看到值和键,只需在迭代器上使用value()方法(Sample04中的测试数据可能会让它看起来有些混乱,所以这里没有这样做):

cout<<idb->key() << " " <<idb->value()  <<endl;

与一些其他编程迭代器不同,没有特殊的前向或后向迭代器的概念,也没有义务保持相同方向继续。考虑搜索一个 HR 数据库中薪酬最高的十位经理。使用Job+Salary作为键,你会迭代一个范围,直到你知道已经到达了经理的末尾,然后向后迭代以获取最后十位。

通过NewIterator()创建迭代器,所以你必须记得删除它,否则会导致内存泄漏。迭代是在数据的一致快照上进行的,并且通过PutGetDelete操作所做的任何数据更改都不会显示,直到创建另一个NewIterator()

搜索键的范围

控制台输出的后半部分是我们迭代部分键的示例,这些键默认是区分大小写的,使用默认的BytewiseComparator

搜索键的范围

搜索的控制台输出

正如我们多次看到的,Get函数寻找键的精确匹配。然而,如果你有一个Iterator,你可以使用Seek,它会跳转到第一个与指定的部分键完全匹配或紧随其后的键。

如果我们只是寻找具有公共前缀的键,最优的比较方法是使用Slice类的starts_with方法:

Void listKeysStarting(Iterator* idb, const Slice& prefix)
{
cout<< "List all keys starting with "
<<prefix.ToString() <<endl;
for (idb->Seek(prefix);
idb->Valid() &&idb->key().starts_with(prefix);
idb->Next())
cout<<idb->key() <<endl;
}

向后搜索稍微复杂一些。我们使用一个保证失败的键。你可以将其视为位于以我们的前缀开始的最后一个键和所需范围之外的下一个键之间。当我们Seek到那个键时,我们需要向前移动一步到前一个键。如果这是有效的并且匹配的,那么它就是我们的范围内的最后一个键:

Void listBackwardsKeysStarting(Iterator* idb, const Slice& prefix)
{
cout<< "List all keys starting with "
<<prefix.ToString() << " backwards " <<endl;
const string keyAfter = prefix.ToString() + "\xFF";
idb->Seek(keyAfter);
if (idb->Valid())
idb->Prev(); // step to last key with actual prefix
else // no key just after our range, but
idb->SeekToLast(); // maybe the last key matches?
for(;idb->Valid() &&idb->key().starts_with(prefix);
idb->Prev())
cout<<idb->key() <<endl;
}

如果你想获取一个范围内的键?第一次,我不同意 LevelDB 附带文档中的说明。他们的迭代示例显示了与以下代码中显示的类似循环,但使用idb->key().ToString() < limit检查键值。这是一个更昂贵的迭代键的方式,因为它为每个被检查的键生成一个临时的字符串对象,如果范围内有数千个键,这将是昂贵的:

Void listKeys Between(Iterator* idb,
const Slice&startKey, const Slice&endKey)
{
cout<< "List all keys >= " <<startKey.ToString()
<< " and < " <<endKey.ToString() <<endl;
for (idb->Seek(startKey);
idb->Valid() &&idb->key().compare(endKey) < 0; 
idb->Next())
cout<<idb->key() <<endl;
}

我们可以使用Slice的另一个内置方法;compare()方法,它返回一个结果<0, 0, 或>0,以指示Slice是否小于、等于或大于它正在比较的其他Slice。这与标准 C 的memcpy语义相同。前面代码片段中显示的代码将找到与startKey相同或之后的键,并且在endKey之前。如果你想使范围包括endKey,将比较改为compare(endKey) <= 0

以简单关系风格链接记录

有许多方法可以实现更丰富的关系索引,这将在稍后讨论。这个简单的例子显示了与一个键关联的值,它存储了数据以及另一个键值。这是经典的记录链表:

以简单关系风格链接记录

键的链随后存储一个键的值

如您所见,每个记录都包含查找另一个相关记录的信息。在这种情况下,关系仅仅是记录以特定的顺序创建,但它可以是任何一致的意义,例如,父子关系。

我们之前看到的循环,创建记录通过存储一个组合prevKey + "\tsome more content"(其中"some more content"通常每个记录都不同)来组成记录值。我们提取那个前一个键并使用它来导航到另一个记录:

string nextKey;
if (db->Get(ropt, firstkey, &nextKey).IsNotFound())
return firstkey + " ** not found **";  
string ret = firstkey;
for (;;) {
  string key = value.substr(0, value.find("\t") );     
  if (db->Get(ropt, key, &value).IsNotFound())
  break;
  ret += " -> " + key;
}
return ret;

注意我们在记录值中使用了简单的制表符作为分隔符,在下一个要使用的键和实际内容之间的文本。这个原则与关系数据库相同;其他搜索的键值包含在记录中。这个例子只有一种记录数据。在下一章中,我们将使用这种技术构建一个二级索引,其中使用电话号码键来查找主记录键。如果你需要查找数据,添加多个键是唯一实现速度的方法。

摘要

在这一章中,我们学习了 LevelDB 中迭代器的概念,作为按其键排序的记录遍历的方式。数据库通过搜索来获取迭代器的起始点,以及示例展示了如何有效地在遍历范围时检查键。最后,我们回到了简单的Get来查找键,以帮助通过数据库实现链表。现在,我们将离开 C++,进入 Objective-C 的世界。

第五章:使用 Objective-C

正如我们所看到的,LevelDB 的本地 C++接口相当简单。然而,大多数 OS X 和 iOS 程序员希望能够使用他们熟悉的NSDataNSString数据类型与数据库一起使用。blocks在 Cocoa API 中的应用正在稳步增加,作为一种将小块逻辑应用于数据集合的方式。有些人也非常反感使用 C++,会避免任何缺乏 Objective-C 接口的东西。

Objective-C 中 LevelDB 的开源封装

有三个重要的 Objective-C 封装 LevelDB,所有这些都是在 2011 年宣布时开始的。它们是搜索 Objective-C LevelDB 时的顶级结果和链接。

两个都附带了自己的项目来构建库或框架,但截至 2013 年 6 月,这些项目与 Xcode 4.6 或更高版本不兼容。这两个都包含了一套有价值的单元测试,但没有 iOS 的示例。它们的相对流行度很难判断。

而不是试图修复他们的构建,我们提供的Sample05包含了所有三个封装的源代码和一套共同的测试项目。它使用了我们之前创建的 LevelDB 库。这种复制封装类的方法更有可能适应 Xcode 的变化,并允许你通过进入源代码进行调试。

Sample05是一个非常重要的示例,可以全面探索代码,因为它包含了到目前为止我们所看到的所有数据更新和迭代概念的 Objective-C 示例代码,每个封装都从 C++重写。这让你可以并排看到风格上的差异。本章中的代码示例使用了所有三个封装,以进行对比。

LevelDB-ObjC,来自github.com/hoisie/LevelDB-ObjC,是最早且最简单的封装,提供了以下功能:

  • 一种使用NSString调用基本的PutGetDelete操作的方法

  • 使用NSKeyedArchiver将对象和字典或对象数组存储起来,以将它们编码到值中

  • 通过一个可以停止迭代的块遍历所有键

APLevelDB,来自github.com/preble/APLevelDB,部分基于 LevelDB-ObjC,试图成为一个更干净的 Objective-C 封装,并且:

  • 添加了独立的迭代器和通过下标访问的类似数组的直接访问:db[@"key"] = @"value"

  • 添加了WriteBatch支持的单独协议

  • 清晰地将不同的概念分离到不同的协议中

  • 而不是埋藏编码实现,获取和设置NSData,这样你可以进行自己的编码并通过NSData*传递

NuLevelDB,来自github.com/nulayer/NULevelDB,是一个非常雄心勃勃的层,包含许多类,具有以下特点:

  • NSData用于键和值,使得组合复杂的键以及像其他NSString键和值一样容易

  • 分离优化的 64 位整数键和许多用于范围和任意整数键集合的枚举(迭代)方法

  • 一致地使用 NSError** 参数来获取错误信息

  • 在文件 NULDBDB+BulkAccess.m 中,轻松复制整个数据库或特定的键列表,到/从 NSDictionaryNSArray

  • 它自己的 NULDBSerializable 协议,可以将对象分解成一系列单独的键/值对。

以下列出的一些问题可能对每个人来说都不是问题。请记住,这些包装器是开源的,易于扩展,如果你缺少某个功能。以下是最严重的不同包装器问题,或者是最有可能让你放弃使用特定包装器的问题。我发现 APLevelDB 通过一些小的修改更容易扩展。问题如下:

  • LevelDB-ObjC 和 NuLevelDB 不符合 ARC 规范。

  • LevelDB-ObjC 公开了 leveldb/db.h 头文件,因此你必须将使用它的任何文件转换为 .mm Objective-C++ 源文件。

  • LevelDB-ObjC 和 APLevelDB 的迭代方法没有任何指定范围的方式,因此你失去了 LevelDB 的大部分功能。它们的块可以结束迭代,但你不能从一个给定的键或部分键开始。

  • NuLevelDB 具有非常丰富的迭代支持,但忽略了任何数据库的自定义比较器,并仅使用 LevelDB 的 BytewiseComparator 类方法。

  • 它们都没有公开 LevelDB 的完整读写选项。NuLevelDB 在写 sync 和读 fill_cache 属性方面表现最佳。

提示

ARC自动引用计数)在 OS X Lion 和 iOS 5 中被引入,并且现在在大多数新的 Objective-C 代码中都被使用。然而,许多较旧的源代码尚未转换为 ARC。要在 ARC 项目中使用非 ARC 源文件,请使用 构建阶段 选项卡,编译源代码 部分,并在需要时为每个文件添加标志 –fno-objc-arc。你会知道这是必要的,因为出现了 ARC 限制 编译器错误。

使用 Objective-C 进行简单的数据访问

我们在 第三章 中看到的 LevelDB-ObjC 包装器中的核心数据创建、查找和删除操作如下所示。

创建一个数据库对象并打开一个文件:

LevelDB* db = [[LevelDB alloc]
  initWithPath:pathToSampleDB(sampleDBname) ];

检查以确保键不存在,然后添加几条记录,检查 Packt 键是否工作,但小写 packt 仍然失败:

assert( [db getString:@"Packt"] == nil );
[db putObject:@"Getting Started" forKey:@"Packt"];
[db putObject:@"with Leveldb" forKey:@"Packt2"];
assert( [[db getString:@"Packt"]
  isEqualToString:@"Getting Started"] );
assert( [db getString:@"packt"] == nil );

再次写入现有的键,更改其值,并检查以确保它已更改:

[db putObject:@"Is Started" forKey:@"Packt"];
assert( [[db getString:@"Packt"] isEqualToString:@"Is Started"] );

使用不同的方式从任意字符创建一个 NSString 对象,并使用该键证明我们可以通过该键读取它:

const char enBuf[] = {"APrefix\0Packt in a string"};
NSString* keyWithNull = [NSString stringWithCharacters:(const
  unichar*)enBuf length:25];
[db putObject:@"Part Key with embedded null" forKey:keyWithNull];
assert( [[db getString:keyWithNull]
  isEqualToString:@"Part Key with embedded null"] );

删除一条记录并检查以确保它不能再被读取:

[db deleteObject:@"Packt"];
assert( [db getString:@"Packt"] == nil );

我们可以打印出刚刚添加的所有键,如 Sample04,但将使用一个块在第一个三个记录后停止。块的一个典型用途是从集合中处理数据,无论是键还是键值。大多数使用块的迭代器也允许块通过返回 NO 来停止迭代:

  __block int count = 0; // updateable counter outside the block
  [db iterateKeys:^(NSString* key) {
    printStr( key );
    return (++count < 3) ? YES : NO;  // block returns a BOOL
  }];

扩展 APLevelDB 以公开 C++ API

如果我们想在 APLevelDB 或其他包装器中添加自己的扩展,必须公开内部的 leveldb::DB*。仅仅获取这个指针就足够了,这样我们就可以使用我们在前面章节中看到的所有 C++ 逻辑。这需要对 APLevelDB.h 进行一些小的修改。

首先,为 getDB 声明一个返回类型,这样就可以安全地将其包含在纯 Objective-C 中,我们不需要强迫人们使用 .mm 文件迁移到 Objective-C++:

#ifdef __cplusplus // forward declaration
  namespace leveldb { class DB; }
  typedef leveldb::DB* leveldbDBPtr;
#else
  typedef void* leveldbDBPtr;
#endif

然后,在 @interface APLevelDB : NSObject 中添加一个公共获取器:

- (leveldbDBPtr) getDB;

这在 ApLevelDB.mm 中简单地实现:

- (leveldbDBPtr) getDB   {  return _db; }

现在,我们可以添加一个 类分类 来扩展 APLevelDB 并添加其他方法。这里只展示了一个例子。我们声明了一个接受一个前缀字符串并应用一个块到匹配该前缀的键的方法,传递一个 BOOL 参数,以便块可以停止枚举:

@interface APLevelDB (APLevelDB_ADSearches)
- (void)enumerateKeysWithPrefix:(NSString*)prefix
  block:(void (^)(NSString *key, BOOL *stop))block;
@end

@implementation APLevelDB (APLevelDB_ADSearches)
- (void) enumerateKeysWithPrefix:(NSString*)prefix
  Keys:(void (^)(NSString* key, BOOL* stop))block
{
  BOOL stop = NO;
  leveldb::Slice prefixSlice = SliceFromString(prefix);
  std::unique_ptr<leveldb::Iterator> iter(
    [self getDB]->NewIterator(leveldb::ReadOptions()) );
  for (iter->Seek(prefixSlice);
    iter->Valid() && iter->key().starts_with(prefixSlice);
    iter->Next()) {
  NSString *k = StringFromSlice( iter->key() );
  block(k, &stop);
  if (stop)
    break;
  } 
}

这是一个如何能够获取数据库指针来允许编写一些 Objective-C++ 代码以向 APLevelDB 添加方法的简单示例。可以采用类似的技术来扩展其他框架。这也展示了 Objective-C++ 如何允许我们混合语言。enumerateKeysWithPrefix:block 方法是 APLevelDB 的一个扩展,具有纯 Objective-C 接口。它接受一个块,可以是纯 Objective-C。C++ 的部分是调用 NewIterator 并通过记录循环调用块。然而,你也可以直接传递 leveldb::DB* 到纯 C++。

导入文本数据以加载数据库

以下函数展示了导入一个制表符分隔的文本文件并使用 APLevelDB 数据库生成记录的完整循环。Sample500.txt 文件包含在 OS X 和 iOS 项目中。我们在 main06ios.mmain06osx.m 中使用了一个小的辅助函数,以找到相对于应用程序的数据文件。这会找到一个打包在 Cocoa iOS 或 OS X 应用程序包中的文件,这是包含演示的常见方法:

NSString* pathToBundledData(NSString* filename)
{
  return [[NSBundle mainBundle]
    pathForResource:filename ofType:@""];
}

命令行 OS X 工具,如 Sample05,不会自动打包文件。为了将样本复制到相对于应用程序的位置,它被添加到 构建阶段 选项卡的 复制文件 设置中,设置相对路径 ./SampleData,正如在辅助函数中使用的那样:

NSString* pathToBundledData(NSString* filename)
{
  return [@"./SampleData/" stringByAppendingString:filename];
}

这里是整个 testAPLevelDBImportStrings 函数。打开文本文件,将其读取到一个单独的字符串中,然后将其拆分为行数组:

NSString* tabSepStr = [NSString
  stringWithContentsOfFile:pathToBundledData(@"Sample500.txt")
  encoding:NSUTF8StringEncoding error:&openError];
NSArray* tabbedLines = [tabSepStr
  componentsSeparatedByCharactersInSet:
    [NSCharacterSet newlineCharacterSet]];

使用另一个辅助函数 pathToSampleDB 创建一个数据库:

NSError* openError;
APLevelDB* db = [APLevelDB
  levelDBWithPath:pathToSampleDB(@"testImportAPLeveldb05")
  error:&openError];

在一个WriteBatch中,为了速度和安全,遍历行数组,每行用制表符分隔,每行写两条记录。第一个键是姓氏和名字的组合,包含一个 JSON 编码的数组。第二个记录由电话号码字段键入,其值是第一个键,所以它就像一个二级索引。以下代码展示了这一点:

id tabSep = [NSCharacterSet   
  characterSetWithCharactersInString:@"\t"];
id<APLevelDBWriteBatch> wb = [db beginWriteBatch];  
for (NSString* line in tabbedLines) {
  NSArray* fields = [line
    componentsSeparatedByCharactersInSet:tabSep];
  NSString* nameKey = [NSString stringWithFormat:@"%@|%@",
    fields[1], fields[0]];
  NSError* encErr;
  NSData* enc = [NSJSONSerialization dataWithJSONObject:fields
    options:0 error:&encErr];
  [wb setData:enc forKey:nameKey];  // main record
  if ([fields[5] length] > 0)
    [wb setString:nameKey forKey:fields[5];
}
[db commitWriteBatch:wb];

这使用 Apple 的标准NSJSONSerialization来打包记录,它对数组进行简单的引号处理以存储为字符串,例如:

["Prince","Kauk","Mckesson Drug Co","AZ","85027","623-581-7435","prince@kauk.com"]

要解包记录,我们使用dataForKey获取数据,然后使用JSONObjectWithData方法重新创建一个数组:

NSData* jsonRec = [db2 dataForKey:@"Bayer|Reva"];
NSError* decErr;
NSArray* ja = [NSJSONSerialization
  JSONObjectWithData:jsonRec options:0 error:&decErr];

这种方法可以序列化字典和简单类型(如NSString)的数组,包括嵌套的数组和字典。如果你的数据主要是文本,它既紧凑又高效。使用NSCoder的其他变体让你可以存储和重新创建自己的对象,但你必须编写编码和解码方法的覆盖。

摘要

快速查看 LevelDB 的 Objective-C 包装器让我们了解了它们是如何用于我们在 C++中掌握的相同任务的。你也学会了加载与你的应用程序捆绑的文本数据的一般技术。文本数据加载器展示了一种新的方法来编码键的数据值并构建电话号码的二级索引。现在,我们将继续在这个基础上添加 Cocoa 用户界面,以便在比控制台输出窗口更令人兴奋的地方查看数据。

第六章。与 Cocoa UI 集成

到目前为止,重点一直是如何用尽可能少的用户界面用 C++ 或 Objective-C 编程数据库调用。现在我们将看到一些在 Cocoa 应用程序中展示这些数据的一些简单方法,足以构建一个完整的应用程序。第一次,我们的示例代码将是一个由用户驱动的应用程序,而不仅仅是测试函数的一系列。这些讨论假设你熟悉使用 Xcode 编辑界面,包括连接到 IBOutletsIBActions。请参阅本章末尾的参考文献(部分:推荐 Xcode 书籍)以获取 Xcode 的帮助。

使用 LevelDB 满足数据源需求

NSTableViewUITableView 中展示数据列表的问题在于,它们期望一个类似数组的模型,具有两个特性:

  • 行数是已知的

  • 可以在任意索引处检索行内容

这与 LevelDB 不太匹配,因为 LevelDB 不了解键的总数,只能通过遍历其他九个键来获取第十个键。典型的解决方案,我们在这些示例中使用,是将数据库中的内容复制到 NSArray 类中,以填充表格视图。对于单个滚动表格中的可用数据量,这表现良好。我用像这里捆绑的 50,000 条记录进行了测试,它仍然在 iPhone4 上快速且可用。

要获取与给定行相关联的数据的完整详细信息,当行被选中时,你需要能够从行号映射到数据源中的位置。如果你允许通过点击标题或拖放新内容来重新排序表格,这将会变得复杂。在这个示例中,我们将不会深入探讨细节,但通常的技术是使用 NSMutableArray 的子类,以便单元格可以被移动。

虽然接下来的讨论都是关于 OS X 应用程序的,但 iOS 版本已在线上提供,几乎完全相同,用 UITableView 替换了 NSTableview。另一个主要区别是控制器逻辑的位置。在简单的 OS X 应用程序中,使用 AppDelegate 的地方,在 iOS 应用程序中你会使用 ViewController 类。

创建 OS X 图形用户界面

OS X 用户界面结合了一个可搜索的记录列表和一个相邻的数据输入表单,以查看和更改记录的详细信息,包括创建新记录。

创建 OS X 图形用户界面

OS X 示例应用程序显示带有搜索结果和记录详细信息的列表

OS X 应用程序是一个包含以下内容的单个窗口 MainMenu06.xib

  • 用于列出记录的 NSTableView

  • 一个 NSSearchField 类,用于输入搜索,随着你输入而触发

  • 一系列 NSTextField 字段,用于编辑记录的各个部分

  • 两个 NSButtons 用于发出命令,保存新建

  • 一个菜单栏,包含与按钮相同的命令(例如,文件 | 保存

它是使用 Xcode 模板创建的新 Cocoa 应用程序,没有检查 基于文档 选项。

将数据库连接到 NSTableView

所有这些界面方法和输出都在 GSwLDB06osxAppDelegate 中。

  • 输入时自动进行实时搜索,因为 NSSearchField 对象的操作连接到 IBActionviewMatches

  • NSTableView 类的 datasourcedelegate 输出连接到 GSwLDB06osxAppDelegate,它有一个相应的输出 tableView,引用 NSTableView

为了支持表格操作,向对象添加了两个独立的协议。NSTableViewDelegate 是关于表格行为,响应诸如编辑和拖动项等事件。提供显示数据的两个关键方法由 NSTableViewDataSource 协议提供。这两个协议通常由一个对象满足,但这不是强制性的:

@interface GSwLDB06osxAppDelegate: NSObject
<NSApplicationDelegate, NSTableViewDataSource,
NSTableViewDelegate>
- (IBAction)viewMatches:(id)sender;
- (IBAction)newRecord:(id)sender;
- (IBAction)saveRecord:(id)sender;
@property (weak) IBOutletNSTextField *FirstNameEntry;
...
@property (weak) IBOutletNSTextField *EmailEntry;
@property (assign) IBOutletNSWindow *window;
@property (retain) IBOutletNSTableView *tableView;
@end

这个 GUI 代理和负责管理数据选择并重用于 iOS 应用中的 Model 类之间存在明显的分离。作为一个 NSTableViewDataSource 协议,代理向模型请求数据。GUI 对 LevelDB 一无所知。《GSwLDB06osxAppDelegate》中的表格相关方法包括:

- (IBAction)viewMatches:(id)sender {
  [self.modelloadStartingWith:[sender stringValue]];
  [self.tableViewreloadData];
  [selfloadFieldsForCurrentSelectedRow];  // explicitly reload
}

- (NSInteger) numberOfRowsInTableView:(NSTableView *)table {
  return [self.modelcountRows];
}

- (id)tableView:(NSTableView *)table
    objectValueForTableColumn:(NSTableColumn *)column
    row:(NSInteger)rowIndex
{ // cheat by using combined Last/First Name rather than two cols
  return [self.modelkeyForRow:rowIndex];
}

模型的公共接口在 Sample06_Model.h 中:

@interface Sample06_Model :NSObject
+ (Sample06_Model*)modelWithSampleDatabasePath:
(NSError**)errorOut;
- (void)loadSampleDatafile:(NSString*)filename;
- (NSString*)keyForRow:(NSInteger)row;
- (int)countRows;
- (void)loadStartingWith:(NSString*)partialKey;
- (NSArray*)fieldsForRow:(NSInteger)index;
- (BOOL)saveRecord:(NSArray*)fields;
@end
enumfieldOffsets {eFirstName=0, eLastName=1, eCompany=2,
eState=3, eZip=4, ePhone=5, eEmail=6};

Sample06_Model.m 中通过类扩展添加了私有方法和属性:

@interface Sample06_Model(){}	
- (Sample06_Model*) initWithSampleDatabasePath:
(NSError**)errorOut;
- (void)loadListForUI;
- (NSString*)mainKeyFrom:(NSString*)key;
- (void)addRecord:(NSArray*)fields
using:(id<APLevelDBWriteBatch>)batch;
@property (retain) APLevelDB* db;
@property (retain) NSArray* rowsForUI;
@end

- (void)loadStartingWith:(NSString*)partialKey {
  self.rowsForUI = [self.dbkeysWithPrefix:partialKey];
}

- (int)countRows{
  if (self.rowsForUI == nil)
    [selfloadListForUI];
  return [self.rowsForUI count];
}

- (NSString*) keyForRow:(NSInteger)row{
  if (self.rowsForUI == nil)
    [selfloadListForUI];
  return (NSString*)(self.rowsForUI[row]);
}

- (void)loadListForUI {
  self.rowsForUI = [self.dballKeys];
}

假设 loadListForUI 可能需要在任何时候调用。虽然它只是一行,但它被分离成一个方法,以便后续代码可以更智能地加载列表。

将记录详细信息连接到编辑表单

编辑 GUI 根据表格中的当前选择显示记录的详细信息。我们可以更改记录并保存它们,或者在不警告丢失更改的情况下点击不同的行。最简单的编辑行为包括:

  • 响应 表格选择的变化,显示详细信息

  • 保存 将输入的详细信息复制回数据库

  • 新建 清除输入字段,以便可以添加记录

模型将记录详细信息呈现为 NSString 值的 NSArray 类,通过枚举 fieldOffsets 索引。你可以将其视为一个水平数组,与在表格中显示的键的垂直数组形成对比。

允许并响应 选择 变化很简单。由协议 NSTableViewDelegate 声明的两个选择相关方法也被使用:

- (void)tableViewSelectionDidChange:(NSNotification*)notification
{
  [selfloadFieldsForCurrentSelectedRow];
}

- (BOOL)selectionShouldChangeInTableView:(NSTableView*)tableView {
  return YES; // assumes we always leave, abandoning changes
}

- (void)loadFieldsForCurrentSelectedRow {  // mirrors saveRecord
if ([self.tableViewnumberOfSelectedRows] == 0) {
    [selfclearEntryFields];
    return;
  }
  NSArray* fields = [self.modelfieldsForRow:row];
  [self.FirstNameEntrysetStringValue:fields[eFirstName] ];
...
  [self.EmailEntrysetStringValue:fields[eEmail] ];
}

- (IBAction)saveRecord:(id)sender {
  NSArray* fields = [NSArrayarrayWithObjects:
    [self.FirstNameEntrystringValue],
...
    [self.EmailEntrystringValue],
  nil ]; // built an array of all field entries
  [self.modelsaveRecord:fields]; // save array to record
}

- (IBAction)newRecord:(id)sender {
  if ([self.tableViewnumberOfSelectedRows] == 0)
    [selfclearEntryFields];
  else  // selecting none will trigger clearEntryFields
    [self.tableViewdeselectAll:sender];
}

Sample06_Model.m 对 GUI 的支持知道如何将 GUI 中的行号映射到数据库键,正如我们所见,这用于提供表视图内容。类似的逻辑提供了我们现在用来检索数据库记录的键:

- (NSArray*)fieldsForRow:(NSInteger)index
{
  NSString* key = [self keyForRow:index];
  NSData* mainRec = [self.dbdataForKey:key];
  NSError* decodeErr;
  NSArray* fields = [NSJSONSerialization
  JSONObjectWithData:mainRec options:0 error:&decodeErr];
  return fields;
}

这样就处理了数据加载。为了保存,我们需要将字段数组发送回模型并更新数据库。这从 GSwLDB06osxAppDelegate 中的 (IBAction)saveRecord 开始。它调用模型的 saveRecord 方法,该方法反过来使用 addRecord 保存一个包含名称的键和 JSON 体的键:

- (IBAction)saveRecord:(id)sender {
  NSArray* fields  = [NSArrayarrayWithObjects:
    [self.FirstNameEntrystringValue],
...
    [self.EmailEntrystringValue],
  nil ];
  [self.modelsaveRecord:fields];
  self.isNewRecord = NO;
  [self.tableViewreloadData];
}

- (void)saveRecord:(NSArray*)fields {
  id<APLevelDBWriteBatch>wb = [self.dbbeginWriteBatch];  
  [selfaddRecord:fieldsusing:wb];
  [self.dbcommitWriteBatch:wb];  
  self.rowsForUI = nil; // force reload when UI refreshes
  return YES;
}

- (void)addRecord:(NSArray*)fields 
  using:(id<APLevelDBWriteBatch>)batch{
  NSString* nameKey = nameKeyFromFields(fields); 
  NSError* encErr;
  NSData* enc = [NSJSONSerializationdataWithJSONObject:fields
    options:0 error:&encErr];
    [batchsetData:encforKey:nameKey];
}

新的动作只是一个清除所有输入字段的 GUI 动作。它设置了代理的属性 isNewRecord,正如你在 saveRecord 中的前述代码中所见,但它对模型或 leveldb 没有影响。只有当你保存新记录时,数据库才会被使用。

推荐的 Xcode 书籍

以下 Packt 出版的书籍在介绍 Xcode 界面编辑器和解释表格类时非常直接,并配有良好的示例:

  • 《Cocoa 和 Objective-C 烹饪书》Jeff HawkinsPackt 出版公司,针对 OS X 上的 NSTableView

  • 《Xcode 4 烹饪书》Steven F DanielPackt 出版公司,针对 iOS 上的 UITableView

摘要

我们现在已经对具有 GUI 和数据模型分离的真实数据库应用程序有了良好的开端。你在模型层中使用了添加数据和搜索的理解。我们涵盖了加载记录列表的需求以及如何根据我们的选择显示详细数据。现在我们有一个更复杂的应用程序,下一章将展示我们如何使用一些调试技术来查看数据变化。之后,我们将回到 Sample06 并添加二级索引、删除以及更复杂的数据更新。

第七章。使用 REPL 和命令行进行调试

本章暂时放下对数据库 API 的编程,通过查看两个不同的支持工具以及如何在您的 iOS 应用中包含一个调试 web 服务器来休息。这个服务器可以用于任何应用,以提供补充正常用户界面的额外访问。能够导出数据是大多数数据库开发者期望的重要功能——SQL 系统通常有在服务器上执行原始 SQL 的方法。包含服务器比使用工具要麻烦,但它们不能在设备上的数据上运行;只能在 OS X(包括您的模拟器工作目录中的数据)上运行。

构建和运行 LevelDB 导出工具

标准的 LevelDB 源代码分发包括一个导出工具,但它不是通过默认的 Makefile 构建的。这个导出工具让您可以看到您表内容的原始副本。在您为给定设置构建了 LevelDB 之后,您可以使用以下命令构建导出:

make leveldbutil

如果您使用 CXXFlags 设置构建了 LevelDB,那么您需要使用相同的设置构建 util 并在 LDFlags 中重复它们以确保 LevelDB 构建。单个命令行看起来如下(没有换行):

CXXFLAGS="-std=c++11 -stdlib=libc++" LDFLAGS=
 "-std=c++11 -stdlib=libc++" make leveldbutil

正如您已经看到的,LevelDB 数据库在文件夹内创建了许多文件。这些文件的作用在 第十章 调整和关键策略 中进一步解释。

根据您对数据库的操作,它可能没有生成任何 .sst 文件。我们的一些快速操作,仅添加少量记录,只会创建 .log 文件。Sample06x 使用 50,000 条记录,以每批 1,000 条的方式写入,因此它保证了写入多个 .sst 文件,给我们一个更有趣的导出。您将在下一章中更详细地讨论 Sample06x,但现在它有两个关键类型,以 N~P~ 开头,它们在我们的代码中以交替的顺序添加。

伴随的代码包含 leveldbutil 构建日志以及运行它的几个日志。对于大型 Sample06x 的一个高度省略的日志在文件 log of dump testLeveldb06x.txt 中,显示了在 OS X 上运行 Sample06x 创建的数据库文件夹上运行的工具。

我们将要查看的第一个文件是 MANIFEST 文件,它显示了写入遍历是如何分批的,以及使用了特殊的比较器(下一章中解释)。目前您对比较器的关注主要是,导出工具忽略了它们,但其他工具如 lev(见以下代码)无法使用自定义比较器打开数据库。因此,使用 leveldbutil 检查 MANIFEST 文件让您可以检查这一点作为失败点,如果您有其他程序在打开时出错。

$ ./leveldbutil dump /tmp/testLeveldb06x/MANIFEST-000002 
$ ./leveldbutil dump /tmp/testLeveldb06x/*.log
$ ./leveldbutil dump /tmp/testLeveldb06x/000005.sst 
$ ./leveldbutil dump /tmp/testLeveldb06x/000007.sst

下一个文件是日志文件——只有一个活动日志文件,因此您可以使用*.log作为此处所示命令。日志包含最后一批写入的记录,从输入文件的 42001 行开始。

日志随后显示了两个.sst文件的省略输出,这两个文件是排序表。LevelDB 处理创建这些不可变存储,只留下.log文件中的最近条目。注意这两个表有一个看似交织的键集。

安装 Node.js 和 lev 实用工具

流行的 node 环境提供了一个服务器端或命令行环境,用于运行 JavaScript 程序,同时也支持控制台甚至桌面程序(使用AppJS等打包器)。为 node 创建的许多库中包括 LevelUP 和 LevelDOWN。LevelDOWN 是 LevelDB C++绑定的简单包装器,提供了映射到 JavaScript 函数的标准 LevelDB API。LevelUP 最初是 LevelDOWN 的高级接口,但现在也是一个抽象层,允许使用提供 LevelDB 函数的其他后端。

来自github.com/hij1nx/levlev实用工具提供了一个命令行和 GUI,可以直接使用这些库与 LevelDB 数据库进行工作。它安装了 LevelUP 和 LevelDOWN。我们只关心将 lev 作为一个实用工具使用,而不关心其实现方式。Node.js 的安装器可以从nodejs.org/获取,是一个简单的 GUI 安装器,将 node 和npm(Node 包管理器)放入/usr/local/bin。您还需要从www.python.org/download/releases/2.7.5/安装 Python 2.7,以便构建 LevelDown(讽刺的是,是的,Python 用于脚本构建 JavaScript 工具的 C++绑定)。

伴随的使用 node 包管理器安装 lev 的日志.txt详细显示了这些包的安装过程,包括如果您未升级 OS X 中的默认 Python 版本时发生的错误。安装由单个命令触发,自动下载、构建和安装以下包:

$sudo npm install –g lev
$lev /tmp/testLeveldb06 --start "World" --end "You" --keys
"Worlds\tTrudy"
"Wubbel\tBuster"
"Wyrosdick\tMerle"
"Youtsey\tLynda"

一旦安装了 lev,它就提供了命令行导出键、删除记录和添加新记录的功能。请参阅dump testLeveldb06x.txt 的日志以获取更多示例。

在您的 iOS 应用内添加 REPL 进行调试

REPL这个术语在脚本语言中常用,意味着读取-评估-打印-循环。REPL 通常接受命令并打印其结果,然后循环接受下一个命令。它就像终端命令行,但嵌入在应用中。

Sample07代码提供了一个包含小型 Web 服务器并为数据库操作提供 REPL 的 iPhone 应用的示例。该应用可以在模拟器和设备上运行。以下截图显示了它在模拟器上的运行,因此显示的 IP 地址是运行模拟器的 OS X 开发系统的地址:

在你的 iOS 应用程序内添加 REPL 进行调试

在模拟器中进行调试时的状态报告,显示用于浏览设备的地址

一旦你正在运行应用程序,你可以打开一个网页浏览器并输入此地址,192.168.0.51:8080,如前一张截图所示。你可以从任何设备连接多个浏览器到服务器——使用 iPad 在 iPhone 或 iPod Touch 上查询数据库!网络服务器完全在后台运行,因此你的正常应用程序行为可以继续。这使得它成为一个可以插入任何应用程序的调试工具(警告:如果包含在分布式应用程序中,这将是一个安全漏洞,除非你添加了身份验证)。

REPL 的第一个版本会寻找已知的命令,或者如果它没有识别出命令,它假定你正在输入一个要搜索的部分键。输入Am的结果如下所示。一些服务器命令包括:

  • help列出命令及其语法,请参阅readme.md文档

  • prefix aKeyaKey设置为从现在起任何键的前缀

  • unprefix清除键前缀

  • put akey aValue添加键aKey的值aValue,使用引号包含空格,使用\嵌入引号,例如put Author "Andy Dent"

  • get akey返回与akey关联的完整值

  • del akey删除键akey

  • count fromPart toPart仅计算记录,其中fromParttoPart都是可选的(它在该部分键范围内运行迭代器)

  • keys fromPart toPart列出键,其中fromParttoPart都是可选的

  • stats显示数据库统计信息

在你的 iOS 应用程序内添加 REPL 进行调试

输入 Am 作为命令后的浏览器结果

正在使用的是开源的GCDWebServer,来自github.com/swisspol/GCDWebServer,模板页面生成使用GRMustache,来自github.com/groue/GRMustache,以提供使用双大括号{{括号}}的通用Mustache语法模板。

这两个源套件的组合正在合并成一个产品,增加了代码,使编写自己的嵌入式调试 REPL 更容易,在github.com/AndyDentFree/REPLierGCDWebServer上,该工具包将在本书发布后继续维护,作为一个通用的 REPL 工具包。

提供 Web 模板的Sample07逻辑在ASDLevelDBREPL.m中,并且可以非常容易地添加到任何应用程序中,只需添加一个ASDLevelDBREPL *属性,然后启动它并将它的 db 属性设置为在Sample06中创建的APLevelDB*属性。

Sample07中,这是在GSdLDB07ViewController.mviewDidLoad中完成的,一个稍微简化的版本如下:

self.webserver = [ASDLevelDBREPL serverWithReporter:^(id msg){
    [self appendToDisplay:msg];
}];
self.webserver[@"title"] = @"Getting Started ... Sample07";
[self.webserver start]; // start the web server FROM MAIN THREAD
NSError* openError;
self.model = [Sample06_Model 
  modelWithSampleDatabasePath:&openError];
if (openError == nil) {
  [self.model loadSampleDatafile:@"Sample500.txt"];
 self.webserver.db = self.model.db;
}

摘要

我们已经介绍了几种预构建的实用工具,用于查看数据库的不同方面,并学习了一种向任何 iOS 应用添加调试 REPL 的有价值方法。接下来,我们将回到我们 GUI 支持的复杂性,使用更丰富的Sample06版本。下一章还将讨论存储更多信息的方法,使我们的数据库能够自我描述,从而可以构建一个更强大的数据库层,以抽象掉一些这些责任。

第八章。更丰富的键和数据结构

在本书的这一章中,我们将回顾一些与 LevelDB 相关的经典数据库理论。这种动机始于更丰富的应用研究。第六章 与 Cocoa UI 集成 介绍了如何将数据库连接到 GUI 并添加记录的基本方法。大多数应用程序还需要处理更改或删除数据。本章的代码将这些操作添加到 Sample06 中,在一个保持相同文件名的副本中,以便与更简单的示例进行比较。本章的前一部分结构与 第六章 与 Cocoa UI 集成 类似,以帮助进一步进行比较。

Sample06x 还通过不区分大小写的名称和智能电话号码改进了搜索。搜索需要通过自定义 LevelDB 的索引功能来支持。作为 Comparator 讨论的一部分,我们将探讨处理二进制整数键的方法。

完成丰富 GUI 的数据源

Sample06 的扩展版本增加了两个主要方面:

  • 更改 tableview 中列出的数据,查看二级索引

  • 删除和重命名记录,意味着键更新

当涉及到更新时,Sample06 展示了如何一个 保存 按钮可以简单地触发将当前键的记录值写回。我们的第一个版本相当有缺陷,因为它每次更改名称时都会创建记录的可见副本。我们需要确保重命名发生,而不是让 保存 行为像真的 另存为 一样。

为列出添加电话号码二级索引意味着每次从 GUI 保存时都要写入第二个键。对于新记录来说,这很简单。然而,当我们保存现有记录时,可能需要删除旧键值,因为它们指向过时的数据。这意味着在模型中需要智能清理,知道何时删除键和何时重写。如果这些概念是熟悉的,可以快速浏览这部分,但仍需查看如何使用键前缀作为标志来跟踪当前列出模式。

将 OS X 图形用户界面扩展以完全支持编辑

完整的 OS X 图形用户界面增加了更多用于编辑和排序列表的动作按钮。以下截图显示了列表的选择:

将 OS X 图形用户界面扩展以完全支持编辑

一个 OS X 示例应用程序,显示具有列表选择和更多编辑控制的列表

扩展的 OS X 应用程序也是一个单窗口,它增加了:

  • 更多按钮和菜单项以提供 视图 命令

  • 还原删除 命令的按钮和菜单项

将数据库连接到 NSTableView

在简单的 OS X 应用程序中,AppDelegate 对象在 iOS 应用程序中扮演着 ViewController 类的角色。因此,所有这些界面方法和出口都在 GSwLDB06osxAppDelegate 中。图形界面在 MainMenu06.xib 中。

  • 查看所有按钮和查看 – 带前缀的所有记录菜单项连接到 (IBAction)viewAllRecords

  • 查看名称按钮和查看 – 名称菜单项连接到 (IBAction)viewNamesOnly

  • 查看电话按钮和查看 – 电话号码菜单项连接到 (IBAction)viewPhonesOnly

  • NSSearchField 类的动作和 NSTableView 类的 datasourcedelegate 输出连接方式与 Sample06 相同

GSwLDB06osxAppDelegate 的扩展声明如下代码所示。我们将在讨论编辑记录时讨论其他动作和输出:

@interface GSwLDB06osxAppDelegate: NSObject
  <NSApplicationDelegate, NSTableViewDataSource,
   NSTableViewDelegate>
- (IBAction)viewAllRecords:(id)sender;
- (IBAction)viewNamesOnly:(id)sender;
- (IBAction)viewPhonesOnly:(id)sender;
- (IBAction)viewMatches:(id)sender;
- (IBAction)newRecord:(id)sender;
- (IBAction)saveRecord:(id)sender;
- (IBAction)revertRecord:(id)sender;
- (IBAction)deleteRecord:(id)sender;
@property (weak) IBOutlet NSTextField *FirstNameEntry;
...
@property (weak) IBOutlet NSTextField *EmailEntry;
@property (assign) IBOutlet NSWindow *window;
@property (retain) IBOutlet NSTableView *tableView;
@end

加载一组行并将它们复制到 tableview 对象的机制与 Sample06 的简单示例相同。所有关于通过电话或通过名称查看的含义的知识都隐藏在模型类中。因此,要更改整个列表为电话,我们有一个调用的 viewPhonesOnly 动作:

- (IBAction)viewPhonesOnly:(id)sender {
  [self.model loadPhones];
  [self.tableView reloadData];
  [self loadFieldsForCurrentSelectedRow];  // explicitly reload 
}

模型的扩展公共接口位于 Sample06_Model.h 中,添加了:

- (void)loadAllRecords;
- (void)loadNames;
- (void)loadPhones;
- (void)deleteMatching:(NSString*)key;
- (NSArray*)fieldsForKey:(NSString*)key;
- (BOOL)saveRecord:(NSArray*)fields
    replacingKey:(NSString*)oldKey;

Sample06_Model.m 中的类扩展也略有不同,添加了一些方法和属性,并用更复杂的一个替换了 addRecord

@interface Sample06_Model() {}
- (NSString*)mainKeyFrom:(NSString*)key;
- (void)addRecord:(NSArray*)fields 
    indexingPhone:(BOOL)addPhoneKey
    using:(id<APLevelDBWriteBatch>)batch;
@property (retain) NSString* lastPrefixUsed;
@property (nonatomic) BOOL neverLoaded;

数据库创建和从 sample50000.txt 加载与 Sample05 非常相似,与 Sample06 相同,除了我们使用 50,000 条记录来查看性能影响。我们将在讨论编辑时详细查看记录添加。继续关注加载表格,与 Sample05 的模型代码相比,最大的不同是它在数组中缓存结果而不是在迭代时仅打印键(对于 50,000 条记录消耗 200 MB 的内存)。它使用相同的 N~P~ 前缀来区分键类型。添加到 Sample05 简单模型中,支持 GUI 意味着模型会记住 lastPrefixUsed,这取决于用户对 名称电话 的选择。我们看到的两个从 GUI 调用的加载方法如下:

- (void)loadPhones {
  self.rowsForUI = [self.db keysWithPrefix:@"P~"
    strippingFirst:2];
  self.lastPrefixUsed = @"P~";
}

- (void)loadStartingWith:(NSString*)partialKey {
  NSString* prefixedKey = [self.lastPrefixUsed 
    stringByAppendingString:partialKey];
  self.rowsForUI = [self.db keysWithPrefix:prefixedKey 
    strippingFirst: [self.lastPrefixUsed length]];
}

这两个都使用了一个添加到 APLevelDB 类别 APLevelDB_ADSearches 中的方法,与我们在 Sample05 中看到的 enumerateKeysWithPrefix 相同。它通过 strippingFirst 参数扩展了该方法,允许调用者指定键上非打印前缀的长度。当我们获取给定类型的所有记录时,前缀长度将与我们所见的相同两个字符(N~P~)。

然而,当响应 GUI 的 viewMatches 动作时,我们正在搜索包含其搜索词的前缀,例如,以 Smith 开头的 N~Smith。我们的前缀是七个字符长,但我们想显示时去掉两个。在 APLevelDB_APLevelDB_ADSearches.mm 中查看,我们看到搜索实现如下:

- (NSArray*)keysWithPrefix:(NSString*)prefix 
    strippingFirst:(int)numCharsToStrip
{
  NSMutableArray *keys = [NSMutableArray array];
  if (numCharsToStrip > 0) {
    [self enumerateKeysWithPrefix:prefix 
      block:^(NSString* key, BOOL* stop) {
        [keys addObject:[key substringFromIndex:numCharsToStrip]];
      }];
  }
  else {  // for speed use a different block that copies whole
    [self enumerateKeysWithPrefix:prefix 
      block:^(NSString* key, BOOL* stop) {
        [keys addObject:key];
      }];
  }
  return keys;
}

在理解了键数组生成的方式之后,让我们回顾Sample06_Model.m,看看如何使用键数组为tableview对象提供值。Sample06countRowskeyForRow方法没有改变,但我们的列表现在更智能了。以下代码片段显示了如何使用键数组为tableview对象提供值:

- (void)loadListForUI {
  if (self.neverLoaded) {
    self.neverLoaded = NO;
    [self loadNames];  // default UI is to give them names first
  }
  else if (self.lastPrefixUsed == @"N~") 
    [self loadNames];
  else if (self.lastPrefixUsed == @"P~") 
    [self loadPhones];
  else
    [self loadAllRecords];
}

你注意到这个小技巧了吗?我们只显示单列表格,但Name键是姓氏和名字的组合,中间用制表符分隔。我们只获取这个作为单个字符串,并允许 GUI 在中间有一个波浪线,而不是分成两列。这使得切换到单列电话列表更容易。

将记录详情连接到编辑表单

扩展的编辑行为包括:

  • 还原:丢弃任何已输入的详细信息并重新加载当前记录

  • 保存:将输入的详细信息复制回数据库,包括更改键和刷新列表

  • 删除:从数据库中移除当前记录和次要键

允许并响应用户选择的变化与Sample06中的处理方式相同,即从被复制的记录中获取值并填充到输入字段中。Sample06_Model中的fieldsForRow方法的核心逻辑保持不变——基本的记录结构没有改变。然而,代码已经被拆分成了单独的fieldsForKey方法,该方法在多个地方被使用。

- (NSArray*)fieldsForRow:(NSInteger)index {
  NSString* key = [self keyForRow:index];
  return [self fieldsForKey:key];    
}

- (NSString*) keyForRow:(NSInteger)row {
  if (self.rowsForUI == nil)
    [self loadListForUI];
  return (NSString*)(self.rowsForUI[row]);
}

- (NSArray*)fieldsForKey:(NSString*)key {
  NSData* mainRec = [self.db dataForKey:[self mainKeyFrom:key]];
  NSError* decodeErr;
  NSArray* fields = [NSJSONSerialization 
    JSONObjectWithData:mainRec options:0 error:&decodeErr];
  assert(decodeErr == nil);
  return fields;
}

由于我们的 GUI 允许用户列出主Name索引或Phone键,因此引入了一点复杂性。我们需要一个Name键来从数据库中获取详细记录,但当前表可能显示的是Phones。这意味着列表中选定的值不是我们需要的键类型。或者,用数据库术语来说,我们是通过外键来列出它的。

在这种情况下,对于模型来说,记住列表的类型至关重要,它将其视为由loadPhones等方法设置的lastPrefixUsed集合。模型对 GUI 一无所知。它只知道它最后被要求获取电话或名称。如果我们有一个Phone键,我们需要读取其记录以获取主键(记住Sample05中的这个次要索引)。以下是完成此操作的方式:

- (NSString*)mainKeyFrom:(NSString*)key {
  if ([key hasPrefix:@"N~"])
    return key;  // original is already prefixed
  if ([self.lastPrefixUsed isEqualToString:@"N~"]) 
    return [@"N~" stringByAppendingString:key];
  if ([self.lastPrefixUsed isEqualToString:@"P &&
    ![key hasPrefix:@"P~"])
      key = [@"P~" stringByAppendingString:key];
  // if get here need to translate a Phone key into a main one,
  // read the main key indexed by phone number
  return [self.db stringForKey:key];  // read fully prefixed key
}

mainKeyFrom的大部分内容是关于键构建,如字符串前缀。最后一行使用stringForKey获取主键作为外键操作;这与我们在第四章中讨论的相同关系搜索,迭代和搜索键

使用键更新保存数据

我们已经看到数据加载被扩展以处理通过不同索引进行列表和搜索。正如原始的Sample06所示,为了保存,我们需要将字段数组发送回模型以更新数据库。这次我们将正确更新索引。这始于GSwLDB06osxAppDelegate知道它正在保存新记录还是旧记录——模型需要知道它替换了什么。

- (IBAction)saveRecord:(id)sender {
  NSArray* fields = [NSArray arrayWithObjects:
    [self.FirstNameEntry stringValue],
    ...
    [self.EmailEntry stringValue],
    nil
  ];

  if (self.isNewRecord) {
    [self.model saveRecord:fields];
    self.isNewRecord = NO;
    [self.tableView reloadData];  // list shows new record
  } else {
    NSString* origKey = [self.model keyForRow:
      [self.tableView selectedRow]];
    if ( [self.model saveRecord:fields replacingKey:origKey] )
      [self.tableView reloadData]; // list shows new Name/Phone
  }
}

简单保存的情况是当保存了一个新记录,因此模型知道它正在创建一个新记录。我们现在看到我们的常见addRecord方法像Sample05一样工作,添加一个指向主键的电话记录,并使用 JSON 作为主体,如下所示:

- (void)saveRecord:(NSArray*)fields {
  id<APLevelDBWriteBatch> wb = [self.db beginWriteBatch];  
  [self addRecord:fields indexingPhone:YES using:wb];
  [self.db commitWriteBatch:wb];  
  self.rowsForUI = nil; // force reload when UI refreshes
  return YES;
}
- (void)addRecord:(NSArray*)fields indexingPhone:(BOOL)addPhoneKey 
    using:(id<APLevelDBWriteBatch>)batch
{
  NSString* nameKey = nameKeyFromFields(fields); 
  NSError* encErr;
  NSData* enc = [NSJSONSerialization dataWithJSONObject:fields 
    options:0 error:&encErr];
  assert(encErr == nil);
  [batch setData:enc forKey:nameKey];  // main record
  if (addPhoneKey && [fields[ePhone] length] > 0) {
    NSString* phoneKey = phoneKeyFromFields(fields);
    [batch setString:nameKey forKey:phoneKey];  
  }
}

保存现有记录的复杂情况必须应对需要更改Phone键的二级索引的需求,如果电话号码或主键发生了变化。如下所示:

- (BOOL)saveRecord:(NSArray*)fields replacingKey:(NSString*)oldKey 
{
  NSArray* oldRecord = [self fieldsForKey:oldKey];
  const int numFields = [oldRecord count];
  bool allSame = true;
  for (int i=0; i<numFields && allSame; ++i) {
    allSame = [fields[i] isEqualToString:oldRecord[i]];
  }
  if (allSame)
    return NO;  // so caller knows no change

  id<APLevelDBWriteBatch> wb = [self.db beginWriteBatch];      
  NSString* keyToUpdate = [self mainKeyFrom:oldKey];
  NSString* newKey = nameKeyFromFields(fields);
  BOOL replacePhone = NO;
  if ( ![newKey isEqualToString:keyToUpdate] ) {
    [wb removeKey:keyToUpdate];
    replacePhone = YES; // value (names) of phone record changed 
  }
  NSString* phoneKey = phoneKeyFromFields(fields);
NSString* oldPhoneKey = phoneKeyFromFields(oldRecord);
if ( ![phoneKey isEqualToString:oldPhoneKey] ) {
  replacePhone = YES;  // key changed
  [wb removeKey:oldPhoneKey];
}
  [self addRecord:fields indexingPhone:replacePhone using:wb];
  [self.db commitWriteBatch:wb];  
  self.rowsForUI = nil;  // forced reload when UI refreshes
  return YES;
}

如果所有键保持不变,但只有一些非键值改变,例如电子邮件,则保存方法可能只是简单地写入一个记录。在最坏的情况下,如果电话号码和其中一个名称都改变了,则之前的代码执行两次删除和两次写入。

对新和删除命令的响应

new命令只是一个 GUI 动作,清除所有输入字段。它设置委托isNewRecord的属性,正如你在saveRecord中之前看到的。模型不知道它是一个新记录,直到你调用之前展示的简单保存。以下代码显示了new命令:

- (IBAction)newRecord:(id)sender 
{
  self.isNewRecord = YES;
  if ([self.tableView numberOfSelectedRows] == 0)
    [self clearEntryFields];
  else  // selecting none will trigger clearEntryFields
    [self.tableView deselectAll:sender]; 
}

delete命令删除当前记录,并尝试保持选择在同一位置,重新加载数据以适应。正如你之前在viewPhonesOnly等方法中看到的,当列表内容改变时,我们需要显式调用loadFieldsForCurrentSelectedRow,但选择可能仍然停留在同一行号。以下代码显示了delete命令:

- (IBAction)deleteRecord:(id)sender 
{
  if ([self.tableView numberOfSelectedRows] == 0)
    return;
  int row = [self.tableView selectedRow];
  NSString* originalKey = [self.model keyForRow:row];
  [self.model deleteMatching:originalKey];
  [self.tableView reloadData];
  int lastRow = [self.model countRows] - 1;
  if (row > lastRow)
    row = lastRow;
  // usually select the same row
  [self.tableView selectRowIndexes:
    [NSIndexSet indexSetWithIndex:row] 
    byExtendingSelection:NO];
  [self loadFieldsForCurrentSelectedRow];
}

model方法只需要删除主和次要电话索引记录,如下所示:

- (void)deleteMatching:(NSString*)key 
{
  NSString* delKey = [self mainKeyFrom:key];
  NSArray* delRecord = [self fieldsForKey: delKey];
  NSString* delPhoneKey = phoneKeyFromFields(delRecord);
  [self.db removeKey:delKey];
  [self.db removeKey:delPhoneKey];
  self.rowsForUI = nil;  // force reload when UI refreshes
}

注意我们是如何读取主记录以获取电话键。根据当前列表是否按电话排序,你可以进行可能的优化,但这样代码更清晰。

LevelDB 的关键设计相对于关系理论和 SQL

一个键应该存在于 LevelDB 中,因为:

  • 你想直接使用相关值。

  • 键本身提供信息,而不需要值。

  • 你可以通过键的一部分或相关值导航到另一个键。最终,这个键链将引导到你想要的价值。

我们迄今为止为电话号码设计的键和值存在一个限制——唯一性问题。我们的键和值对看起来如下:

LevelDB 与关系理论和 SQL 的关键设计

Sample06 名称和地址数据库中的当前键和值

本章中使用的惯例是,键本身将位于普通矩形中,相关值位于由菱形和箭头指向的 3D 框中(UML 风格表示包含关系)。因此,如果你看到一个不指向 3D 框的矩形,那么它就是一个没有值的键(如下一张图片所示)。

这种天真键设计的缺点在于,它只允许整个数据库中有一个特定的电话号码出现一次,因为我们只是从电话号码中构建键。这是一个明显的缺陷——人们会共享电话号码。类似的问题也出现在名字上——只允许一个安迪·邓特存在。如果这让你感到困惑,请记住,LevelDB 中的键值存储就像一个巨大的字典——键是唯一的,唯一让键重复值的方法是添加更多信息,以确保每个键仍然是唯一的。

通过在键的末尾简单地添加一个唯一的后缀,可以固定名字,例如为每个记录递增一个整数。为了让电话号码工作,我们需要将相关的名字从值中移出并放入键中,这样我们就有了一个没有值的电话键。这个移动本身只会允许每个唯一名字有一个电话键,所以如果我们想要很多电话号码,我们还必须在每个电话键的末尾添加一个唯一的后缀。

LevelDB 的键设计与关系理论和 SQL 的比较

改变键结构以支持在名称和地址数据库中重复的名字和电话号码

如果你已经研究了关系理论或范式,在你的 SQL 数据库使用中,这开始变得熟悉起来。我们可以应用一个简单的原则——当不确定时,将你的 LevelDB 键视为等同于关系表,并且只将非键属性移动到相关的值中。

决定何时存储一条记录或拆分值

我们到目前为止的记录是一个简单的数组,使用 JSON 序列化器编码。正如你之前看到的,当我们更新 GUI 中的任何字段时,我们需要重写这个记录。如果我们更改电话号码或名字,我们也需要重写电话号码二级索引记录。我们可以通过以下图中的方式将数据移动到键中,使其更新更加灵活:

决定何时存储一条记录或拆分值

如果重构 Sample06 数据库以允许多个电话号码,键和值

在之前的图中,看起来我们有了从两个到四个键的爆炸性增长。电话号码现在是成对的键,以P~R~为前缀。这是在键值存储之上构建图数据库的常见模式。两个事物相关的事实至少需要两个键来描述,这样你就可以通过任一相关项进行搜索。由于电话号码不在主数据中,我们需要能够在它们更新时通过 nameId 回到它们。

另一个考虑因素是,在许多键中重复名称会消耗大量存储空间。为了避免名称作为电话键一部分带来的存储开销,我们需要一个独特的 nameId,它可以与电话一起使用。我们可以用这个来代替之前作为名称键一部分的唯一后缀,这样我们的名称键仍然几乎相同。然而,主要价值不再直接与那个名称键相关联。相反,nameId 被用作详细记录的键。直接的后果是我们列出按名称排列的人时,读取次数翻倍以获取更多详细信息。如果我们考虑Sample06中的使用模式——tableview对象的物品列表仍然来自对名称键的单次迭代。这实际上并不昂贵,因为我们选择特定名称时只需增加一点加载详细信息的开销。

我们将不再在 JSON 数组中存储电话号码,而是将依赖一个键来提供该号码。这意味着我们必须能够从名称中检索电话号码。因此,我们有了使用 P~和 R~前缀的成对键。人们经常更换电话号码,因此通过将电话号码从主要详细信息中提取出来,我们避免了主要键及其相关值的重写。

我们现在可以轻易地扩展数据库以允许多个电话号码,尽管这需要重大的 GUI 重写,就像苹果的联系人编辑号码一样。第十章,调整和键策略,更详细地讨论了这种键重构的影响。

如果你熟悉关系型数据库规范化,上述分解的原因对你来说应该是常见模式。明显的区别是,单个关系型表可以允许从任一方面进行查询,例如,一个包含电话号码、nameId 和电话角色的单行(元组)。然而,在大多数数据库服务器上,数据库管理员通常会指定至少两个这些列的索引,以提高搜索性能,因此实际存储在 SQL 数据库中的数据将开始类似于我们的键图。

关系型数据库强制你在需要表示多个值时,例如我们的电话号码,将数据分解到单独的表中。我们可以在记录中存储多个 JSON 值以获得更大的灵活性。数千个详细信息可以隐藏在个人的记录中,而不需要复杂的表结构。唯一的缺点是,通过这些详细信息进行搜索需要读取整个数据库。

实现 LevelDB 的模式

在经典的数据库中,模式定义描述了每个表。表有严格的数据类型,并且每一行都有相同的内容。在 ORM对象关系映射)产品上已经做了很多工作,并且有多个模式可以应用。参见 Scott Amblerwww.agiledata.org/essays/mappingObjects.htmlMartin Fowlermartinfowler.com/eaaCatalog/,以获取关于 ORM 的详细讨论。

NoSQL 数据库兴起的一个原因是它们增加的灵活性。虽然我们一直在我们的记录中存储相当一致的数据,但仅使用 JSON 序列化方法就允许我们轻松地将复杂的字典映射到记录中。LevelDB 对一条记录的结构与另一条记录不同并不关心。记住,虽然我们一直在谈论好像我们的不同键位于不同的索引中,但实际上它们只是在单个键值存储中,只是我们的前缀约定将它们区分开来。

那么,模式支持在 LevelDB 中能提供什么价值呢?它不是一个约束性或强制性的东西,但作为 APLevelDB 的扩展,提供了管理复杂键的辅助工具。我们在本章的第一部分添加的所有工作只是为了维护指向主记录的两个键。想象一下,在混合中添加另外五个键,并且还需要生成唯一标识符。如果能简单地声明键之间的关系以及它们是如何派生的,提供任何自定义键组装的最小代码量,那将是非常好的。

模式支持还有助于将内容映射到详细记录或存储在相关键中的数据,例如电话号码。原始的 Sample06 使用枚举来索引值的 JSON 数组。使用模式允许我们通过苹果的键值编码的点式风格的关键路径来补充这些枚举。模式还提供了一个通用的接口来读取这些详细信息,以帮助您包括一个 REPL 进行调试。每个模式条目都存储在数据库中,使用一个简单的键,例如 ~~Person,其整个模式条目详细信息存储在键的值的 JSON 字典中。

到目前为止,APLevelDB 以及模式支持的所有更改都已汇总并发布在 github.com/AndyDentFree/APLevelDBSS 上,并在 Sample06 的另一个副本中展示。Sample06 的扩展功能与上一图中的 Sample06sch 中的重构键结构相结合。

处理整数键的端序

LevelDB 实际上并不关心您存储的是字符串值还是任意二进制字节——我们已经看到您可以将结构推入记录中,并且 Slice 结构与二进制键和值一起工作。如果您想要一个高效、唯一、紧凑的键,将二进制整数存储为键的全部或部分是一个明显的选择,例如我们的 nameId。

然而,默认使用的 BytewiseComparator 如果你尝试使用从内存中直接获取的整数来创建键,将会引起问题。Intel 和 ARM 芯片(Mac 和 iPhone)以 小端序 存储整数,这意味着最低有效字节在左边,按字节排序整数键将不会工作。这只有在你想对键进行排序时才是一个问题。如果你的二进制整数只是提供了一个唯一的后缀,那么忽略这一点。

本章的代码包括一个文件,Log of Listing Binary Keys Sample08 OSX.txt,它展示了这种情况的影响以及如何通过自定义比较器(见 使用比较器来改变键的顺序 部分)或简单地通过翻转整数中的字节顺序来修复它:

Using database with standard BytewiseComparator
Listing the keys in decimal and hex
 256 ( 100)
 512 ( 200)
 768 ( 300)
   1 (   1)
 257 ( 101)
 ...
Using database with binaryComparator
Listing the keys in decimal and hex
   1 (   1)
   2 (   2)
   3 (   3)
…

同一段代码生成了两个列表——一个简单的函数,它循环添加一些键,然后迭代数据库,读取那些已添加的键:

  for (int i=1; i<1000; i+=1) {
    assert( [db addBinaryKey:&i length:sizeof(i)] );
  }
  printStr(@"Listing the keys in decimal and hex");
  [db enumerateBinaryKeys:^(NSData *key, BOOL *stop) {
    long n;
    [key getBytes:&n length:sizeof(n)];
    printStr( [NSString stringWithFormat:@"%4ld (%4lx)",n, n] );
  }];

通过使用默认的 BytewiseComparator 的数据库并翻转键以存储小端序的字节,我们得到相同的有序结果。如果我们正在进行搜索,我们还需要翻转输入的搜索值:

  for (long i=1; i<1000; i+=1) {
    long flippedI = htonl(i);
    assert( [db addBinaryKey:&flippedI length:sizeof(flippedI)] );
  }
  printStr(@"Listing the keys in decimal and hex");
  [db enumerateBinaryKeys:^(NSData *key, BOOL *stop) {
    long n;
    [key getBytes:&n length:sizeof(n)];
    n = htonl(n);
    printStr( [NSString stringWithFormat:@"%4ld (%4lx)",n, n] );
  }];

系统函数 htonl 是一个非常高效的字节交换器。它有兄弟函数 htnollhtons 用于 64 位和 16 位整数。如果你的键有多个整数组件,可能混合了二进制整数和字符串,那么你需要单独交换每个整数。

使用比较器来改变键的顺序

LevelDB 文档讨论了自定义比较器的使用,它们可以成为你的数据库工具包的有力补充,但人们经常误解它们的作用。比较器就像你传递给标准库排序的排序函数,但它们对数据库有持久的影响。LevelDB 表的核心数据结构按 Comparator 排序存储键。当你搜索时,比较器的 Compare 函数作为树遍历的一部分被调用。编写比较器是一项终身承诺,即该数据库的终身承诺。你的代码提供了比较器,因此要打开和使用数据库,你必须持续提供那个比较器。比较器对象通过一个虚拟函数提供一个 Name,在打开时进行检查,以确认你已指定了一个与用于创建数据库的比较器相匹配的比较器。

正如我们之前看到的,你可以通过仔细使用键前缀和翻转二进制键中整数的顺序来完成很多事情。然而,有些事情只能通过自定义比较器来完成。增强的 Sample06 展示了两个例子:

  • 对于姓名,我们忽略大小写。这提供了更自然的用户体验。

  • 对于电话号码,我们忽略任何非数字字符。如果号码带有破折号、空格、点或没有分隔符,这都不会影响。

这两个角色是不同的,但数据库只能使用一个比较器。我们仍然使用 APLevelDB 作为我们的基本 Objective-C 接口,但它需要一些修改才能允许我们指定比较器,这只是一个简单地将函数指针传递给leveldb::Options结构,该结构用于打开数据库时。我们首先在APLevelDB.h中添加一个指针类型,并添加一个额外的数据库工厂方法,该方法接受一个比较器,如下面的代码所示:

#ifdef __cplusplus
  typedef leveldb::Comparator* leveldbComparatorPtr;
#else
  typedef void* leveldbComparatorPtr;
#endif
...
@interface APLevelDB : NSObject
...
+ (APLevelDB *)levelDBWithPath:(NSString *)path 
    error:(NSError **)errorOut
    comparator:(leveldbComparatorPtr)adoptedComparator;
- (leveldbComparatorPtr) getComparator;

APLevelDB.mm中的匹配更改包括添加一个指向比较器的ivar,并重写原始的init方法,使其接受一个比较器,如下面的代码所示。

@interface APLevelDB () {
  ...
 leveldbComparatorPtr _comparator;
}
- (id)initWithPath:(NSString *)path error:(NSError **)errorOut
     comparator:(leveldbComparatorPtr)adoptedComparator;
  ...
@end

@implementation APLevelDB
...
+ (APLevelDB *)levelDBWithPath:(NSString *)path 
  error:(NSError **)errorOut {
    return [[APLevelDB alloc] initWithPath:path 
      error:errorOut comparator:NULL];
}

+ (APLevelDB *)levelDBWithPath:(NSString *)path 
  error:(NSError **)errorOut 
  comparator:(leveldbComparatorPtr)adoptedComparator {
    return [[APLevelDB alloc] initWithPath:path 
      error:errorOut comparator:adoptedComparator];
}

- (id)initWithPath:(NSString *)path error:(NSError **)errorOut
  comparator:(leveldbComparatorPtr)adoptedComparator 
{
  if ((self = [super init])) {
    _path = path;
    _comparator = adoptedComparator;  // maybe NULL    
    leveldb::Options options = [[self class]defaultCreateOptions];
    if (adoptedComparator != NULL)
 options.comparator = adoptedComparator;
    leveldb::Status status = leveldb::DB::Open(options, 
      [_path UTF8String], &_db);
...

在将比较函数指针传递给选项后,数据库会自动使用它。然而,你必须确保所有使用该数据库的程序继续使用相同的比较器。比较器的核心是Compare函数,它要么调用我们自己的不区分大小写的Name比较,仅数字的电话比较,或者默认使用标准的BytewiseComparator。我强烈建议包括一个默认回退,如果你有像我们的前缀那样检测到的键类型。我们根据我们的键方案假设第二个字符中的~表示一个特殊键,以下代码展示了这一点:

  virtual int Compare(const Slice& a, const Slice& b) const {
    const char* adata = a.data();
    const char* bdata = b.data();
    if (adata[0] != bdata[0]) { // check first character
      if (adata[0] < bdata[0])
        return -1;
      return 1;
    } // 1st char the same, is it a N~ or P~ key?   
    if (adata[1] != '~' || bdata[1] != '~')  // not special key
      return BytewiseComparator()->Compare(a, b); // USE DEFAULT
    if (adata[0] == 'P') // only check digits, skipping any others
      return ComparePhones(adata+2, bdata+2, 
               a.size()-2, b.size()-2); 
    return CompareCaseInsensitive(adata+2, bdata+2, 
               a.size()-2, b.size()-2);
  }  

在 LevelDB 内部,比较器的使用是自动的,但在我用于迭代键的代码中,我陷入了常见的陷阱,即绕过了它。扩展APLevelDB_ADSearches添加了一个比较两个键的方法,我们的第一个实现使用了高效的Slice::starts_with函数。这只有在使用与starts_with相同逻辑的标准Comparator函数时才是安全的:

 - (void)enumerateKeysWithPrefix:(NSString*)prefix 
         block:(void (^)(NSString* key, BOOL* stop))block
{
  BOOL stop = NO;
  leveldb::Slice prefixSlice = SliceFromString(prefix);
  std::unique_ptr<leveldb::Iterator> iter( 
    [self getDB]->NewIterator(leveldb::ReadOptions()) );
  leveldbComparatorPtr customComp = [self getComparator];
  if (customComp == nullptr) {  // safe to use starts_with
    for (iter->Seek(prefixSlice); iter->Valid() && 
      iter->key().starts_with(prefixSlice); iter->Next()) {
      NSString *k = StringFromSlice( iter->key() );
      block(k, &stop);
      if (stop)
        break;
    }
  } else {
    // custom comparator so have to use it to check the prefix
    const size_t prefixLen = prefixSlice.size();
    for (iter->Seek(prefixSlice); iter->Valid(); iter->Next()) {
      leveldb::Slice key(iter->key());
      leveldb::Slice partKey( key.data(), 
        std::min(key.size(), prefixLen));
      if (customComp->Compare(partKey, prefixSlice) != 0)
        break;
      NSString *k = StringFromSlice( iter->key() );
      block(k, &stop);
      if (stop)
        break;
    }   
  }
}

LevelDB 网站上用于迭代的示例代码中展示了相同的错误,它比较一个字符串it->key().ToString() < limit,并且没有使用自定义比较器。这个例子是源代码的一部分,因此这个陷阱已经被广泛传播!

摘要

以下总结了本章的技术,按其依赖关系排序。

  • 添加额外的二级(外键)

    • 在不读取所有内容的情况下获得额外的搜索性能

    • 增加了代码和磁盘空间的开销

  • 将数据重构到多个键中

    • 与其他额外键的收益和成本相同

    • 获得更多稳定的主数据,不会被重写

    • 在初始键之后,通过另一个键(可能是 ID 键)到主数据时,增加了额外的读取开销

  • 使用 ID 键为主记录提供间接引用:如果你有多个需要引用相同数据的键,并且主键很长或可变,请使用此方法:

    • 与其他额外键的收益和成本相同

    • 通过较小的二级键获得更小的索引

    • 如果主值更新时对更改项的依赖性较小(例如使用 nameId 而不是全名),则获得二级键的稳定性

    • 生成唯一 ID 值时增加了额外的逻辑

    • 即使对于最常见的键,也需要额外的读取开销,因为它们必须使用 ID 键来获取数据

  • 以对(图数据库)风格使用多个键:当你已经将一个单独的键因子化以避免频繁更新的数据包含在主记录中时使用此功能:

    • 维护键增加了复杂性

    • 配对键的成本索引空间加倍

  • 整数键的端序翻转:当你在键中有二进制整数值且关心它们对排序顺序的影响,但不想使用自定义比较器时使用此功能

  • 自定义比较器(使用):使用自定义比较器的理由包括:

    • 排序需要自定义逻辑,例如依赖于地区依赖性,如某些欧洲语言

    • 如果你想在 GUI 中直接使用键,因此不能容忍预处理中信息丢失,例如强制所有传入键转换为小写

    • 如果你可以优化计算下一个键的 delta 的方式,自定义比较器可能会节省大量的索引空间

    • 当键包含应被忽略的重要数据,因为它不帮助排序时

  • 自定义比较器(避免):避免使用自定义比较器的理由包括:

    • 如果你使用多种语言或框架,并且其中一些没有在数据库打开时设置比较器的支持

    • 当数据库将与其他人共享,而你又无法向他们提供比较器代码时

我们通过更多复杂的编辑和保存数据场景完善了Sample06 GUI 示例。有了在名称和电话之间翻转列表的能力,我们再次看到了二级索引在 LevelDB 中的应用。我们还看到了当数据更改或被删除时维护该索引的负担。

在对仅一个表和两个索引的工作量有了一定的痛苦认识之后,我们更理论性地研究了设计键结构,并了解到数据库在Sample06中可以有多复杂。APLevelDBSS 的架构支持被引入,以使使用复杂的键结构更加容易。

回顾键中的二进制值,我们最后查看比较器如何支持二进制值或仅使搜索体验更加用户友好。

在下一章中,我们将构建一个使用所有这些技术和 LevelDB 扩展的复杂数据库的不同真实世界示例,跟踪和查找文档。

第九章。文档数据库

本章将展示我们在前几章中讨论的一些理论,并展示如何使用更丰富的记录结构和多个键来构建文档数据库。它使用扩展的 APLevelDBSS 框架进行这些搜索和构建键,包括单词索引。

这是一个相当简单的文档数据库,但足以记录各种书籍和出版物,以及本地的 PDF 或其他文件。构建每个项目的列表并输入其详细信息的过程与我们之前章节中提到的Sample06 GUI 中所覆盖的过程非常相似。对于 OS X,这个应用程序引入了一种新的 GUI 技术,即如何通过将文件拖放到我们的窗口上来获取文件引用,因此本章将讨论这一过程的细节。

到目前为止的示例尚未涵盖如何处理多个数据库。我们使用了指向已知数据库位置的固定路径。本章将介绍如何打开其他数据库,包括讨论 iOS 和 OS X 的包惯用语,它将文件夹视为文档。

搜索文档数据库的关键设计

我们希望通过标题、关键词和作者检索文档,因此为每个都显示了以下图示中的键。

搜索文档数据库的关键设计

文档数据库中的典型 NoSQL 风格的数据和关系

在关系数据库中,会使用更多的表来跟踪作者,这使得确定共同作者变得更容易,但这并不是唯一可以使用的模式。

在 JSON 或其他编码值内部存储多个嵌套值是典型的 NoSQL 模式,有助于简化键。此模式还通过忽略实际上是不同人的相同作者姓名而稍微简化了一些。请注意,我们没有任何明确的键来跟踪共同作者。

使用 APLevelDBSS 定义模式

下面的代码显示了与之前图示相匹配的模式定义,使用每个键的属性来定义键的部分。docKey还定义了valueFields,这些字段定义了记录支持键和我们的 GUI 所需的最小内容。与经典的关系模式不同,任何单个记录中可能包含的域可能比我们定义的要多:

- (void)defineSchema {
  // define individual properties for keys to be easier to use
  self.authorKey = [ASDLevelDBKey key:@"Author name" withParts:@[
    @"A~",   
    [ASDLevelDBKey partFromPath:@"doc.authors.name"], 
    [ASDLevelDBKey partFromId:@"doc"]  ]];
  self.wordKey = [ASDLevelDBKey key:@"Words" withParts:@[
    @"W~", 
    [ASDLevelDBKey partFrom:@"doc.desc" 
      valueGenerator:^(NSString* source){ 
        return [Sample09_Model uniqueWordsFromString:source];
      }], 
    [ASDLevelDBKey partFromId:@"doc"]
  ]];
  self.docTitleKey = [ASDLevelDBKey key:@"Document" withParts:@[
    @"D~", [ASDLevelDBKey partFromPath:@"doc.title"], 
    [ASDLevelDBKey partFromId:@"doc"]  ]];
  self.docKey = [ASDLevelDBKey key:@"doc" withParts:@[
    @"i~", [ASDLevelDBKey partGeneratingId:@"doc"]  ]
    valueFields:@[
 @"title", @"desc", @"fileURL",
 [ASDLevelDBField multiple:@"authors" fields:@[@"name"]]
    ]  ];
  // now hook everything up so the key paths can reconcile
  self.schema = [ASDLevelDBSchema schemaWithKeys:@[
    self.authorKey, self.wordKey, self.docTitleKey, self.docKey
  ]];
}

APLevelDBSS 的模式处理将协调键定义,因此任何具有partFromPath的项都将使用路径字符串导航到原始数据,将doc.desc映射到doc键,然后在其中创建一个desc字段。

您可以看到wordKey有一个valueGenerator块,它生成多个值,使用以下代码中的uniqueWordsFromString。我们将所有键视为可能从块生成零个或多个值,或者作为它们路径的副作用,例如doc.authors.name与姓名列表匹配:

+ (NSArray*) uniqueWordsFromString:(NSString*)src {
  static NSMutableCharacterSet* seps = Nil;
  if (seps==Nil) {
    seps=[NSMutableCharacterSet
      whitespaceAndNewlineCharacterSet];
    [seps formUnionWithCharacterSet:
      [NSMutableCharacterSet punctuationCharacterSet]];
  }
  NSArray* words=[src componentsSeparatedByCharactersInSet:seps];
  NSMutableSet* uw=[NSMutableSet setWithCapacity:[words count]];
  for (id w in words) {
     if ([w length]>=3)
       [uw addObject:[w lowercaseString]];       
  }
  return [[uw allObjects] 
    sortedArrayUsingSelector:@selector(localizedCompare:)];
}

前面的单词生成器远比一个生产质量的生成器简单,后者会使用词干提取来匹配具有相同基词的单词,例如复数形式。它还应该有一个停用词列表来跳过某些单词,而不仅仅是检查length >=3个字符。

提取文本进行索引的字段有一大批研究成果。你可以阅读有关词干算法的内容,并下载 BSD 许可的 Snowball 算法源代码,它是经典 Porter 算法的后继者,在snowball.tartarus.org/index.php

使用文本索引的两个主要开源项目是 Sphinx 和 Solr;Lucene 的一部分。Packt Publishing 有许多关于它们的书籍,包括:www.packtpub.com/sphinx-search-beginners-guide/bookwww.packtpub.com/apache-solr-4-cookbook/book

用于跟踪文档的数据库字段

文档通过一个 URL 来识别,这个 URL 可能指向外部互联网资源或本地文件。就数据库而言,这只是一个字符串值。OS X 示例允许你拖动一个文档并将其拖放到窗口上,通过注册处理NSURLPboardType并添加两个处理方法:

- (void)awakeFromNib {
  [super awakeFromNib];
  [self.window registerForDraggedTypes:@[NSURLPboardType]];
}
- (BOOL)performDragOperation:(id <NSDraggingInfo>)sender {
  NSPasteboard *p = [sender draggingPasteboard];  
  if ( [[p types] containsObject:NSURLPboardType] ) {
    [self.docURL setStringValue:[NSURL URLFromPasteboard:p]path]];
    [self.docURL needsDisplay];
  }
  return YES;
}
- (NSDragOperation)draggingEntered:(id <NSDraggingInfo>)sender {
  NSDragOperation dragMask = [sender draggingSourceOperationMask];
  NSPasteboard *p = [sender draggingPasteboard];  
  if ( [[pboard types] containsObject:NSURLPboardType] ) {
    if (dragMask & NSDragOperationGeneric) 
      return NSDragOperationGeneric;
  }
  return NSDragOperationNone;
}

一旦前面的代码设置了文本字段docURL,我们就将其与数据库一起使用,就像用户在输入字段中输入的字符串值一样。

通过包将数据库作为文档

我们之前的数据库示例在临时位置创建了一个数据库。大多数应用程序需要一个更像文档本身的数据库,这样我们就可以将其复制到其他地方并打开多个数据库。"Sample09"就是这样的一个NSDocument应用程序,支持独立作者的集合和其他文档。记住,我们使用一个属性来跟踪我们的数据库——没有任何东西阻止应用程序拥有多个这样的属性和打开的数据库。

小贴士

与典型的基于文档的应用程序不同,我们一旦打开新窗口就必须让数据库开始运行——我们将持续保存而不是按需保存。为了确保这一点,NSWindowController可以通过[[self document] saveDocumentAs:self]触发另存为面板,以便选择数据库的位置。

复杂性在于如何处理数据库实际上存储在许多文件目录中的事实。Apple 的Bundle 编程指南描述了如何创建文档包,这是一种将特别命名的目录视为单个文档的方法。我们使用leveldb作为扩展,但你也可以选择自己的。

首先编辑xxx-info.plist文件,并向CFBundleDocumentTypes添加两个条目:

  1. 添加一个LSTypeIsPackage设置为True

  2. 添加一个CFBundleTypeExtensions,指定所需的扩展名,leveldb

如果你编译并运行该程序,你会发现任何以该扩展名命名的目录,例如blah.leveldb,现在在查找器中都会显示为一个单独的项目,你必须右键单击并选择显示包内容来导航其中。将NSDocument子类连接到打开该目录中的数据库需要两个重写:

- (BOOL)readFromFileWrapper:(NSFileWrapper*)fileWrapper
   ofType:(NSString*)typeName error:(NSError**)outError {
  self.db = [APLevelDB levelDBWithPath:[fileWrapper filename] 
    error:outError];
  return outError == nil;
}

- (BOOL)writeToURL:(NSURL*)url ofType:(NSString*)typeName
  error:(NSError**)outError {
  if (self.db == nil) { // our initial saveDocumentAs
    self.db = [APLevelDB levelDBWithPath:[url path]
      error: outError];
    return outError == nil;
  }
  return NO; 
}

在上述两种方法中,我们最终都得到了一个从 OS X 函数提供的 GUI 中选择的文件或另存为位置的完整路径。在 OS X 中,这可能是一个沙盒位置——我们只是被返回了一个可用的路径字符串。

摘要

我们通过包括两个多个键来源的架构学习到了另一个关键的设计练习——单词索引和多个作者。典型的拖放桌面行为在 OS X 中展示了添加文件链接,因此你可以看到文件 URL 如何成为数据库中的一个字符串值。我们最终看到了如何在桌面上打开多个数据库并将它们作为单个文档处理,而不是在固定位置。现在,我们将更深入地探讨 LevelDB,并了解更多关于设计权衡和设置的信息。

第十章:调整和键策略

LevelDB 有两个关键架构原则——不可变性和写入速度。不可变性虽然微妙但很重要——数据在 LevelDB 中永远不会被更新。相反,它被标记为已删除或被新的副本所取代。从应用程序代码来看,这似乎是一个无关紧要的问题,因为你似乎在更新键值。然而,理解数据库结构和遵循描述的行为至关重要。

在你获得 LevelDB 编程的新经验后,我们将从可调整的角度研究其实现。更多细节和文件格式在代码注释和 LevelDB 源代码的doc文件夹中的文件中解释。

我们将讨论调整设置以及 LevelDB 允许你插入自定义类的地方。它可以像我们迄今为止的示例中那样使用,即直接从开源箱中使用,但也有一些扩展点和参数让你可以改变其行为。一些组织甚至更进一步,定制 LevelDB 源代码,然后发布他们的版本。Riak 和 HyperDex 服务器是两个重要的 NoSQL 服务器,它们已经将他们的 LevelDB 修改作为独立的分支发布。我们将在接下来的调整说明中简要讨论它们。

理解 LevelDB 中的 Level

如你在以下图中可以看到,LevelDB 的主要存储是一个由级别组成的排序字符串表文件系列。它们目前有.sst扩展名,但不久将改为.ldb,以避免与微软冲突。每个更深层级的文件大小是前一个级别的文件大小的十倍。顶级是一个未排序的记录混合,排序发生在记录写入下一个级别时。

每次将一个级别的文件合并到下一个级别时,都会执行复制、排序和压缩。这种写放大是 LevelDB 架构中最大的性能权衡。在一个大型数据库中,某个值在其生命周期内可能被写入磁盘多达十一次,因为它在不同的级别之间被复制。这种方法的大优点是写入数据速度快,无需暂停进行索引更新。传统的 B-tree 索引在平衡树时也会重写数据。

数据并不会直接从你的函数调用跳转到 level 表。从write()操作开始,数据首先进入一个memtable,这是一个跳表结构。它同时被写入磁盘上的日志文件,以便在应用程序失败时提供数据恢复。当这个日志达到 4MB 的限制(由write_buffer_limit控制)时,LevelDB 开始写入一个新的日志,并切换到备用的memtable。内存中只维护这两种结构。正在写入的那个被称为 imm,因为它被视为不可变的,并且一个后台线程将其复制到一个新的.sst文件,位于 0 级:

理解 LevelDB 中的级别

LevelDB 的数据生命周期:复制和排序到更大的级别

为了以持久的方式管理这些文件集合,会写入一个manifest文件,记录每个.sst文件中使用的键范围和级别。记住,这些是不可变文件。每当通过写入当前的memtable或压缩线程合并文件并将其推到下一级别时添加新的.sst文件,manifest 都会添加一条记录。此外,在数据库目录中还有一个名为 CURRENT 的纯文本文件,它仅包含最新 manifest 文件的名称。

理解删除是另一种形式的写入

上述不可变级别和写入的解释应该能帮助你理解为什么我们看起来更新了一个键——使用相同键写入了一个新值,并逐级下渗。删除键的工作方式也是从上到下。与树索引不同,我们实际上不能删除一个键。相反,写入的是相同键的副本,并带有特殊标记以表明它已被删除。

理解从上到下读取的工作方式

我们已经看到写入值是如何从上到下,通过memtable推入级别文件的。读取可以说是从上到下拉取,是一个可能排除的过程。当Get调用查找键时,以下步骤发生,直到找到为止,包括找到带有删除标记的键,或者确定没有这样的键:

  1. 扫描当前的 memtable 跳表(可以找到键后退出)。

  2. imm memtable 跳表如果非空,也会被扫描。它将只包含内容,如果它正在写入磁盘过程中(可以找到键后退出)。

  3. 检查 Manifest 以确定键是否在已知存在于级别文件中的范围内(可以说没有)。

  4. 如果候选级别文件尚未缓存为打开状态,则将其打开。

  5. 如果使用filter policy(将在本章后面详细描述),则检查过滤器以查看键是否可能在级别文件中(可以说没有)。

  6. 使用文件的索引来确定文件是否包含一个包含键范围的块,该范围包括键(可以说没有)。

  7. 如果可能包含键的块不在块缓存中,则从级别文件中读取该块。

  8. 顺序遍历块中的键值对以读取值,或确定键最终不在该文件中(最终找到或没有)。

记住键和值可以是任意长度,因此无法计算一个偏移量以跳转到给定键值的起始位置,因此所有这些深入级别的努力。内存缓存和过滤器有很大帮助。有关布局和索引如何指向块的更多详细信息,请参阅doc/table_format.txt

理解快照如何使读取可预测

不变表架构的一个微妙方面是快照的使用方式。这个名字有点误导,因为它暗示这是一个重量级的数据库快照。但实际上,它们提供了一种以低成本冻结你对数据库视图的方法——它们基本上只是一个特殊的数字。

数据库内部使用的键由你的键、一个标志值和快照号组成。标志表示这是一个数据键还是一个删除键——正如我们之前看到的,这是由于Delete操作添加的特殊键。同时读写操作通过快照号得到保护,有效地隔离了正在迭代的键与任何删除或重写这些键值的操作。

你可以将快照视为影响任何单个GetIterator读取,通过ReadOptions结构指定它:

  leveldb::ReadOptions options;
  options.snapshot = db->GetSnapshot(); // save before work
  ... // work that adds or deletes keys
  leveldb::Iterator* iter = db->NewIterator(options);
  ... // view keys existing from snapshot time
  delete iter;
  db->ReleaseSnapshot(options.snapshot); // MANDATORY cleanup

虽然GetSnapshot返回的对象应该被删除以帮助数据库状态,但其行为就像你传递了快照号来形成用于读取的关键一样。每次新的写入或写入批次都会增加当前数字,因此实际在快照中搜索的键将不会看到后续的键。

使用快照只是数据库当前打开会话中的一个临时活动。它们在底层以不可见的方式表示,这意味着没有安全的方法将快照持久化到磁盘并在程序后续运行中使用它。

理解布隆过滤器如何帮助猜测

找不到某物通常比找到它要慢——你何时放弃?在大多数应用程序中,你不会在数据库中存储每个可能的关键值。LevelDB 中最大的优化之一是使用过滤器策略来决定给定的键是否存在于某个级别。

我们可以从清单文件中知道哪个级别文件包含我们的键的范围。如果你使用过滤器,过滤器数据会为每个打开的文件进行缓存,因此它提供了一个快速回答,即该键是否存在于该表中,而不需要读取索引和扫描块。为你提供的默认过滤器是一个布隆过滤器。

理解布隆过滤器如何帮助猜测

来自 Jason Davies 在线演示器的操作中的布隆过滤器

前面的图显示了在www.jasondavies.com/bloomfilter/的动态演示器中输入了七个值后的快照,这是一个理解它们工作原理的好方法。如果该网站仍在运行,请去尝试输入一些值并观察不同值下的位向量变化,然后继续阅读本章。我在阅读论文和查看静态图表的几次尝试后,在他的网站上有了“啊哈”的顿悟。

希望你已经看到了过滤器在实际操作中的精彩演示,以下内容将使你更容易理解。布隆过滤器利用一个简单的洞察——通过组合使用一些简单、快速的哈希函数可以减少它们发生冲突的机会。组合的哈希函数将它们的结果写入相同的位掩码。通常计算三个简单的哈希值比尝试计算一个完美的哈希值要快得多。过滤器不像哈希表那样工作——它无法处理冲突,因为这是其他 LevelDB 数据结构的任务,它们到达实际键。

小贴士

哈希函数旨在提供一个小的值,该值映射到一个更大的键。根据你的数据,可能会有冲突的值。一个糟糕的哈希是当太多的原始字符串生成相同的哈希值。如果你对哈希值的概念完全陌生,只需想象将你的键的第一个小写字母作为哈希值。如果你只有二十个以不同字母开头的名字,这将是一个完美的哈希。如果它们都是 Smith,这将是一场灾难。

布隆过滤器保证没有假阴性。如果它说一个键不存在,那么它绝对不存在。但是,如果它说键存在,那么它可能存在——另一个键可能有相同的哈希序列。决定使用过滤器是一个经典的权衡,以牺牲更多的磁盘空间为代价来提高性能,存储过滤器数据。这可以通过更改每键位数或甚至过滤器算法来进一步细化——更多的位数通常以牺牲更多空间为代价带来更好的性能。

如果你知道你的键几乎总是存在于数据库中,那么使用布隆过滤器是没有意义的!

使用布隆过滤器或替代方案进行调整

LevelDB 不关心你使用什么类型的过滤器,如果有的话。它提供了一个钩子供你指定一个FilterPolicy对象。你可以通过继承该接口来提供任何你喜欢的过滤器。使用过滤器不是强制性的,但通常至少通过使用NewBloomFilterPolicy提供的默认过滤器来提高性能。然而,如果你有一个自定义的比较器,它忽略了键的某些区域或以错误的顺序处理它们,那么你不能使用默认的过滤器策略。如果你的键包含大量信息,而其中只有一小部分是唯一的,你也可能需要一个自定义策略。

你的自定义过滤器可能仍然使用布隆算法,或者可能是你自己的。过滤器存储在磁盘上的数据没有假设——LevelDB 只是存储和检索过滤器对象提供的字节,在每个级别文件的末尾。

如果你正在使用标准过滤器,那么有一个调整的机会,因为它需要你指定每个键使用多少位。建议的值是每个键 10 位,这是为该特定文件缓存的过滤器内存影响。如果你有一个包含大量稀疏键的数据库,你可能需要使用更多的位来提高准确性并避免索引扫描。

Basho 的 Riak 服务器使用 Erlang 包装器 eleveldb,它是一个 LevelDB 克隆。它可在 github.com/basho/leveldb 上找到,并包括改进的布隆过滤器以及其他更适合其服务器环境的更改。他们声称他们的过滤器在磁盘上占用的空间更少,并且错误正率为 0.05%,相比之下,标准 Google 版本(如前所述,每键 10 位)的错误正率为 1%。1%的错误正率意味着,当过滤器说一个键存在时,100 次中有 1 次你将遍历 SSTable 并发现该键实际上并不存在。他们的过滤器可以被复制并用作标准过滤器的直接替换。

使用影响性能的设置

以下设置在 include/options.h 中有文档说明,并带有重要的注释,所有这些都在 LevelDB::Options 结构中设置,该结构传递给 Open

  • write_buffer_size 默认为 4 MB,更大的值将提高写入性能,如 Riak 所使用,但写入内存表到磁盘时可能会导致阻塞。记住,只有两个内存表缓冲区,如果imm仍在写入且当前缓冲区已满,则将发生停滞。

  • max_open_files 默认为 1000,对于大多数数据库来说将足够。如果你在服务器上有一个庞大的数据库,则可以增加这个值,因为它将允许更多的级别文件被缓存打开,从而避免打开它们和读取其索引和过滤器块的成本。

  • block_cache 是指向由 NewLRUCache 创建的缓存的指针,默认为 8 MB,请参阅以下讨论。

  • block_size 每块的用户数据,默认为 4 KB,影响级别表的索引,每个块有一个索引条目。除非你有许多大于 4 KB 的键,否则不要更改此设置。它还用于 I/O 刷新,因此选择一个更大的大小可能会使非常活跃的数据库容易受到操作系统崩溃丢失数据的影响。

  • block_restart_interval 默认为 16,除非你有大量具有最小变化的连续键,否则不要更改。这是检查点间隔,在此间隔内会写入整个新键而不是仅写入尾部更改。

  • filter_policy 默认为 NULL,除非使用前面讨论过的 Riak 等替换方案,否则使用 NewBloomFilterPolicy 创建策略。使用过滤器策略会占用存储空间并使用一些内存,但如果存在键不在表中的合理可能性,则可以优化键查找。

根据场景调整和结构化数据

以下场景为我们在这里和前几章中讨论的设置和键设计技术提供了上下文。

根据更新率选择数据结构

正如我们在第八章中讨论的,更丰富的键和数据结构,你可以决定将一些值移动到单独的键中,而不是将它们保留在主记录中。正如你现在应该理解的那样,如果主记录非常静态,它将倾向于迁移到层级表,然后在那里停留,而新的键值将从顶部向下推送,用于你经常更新的数据。如果主记录使用升序标识符进行索引,这种仓库方法将工作得更好,因为它们的层级表不需要回退。

在压缩过程中有一个优化,即在合并时,如果不需要回退,只需简单地将这些表复制到较大的表中。你也可以调用CompactRange函数来强制对给定键范围进行压缩。

基于预期访问的键性能缓存选择

缓存是一个复杂的过程。在ReadOptions中你可以应用一个有趣的附加选项,通过将fill_cache标志设置为 false 来绕过缓存。例如,想象一下你有一个数据库已打开,某些用户操作需要你离开并读取一些与大多数用户驱动操作流程不太相关的键。他们到目前为止对数据库的使用可能已经很好地加载了缓存,记录被大量重用。使用fill_cache=false创建的迭代器将避免刷新当前缓存。

另一个考虑因素是使用更大的缓存大小。缓存是一个对象,它可以通过标准调用或如果你对其进行了子类化,使用你自己的工厂来创建:

leveldb::Options options;
options.cache = leveldb::NewLRUCache(100 * 1048576); // 100MB
leveldb::DB* db;
leveldb::DB::Open(options, name, &db);
...
delete db
delete options.cache;  // mandatory cleanup

缓存是一个读取缓存——只有在你进行大量读取时才会有所帮助,其大小应该基于读取的数据量。否则,你就是在浪费内存。

根据角色使用多个数据库

Riak 服务器通过在每个平台上使用 7 到 64 个 LevelDB 数据库来实现极高的数据库吞吐量,部分原因是为了提高写入性能。你也可以利用不同的数据库作为机会,根据角色调整不同的设置。想象一下,你有一个非常动态的审计跟踪——它可以使用一个小缓存,避免过滤策略的开销,优化于写入。为了优化健壮性,你可以减少其write_buffer_size或显著增加其大小以获得高吞吐量。然而,经验报告表明,除非你有截然不同的用户画像,否则选择在数据库之间分割使用是一种后期优化。许多用户行为的自然不可预测性通常最好通过拥有一个单独的数据库来缓存数据,并在积累数据时构建层级来实现。

重新考虑生成键的策略

我们刚刚讨论了避免表更新的愿望如何导致使用不同的键,并解释了如何通过稳定的键范围来实现最佳压缩。关于键的生成方式,有一些可能需要考虑的点,这些点会影响层级表。

考虑的最简单场景是我们用于名称和地址数据库的批量加载数据。当Sample06转向使用多个键时,我们通过一个循环创建两个不同前缀的键来加载它们。这导致了很多键重叠,并在从 0 级到 1 级的压缩过程中产生了大量的排序。如果存在一次性加载大量记录的情况,比如我们的 50,000 行样本,考虑使用两次数据加载。为每个前缀使用单独的遍历意味着我们生成的键将已经按前缀分组,这减少了压缩时的排序。

如果你正在生成唯一的 ID 后缀,如我们添加的nameId以使名称唯一,两次遍历加载可能并不总是容易。然而,即使有这种唯一的键,你仍然可以在后续遍历中通过数据库循环生成二级键。这为数据加载增加了更多的处理,但与后续的多次读取操作相比,可能是一个好的权衡。

请记住,级别表中的键值以尾随增量形式存储,跳过了公共前缀。你应该小心避免添加可能会破坏这一点的后缀。如果你考虑将某个共同值添加为键后缀,看看是否将其作为前缀更合理。这通常需要一些应用程序逻辑更改,但可能会带来重大的表改进。这种复杂的变化只有在你有极端的性能要求时才有用,但这里提到以供你考虑。

另一个可以考虑的是,如果你的键有一个共同的值字段,以利用键增量。如果值中有许多键不变的内容,它将为每个键重复。如果你将它移动到记录的键而不是值一侧,你可能会从键压缩中获得好处。

最后,请记住 LevelDB 的 Bloom 过滤器和键范围行为使其非常擅长确定一个键值是否不在数据库中。如果你有二进制标志,考虑是否可以反转它们的行为,并存储一个键来表示相反的情况,这样你的正常搜索就会是如果标志键缺失。

概述

我们对内存和磁盘上的结构有了更多的了解,这些结构赋予了 LevelDB 其名称和行为。将这些内容置于我们整本书中编程的 API 的上下文中,为你提供了更明智的方式来构建程序和考虑你的键策略。你还了解了一些可能影响性能和内存使用的不同设置,这可能会让你使用具有不同设置的多个数据库。

总结 LevelDB 生态系统,我们将告别原生代码世界,并以一个附录结束,回顾一些更常见的脚本语言包装器,这些包装器允许你在不编译的情况下使用 LevelDB。

附录 A. 脚本语言

脚本语言最初被设计为用于脚本常见任务的轻量级语言,因此得名。它们被视为用于命令行工具或作为允许轻松定制更大程序控制流的嵌入式语言。本附录介绍了它们如何与 LevelDB 一起工作,保持简单以帮助您将所学代码与之类比。它并不讨论 LevelDB 作为脚本数据库解决方案的适用性——假设您已经对 LevelDB 感兴趣,或者已经在使用 LevelDB,只是想用它与其他工具一起使用。

在科学界,Python 的可用性使其在更复杂的程序中越来越受欢迎,并且它在使用 Django 等框架的 Web 应用程序中得到了增长。Ruby on Rails 对 Ruby 的普及做出了重大贡献,以至于有些人甚至不认为 Ruby 是一种独立语言。JavaScript,更正式地称为 ECMAScript,最初是一种浏览器语言,它也发展到了服务器端编程。流行的 Node.js 环境使用 Google 的 V8 引擎打包它,以便从原生代码中轻松调用独立编程环境。在这些所有情况下,这些语言提供了一种相对低开销的方式来调用 C 函数,从而使用提供 C 或 C++ 接口的外部库。

无论是在编写 Web 应用程序还是进行本地数据处理时,数据库访问通常很有用。典型的 Web 应用程序使用数据库服务器,虽然它可能由 LevelDB 支持,但不能说它直接使用 LevelDB。然而,如果您需要一个单访问数据库,从脚本语言中调用它很方便,因此已经为 LevelDB 从这些语言编写了多个访问层。

重要的是要记住,LevelDB 的主要接口是 C++,并且这并不会因为您使用脚本语言编程而改变。正如您通过本书中的 Objective-C 示例所了解的,LevelDB 的 C++ 接口非常轻量级。您可以认为我们在这里查看的脚本语言接口是我们一直在使用的 Objective-C 框架的同等物。

我们在多个章节中看到,使用自定义比较器可以为你的数据库增加价值,尤其是如果你使用复合键,或者进行不区分大小写的搜索时。虽然许多包装项目承诺比较器作为未来的功能,但它们似乎缺少这一点。记住,比较器是一个由核心 LevelDB 代码调用的 C++对象。要在脚本语言中实现它们,需要编写一个能够从 C++回调到脚本的代码桥接器。这样的回调函数比从脚本到数据库库的正常调用方向要复杂得多。然而,如果你在更大的本地程序中托管你的脚本解释器,你仍然可以用 C++编写自定义比较器,并使其与数据库一起工作。

在 Node.js 中使用 LevelDB

我们在第七章中介绍了 Node.js 和 LevelUP 以及 LevelDOWN 包装器的安装,使用 REPL 和命令行进行调试,作为安装lev实用程序的一部分。LevelDOWN 基本上是将 C++接口重新发布为 Node.js JavaScript 语法。两者都可通过npm(Node 包管理器)安装,现在已包含在 level 包中。主页提供了更多安装选项,网址为github.com/rvagg/node-levelup,如果你想为项目做出贡献,也可以从该地址克隆 GIT 仓库。

LevelUP 的有趣演变过程是一个抽象层,隐藏了底层使用 LevelDB 的过程,以至于它不再需要安装 LevelDOWN,也可以与其他实现一起工作,包括内存存储 MemDown(更多详情请参阅之前提到的主页)。

Node.js 程序以一系列异步调用的回调函数的形式编写,考虑到它原本是作为 Web 应用的服务器端语言,这样做是有意义的。因此,一个简单的写入一些数据并读取回的数据程序结构如下:

var levelup = require('levelup')
// open a data store
var db = levelup('/tmp/testleveldb11_node.db')
// a simple Put operation
db.put('name', 'William Bloggs', function (err) {
  // a Batch operation made up of 3 Puts (after 1st Put finished)
  db.batch([
      { type: 'put', key: 'spouse', value: 'Josephine Bloggs' }
    , { type: 'put', key: 'dog', value: 'Kilroy' }
    , { type: 'put', key: 'occupation', value: Dev' }
  ], function (err) {
    // asynch after batch put finishes, another nest
    // read store as a stream and print each entry to stdout
    db.createReadStream()
      .on('data', console.log)
      .on('close', function () {
        db.close()
      })
  })  // end of batch Put
})  // end of top-level Put

使用 LevelDB 作为模块化数据库的节点包生态系统非常庞大。其中一个特别有趣的是 levelgraph。您可以从主页 github.com/mcollina/levelgraph 下载它,并与 LevelUP 一起使用以提供数据库层。它使用配对键以类似我们第八章中描述的方案支持的方式提供完整的图数据库抽象,更丰富的键和数据结构。然而,levelgraph 进一步支持来自图数据库理论的经典三元组操作。它可以扩展以支持来自 github.com/mcollina/levelgraph-n3 的 levelgraph-n3 插件,该插件允许紧凑的 N3 语法。如果您对图数据库和基于三元组的知识表示感兴趣,请参阅 www.w3.org/TeamSubmission/n3/

使用 Python 从 LevelDB 读取

有许多针对 LevelDB 的 Python 包装器,它们使用 Cython 工具集生成一个接口层来与 C++ 类进行通信。最新且经常推荐的包装器是 plyvel:plyvel.readthedocs.org/en/latest/installation.html

然而,还有一个更底层的纯 C API 用于 LevelDB,它允许您直接调用共享库中的函数。一个简单的 Python 包装器 code.google.com/p/leveldb-py/ 非常简单,以至于它仅用一个文件实现。该文件 leveldb.py 和单元测试 test_leveldb.py 包含在本章的示例代码中。您不需要使用 pip 安装或其他命令,只需将文件包含在您的调用旁边即可。

这个简单的包装器,就像许多其他脚本语言包装器一样,期望在标准系统位置有一个动态库。这反映了它们的 Unix 血统。许多安装程序实际上会重新构建 LevelDB 并将其推送到这个位置,但这个需要您自己完成这项工作。为了提供这个库,请回到第一章中关于构建 LevelDB 的说明 下载 LevelDB 和使用 OS X 构建,但这次,在您构建了 LevelDB 之后,而不是重命名静态库,请将四个文件复制到 /usr/local/lib

libleveldb.a
libleveldb.dylib
libleveldb.dylib.1
libleveldb.1.12

这确保了在标准位置存在一个动态库,因此尝试打开名为 leveldb 的动态库将能够成功。

编写类似之前看到的 Node.js 代码的数据库的代码看起来与我们已经看到的 C++ 示例非常相似:

#!/usr/bin/env python
import leveldb

# open a data store
db = leveldb.DB("/tmp/testleveldb11_py.db",
 create_if_missing=True)

# a simple Put operation
db.put('name', 'William Bloggs')

# a Batch operation made up of 3 Puts
b = db.newBatch()
db.putTo(b, key = 'spouse', val= 'Josephine Bloggs')
db.putTo(b, key = 'dog', val= 'Kilroy')
db.putTo(b, key = 'occupation', val= 'Dev')
db.write(b, sync=True)
db.close()

为了读取这些内容,我们可以简单地遍历所有键并获取它们关联的值:

for k in db.keys(): 
  print k, db.get(k)

除了支持我们迄今为止看到的所有基本 LevelDB 功能外,leveldb.py还包括与我们在 Objective-C 中添加的前缀逻辑类似的逻辑,这样你可以获取键的子集。test_leveldb.py中的单元测试包括如下代码:

def testPutGet(self):
    db = self.db_class(self.db_path, create_if_missing=True)
    db.put("key1", "val1")
    db.put("key2", "val2", sync=True)
    self.assertEqual(db.get("key1"), "val1")
...
    self.assertEqual(list(db.keys()), ["key1", "key2"])
    self.assertEqual(list(db.keys(prefix="key")), ["1", "2"])

从最后一行可以看出,前缀检索键会自动去除前缀,这与我们在 Objective-C 中获取TableView键时所做的代码类似。

使用 Ruby 从 LevelDB 中获取

最常推荐的 LevelDB Ruby 包装器来自github.com/wmorgan/leveldb-ruby,如本章代码示例中包含的日志文件所示,可以使用 gem 命令进行安装:

sudo gem install leveldb-ruby

注意,它自 2011 年以来一直处于停滞状态,且非常基础,甚至不包括批处理支持。然而,它使用与之前 Python 代码类似的代码支持了基本功能:

require 'leveldb'
# open a data store
db = LevelDB::DB.new("/tmp/testleveldb11_ruby.db")
# a simple Put operation
db.put('name', 'William Bloggs')
db.put('spouse', 'Josephine Bloggs')
db.put('dog', 'Kilroy')
db.put('occupation', 'Dev')
db.close()

与 Python 代码不同,Ruby 代码在读取时是惯用的,你可以直接将数据库当作字典来处理,并对其应用一个块:

db.each do |k,v|
  puts "Key=#{k}, Value=#{v}"
end

一个更实用且更完整的包装器是github.com/DAddYE/leveldb,它包括更友好的迭代器和批处理,但安装过程更复杂,需要 Ruby 2.0。它增加了批处理支持:

db.batch do |b|
  b.put 'spouse', 'Josephine Bloggs'
  b.put 'dog', 'Kilroy'
  b.delete 'name'
end

这个示例作为 Ruby 风格的代码块使用了一个惯用表达式,即块包含所有要应用到批处理中的逻辑,因此暗示在块末尾进行写入。

摘要

我们在三种主流脚本语言中看到了不同的编码风格。抓住机会进一步探索那些链接,并考虑使用脚本语言作为 REPL 来探索 LevelDB 中的想法。你可能想用它快速生成大量数据库或玩转不同的键结构。

posted @ 2025-10-09 13:23  绝不原创的飞龙  阅读(7)  评论(0)    收藏  举报