实战GunDB:设计一个高性能的图书馆数据库系统

2022-05-07  本文已影响0人  程序猿老古

最近正在开发一个去中心化的即时聊天应用,因为考虑到即时聊天这种社交类应用的数据大量,高并发,而且数据之间的关系复杂,所以决定使用图数据库(Graph Database)。同时,考虑到这个聊天应用的去中心化需求,查了很多图数据库,最终找到一个相当成熟的去中心化图数据库GunDB

网上关于GunDB的资料很少,这篇写的很详实,而且有可执行的源码,故翻译成中文,供有这方面需求的朋友学习。

原文链接:Data Modeling with GunDB

在这篇文章中,将使用GunDB创建一个图书馆藏书的数据模型,这是一个为图书爱好者提供的社交应用。这个应用程序实现读者从他们喜欢的书中创建最喜欢的列表,留下评论并关注其他读者和作者。

我将从介绍什么是GunDB数据建模开始,然后将引导你为这个应用创建一个图数据模型(Graph DB model)。然后,我将用GunDB实现实体(Entities)和关系(Relationship),最后我将创建一些模拟数据,并展示如何对查询数据。如果你只想阅读与GunDB有关的部分,你可以跳到"为GunDB设计"部分。如果你是GunDB的新手,你可能想看看我关于GunDB基础知识的另一篇文章。

本文的源码可在Gitlab下载。

声明:
本文介绍的代码示例和数据模型仅用于演示,对于所讨论的业务领域可能并不准确。此外,所介绍的模型并没有涵盖文章中提到的所有用例。然而,有足够的例子介绍,你可以设计你自己的模型或扩展和改进讨论的模型。

一 GunDB简介

一般来说,数据建模(Data Modeling)是一个设计过程,在这个过程中,你要确定系统中的实体(Entities),描述它们的属性(attributes)和实体之间的关系(Relationship)。最终的结果最好是一个与数据库无关的文件,并附有图表,以记录结果。同样重要的是,该文件在项目开发过程中是可维护的,可以使用版本控制的代码,或使用图表工具,如 draw.io。

如何开始建模过程呢?你可以从收集尽可能多的信息开始。你与最了解业务需求的人交谈,并确定用例。用例可以揭示一个系统的很多情况,可以非常有效地指导设计过程。此外,用例直接对应于软件团队可以转化为可交付的功能。

在用例被记录下来后,下一步是提取实体、实体的属性和关系。这通常涉及到列出实体、它们的属性和画一些描述关系的图。最后的结果是一个可维护的文件,每个人都可以在项目的整个开发生命周期中参考和更新。

在文件的第一个版本定稿后,你就可以开始添加你要使用的数据库系统的具体设计要求。以关系型数据库为例,它将涉及到识别表、外键、连接表、模式等等。对于基于文档的数据库,例如,它将涉及到识别文档、参考文献,也许还有一些模式。

现在,对于图形数据库,不管是什么数据库,你几乎总是要决定以下三个部分。

图书馆应用程序的建模

在下面的章节中,我们将经历图书馆应用程序的设计过程。我们将确定一些实体、它们的属性和它们的关系。我们将为GunDB扩展设计,最后创建一些模拟数据并探索运行查询。请注意,应用程序的一些功能可能会被遗漏在数据模型中。但我们将涵盖足够多的实体和关系,这样,如果你对设计整个应用程序的模型感兴趣,你可以有一个很好的起点。

二 实例

下面是我们在收集了关于这个应用程序的信息后确定的用例。

用户(Users)

读者(Readers)

种子(Feed)

这个想法来源于,任何使用该应用程序的人,包括匿名用户,都可以看到最新的更新。例如,他们可以看到已添加的新书或本月最受欢迎的书籍,等等。

书籍(Books)

三 实体、关系和查询

现在来定义实体、实体的属性和关系。从上面的用例来看,我们将关注以下内容:

实体(Entities)

下面是我们要关注的系统的一些实体。

属性 Attributes

User属性

Reader属性

Author属性

Book属性

Category属性

关系 Relationship

下面是各Entities之间的一些关系。

下图总结了上述关系
[图片上传失败...(image-f10aab-1651928957290)]

查询

