CohesionKit - 单一数据源

swift platforms test twitter

保持你的模型在应用中同步,永不再有不一致。使用最新的 Swift 特性设计。

为什么使用 CohesionKit?

特性

CohesionKit 应该放在你的技术栈的哪里?

CohesionKit 作为一个单一数据源解决方案,它处理你对象的生命周期和来自 *任何* 源的同步。

你应该将 CohesionKit 放在你的数据源 (REST API, GraphQL, ...) 前面,然后再将数据返回给你的应用。

sequenceDiagram
    autonumber

		YourApp ->>DataSource: findBooks
		DataSource ->>GraphQL: query findBooks
		GraphQL -->>DataSource: FindBooksQueryResult
		DataSource ->>CohesionKit: store books [A,B,C]
		CohesionKit -->> YourApp: Publisher<[A,B,C]>

		WebSocket ->> WebSocketListener: book A updated
		WebSocketListener ->> CohesionKit: update book A
		CohesionKit -->> YourApp: Publisher<[A,B,C]>
加载中

安装

dependencies: [
    .package(url: "https://github.com/pjechris/CohesionKit.git", .upToNextMajor(from: "0.7.0"))
]

示例

库带有一个 示例项目,以便你可以看到真实的使用案例。它主要展示了

开始上手

存储对象

首先创建一个 EntityStore 的实例

let entityStore = EntityStore()

EntityStore 允许你存储 Identifiable 对象

struct Book: Identifiable {
  let id: String
  let title: String
}

let book = Book(id: "ABCD", name: "My Book")

entityStore.store(book)

然后你可以在代码中的任何位置检索对象

// somewhere else in the code
entityStore.find(Book.self, id: "ABCD") // return Book(id: "ABCD", name: "My Book")

观察变更

每次 EntityStore 中的数据更新时,都会向任何已注册的观察者触发通知。要将自己注册为观察者,只需使用 storefind 方法的结果

func findBooks() -> some Publisher<[Book], Error> {
  // 1. load data using URLSession
  URLSession(...)
  // 2. store data inside our entityStore
    .store(in: entityStore)
    .sink { ... }
    .store(in: &cancellables)
}
entityStore.find(Book.self, id: 1)?
  .asPublisher
  .sink { ... }
  .store(in: &cancellables)

CohesionKit 具有 弱内存策略,你应该了解一下。因此,来自 entityStore.store 的返回值必须被强引用,以避免丢失值。

为了简洁起见,接下来的示例将省略 .sink { ... }.store(in:&cancellables)

关联对象

要存储包含嵌套标识对象的对象,你需要使它们遵循一个协议:Aggregate

struct AuthorBooks: Aggregate {
  var id: Author.ID { author.id }

  var author: Author
  var books: [Book]

  // `nestedEntitiesKeyPaths` must list all Identifiable/Aggregate this object contain
  var nestedEntitiesKeyPaths: [PartialIdentifiableKeyPath<Self>] {
    [.init(\.author), .init(\.books)]
  }
}

然后 CohesionKit 处理这三个实体的同步

只接受可写的键路径。 使用 KeyPath (let) 将导致错误:“键路径值类型 KeyPath 无法转换为上下文类型 WritableKeyPath”

这使你能够彼此独立地检索它们

let authorBooks = AuthorBooks(
    author: Author(id: 1, name: "George R.R Martin"),
    books: [
      Book(id: "ACK", title: "A Clash of Kings"),
      Book(id: "ADD", title: "A Dance with Dragons")
    ]
)

entityStore.store(authorBooks)

entityStore.find(Author.self, id: 1) // George R.R Martin
entityStore.find(Book.self, id: "ACK") // A Clash of Kings
entityStore.find(Book.self, id: "ADD") // A Dance with Dragons

你也可以随意修改它们中的任何一个。请注意,更改从对象本身和聚合对象中都可见

let newAuthor = Author(id: 1, name: "George R.R MartinI")

entityStore.store(newAuthor)

entityStore.find(Author.self, id: 1) // George R.R MartinI
entityStore.find(AuthorBooks.self, id: 1) // George R.R MartinI + [A Clash of Kings, A Dance with Dragons]

