FDBSwift v5 Swift: 5.5

第五章:Swift 觉醒

这是用于 Swift 的 FoundationDB 客户端。它相当底层,(几乎)无 Foundation 依赖,并且可以集成到 async/await 中(不包含且不需要 Swift-NIO)。

安装

显然,您需要先安装 FoundationDB。从 官方网站 下载。接下来的部分比较棘手,因为 CFDB 模块(C 绑定)不会自行链接 libfdb_c 库,并且 FoundationDB 安装过程中尚未提供 pkg-config。因此,您必须自行安装。运行

chmod +x ./scripts/install_pkgconfig.sh
./scripts/install_pkgconfig.sh

或复制 scripts/libfdb.pc(选择您的平台)到 macOS 上的 /usr/local/lib/pkgconfig/ 或 Linux 上的 /usr/lib/pkgconfig/libfdb.pc

从 v4 迁移到 v5

v5 在内部结构和 API 方面都是一个巨大的更新。最重要的更新当然是 async/await 的采用。因此,Swift-NIO 依赖项已被删除,因为它变得冗余。自然地,许多 API 因为过时而消失了。但是,剩余的 API 看起来几乎相同,只是它不再使用事件循环和 futures。

在 v4(和更早版本)中,有一个阻塞式 API。当然,几乎没有人使用它,因为,嗯,为什么呢?然而,在 v5 开发过程中,结果表明此 API 是采用 async/await 的完美基础,只需在签名中添加 async 即可。就像它一直在等待它一样。我一直维护这个 API 以及 NIO API,这真是太好了。对我来说是件好事。

所以现在只有两种使用 FDBSwift 的方式

  1. AnyFDB(因此,FDB,默认实现)中使用自动提交的 Oneshot API,例如 get(key:)set(key:value:)atomic(_ op:key:value) 等。
  2. 事务性 API,有两种风格
    1. 使用扁平流程的手动事务管理:fdb.begin(),一些操作和 transaction.commit()(基本上您可以完全控制事务,并且必须手动捕获错误并自行处理重试错误,请参阅下面的详细信息和相关部分)。
    2. 包装事务:fdb.withTransaction { transaction in /* 一些操作 */ },它为您管理重试,但您仍然需要注意其他错误。

另一个不太小的变化是,错误情况 FDB.Error.transactionRetry 不再具有关联值 AnyFDBTransaction。在 v4 中,当您可以开始一个 NIO 事务,并且在任何时候(包括 flatMapError/recover 等)都没有对其的引用时,它非常方便(我个人有很多这样的情况)。现在这个问题已经彻底消失,并且完全不需要这个关联值。

用法

根概念

默认情况下(并且在核心中),此包装器以及 C API 都使用字节键和值(不是指针,而是 Array<UInt8>)。有关更多详细信息,请参阅 键、元组和子空间 部分。

值始终是字节 (typealias Bytes = [UInt8])(如果未找到键,则为 nil)。您可能会问为什么不是 Data?我想尽可能长时间地保持无 Foundation 依赖(说真的,仅仅为了 Data 对象而导入半个世界,而 Data 对象只是 NSData 的一个花哨的包装器,而 NSData 又是 [UInt8] 的一个花哨的包装器?)(您是否忘记了您需要用 autoreleasepool 包装所有 Data 对象,否则您会得到花哨的内存泄漏?)(除了 Linux 之外,是的),您可以始终使用 Data(bytes: myBytes) 初始化器将字节转换为 Data(您为什么要这样做?哦,是的,JSON...好的,但请您自己完成,扩展来救援)。

连接

// Default cluster file path depending on your OS
let fdb = FDB()

// OR
let fdb = FDB(clusterFile: "/usr/local/etc/foundationdb/fdb.cluster")

您可以选择性地传递网络停止超时。

请记住,此时连接尚未建立,它在第一次实际数据库操作时自动建立。如果您想显式连接到数据库并捕获可能的错误,只需调用

try fdb.connect()

断开连接是自动的,在 deinit 时进行。但您也可以直接调用 disconnect() 方法。请注意,如果在断开连接期间出现任何问题,您将收到无法捕获的致命错误。这没什么大不了的,因为断开连接应该只发生一次,当您的应用程序关闭时(并且您不应该真正关心那时的致命错误)。此外,您非常应该确保 FDB 在实际关闭之前真正断开连接(捕获 SIGTERM 信号并等待 disconnect 完成),否则您可能会遇到未定义的行为(我个人还没有真正遇到过这种情况,但这并非危言耸听;当您不遵循 FoundationDB 建议时,事情确实会变得非常混乱)。