在本节中,我们将考虑如何使用这些数据。换句话说,是如何对数据进行查询。定义查询有助于设计一个好的模型,帮助我们回答关于数据的问题。下面是一个关于我们如何使用数据和我们想要运行的查询的总结。请注意,这里我们主要关注的是实体之间的关系和一些关于数据的整体见解。

对于读者

对于作者

对于书籍

对于出版商

对于图书类别

除了上述询问外,我们还希望能够回答以下问题:

四 设计GunDB数据结构

在这一节中,我们将使用前几节的信息,创建一个数据模型,通过GunDB实现。

节点和属性

首先,我们先来看在GunDB中代表实体的节点(Node),并定义它们的属性。

Book

book
- uuid: string (internal)
- type: string (internal)
- title: string
- subtitle: string
- isbn: string
--
* reviews: Set
* categories: Set
* authors: Set
* publisher: Link

可以使用draw.io,创建数据模型。新建空白图后,选择: Arrange->Insert->Advanced->From Text...,然后将上述内容粘贴进去,在下拉框中选择List,点击确认,就可以生成一个数据实体图,如下图:
[图片上传失败...(image-527004-1651928957290)]

Book Category

book_category
- uuid: string (internal)
- type: string (internal)
- name: string
--
* books: Set

User

user
- uuid: string (internal)
- type: string (internal)
- name: string
- username: string
- email: string
--
* roles: Set

Reader

reader
- uuid: string (internal)
- type: string (internal)
- name: string
--
* book_reviews: Set
* following: Set
* followers: Set
* favorite_books: Set

Author

author
- uuid: string (internal)
- type: string (internal)
- name: string
--
* books: Set
* following: Set
* followers: Set

Publisher

publisher
- uuid: string (internal)
- type: string (internal)
- name: string
- address: string
--
* books: Set
* authors: Set

上述节点建立完成后,如下图:
[图片上传失败...(image-257a7b-1651928957290)]

点击这里,获得完整的数据模型

关系

在本节中,我们将用GunDB构建节点之间的关系。

GunDB默认创建了单向的关系,并且不强迫你为边(Edges)定义属性。它给你自由来决定哪些边需要属性,哪些实体需要双向关系。你可以完全控制设计数据模型图。使用链接节点(Link Node)来描述关系是很有用的。

评论(Review Book)

评论一本书可以用读者和书之间的一个链接节点来表示,其属性如下。

review_book (Link Node)
- uuid: string (internal)
- type: string (internal)
- name: string (internal)
- rating: number (integer, 1 <= n < 6)
- content: string (max 375 characters)
--
* book: Node
* reader: Node

这个链接节点的属性,包括评级值(rating)和评论的内容(content)。它还包括两个引用,一个是book,另一个是reader,描述了review_bookbookreader的关系。

注意:引用有两种,一种是Set,另一种是Node。如果引用是一对多的话,用Set,如书单的books引用,因为对应多本书,所以用Set;而review_book中的book,因为与书籍是一对一,所以用Node。相应的,如果是Set,使用set()添加数据;如果是Node,则使用put()。除此之外,建议属性的命名上,所有Set为复数词,Node为单数词。

[图片上传失败...(image-2952ce-1651928957290)]

上图表示以下动作:

我们也可以用纯文本来描述上面的关系。

;Review Book:
reader->book_reviews->book_reviews(set)
book_reviews(set)->review_book
review_book->reader->reader
review_book->book->book
book->reviews->reviews(set)
reviews(set)->review_book

我们要探讨的其余关系,就结构而言,与上面讨论的关系非常相似。也就是说,起点或终点的节点将有一组指向关系链接。而节点链接本身将有一个指向"源"的引用,另一个指向"目的"节点。

注:上述描述关系的描述可以在draw.io中添加图,方式与上述添加节点属性类似,只是在下拉框中,请选择Diagram

Author Book

创作一本书可以用作者和书之间的链接节点来表示,其属性如下。

author_book (Link Node)
- uuid: string (internal)
- type: string (internal)
- name: string (internal)
- date: string (ISO date)
--
* book: Node
* author: Node

[图片上传失败...(image-3c388e-1651928957290)]

;Author Book:
author->books->books(set)
books(set)->author_book
author_book->author->author
author_book->book->book
book->authors->authors(set)
authors(set)->author_book

Favorite Books

Attributes:

