底层版本化存储 (LLVS)

作者:Drew McCormack (@drewmccormack)

LLVS 简介

是否一直希望像使用 Git 等工具推送和拉取源代码一样轻松地移动应用程序的数据?如果是,请继续阅读。

为何选择 LLVS?

应用程序数据比以往任何时候都更加分散。在单个设备上集中数据的情况已不多见。通常,一个用户会拥有多个设备,从手机到笔记本电脑和手表,并且每个设备可能运行多个独立的进程(例如,今日扩展、共享扩展),每个进程都使用数据的副本。如何在不编写数千行自定义代码来处理每种场景的情况下,共享所有这些数据,为用户提供一致的视图?

软件开发人员在源代码控制领域也面临着类似的情况。他们面临着这样的问题:多个人如何在多台计算机上协同处理相同的源文件?我们都知道结果如何。源代码控制管理 (SCM) 的进步催生了 Git 和 GitHub 等系统。

这些工具成功地解决了去中心化协作的问题,但奇怪的是,之前没有将相同的方法应用于应用程序数据。这就是 LLVS 的用武之地。它为在应用程序所处的去中心化世界中存储和移动数据提供了一个基本框架。

什么是 LLVS?

LLVS 最好的描述是去中心化、版本化、键值存储框架

它的工作方式有点像传统的键值存储,您可以在其中为给定的唯一键插入数据值。但 LLVS 在此基础上增加了一些额外的维度,即,当存储一组值时,会在存储中创建一个新版本,并且每个版本都有其他版本的祖先关系,您可以及时追溯。就像 Git 一样,您可以随时检索任何版本的值,确定任意两个版本之间更改的值,以及合并版本。

所有这些本身就很棒,但如果它孤立于单个存储,那么在我们这个去中心化的世界中仍然不是很有用。因此,LLVS 可以像使用 Git 从其他存储库推送拉取一样,从其他存储发送接收版本。

如果这仍然让您想知道 LLVS 是关于什么的,这里还有一些其他描述,可能有助于您理解它。LLVS 是...

LLVS 不是什么?

此时,您可能正在尝试给 LLVS 贴标签;尝试根据您已经熟悉的事物对其进行分类。请保持开放的心态,以免错过该框架重要的、非典型的方面。为了帮助您朝这个方向前进,以下列出了 LLVS 不是什么

它适合在哪里?

LLVS 是一种抽象。它处理数据集的历史记录,而无需知道数据实际代表什么、数据如何存储在磁盘上,甚至数据如何在设备之间移动。

LLVS 包含一些类来帮助您入门。您可以使用现有的存储类(例如基于文件)设置基本存储,并使用现有的云服务(例如 CloudKit)分发数据,但您也可以选择添加对您自己的存储(例如 SQLite)或云服务(例如 Firebase)的支持。您可以自由使用任何您喜欢的数据格式,包括序列化的 Swift Codable 值、JSON 和端到端加密格式。

安装

Xcode 11 或更高版本中的 Swift Package Manager (SPM)

开始使用 LLVS 最简单的方法是使用 Xcode 11 或更高版本,它支持 Swift Package Manager (SPM)。

  1. 要添加 LLVS,请选择File > Swift Packages > Add Package Dependency...
  2. 输入 LLVS 仓库 URL:https://github.com/mentalfaculty/LLVS
  3. 现在输入您需要的最低版本(例如 0.3.0),然后添加软件包。
  4. 在左侧的 Xcode 源代码列表中选择您的项目。
  5. 选择您的应用程序目标,然后转到General选项卡。
  6. Frameworks, Libraries, and Embedded Content例如 LLVS、LLVSCloudKit)中将您需要的框架添加到您的应用程序目标。

Swift Package Manager

如果您不是使用 Xcode 构建,您可以改为将其添加到您的 SPM Package.swift 中。