在连接到 FDB 集群之前,您还可以设置网络选项

try fdb.setOption(.TLSCertPath(path: "/opt/fdb/tls/chain.pem"))
try fdb.setOption(.TLSPassword(password: "changeme"))
try fdb.setOption(.buggifyEnable)

有关完整的网络选项集,请参阅 FDB+NetworkOptions.swift 文件。

键、元组和子空间

所有键都是 AnyFDBKey 协议

public protocol AnyFDBKey {
    func asFDBKey() -> Bytes
}

此协议被 StringStaticStringTuple(不是 Swift 中的 Tuple 类型)、SubspaceBytes(又名 Array<UInt8>)采用,因此您可以自由使用这些类型中的任何一种,或在您的自定义类型中采用此协议。

由于您可能希望在应用程序中拥有某种键命名空间,因此您应该坚持使用 Subspace,它是一个用于创建命名空间的极其有用的工具。在底层,它利用了 Tuple 概念。您真的不必费心深入研究它(简而言之:基本上是打折的 MsgPack,一种棘手的二进制协议),只需记住,目前子空间接受 StringIntFloat(又名 Float32)、DoubleBoolUUIDTuple(因此 FDBTuplePackable)、FDB.Null(您为什么要这样做?)和 Bytes 作为参数。

// dump subspace if you would like to see how it looks from the inside
let rootSubspace = FDB.Subspace("root")

// also check Subspace.swift for more details and usecases
let childSubspace = rootSubspace["child"]["subspace"]

// OR
let childSubspace = rootSubspace["child", "subspace"]

// Talking about tuples:
let tuple = FDB.Tuple(
    Bytes([0, 1, 2]),
    322,
    -322,
    FDB.Null(),
    "foo",
    FDB.Tuple("bar", 1337, "baz"),
    FDB.Tuple(),
    FDB.Null()
)
let packed: Bytes = tuple.pack()
let unpacked: FDB.Tuple = try FDB.Tuple(from: packed)
let tupleBytes: Bytes? = unpacked.tuple[0] as? Bytes
let tupleInt: Int? = unpacked.tuple[1] as? Int
// ...
let tupleEmptyTuple: FDB.Tuple? = unpacked.tuple[6] as? FDB.Tuple
let tupleNull: FDB.Null? = unpacked.tuple[7] as? FDB.Null
if tupleNull is FDB.Null || unpacked.tuple[7] is FDB.Null {}

// you get the idea

设置值

就这么简单

try await fdb.set(key: "somekey", value: someBytes)

// OR
try await fdb.set(key: Bytes([0, 1, 2, 3]), value: someBytes)

// OR
try await fdb.set(key: FDB.Tuple("foo", FDB.Null(), "bar", FDB.Tuple("baz", "sas"), "lul"), value: someBytes)

// OR
try await fdb.set(key: Subspace("foo", "bar"), value: someBytes)

获取值

值始终为 Bytes?(如果未找到键,则为 nil),您应该在使用前解包它。键当然仍然是 AnyFDBKey

let value = try await fdb.get(key: "someKey")

范围获取(多重获取)

由于在 FoundationDB 中,键在底层字节上按字典顺序排序,因此您可以通过查询从键 somekey\x00 到键 somekey\xFF(从字节 0 到字节 255)的范围来获取所有子空间值(甚至来自整个数据库)。不过,您不应该手动执行此操作,因为 Subspace 对象有一个为您执行此操作的快捷方式。

此外,get(range:)(及其版本)方法不返回 Bytes,而是返回一个特殊的盒子结构 FDB.KeyValuesResult,其中包含一个 FDB.KeyValue 结构数组和一个标志,指示数据库是否可以提供更多结果(分页,有点像)。

public extension FDB {
    /// A holder for key-value pair
    public struct KeyValue {
        public let key: Bytes
        public let value: Bytes
    }
    
    /// A holder for key-value pairs result returned from range get
    public struct KeyValuesResult {
        /// Records returned from range get
        public let records: [FDB.KeyValue]

        /// Indicates whether there are more results in FDB
        public let hasMore: Bool
    }
}

如果范围调用返回零记录,则它将导致一个空的 FDB.KeyValuesResult 结构(而不是 nil)。