favorite_list (Link Node)
- uuid: string (internal)
- type: string (internal)
- name: string (internal)
- list_name: string
- is_public: string ("true" or "false")
--
* books: Set
* belongs_to: Node

[图片上传失败...(image-f9a2f5-1651928957290)]

Diagram:

;Reader's favorite books:
reader->favorite_books->favorite_books(set)
favorite_books(set)->book_list
book_list->books->books(set)
book_list->belongs_to->reader
books(set)->book

Book Category

Attributes:

book_category (Link Node)
- uuid: string (internal)
- type: string (internal)
- name: string (internal)
- category_name: string
--
* book: Node
* belongs_to: Node

[图片上传失败...(image-5f1117-1651928957290)]

Diagram:

;Books in a category:
category->books->books(set)
books(set)->book_category
book_category->belongs_to->category
book_category->book->book
book->categories->categories(set)
categories(set)->book_category

Publish Book

Attributes:

publish_book (Link Node)
- uuid: string (internal)
- type: string (internal)
- name: string (internal)
- date: string (ISO date)
--
* book: Node
* publisher: Node

[图片上传失败...(image-b7a5ca-1651928957290)]

Diagram:

;Publish Book:
publisher->books->books(set)
books(set)->publish_book
publish_book->publisher->publisher
publish_book->book->book
book->publication_details->publish_book

User Roles

Attributes:

role (Link Node)
- uuid: string (internal)
- type: string (internal)
- name: string (internal)
- role_name: string
--
* role_type: Node
* user: Node
* permissions: Set

[图片上传失败...(image-158afd-1651928957290)]

Diagram:

;User Roles:
user->roles->roles(set)
roles(set)->role
role->assigned_to->user_type
role->user->user
user_type->role->role
roles/name->role

Follow Readers/Authors

Attributes:

follow (Link Node)
- uuid: string (internal)
- type: string (internal)
- name: string (internal)
- date: string (ISO date)
--
* reader: Node
* by_reader: Node

[图片上传失败...(image-c5dea4-1651928957290)]

请注意,可以为读者关注作者或作者关注读者或其他作者定义类似的关系。你仍然可以把关系名称保留为"follow",但这样你可能想把链接属性重命名为更合适的名称。例如,一个作者跟随一个读者的关系可以定义为:

follow (Link Node)
- uuid: string (internal)
- type: string (internal)
- name: string (internal)
- date: string (ISO date)
--
* reader: Node
* by_author: Node

另一种可能性是将"关注"关系从一个用户概括到另一个用户,而不必担心用户的类型。在这种情况下,这种关系可以定义为:

follow (Link Node)
- uuid: string (internal)
- type: string (internal)
- name: string (internal)
- date: string (ISO date)
--
* user: Node
* by_user: Node

在这种情况下,只要用户和用户类型链接得当,你就可以从任何节点上遍历图,找到他们的追随者,以及他们在追随谁。在下一节用GunDB创建图时,我们将对此进行更多的探讨。

五 创建数据图(Graph)

现在我们已经定义了节点、节点属性和节点间的关系,现在添加一些模拟数据并测试这个模型。这里的目标是用GunDB创建一个图(Graph),使用上面的定义,并对数据运行一些查询,以验证我们得到正确的数据。

添加一些模拟数据

function fakeBooks(opts = {n: 5}) {
  const books = [];
  let howMany = opts.n;

  while(howMany-- > 0) {
    let id = uuid();
    let count = (opts.n - howMany);
    const book = {
      uuid: id,
      type: "Book",
      title: `The Book Title ${count}`,
      subtitle: `Lorem ipsum dolor ${count}`,
      isbn: count,
    };
    books.push(book);
  }
  return books;
}

上面的函数,默认情况下,返回一个由5个代表书籍的普通JavaScript对象组成的数组。接下来,我们要调用这个函数,从书籍对象中创建Gun节点

const db = Gun();
const books = fakeBooks();
for (let b of books) {
  db.get(b.uuid).put(b);
}

为了方便起见,我们还将创建一个JavaScript数组,用来保存对我们刚刚创建的Gun节点的引用。

const bookNodes = books.map(b => db.get(b.uuid));

创建关系

建立评论(Review book)-书关系