dependencies: [
    .package(url: "https://github.com/mentalfaculty/LLVS.git, from: "0.3.0")
]

使用 Xcode 手动操作

要手动将 LLVS 添加到您的 Xcode 项目...

  1. 下载源代码或使用 Git 将项目添加为子模块。
  2. LLVS 根文件夹拖到您自己的 Xcode 应用程序项目中。
  3. 在源代码列表中选择您的项目,然后选择您的应用程序目标。
  4. General选项卡的Frameworks, Libraries, and Embedded Content部分中添加 LLVS 框架。

先试用一下

如果您不想麻烦地安装框架,但又想在实践中测试它,您可以尝试通过 Test Flight 试用 LoCo 示例应用程序。使用下面的链接将应用程序添加到您 iOS 设备上的 Test Flight。

https://testflight.apple.com/join/nMfzRxt4

快速入门

本节将引导您尽可能快速地启动并运行一个通过 CloudKit 同步的 iOS 应用程序。

消息

我们将逐步介绍一个名为“TheMessage”的项目,您可以在 Samples 目录中找到它。这是您可以想象到的最简单的应用程序,这正是它对我们有用的原因。

屏幕上显示一条消息。用户可以选择编辑消息。消息保存在 LLVS 存储中,并通过 CloudKit 公共数据库同步到使用该应用程序的任何其他人。因此,消息在所有用户之间共享,并且可以由他们中的任何一个更新——他们都共享相同的 LLVS 分布式存储。

设置项目

在我们查看代码之前,让我们先了解一下设置项目的一些方面。

创建 Xcode 项目

TheMessage 是使用Single View App模板生成的 Xcode 项目。我们将使用 SwiftUI 作为视图层,因此请确保选中它。

添加 LLVS

项目到位后,我们现在需要添加 LLVS。您可以使用上面安装部分中的任何方法。

示例项目采用的方法是将 LLVS 根文件夹拖到 Xcode 项目中,然后将框架添加到目标。

TheMessage 需要的框架是 LLVS 和 LLVSCloudKit。

添加 CloudKit

现在 LLVS 已经就位,我们需要设置 CloudKit。

  1. 在源代码列表中选择 TheMessage 项目。
  2. 选择应用程序目标,然后选择Signing & Capabilities选项卡。
  3. 按 + 按钮,然后添加 iCloud。
  4. 选中 CloudKit 复选框。
  5. 为应用程序添加容器。例如 “iCloud.com.yourcompany.themessage”

添加存储协调器

大多数源代码直接驻留在 AppDelegate 文件中。(请勿在家中尝试!)

首先,我们有代码来设置 StoreCoordinator 对象。

lazy var storeCoordinator: StoreCoordinator = {
    LLVS.log.level = .verbose
    let coordinator = try! StoreCoordinator()
    let container = CKContainer(identifier: "iCloud.com.mentalfaculty.themessage")
    let exchange = CloudKitExchange(with: coordinator.store, 
        storeIdentifier: "MainStore", 
        cloudDatabaseDescription: .publicDatabase(container))
    coordinator.exchange = exchange
    return coordinator
}()

StoreCoordinator 负责处理管理 LLVS 存储的许多繁琐方面,例如跟踪您的应用程序正在使用的数据版本,以及合并来自其他设备的数据。它还使保存和获取数据更加方便,因此非常适合您的第一个应用程序。

除了创建 StoreCoordinator 之外,上面的代码还设置了 CloudKitExchange,并将其附加到协调器。Exchange 是一个可以发送和接收存储数据的对象;在本例中,它将数据发送到 CloudKit,以便其他设备可以将其添加到其本地存储,并从 CloudKit 接收其他设备所做的更改。

存储一些数据

AppDelegate 包含屏幕上显示的消息的数据,以及从存储中获取数据并将其保存到存储的函数。

let messageId = Value.ID("MESSAGE") // Id in the store
@Published var message: String = ""

消息在 LLVS 中有一个标识符,称为值标识符。值标识符是 LLVS 键值存储中的。它们唯一地标识存储中的一个值。

如您所见,我们为我们的消息声明了一个固定的标识符,类型为 Value.ID。它设置为字符串“MESSAGE”,但实际值是任意的。我们需要它在所有设备上都相同,但它可以是任何字符串。只要我们最终得到一个标识符,以便所有用户都更新相同的消息。

在 LLVS 中存储数据由 post 函数处理。

/// Update the message in the store, and sync it to the cloud
func post(message: String) {
    let data = message.data(using: .utf8)!
    let newValue = Value(id: messageId, data: data)
    try! storeCoordinator.save(updating: [newValue])
    sync()
}

LLVS 存储 Value,它具有标识符(键),并包含一些数据。因此,我们在应用程序中使用的任何数据都需要转换为 Data 才能存储在 Value 中。在本例中,消息只是一个 String,这很简单。在更高级的应用程序中,您可能会使用 Codable 类型。

要使用 StoreCoordinator 保存,我们只需传入我们正在更新的值数组。如果需要,我们还可以传递要插入和删除的值。(请注意,如果值尚不在存储中,则更新将插入该值。)

更新值后,会进行同步,以确保数据上传到 CloudKit。这将在下面详细讨论。

获取数据

从 LLVS 存储中获取消息同样简单。

/// Fetch the message for the current version from the store
func fetchMessage() -> String? {
    guard let value = try? storeCoordinator.value(id: messageId) else { return nil }
    return String(data: value.data, encoding: .utf8)
}

如果 StoreCoordinatorvalue(id:) 函数在存储中存在值,则返回该值,否则返回 nilfetchMessage 函数使用它来尝试获取消息值,使用与我们在保存中使用的相同的消息标识符。如果找到,则从值中提取数据并转换为消息 String;如果未找到,则该函数返回 nil

同步

与 LLVS 同步也非常简单。

func sync() {
    // Exchange with the cloud
    storeCoordinator.exchange { _ in
        // Merge branches to get the latest version
        self.storeCoordinator.merge()
    }
}

无需网络代码或复杂的 CloudKit 操作。只需调用 exchange 即可与云端发送和接收任何更改,然后调用 merge 以解决设备之间所做的任何更改。

发布!

其余代码是通用的 SwiftUI,此处不再赘述。总而言之,The Message 不到 200 行代码。当然,它没有做太多事情,但正如我们在上面看到的,保存、获取甚至同步都可以在几行代码中实现。

如果我们要通过 App Store 分发 The Message,我们首先需要转到 CloudKit Dashboard 并确保开发架构部署到生产环境。

高级:Store

快速入门使用了名为 StoreCoordinator 的类,它大大简化了 LLVS 的使用。您可以忽略许多内部细节,例如版本、分支和合并。这是开始使用该框架的好方法,对于许多应用程序来说,这将是您所需要的全部。

但 LLVS 还有更多功能。它使您可以完全访问数据的历史记录。您可以获取任何版本的数据,比较版本之间的更改,并应用强大的合并算法来创建新版本。

所有这些都通过 LLVS 的核心类 Store 提供。

创建 Store

StoreCoordinator 包装了一个 Store,可以通过 store 属性访问它,但您也可以完全忽略 StoreCoordinator 并直接使用 StoreStoreCoordinator 只是一个便利工具——您可以使用 Store 完成所有操作。

在单个设备上创建版本化存储就像传入目录 URL 一样简单。

let groupDir = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.mycompany.myapp")!
let rootDir = groupDir.appendingPathComponent("MyStore")
let store = Store(rootDirectoryURL: rootStoreDirectory)

此代码使用应用程序组容器,如果您想与应用程序扩展共享数据,这将非常有用。(LLVS 存储可以在多个进程之间直接共享,例如主应用程序和共享扩展。)

插入值

首次创建存储时,您可能需要添加一些初始数据。

let v1 = Value(idString: "ABCDEF", data: "First Value".data(using: .utf8)!)
let firstVersion = try store.makeVersion(basedOnPredecessor: nil, inserting: [v1])

此代码在存储中创建一个新版本,插入一个新值。

basedOnPredecessor 的参数为 nil,这表示这是存储中的第一个版本——没有先前的版本。(对于 Git 用户,这与初始提交相同。)

通常,您可能需要插入、更新和删除多个值。所有这些都可以在单个 makeVersion 调用中处理。创建的版本适用于整个存储,而不仅仅是正在操作的值。

如果您正在使用 StoreCoordinator,它将负责存储创建的新版本;如果您直接使用 Store,则由您来跟踪您认为哪个版本是应用程序 UI 的当前版本。通常,您会将其存储在某个位置(例如变量、文件、用户默认设置),以便可以在获取数据时使用它。

更新值

存储后续值非常相似。唯一的区别是您需要传入更改所基于的版本,这通常是您的应用程序正在使用的当前版本。

let v2 = Value(idString: "CDEFGH", data: "My second data".data(using: .utf8)!)
let secondVersion = try store.makeVersion(basedOnPredecessor: firstVersion.id, inserting: [v2])

这里的主要区别是在创建版本时传入了非 nil 的前任版本。前任版本是我们上面创建的第一个版本的标识符,firstVersion.id

版本是存储范围的

重要的是要意识到版本适用于整个存储。它们是全局的,并形成存储的完整历史记录,就像在 Git 等系统中一样。一旦添加了值,它就会一直存在,直到在未来的版本中被删除或更新。

获取数据

一旦我们在存储中有了数据,我们就可以再次检索它。使用 LLVS,您需要指示您想要哪个版本的数据,因为存储包含完整的更改历史记录。

let value = try store.value(idString: "CDEFGH", at: secondVersion.id)!
let fetchedString = String(data: value.data, encoding: .utf8)

在这里,我们获取了上面添加的第二个值,并将其转换回字符串。我们传入了第二个版本标识符;如果我们传入第一个版本,则会返回 nil,因为该值尚未在该版本中添加。

我们上面添加的第一个值呢?它是在第二个版本之前添加的,因此它在未来的版本中继续存在。因此,我们可以以完全相同的方式获取它。

let valueId = Value.ID("ABCDEF")
let value = try store.value(id: valueId, at: secondVersion.id)!
let fetchedString = String(data: value.data, encoding: .utf8)

我们为此调用使用了略有不同的 value 函数,它采用值标识符而不是字符串,但最终结果是相同的。

更新和删除值

就像您可以插入新值一样,您也可以更新现有值和删除它们。

let updateData = "An update of my first data".data(using: .utf8)!
let updateValue = Value(idString: "ABCDEF", data: updateData)
let removeId = Value.ID("CDEFGH")
let thirdVersion = try store.makeVersion(basedOnPredecessor: secondVersion.id, 
    updating: [updateValue], removing: [removeId])

第三个版本基于第二个版本。有两个更改:它使用新数据更新“ABCDEF”的值,并删除“CDEFGH”的值。

如果我们现在尝试获取标识符为“CDEFGH”的值,我们将得到 nil

// This will be nil
let value = try store.value(id: Value.ID("CDEFGH"), at: thirdVersion.id)

分支

如果更改的历史记录是串行的——一组更改始终基于前一组——那么处理数据就很容易。当进行并发更改时,情况会变得更加复杂。如果大约在同一时间添加了两个版本,您最终可能会导致数据发散,并且这可能需要在稍后进行合并。

这种情况的一个例子是,当用户在两个不同的设备上进行更改时,没有进行干预同步。稍后,当数据确实传输时,版本会分支,而不是出现在连续的祖先线中。即使您不使用同步也可能发生这种情况;例如,如果您有一个共享扩展,并且它在您的主应用程序也在添加版本时添加了一个版本。

前任版本、后继版本和头版本

Version 类型构成了 LLVS 中历史记录跟踪的基础。一个版本最多可以有两个前任版本作为基础,并且可以有零个或多个后继版本。

是没有后继版本的版本:没有基于头版本的版本。它们形成分支的尖端;如果您将版本历史记录想象成一棵树,底部扎根于初始版本,则头形成顶部分支的尖端。

头很重要,因为它们通常代表最近的更改。大多数时候,您的应用程序将使用头作为当前版本,并将新版本基于头。

头也很重要,因为如果存在多个头,它们通常需要合并在一起,为应用程序创建一个单一的头版本以供使用。

导航历史记录

History 类用于查询存储的历史记录。例如,您可以随时询问头,如下所示...

var heads: Set<Version.Identifier>?
store.queryHistory { history in
    heads = history.headIdentifiers
}

queryHistory 函数使您可以访问存储的历史记录对象。您需要为其提供一个块,该块将同步调用。历史记录未作为简单属性提供的原因是为了控制来自不同线程的访问。使用块可以序列化对历史记录的访问,因此在您查询时它不会更改。

一旦您有了 History 对象,您就可以请求头,但您也可以检索您选择的任何版本。

获取最新的头也很容易。

let version: Version? = store.mostRecentHead

最新的头是在启动扩展或在新设备上首次同步时使用的便捷版本。实际上,您是在说“带我到最新的数据”。

合并

LLVS 的优势之一是它为您提供了一种系统化的方法来解决版本之间的差异。如果两个设备在大约同一时间编辑了特定的值,您不仅可以识别冲突,还可以将两个不同的数据值合并到一个新的、一致的版本中。

LLVS 中有两种类型的合并:双向合并和三向合并。大多数时候,您将处理三向合并。三向合并涉及两个版本——通常是头版本——和一个所谓的公共祖先。公共祖先是存在于正在合并的两个版本的祖先关系中的版本;它是两个版本在分歧之前达成一致的历史记录中的一个点。

以这种方式合并比您从大多数数据建模框架获得的功能强大得多,因为您不仅知道最新的值是什么,还知道它们过去是什么,因此您可以确定发生了什么变化。

LLVS 支持第二种类型的合并:双向合并。当多个不具有任何前任版本的数据集添加到存储时,就会发生这种情况。实际上,您有两个初始版本。

您可以非常轻松地开始合并。

let arbiter = MostRecentChangeFavoringArbiter()
let newVersion = try! store.merge(currentVersionId, with: otherHeadId, resolvingWith: arbiter)

如果事实证明 otherHeadId 的版本是当前版本的后代,则实际上不会添加新版本,而只会返回新头。(用 Git 术语来说,它会快进。)

但是,通常情况下,这两个版本会发散,并且需要进行三向合并。合并方法将向后搜索历史记录,并找到一个公共祖先。然后,它将通过将两个新版本与公共祖先进行比较来确定它们之间的差异。这些差异将传递给 MergeArbiter 对象,其任务是解决任何冲突。

在本例中,我们使用了名为 MostRecentChangeFavoringArbiter 的内置仲裁器类。顾名思义,当发生冲突时,它将选择最近更改的值。

在您的应用程序中,您更有可能创建自己的仲裁器类,以自定义方式合并您的数据。您还可以选择处理某些特定情况,并将更标准的任务交给现有的仲裁器类之一。

MergeArbiter 内部

您可能想知道仲裁器类的内部结构是什么样的。它通常是一个循环遍历差异,处理每种类型的冲突。最简单的一个是 MostRecentBranchFavoringArbiter,它将简单地偏爱具有最新时间戳的分支。这是整个类。

public class MostRecentBranchFavoringArbiter: MergeArbiter {
    
    public init() {}
    
    public func changes(toResolve merge: Merge, in store: Store) throws -> [Value.Change] {
        let v = merge.versions
        let favoredBranch: Value.Fork.Branch = v.first.timestamp >= v.second.timestamp ? .first : .second
        let favoredVersion = favoredBranch == .first ? v.first : v.second
        var changes: [Value.Change] = []
        for (valueId, fork) in merge.forksByValueIdentifier {
            switch fork {
            case let .removedAndUpdated(removeBranch):
                if removeBranch == favoredBranch {
                    changes.append(.preserveRemoval(valueId))
                } else {
                    let value = try store.value(valueId, at: favoredVersion.id)!
                    changes.append(.preserve(value.reference!))
                }
            case .twiceInserted, .twiceUpdated:
                let value = try store.value(valueId, at: favoredVersion.id)!
                changes.append(.preserve(value.reference!))
            case .inserted, .removed, .updated, .twiceRemoved:
                break
            }
        }
        return changes
    }
}

这个类的引擎是对fork的循环。fork 总结了每个分支对单个值标识符所做的更改。fork 可以是非冲突的,例如 .inserted.removed.updated.twiceRemoved。这些类型涉及单个分支上的更改,或者两个分支上的更改,这些更改可以被认为是等效的(例如删除两个分支上的值)。

或者,fork 可能是冲突的。仲裁器至少需要为任何冲突的 fork 返回新的更改。这就是他们解决合并中的冲突的方式。他们可以返回一个全新的更改来解决冲突的 fork,或者他们可以保留现有的更改。

您可以在上面的代码中看到,当数据在每个分支上插入或在每个分支上更新时,仲裁器保留来自较新分支的值。当遇到 .removedAndUpdated 时——一个分支删除值,另一个分支应用更新——仲裁器再次保留在最新分支上所做的更改。

入门时您无需过多担心仲裁器。您可以只选择一个现有的类,然后从那里开始。稍后,当您需要更多控制时,您可以考虑开发自己的自定义类,该类符合 MergeArbiter 协议。

设置 Exchange

LLVS 是一个去中心化存储框架,因此您需要一种在存储之间移动版本的方法。为此,我们使用 Exchange。Exchange 是一个可以发送和接收数据的类,目的是将数据移动到/从其他存储。

CloudKit 是在 Apple 平台上转移数据的好选择。CloudKitExchange 类可用于使用 CloudKit 在 LLVS 存储之间移动数据。

要开始使用,我们创建 exchange。

self.cloudKitExchange = CloudKitExchange(with: store, storeIdentifier: "MyStore", cloudDatabaseDescription: .privateDatabaseWithCustomZone(CKContainer.default(), zoneIdentifier: "MyZone"))

要从云端检索新版本,我们只需调用 retrieve 函数,它是异步的,带有一个完成回调。

self.cloudKitExchange.retrieve { result in
    switch result {
    case let .failure(error):
        // Handle failure
    case let .success(versionIds):
        // Handle success
    }
}

将新版本发送到云端同样简单。

self.cloudKitExchange.send { result in
    switch result {
    case let .failure(error):
        // Handle failure
    case .success:
        // Handle success
    }
}

LLVS 对您设置的 exchange 数量没有限制。您可以为单个存储设置多个 exchange,有效地通过不同的路由推送和拉取数据。

Exchange 也不仅限于云服务。您可以编写自己的点对点类,该类符合 Exchange 协议。LLVS 甚至包括 FileSystemExchange,它是一个通过文件系统中的目录工作的 exchange。这对于测试您的应用程序而无需使用云端非常有用。

了解更多

了解 LLVS 在实践中如何工作的最佳方法是查看提供的 Samples。有一些非常简单的示例,如 TheMessage,也有更高级的应用程序,如 Loco-SwiftUI,一个联系人簿应用程序。

LLVS 博客上也有有用的帖子。

如何构建您的数据

如果您正在查看示例代码,请记住 LLVS 对您放入其中的数据没有任何限制。完全取决于您如何构建应用程序数据。LLVS 为您提供了一种存储和移动数据、以及跟踪数据如何更改的方法,但数据本身对于框架是不透明的。

需要考虑的一件事是您放入每个 Value 的数据的粒度。您可以非常细粒度,将模型类型的每个属性放入单独的 Value 中,或者您可以走向另一个极端,将所有数据放入单个 Value 中。一个好的中间立场是将模型(例如结构或类)中的每个实体放入 Value 中。

这些方法各有优缺点

我们对入门的建议是使用每个实体一个值的方法——它是刚刚好的。

LLVS 的特性

以下是 LLVS 特性列表,其中一些特性可能从上面的描述中不明显。