一个纯 Swift 实现的类数据库持久化存储,用于应用程序和其他单写多读环境。
在您的 Package.swift
文件中添加 CodableDatastore
作为依赖项以开始使用它。然后,将 import CodableDatastore
添加到您希望使用该库的任何文件中。
请查看发布以获取推荐版本。
dependencies: [
.package(
url: "https://github.com/mochidev/CodableDatastore.git",
.upToNextMinor(from: "0.3.8")
),
],
...
targets: [
.target(
name: "MyPackage",
dependencies: [
"CodableDatastore",
]
)
]
使用 CodableDatastore
有三个基本步骤
DatastoreFormat
来为您的数据存储声明格式。首先,您必须确定模型的形状。每个数据存储只能包含一种类型,但持久化可以协调跨不同类型的访问。由于 CodableDatastore
的设计考虑了值类型,因此请避免为您的模型对象使用类。
您还需要一个对应于数据存储的格式,它描述了您的类型的标识方式、版本信息以及您希望自动创建的任何索引。
CodableDatastore
鼓励一种模式,您声明的格式成为您的类型及其相关元类型的主要命名空间
import Foundation
import CodableDatastore
struct BookStore: DatastoreFormat {
// These are both required, and used by the initializer for `Datastore` below.
static let defaultKey: DatastoreKey = "BooksStore"
static let currentVersion: Version = .current
// A required description of your current and past versions you support
// decoding. Note that the current version should always have a sensible
// value, as it will get encoded to the persistence.
// Either `Int`s or `String`s are supported.
enum Version: String {
case v1 = "2024-04-01"
case current = "2024-04-09"
}
// A required "pointer" to the latest representation of your type.
typealias Instance = Book
// Optional if the Instance above is Identifiable, but required otherwise.
// typealias Identifier = UUID
// The first version we shipped with and need to support.
struct BookV1: Codable, Identifiable {
var id: UUID
var title: String
var author: String
}
// The current version we use in the app.
struct Book: Codable, Identifiable {
var id: UUID
var title: SortableTitle
var authors: [AuthorID]
var isbn: ISBN
}
// A non-required helper for instanciating a datastore. Note many
// parameters are inferred, since `DatastoreFormat` declares a specialized
// `Self.Datastore` with all the generic parameters filled out.
// `CodableDatastore` comes with initializers for JSON and Property list
// stores, though completely custom coders can easily be supported by using
// the default initializers on `Datastore`.
static func datastore(for persistence: DiskPersistence<ReadWrite>) -> Self.Datastore {
.JSONStore(
persistence: persistence,
// This is where all the migrations for different versions are
// defined; Simply decode the type you know if stored for a given
// version, and convert it to the modern type you use in the app.
// Conversions are typically only done at read time.
migrations: [
.v1: { data, decoder in try Book(decoder.decode(BookV1.self, from: data)) },
.current: { data, decoder in try decoder.decode(Book.self, from: data) }
]
)
}
// Declare your indexes here by declaring stored properties in one of the
// provided `Index` types. The keypaths refer to the main Instance for the
// format you declared. Both stored and computed keypaths are supported.
// Indexes are automatically re-computed whenever the names or types of
// these declarations change, though the key path itself can change silently.
// Note: Manual migrations of these is not currently supported but is planned.
let title = Index(\.title)
let author = ManyToManyIndex(\.authors)
// A one to one index where every isbn points to exactly one book.
// Note the index is marked with `@Direct`, which optimizes reads by isbn by
// trading off the additional storage space needed to store full copies of
// the `Book` struct in that index.
@Direct var isbn = OneToOneIndex(\.isbn)
}
// A convenience alias for the rest of the app to use.
typealias Book = BookStore.Book
// Declare any necessary conversions to make old stored instances continue working.
extension Book {
init(_ bookV1: BookStore.BookV1) {
self.init(
id: id,
title: SortableTitle(title),
authors: [AuthorID(authors)],
isbn: ISBN.generate()
)
}
}
接下来,设置一个 actor 或其他管理器来“拥有”您的持久化和数据存储,使其对您的应用程序有意义。请注意,持久化和数据存储不需要异步或可抛出的上下文即可实例化,并提供延迟方法在您可以选择在这些操作周围显示 UI 时执行此操作。保留对您创建的持久化和数据存储 actor 的引用,并将它们单独传递到您的应用程序中,或者将它们抽象到一个具有用于常见操作的 getter 和 setter 的管理器中。
import Foundation
import CodableDatastore
actor LibraryPersistence {
// Keep these around so we can access them as necessary
let persistence: DiskPersistence<ReadWrite>
let bookDatastore: BookStore.Datastore
let authorDatastore: AuthorStore.Datastore
let shelfDatastore: ShelfStore.Datastore
init() async throws {
// Initialize the persistence to Application Support, or pass in a readWriteURL
persistence = try DiskPersistence.defaultStore()
// Make sure we can write and access it
try await persistence.createPersistenceIfNecessary()
// Initialize the datastores so we can refer back to them
bookDatastore = BookStore.datastore(for: persistence)
authorDatastore = AuthorStore.datastore(for: persistence)
shelfDatastore = ShelfStore.datastore(for: persistence)
// Warm the datastores to re-build any indexes changed suring development.
// This is an excellend opportunity to show migration UI if the process takes longer than a second.
try await bookDatastore.warm { progress in
switch progress {
case .evaluating:
// Always called
print("Checking Books…")
case .working(let current, let total):
// Only called if migrating. Signal some UI and log the values.
// `current` is 0-based.
print(" → Migrating \(current+1)/\(total) Books…")
case .complete(let total):
// Always called
print(" ✓ Finished checking \(total) Books!")
}
}
try await authorDatastore.warm()
try await shelfDatastore.warm()
}
}
一旦您拥有数据存储,您可以在任何异步抛出上下文中从中读取
let bookByID = try await bookDatastore.load(bookID)
let bookByISBN = try await bookDatastore.load(isbn, from: \.isbn)
请注意,在上面的示例中,bookID
的类型为 BookStore.Identifier
,也称为 Book.ID
,而 \.isbn
是 BookStore
上指向 ISBN 索引(一对一索引)的键路径。这两者都返回可选值,因此如果给定键的实例为 nil
。
还可以作为异步序列获得一系列结果
for try await book in bookDatastore.load("A"..<"B", from: \.title) {
print("Book that starts with A: \(book.title)")
}
guard let dimitri = authorDatastore.load("Dimitri Bouniol", from: \.fullname).first(where: { _ in true })
else { throw NotFoundError() }
for try await book in bookDatastore.load(dimitri.id, from: \.author) {
print("Book written by Dimitri: \(book.title)")
}
let allShelves = try await shelfDatastore.load(...).reduce(into: []) { $0.append($1) }
写入或删除同样简单直接
let oldValue = try await bookDatastore.persist(newBook)
let oldValue = try await bookDatastore.delete(oldBookID)
let oldOptionalValue = try await bookDatastore.deleteIfPresent(oldBookID)
// Passing an Identifiable instance also works:
let oldValue = try await bookDatastore.delete(oldBook)
如果您正在读取和写入多个内容,您可以将它们包装在一个事务中,以确保它们都一起写入持久化,从而确保您要么拥有所有数据,要么在发生错误时没有任何数据
try await persistence.perform {
try await authorDatastore.persist(newAuthor)
for newBook in newBooks {
guard let shelf = try await shelfDatastore.load(newBook.genre, from: \.genre)
else { throw ShelfNotFoundError() }
newBook.shelfID = shelf
try await authorDatastore.persist(newBook)
}
}
请注意,在上面的示例中,即使作者首先被持久化,但如果在获取图书的书架时发生错误,作者将不会出现在未来的数据存储读取中。此外,无论异步上下文如何,都不会同时发生两次写入,因为所有单独的操作本身都是完整的事务。
CodableDatastore
是类型的集合,它可以轻松地与独立类型的大型数据存储接口,而无需将整个数据存储加载到内存中。
警告 请仔细考虑在生产项目中使用此项目。由于此项目刚刚进入 Beta 阶段,我无法强调对于任何依赖此代码的产品发布都必须非常小心,因为您可能会在迁移到较新版本时遇到数据丢失。虽然可能性较小,但底层模型有可能发生不兼容的更改,不值得通过迁移来支持。在此之前,请享受作为旁观者的代码或在玩具项目中试用它以提交反馈!如果您想在
CodableDatastore
进入生产就绪状态时收到通知,请在 Mastodon 上关注 #CodableDatastore。
随着此项目走向成熟发布,该项目将专注于以下列出的功能和工作
以上列表将在开发期间保持更新,并且在此过程中可能会看到新增内容。
一旦 1.0 版本发布,就该开始开发其他功能了
使类型符合 Codable 和 Identifiable 作为其唯一要求意味着许多类型不需要额外的符合性或转换即可在应用程序的其他层(包括视图层和网络层)中使用。但是,类型必须符合 Identifiable,以便在索引需要时可以更新它们。
由于 CodableDatastore
与 Codable 类型一起工作,因此它可以灵活地支持不同类型的 coder。开箱即用,我们计划支持 JSON 和 Property List coder,因为它们为用户提供了一种简单的方法来调查保存到存储中的数据(如果他们需要这样做)。
所有文件操作都将在被修改文件的副本上进行操作,最终通过使用指向更新文件集的指针更新根文件来持久化,并在旧文件引用不再被引用时删除它们。这意味着如果进程因任何原因中断,数据完整性将得到维护和一致。
此外,如果识别出任何未引用的文件,可以将它们放在“已恢复文件”目录中,允许应用程序的开发者帮助他们的用户在发生灾难时恢复数据。
一种常见的模式是 App Extensions 需要从主应用程序读取数据,但不能写入数据。在这种情况下,数据存储可以在初始化时安全地以只读方式打开,从而允许应用程序扩展读取该数据存储的内容。
对于 Extension 需要为应用程序写入数据的情况,建议使用单独的持久化来传达数据流,因为持久化不支持多个写入进程。
由于 CodableDatastore
直接使用用户指定的索引进行配置,因此 CodableDatastore
可以做出性能保证,而没有任何隐藏的陷阱,因为数据只能通过这些索引之一访问,并且数据无法通过非索引键加载。
CodableDatastore
大量使用了 Swift 的并发 API,所有读取和写入都是异步操作,可能会以用户可以处理的方式失败,并提供通过 AsyncSequences 加载数据的流,从而允许数据以消费者期望的速率高效加载。
应用程序在开发过程中会更改其访问数据的方式,索引也会随之发展。由于索引是在代码中配置的,因此它们可以在构建或发布之间更改,因此 CodableDatastore
支持在确定索引已重新配置时重新索引数据。提供了一种方法,允许应用程序等待重新索引过程并显示进度,以便在发生这种情况时可以向用户显示用户界面。
随着应用程序的发展,它们存储的数据类型也随之发展。 CodableDatastore
在旧类型和新类型之间提供无忧迁移,并提供类型化版本,以帮助您确保涵盖所有基础。您只需确保对旧类型进行版本控制,并提供它们与您期望的类型之间的转换。
如果需要,甚至可以在保存时完成此迁移,这意味着只要支持这些类型并且不需要重新计算索引,用户就不需要等待执行迁移。
此外,我们的目标是确保针对数据快照测试迁移同样容易,从而允许用户放心地发展他们的类型。
当多个数据模型之间的一致性对于应用程序正常运行至关重要时,支持原子事务非常重要。事务正在进行中意味着该事务更新的对象在该事务的持续时间内被锁定(其他事务将等待此事务完成),并且所有数据都在单个最终原子写入中写入磁盘,然后返回事务已完成。重要的是,这是跨共享通用配置的数据存储完成的,允许用户将独立类型一起保存。这也意味着如果事务失败,它所做的任何更新都将在过程中回滚。
与其将配置分散到多个不同的类型或文件中,CodableDatastore
旨在允许库的用户在代码中定义所有配置,理想情况下是在应用程序中的一个位置。
配置可以描述磁盘上的持久化或内存中的持久化,从而允许针对内存版本编写基于应用程序的测试,而几乎不需要重新配置。此外,由于对数据存储的所有访问都是通过公共 actor 进行的,因此使用兼容类型存根新的数据存储应该很容易实现。
Swift 5.9 将引入可变泛型,允许在同一数据存储上描述具有不同键路径的多个索引。目前,我们将根据需要硬编码它们。
虽然未计划在 1.0 版本中实现,但此系统应通过复制文件结构来相对轻松地支持轻量级快照,并利用 APFS 快照来确保数据实际上没有被复制。通过 API 执行此操作的支持即将推出。
虽然 CodableDatastore
旨在维护保存到文件系统的内容的一致性,但它没有采取任何措施来维护文件系统在此期间没有损坏数据。这可以使用与每个文件一起保存的附加纠错码来解决,以纠正可能发生的位错误。
将来可以支持加密磁盘上的数据存储。
这通常会显着增加索引结构的复杂性,并使用户无法理解在不同类型之间创建相互依赖关系对性能的影响。
相反,CodableDatastore
旨在提供具有相同配置的不同数据存储之间的强大事务,允许用户通过更新两个或多个数据存储来构建自己的关系,而不是自动构建这些关系。
虽然支持多个读取器,但 CodableDatastore
旨在一次只有一个进程写入磁盘持久化。这意味着如果来自不同进程发生多次写入,则行为未定义。通常,这在基于服务器的部署中会成为问题,因为服务器应用程序传统上在单台机器上的多个进程上运行,但大多数基于 Swift 的服务器应用程序使用单个进程和多个线程来实现更好的性能,因此将是兼容的。
如果您正在设计运行多个进程的可扩展系统,请考虑设置一个具有数据存储的单个实例,或多个具有自己独立数据存储的实例,以维护这些承诺。虽然并非不可能,但分片和其他策略来保持多个独立数据存储同步留给此库的用户练习。
欢迎贡献!请查看已有的 issue,或开始新的讨论以提出新功能。虽然不能保证功能请求,但符合项目目标并在事先讨论过的 PR 非常受欢迎!
请确保所有提交都具有干净的提交历史记录,文档齐全且经过全面测试。请在提交之前 rebase 您的 PR,而不是合并 main
。需要线性历史记录,因此 PR 中的合并提交将不被接受。
为了支持这个项目,请考虑在 Mastodon 上关注 @dimitribouniol,在 Code Completion 上收听 Spencer 和 Dimitri 的节目,或下载 Dimitri 的妻子 Linh 的应用程序 Not Phở。