现在,我们有了一些假数据,让我们去创建关系。我们要看的第一个关系是书评。下图说明了我们将需要创建的节点和链接。
[图片上传失败...(image-9a1cb8-1651928957290)]

以上六个步骤用代码实现如下:

function reviewBook(opt) {
  const {db, reader, book, rating, content} = opt;
  const linkId = uuid();

  const review_book = db.get(linkId).put({ // A
    uuid: linkId,
    type: "Link",
    name: "review_book",
    rating: rating,
    content: content,
  });
  review_book.get("book").put(book); // B
  review_book.get("reader").put(reader); // C

  db.get(`reviews/${rating}`).set(review_book); // D

  book.get("reviews").set(review_book); // E
  reader.get("book_reviews").set(review_book); // F

  return review_book;
}

在上面的片段中,reviewBook函数接受了一个对象,该对象持有以下引用。

然后,我们在A行创建一个review_book的评论节点,使用传入的值设置评级和内容。在B行,我们在评论上创建一个名为book的属性,指向给定的book节点。在C行,我们定义了一个名为读者的属性,指向给定的读者节点。在D行,我们将评论节点添加到review/rating集。这个集合将帮助我们通过评级值来引用评论。在E行,我们在给定的书籍节点上创建了一个叫做reviews的集合,并将评论节点添加到其中。在F行,我们在读者节点上创建一个名为book_reviews的集合,并将评论节点加入其中。最后,我们从函数中返回review_book链接节点。

现在我们有了这个函数,在主文件中我们可以让读者创建评论。例如,我们可以由读者1创建两个评论。

reviewBook({
  db,
  reader: readerNodes[0],
  book: bookNodes[0],
  rating: 5,
  content: "Great book!",
});

reviewBook({
  db,
  reader: readerNodes[0],
  book: bookNodes[1],
  rating: 1,
  content: "It was ok.",
});

为了快速测试书评关系,我们可以运行以下查询并手动验证结果。

readerNodes[0].get("book_reviews").map().once(console.log);
readerNodes[0].get("book_reviews").map().get("book").get("title").once(console.log);
bookNodes[0].get("reviews").map().once(console.log);
bookNodes[0].get("reviews").map().once(review => {
  if(review.rating === 5) {
    db.get(review.book).once(b => {
      console.log(review);
    });
  }
});
bookNodes[0].get("reviews").map().get("reader").get("name").once(console.log);
db.get("reviews/1").map().once(console.log);
db.get("reviews/1").map().get("book").get("title").once(console.log);
db.get("reviews/1").map().get("reader").get("name").once(console.log);

使用Promise方式查询

GunDB使用一个流式API,这对实时应用程序来说是完美的。但是在某些情况下,你可能想使用Promise来获得一组结果,而不是侦听更新。GunDB包含一个扩展,可以让你把查询变成一个Promise。在本节中,我们将使用then把上面的一些查询变成Promise

首先,我们需要包含then扩展。如果你使用npm来安装Gunthen扩展被包含在node_modules/gun/lib/then中。你可以在包含Gun后加载它。

const gun = require("gun");
require("gun/lib/then")。

在包含then扩展后,既可以使用Promise方式查询记录,下面的代码返回一个Promise,该Promise可解析为一个包含键和每个键的元数据的图:

const result = readerNodes[0].get("book_reviews").then();

现在,为了提取数据,你可以这样做:

const removeMetaData = (o) => { // A
  const copy = {...o};
  delete copy._;
  return copy;
};

const bookReviews = readerNodes[0].get("book_reviews").then() // B
.then(o => removeMetaData(o)) // C
.then(refs => Promise.all(Object.keys(refs).map(k => db.get(k).then()))) // D
.then(r => console.log(r)); // E

下面是另一个使用then的例子,用来显示所有一星评价的书籍:

db.get("reviews/1").then()
.then(filterMetadata)
.then(r => Promise.all(Object.keys(r).map(k => db.get(k).then())))
.then(r => Promise.all(Object.keys(r).map(k => db.get(r[k].book["#"]).then())))
.then(r => log(r));

以上测试代码在github上可以找到,点击

建立作者Author-书Book的关系

我们要写的下一个是作者与书籍的关系。下图说明了我们将要创建的链接和节点。
[图片上传失败...(image-5af248-1651928957290)]