let subspace = FDB.Subspace("root")
let range = subspace.range
/*
  these three calls are completely equal (can't really come up with case when you need second form,
  but whatever, I've seen worse whims)
*/
let result: FDB.KeyValuesResult = try await fdb.get(range: range)
let result: FDB.KeyValuesResult = try await fdb.get(begin: range.begin, end: range.end)
let result: FDB.KeyValuesResult = try await fdb.get(subspace: subspace)

// although call below is not equal to above one because `key(subspace:)` overload implicitly loads range
// this one will load bare subspace key
let result: FDB.KeyValuesResult = try await fdb.get(key: subspace)

result.records.forEach {
    dump("\($0.key) - \($0.value)")
}

清除值

清除(删除、删除,随便你怎么称呼)记录也很简单。

try await fdb.clear(key: childSubspace["concrete_record"])

// OR
try await fdb.clear(key: rootSubspace["child"]["subspace"]["concrete_record"])

// OR EVEN
try await fdb.clear(key: rootSubspace["child", "subspace", "concrete_record"])

// OR EVEN (this is not OK, but still possible :)
try await fdb.clear(key: rootSubspace["child", FDB.Null, FDB.Tuple("foo", "bar"), "concrete_record"])

// clears whole subspace, including "concrete_record" key
try await fdb.clear(range: childSubspace.range)

原子操作

FoundationDB 还支持原子操作,如 ADDANDORXOR 等等(请参阅 文档)。您可以使用 atomic(_ op:key:value:) 方法执行这些操作中的任何一个

try await fdb.atomic(.add, key: key, value: 1)

考虑到最流行的原子操作是递增(或递减),我添加了方便的语法糖

try await fdb.increment(key: key)

// OR returning incremented value, which is always Int64
let result: Int64 = try await fdb.increment(key: key)

// OR
let result = try await fdb.increment(key: key, value: 2)

但是,请记住,上面的示例不再是原子的。

以及递减,它只是 increment(key:value:) 的代理,只是反转了 value

let result = try await fdb.decrement(key: key)

// OR
let result = try await fdb.decrement(key: key, value: 2)

事务

之前的所有示例都利用了 FDB 对象方法,这些方法是隐式事务性的。如果您想在一个事务中执行多个操作(并体验 ACID 的所有乐趣),您应该首先在 FDB 对象上下文中使用 begin() 方法开始事务,然后执行您的操作(只是不要忘记在最后 commit() 它,默认情况下,如果未显式提交,事务将回滚,或者在 5 秒超时后回滚)。

let transaction = try fdb.begin()

transaction.set(key: "someKey", value: someBytes)

try await transaction.commit()

// OR
transaction.reset()

// OR
transaction.cancel()

或者您可以将事务对象留在原处,它会在 deinit 时重置并销毁自身。将其视为自动回滚。请参阅有关重置和取消行为的官方文档:https://apple.github.io/foundationdb/api-c.html#c.fdb_transaction_reset

不过,实际上没有必要提交只读事务 :)

此外,您可以使用 transaction.setOption(_:) 方法设置事务选项

let transaction: AnyFDBTransaction = ...
try transaction.setOption(.transactionLoggingEnable(identifier: "debuggable_transaction"))
try transaction.setOption(.snapshotRywDisable)

有关选项的完整列表,请参阅 Transaction+Options.swift

冲突、重试和 withTransaction

由于 FoundationDB 是一个相当事务性的数据库,因此有时由于序列化失败(更常见且错误地称为死锁),commit 可能不会成功。当两个或多个事务创建重叠的冲突范围时,可能会发生这种情况。或者,简单来说,当它们尝试同时访问或修改相同的键(除非它们处于 snapshot 读取模式)时。这是预期的(并且在某种程度上是受欢迎的)行为,因为这就是 ACID 的工作方式。

在这些[并非罕见]的情况下,允许再次重放事务。您如何知道您的事务是否可以重放?它失败并出现特殊的错误情况 FDB.Error.transactionRetry。如果您的事务因这个特定的错误而失败,则意味着事务已经回滚到其初始状态,并准备好再次执行。

您可以手动实现此重试逻辑,或者您可以只使用 FDB 实例方法 withTransaction。以下示例应该是不言自明的

let maybeString: String? = try await fdb.withTransaction { transaction in
    guard let bytes: Bytes = try await transaction.get(key: key) else {
        return nil
    }
    try await transaction.commit()
    return String(bytes: bytes, encoding: .ascii)
}

因此,您的代码块将被温和地重试,直到事务成功提交(或者直到数据库确定它已经被重试足够多次并且是时候放手了)。