你可能会考虑直接在 Author 上存储书籍 (author.books)。在这种情况下,Author 需要实现 Aggregate 并将 books 声明为嵌套实体。

然而,我强烈建议你不要将 Identifiable 对象嵌套到其他 Identifiable 对象中。 如果你想了解更多关于这个主题的信息,请阅读 处理关系 这篇文章。

存储 vs 更新

目前我们只关注了 entityStore.store,但 CohesionKit 还提供了另一种存储数据的方法:entityStore.update

有时两者都可以使用,但它们各有不同的用途

  1. store 适用于存储从 Web 服务检索的完整数据,例如 GET /user
  2. update 通常用于部分数据。 它也是从 websockets 接收事件时的首选方法。

高级主题

枚举支持

从 0.13 版本开始,库支持枚举类型。 请注意,你需要遵循 EntityWrapper 协议,并为你想要存储的每个实体提供计算型的 getter/setter。

enum MediaType: EntityWrapper {
  case book(Book)
  case game(Game)
  case tvShow(TvShow)

  func wrappedEntitiesKeyPaths<Root>(relativeTo parent: WritableKeyPath<Root, Self>) -> [PartialIdentifiableKeyPath<Root>] {
    [.init(parent.appending(\.book)), .init(parent.appending(\.game)), .init(parent.appending(\.tvShow))]
  }

  var book: Book? {
    get { ... }
    set { ... }
  }

  var game: Game? {
    get { ... }
    set { ... }
  }

  var tvShow: TvShow? {
    get { ... }
    set { ... }
  }
}

struct AuthorMedia: Aggregate {
  var author: Author
  var media: MediaType

  var nestedEntitiesKeyPaths: [PartialIdentifiableKeyPath<Self>] {
    [.init(\.author), .init(wrapper: \.media)]
  }
}

别名

有时你需要检索数据而不知道对象 ID。 常见的情况是当前用户。

CohesionKit 提供了一种合适的机制:别名。 别名允许你使用键注册和查找实体。

extension AliasKey where T == User {
  static let currentUser = AliasKey("user")
}

entityStore.store(currentUser, named: .currentUser)

然后在其他地方请求它

entityStore.find(named: .currentUser) // return the current user

与常规实体相比,别名对象是长生命周期的对象:即使没有人观察它们,它们也会保留在存储中。 这允许在别名值更改时通知已注册的观察者

entityStore.removeAlias(named: .currentUser) // observers will be notified currentUser is nil.

entityStore.store(newCurrentUser, named: .currentUser) // observers will be notified that currentUser changed even if currentUser was nil before

过时数据

存储数据时,CohesionKit 实际上要求你设置修改时间戳。 Stamp 用作比较数据新鲜度的标记:时间戳越高,数据越新。

默认情况下,CohesionKit 将使用当前日期作为时间戳。

entityStore.store(book) // use default stamp: current date
entityStore.store(book, modifiedAt: Date().stamp) // explicitly use Date time stamp
entityStore.store(book, modifiedAt: 9000) // any Double value is valid

如果由于某种原因你尝试存储时间戳低于已存储数据的日期的数据,则更新将被丢弃。

弱内存管理

CohesionKit 具有弱内存策略:只要有人使用对象,对象就会保存在 EntityStore 中。

为此,只要你对数据感兴趣,就需要保留观察者

let book = Book(id: "ACK", title: "A Clash of Kings")
let cancellable = entityStore.store(book) // observer is retained: data is retained

entityStore.find(Book.self, id: "ACK") // return  "A Clash of Kings"

如果你不创建/保留观察者,那么一旦实体不再有观察者,它们将自动从存储中丢弃。

let book = Book(id: "ACK", title: "A Clash of Kings")
_ = entityStore.store(book) // observer is not retained and no one else observe this book: data is released

entityStore.find(Book.self, id: "ACK") // return nil
let book = Book(id: "ACK", title: "A Clash of Kings")
var cancellable = entityStore.store(book).asPublisher.sink { ... }
let cancellable2 = entityStore.find(Book.self, id: "ACK") // return a publisher

cancellable = nil

entityStore.find(Book.self, id: "ACK") // return "A Clash of Kings" because cancellable2 still observe this book

许可

本项目根据 MIT 许可证发布。 有关详细信息,请参阅 LICENSE 文件。