定义节点和关系如下:

function authorBook(opt) {
  const {db, author, book, date} = opt;
  const linkId = uuid();

  const authorBookNode = db.get(linkId).put({
    uuid: linkId,
    type: "Link",
    name: "author_book",
    date: date,
  });
  authorBookNode.get("book").put(book);
  authorBookNode.get("author").put(author);

  book.get("authors").set(authorBookNode);
  author.get("books").set(authorBookNode);

  return authorBookNode;
}

下面是一些查询脚本,也可以从这里获得

authorNodes[0].get("books").map().get("book").get("title").once(log);
bookNodes[0].get("authors").map().get("author").get("name").once(log);

还可以利用book_review做组合查询,例如:

authorNodes[0].get("books").map().get("book").get("title").once(log);
authorNodes[0].get("books").map().get("book").get("reviews").map().get("rating").once(log);

建立收藏书单(Favorite Books)

读者可以创建收藏书单,并将他们喜欢的书添加到书单中。下图显示了将要创建的书单节点和链接节点。

[图片上传失败...(image-c91dd4-1651928957290)]

定义节点和关系如下:

function favoriteBooks(opt) {
  const {db, reader, books, listName} = opt;
  const listId = uuid();

  const list = db.get(listId).put({
    uuid: listId,
    type: "Link",
    name: "favorite_list",
    list_name: listName,
        is_public: "true",
  });

  const faveBooks = db.get(uuid());
  for (book of books) {
    faveBooks.set(book);
  }

  list.get("books").put(faveBooks);
  list.get("belongs_to").put(reader);

  reader.get("favorite_books").set(list);

  return list;
}

下面是一些查询脚本,也可以从这里获得

readerNodes[0].get("favorite_books").map().get("books").map().get("title").once(log);
readerNodes[0].get("favorite_books").map().once(list => {
  if(list.list_name === "List 1") {
    db.get(list.books).map().get("title").once(log);
  }
});
readerNodes[0].get("favorite_books").map().once(list => {
  if(list.is_public === "false") {
    log(list.list_name);
  }
});

出版书籍

出版图书可以用出版商(publisher)和图书(book)之间的链接节点来表示。下图显示了这些节点和关系。

[图片上传失败...(image-48f67-1651928957290)]

定义节点和关系如下:

function publishBook(opt) {
  const {db, publisher, book, date} = opt;
  const linkId = uuid();

  const publishLink = db.get(linkId).put({
    uuid: linkId,
    type: "Link",
    name: "publish_book",
    date: date,
  });
  publishLink.get("book").put(book);
  publishLink.get("publisher").put(publisher);

  book.get("publisher").put(publishLink);
  publisher.get("books").set(publishLink);

  return publishLink;
}

下面是一些查询脚本,也可以从这里获得

publisherNodes[0].get("books").map().get("book").get("title").once(log);
bookNodes[0].get("publication_details").get("publisher").get("name").once(log);

图书分类

对书籍的分类可以用类别节点和书籍节点之间的链接节点来表示。下图显示了我们需要创建的节点和链接。

[图片上传失败...(image-e714f2-1651928957290)]

定义节点和关系如下:

function bookCategory(opt) {
  const {db, category, book, categoryName} = opt;
  const linkId = uuid();

  const categoryLink = db.get(linkId).put({
    uuid: linkId,
    type: "Link",
    name: "book_category",
    category_name: categoryName,
  });

  categoryLink.get("book").put(book);
  categoryLink.get("belongs_to").put(category);
  db.get(`category/${categoryName}`).set(categoryLink);

  book.get("categories").set(categoryLink);
  category.get("books").set(categoryLink);

  return categoryLink;
}

下面是一些查询脚本,也可以从这里获得

categoryNodes[0].get("books").map().get("book").get("title").once(log);
bookNodes[0].get("categories").map().get("belongs_to").get("name").once(log);
db.get("category/Category 1").map().get("book").get("title").once(log);

用户角色

为了给用户分配角色,我们可以创建一个集合,并向该集合添加角色链接。下图显示了我们需要创建的节点和链接。

[图片上传失败...(image-d7380c-1651928957290)]

The role node link contains the role's information. Its user property references the user node and its assigned_to property references a user type node. A user type node can be a reader, author, or publisher.
The roles property in the user node is a set that holds references to role nodes.
The user type node has a role property that points to the role node.