写入 Versionstamped 键

作为一种特殊的原子操作,值可以写入保证唯一的特殊键。这些键在其元组中使用了不完整的版本戳,这些版本戳将在写入数据时由底层集群完成。然后可以检索事务中使用的 Versionstamp,以便在其他地方引用它。

可以使用 FDB.Versionstamp() 初始化器创建不完整的版本戳并将其添加到元组中。userData 字段是可选的,用于进一步排序同一事务中写入的多个键。

在事务块中,可以使用 set(versionstampedKey:value:) 方法写入具有不完整版本戳的键。此方法将搜索键中不完整的版本戳,如果找到,则会将其标记为在写入集群后替换为完整的版本戳。如果未找到不完整的版本戳,则将抛出 FDB.Error.missingIncompleteVersionstamp 错误。

如果您需要键中使用的完整版本戳,您可以在提交事务之前调用 getVersionstamp()。请注意,此方法必须在写入版本戳键的同一事务中调用,否则它将不知道要返回哪个版本戳。另请注意,此版本戳不包含与其关联的任何用户数据,因为它将是相同的版本戳,无论写入了多少个版本戳键。

let keyWithVersionstampPlaceholder = self.subspace[FDB.Versionstamp(userData: 42)]["anotherKey"]
let valueToWrite: String = "Hello, World!"

var versionstamp: FDB.Versionstamp = try await fdb.withTransaction { transaction in
    try transaction.set(versionstampedKey: keyWithVersionstampPlaceholder, value: Bytes(valueToWrite.utf8))
    try await transaction.commit()
    return try await transaction.getVersionstamp()
}

versionstamp.userData = 42
let actualKey = self.subspace[versionstamp]["anotherKey"]

// ... return it to user, save it as a reference to another entry, etc…

完整示例

let key = FDB.Subspace("1337")["322"]

let resultString: String = try await fdb.withTransaction { transaction in
    try transaction.setOption(.timeout(milliseconds: 5000))
    try transaction.setOption(.snapshotRywEnable)

    transaction.set(key: key, value: Bytes([1, 2, 3]))

    guard let bytes = try await transaction.get(key: key, snapshot: true) else {
        throw MyApplicationError.Something("Bytes are not bytes")
    }
    guard let string = String(bytes: bytes, encoding: .ascii) else {
        throw MyApplicationError.Something("String is not string")
    }

    try await transaction.commit()

    return string
}

print("My string is '\(resultString)'")

调试/日志记录

FDBSwift 支持官方社区 Swift-Log 库,并带有自定义后端 LGNLog,因此 FDBSwift 将使用 Logger.current 日志记录器(可以通过 Logger.$current.withValue(contextLogger) { ... } 绑定到 TaskLocal 日志记录器),并遵守日志级别和其他 LGNLogger 配置条目,请参阅相关文档。

故障排除

包无法编译,出现类似 Undefined symbols for architecture 和大量类似废话的东西。求助。

您没有正确安装 FoundationDB 的 pkg-config,请参阅 安装部分

包在 macOS 中可以编译,但在运行时我收到错误 The bundle “FDBTests” couldn’t be loaded because it is damaged or missing necessary resources. Try reinstalling the bundle。怎么办?

在控制台中执行此神奇命令:install_name_tool -id /usr/local/lib/libfdb_c.dylib /usr/local/lib/libfdb_c.dylib

感谢 @dimitribouniol 和他的 精彩调查

我在第二个操作中收到奇怪的错误:API version already set。我应该反思人生吗?

(反思并分析事物总是好的)您尝试创建多个 FDB 类的实例,这是 a) 禁止的 b) 完全不需要的,因为一个实例对于任何应用程序都足够了(如果不够,请考虑横向扩展,FDB 绝对不应该是您应用程序的瓶颈)。严格来说,这不是很好,应该有一种在运行时创建多个 FDB 连接的方法,我肯定会尝试使其成为可能。尽管如此,我认为 FDB 连接池不是一个好主意,它已经为您完成了一切。

警告

虽然我的目标是实现 Tuple 层的完全跨语言兼容性,但我不能保证这一点。在开发过程中,我参考了 Python 实现,但可能存在细微差异(例如 Unicode 字符串和字节字符串打包,请参阅关于字符串的 设计文档 和关于此的 我的评论)。总的来说,它应该已经相当兼容了。也许有一天我会花一些时间来确保打包兼容性,但对我来说这不是高优先级。

TODO