定义节点和关系如下:

function userRole(opt) {
  const {db, name, userTypeNode, user} = opt;
  const linkId = uuid();

  const roleLink = db.get(linkId).put({
    uuid: linkId,
    type: "Link",
    name: "role",
    role_name: name,
  });

  roleLink.get("user").put(user);
  roleLink.get("role_type").put(userTypeNode);

  db.get(`roles/${name}`).set(roleLink);

  const userUuid = user._.put.uuid; // HACK, DONT DO THIS IN ACTUAL APP
  const userType = userTypeNode._.put.type.toLowerCase() + "s"; // HACK, DONT DO THIS IN ACTUAL APP

  db.get(`users/${userUuid}`).set(user);
  db.get('users').set(user);
  db.get(userType).set((userTypeNode));

  user.get("roles").set(roleLink);
  userTypeNode.get("role").put(roleLink);

  return roleLink;
}

下面是一些查询脚本,也可以从这里获得

db.get("readers").map().get("name").once(log);
db.get("users").map().get("email").once(log);
userNodes[0].get("roles").map().get("role_name").once(log);

关注与被关注

"关注"关系与我们迄今为止所涉及的关系有一点不同。这是因为上面的大多数关系,如写书或评书,都意味着是双向的关系。但是关注不一定是双向的。例如,用户A可以关注用户B,但这并不意味着用户B也在立即关注用户A。正因为如此,我们需要为每个用户定义两个集合,以记录"关注"和"追随者 "的情况。下图展示了用户A关注用户B时我们需要创建的节点和链接。

[图片上传失败...(image-a4fe2f-1651928957290)]

如果用户B同时也关注了用户A,则关系变为下图:
[图片上传失败...(image-7652dc-1651928957290)]

实现代码如下:

function follow(opt) {
  const {db, sourceNode, destinationNode} = opt;
  const linkId = uuid();

  const followLink = db.get(linkId).put({
    uuid: linkId,
    type: "Link",
    name: "follow",
    date: new Date().toISOString(),
  });
  followLink.get("who").put(destinationNode);
  followLink.get("by").put(sourceNode);

  sourceNode.get("following").set(followLink);
  destinationNode.get("followers").set(followLink);

  return followLink;
}

下面是一些查询脚本,也可以从这里获得

readerNodes[1].get("following").map().get("who").get("name").once(log);
readerNodes[1].get("followers").map().get("by").get("name").once(log);

整体性的查询

现在我们已经创建了一些数据并添加了一些关系,让我们来问一下下面这个整体性的问题。

// uuids of the books included in all favorite lists
q(db).get("readers").getSet()
.get("favorite_books").getSet()
.get("books").getSet().get("uuid")
.data();

// uuids of the 5-star books
q(db).get("reviews/5").getSet()
.get("book")
.get("uuid")
.data();

然后,我们可以计算被列入最喜爱名单的书的数量,并按降序排列。第一本书将是收录数量最多的书。然后,我们可以简单地检查该书是否被列入五星级名单。你可以在main.js文件中看到完整的代码片段。

你可以使用上面的例子来运行各种查询,以获得对你的数据的有趣的洞察力。我们的想法是,首先运行一些查询来收集感兴趣的尿素。一旦你有了这些数据,只需做一些基本的比较,就可以得到你要找的答案了。

进一步的改进

有几件事情你应该考虑改进实现。

如果你有任何问题或不确定的地方,请加入GunDB的聊天室,每个人都非常友好和有帮助。

总结

希望这篇文章能给你一些关于如何使用GunDB进行图数据建模的启发。在这篇文章中,我只是触及了GunDB可能的内容,这并不是整个Gun系统的全部模型。数据建模,就像任何设计过程一样,需要大量的计划和实验。然而,一个好的、经过深思熟虑的设计,可以为你在以后的工作中节省大量的时间和精力。如果你对如何改进本文介绍的模型有任何想法,请留言,我们非常感谢任何意见。

另外,我已在draw.io上共享了本文中的图数据库图,见:https://app.diagrams.net/#G1TbiTdr3IyE8YWIoNhC60jxj2VL8fMkV4

上一篇 下一篇

猜你喜欢

热点阅读