将 Git 变成应用程序数据库

2025-06-07

将 Git 变成应用程序数据库

如今,当你创建应用程序时,数据库的选择有很多。

你可能没有意识到,Git 是其中一个非常有优势的选择。为什么以及如何选择 Git?请继续阅读。

最近,我和弟弟一起创建了一个简单的发票应用程序,作为我们个人项目的需求。一开始,我们打算在单用户模式下本地运行该应用程序,但我们需要能够从多个位置访问数据。

过去,我们讨论过使用文件系统和 Git 来创建一个简单但功能强大的数据库的可能性,所以我们决定利用这个机会对这个想法进行概念验证。 

为什么选择 Git?

让我们看看使用 Git 支持的文件系统作为应用程序数据库的好处。

  • 历史记录 - 说实话,在普通数据库中管理历史记录通常并不轻松。最终你会得到一个满是记录的表,你必须检查日期才能获取当前活动的记录。或者你必须将记录移动到历史表中。无论哪种方式,都很繁琐。但使用 Git,你几乎可以免费获取历史记录。你甚至可以比较版本,还能知道谁、何时以及为什么进行了更改。

  • 免费托管 – 现在,您可以在 Bitbucket 和 Github 上免费创建私有仓库。与普通的免费数据库托管相比,Github 和 Bitbucket 都没有行限制和读写限制,而且我认为它们都是可靠的提供商。

  • 人性化数据存储 - 通过将数据存储在纯文本文件中,您可以使用常用的文本编辑器读取甚至编辑数据。得益于此,您可以快速破解数据,或快速构建新功能的原型。例如,您需要克隆一张旧发票,却又没时间在应用程序中实现此功能?没问题,只需复制文件,打开您常用的文本编辑器,即可完成。这比大多数数据库管理工具都要简单得多。

  • 分布式和离线数据库 - Git 的一大优势在于其分布式特性。每个本地仓库都包含完整的历史记录。即使其中一个仓库宕机,通常也不会导致数据丢失。此外,它还可以离线工作,避免任何额外的麻烦。

  • 冲突合并 - 当两个用户更改同一实体时,必须进行冲突解决。这通常意味着要么后更改生效,要么先更改生效。另一方面,Git 允许用户手动合并冲突,并且在许多情况下可以自动合并更改。

现在,我们知道了 Git 应用程序数据库的好处,让我们深入实现它。

如何存储文件

首先,我们需要确定如何构建数据库模型。我们将使用文件夹来存储集合,使用文件来存储文档。

文件系统层次结构

这样,我们可以轻松地将所有实体集中到一个集合中,而且对人类来说也更具可读性。我们将使用非裸存储库,因此能够直接访问文件,我认为这是一大优势。

让我们在这里指出一篇有趣的文章https://www.kenneth-truyers.net/2016/10/13/git-nosql-database/,它也涉及使用 Git 作为数据库的概念,但它使用的是裸存储库。

为了加快查找速度,我们将文档 ID 直接存储在文件名中。这样,就无需打开文件即可获取 ID,而且对人类来说也更具可读性。此外,还可以在文件名中存储更多字段,从而创建某种索引。

我们将使用 YAML 作为文档文件的文件格式。YAML 非常适合序列化数据,同时保持其可读性,因此我们可以更轻松地检查 Git 历史记录中的差异。

让我们表演一下

好的,现在我们有了文件结构,为了提高速度,我们需要缓存。我们将在两个层面进行缓存。首先,我们将缓存集合,然后缓存单个文档。

缓存单个文档更有趣,所以我们就从这开始吧。我们最好不要将文档永久地保存在内存中,而是应该为它们指定 TTL(生存时间)。在实际实现中,我们将使用node-cache库。

async function getDocument(collection: string, id: string): Promise<any> {
  const documentCacheKey = getDocumentCacheKey(collection, id);
  let document = documentCache.get(documentCacheKey);
  if (!document) {
    const file = getDocumentPath(collection, id);
    const fileContent = await readFile(file, 'utf-8');
    document = yaml.safeLoad(fileContent) || {};
    (document as any)['id'] = id;
    documentCache.set(documentCacheKey, document);
  }
  return document;
}
function getDocumentCacheKey(collection: string, id: string) {
  return `${collection}-${id}`;
}
function getDocumentPath(collection: string, id: string) {
  return path.join(baseDir, collection, `${id}.yaml`);
}
Enter fullscreen mode Exit fullscreen mode

首先,我们检索给定文档的缓存键。为此,我们将所需文档的集合(文件夹)名称和 ID 连接起来。然后,我们使用此键访问缓存中的文档。如果不存在,我们需要从文件系统加载它,然后将其存储在缓存中,并使用我们的缓存键。

为了解析 YAML 文件,我们将使用js-yaml库。

对于缓存集合,一个简单的数组字典就足够了。在这种情况下,我们不需要 TTL,因为我们查询集合的次数会比查询实际文档的次数多,而且内存中实际的内容相当小。

async function getCollection(collection: string): Promise<string[]> {
  if (!collectionCache[collection]) {
    collectionCache[collection] =
      (await glob(path.join(baseDir, collection, '*.${yaml}')))
        .map(p => ({
          id: path.basename(p, path.extname(p)),
          path: p
        }));
  }
  return collectionCache[collection]!
    .map(f => f.id);
}
Enter fullscreen mode Exit fullscreen mode

文本编辑器来救援

由于我们所有文档都可以通过文件系统轻松访问,因此如果不支持应用程序外部的更改,那就太可惜了。这样,我们就可以处理应用程序中未直接支持的可用性模式。例如,正如我们之前所说,在将此功能添加到应用程序之前,我们将能够复制发票。

为此,我们将使用chokidar库引入文件观察器。

watcher = chokidar.watch(`**/*.yaml`, { cwd: baseDir });
watcher
  .on('add', file => onChange(file, 'add'))
  .on('unlink', file => onChange(file, 'unlink'))
  .on('change', file => onChange(file, 'change'));

function onChange(file: string, changeType: 'add' | 'unlink' | 'change') {
  const collection = path.basename(path.dirname(file));
  const id = path.basename(file, path.extname(file));
  invalidateDocumentInCache(collection, id, changeType !== 'change');
}
function invalidateDocumentInCache(collection: string, id: string,
  invalidateCollectionCache: boolean) {
  const documentCacheKey = getDocumentCacheKey(collection, id);
  documentCache.del(documentCacheKey);
  if (collectionCache && invalidateCollectionCache) {
    collectionCache[collection] = null;
  }
}
Enter fullscreen mode Exit fullscreen mode

如果检测到 YAML 文件发生更改,我们会将其从节点缓存中移除。如果文件被移除、添加或重命名(添加 + 取消链接),我们还会清除集合缓存中的整个集合。

让我们搜索一下

最后,我们已做好一切准备,可以进行实际查询了。我们将从两个层面进行查询:按 ID 查询和按内容查询。我们将使用micromatch库进行按 ID 查询。

async function getAllIds(query?: IdQuery): Promise<string[]> {
  let ids = await getCollection(collectionName);
  if (query && query.id) {
    ids = ids.filter(d => micromatch.isMatch(d, query.id!));
  }
  return orderBy(ids, 'id');
}
Enter fullscreen mode Exit fullscreen mode

Micromatch 支持我们根据 ID 进行简单的查询。例如,对于发票,每个 ID 由年份和该年份的数字组成,因此看起来像“201933”。要获取特定年份创建的所有发票,我们可以使用“${year}*”查询。

对于按文件内容进行查询,我们将使用简单的过滤。

let docs = await Promise.all(
    collection.map(doc => doc.getDocument(collectionName, doc)));
if (query && query.where) {
  docs = docs.filter(query.where)
}
Enter fullscreen mode Exit fullscreen mode

保存并加载  

为了保持本地和远程 Git 仓库同步,​​我们需要定期拉取和推送。对于单页面应用,拉取操作的最佳时机是在页面刷新时。每次更改后都可以推送,这样远程仓库就能尽快更新。在实际的 Git 操作中,我们将使用simple-git库,它能简化 Git 操作。

async function pull(): Promise<any> {
  await ensureRepo();
  await repo!.pull();
}
async function commitAndPush(message: string): Promise<any> {
  await ensureRepo();
  await repo!.add('.');
  var commitRes = await repo!.commit(message);
  var pushRes = await repo!.push();
}
async function ensureRepo() {
  if (!repo) {
    repo = simplegit(db.dir);
  }
}
Enter fullscreen mode Exit fullscreen mode

然后在实际更新代码中:

async function update(invoice: InvoiceUpdateModel): Promise<InvoiceDocument> {
  let invoiceDocument = await db.invoices.single(invoice.id);
  invoiceDocument = { ...invoiceDocument, ...invoice };
  invoiceDocument = await db.invoices.update(invoiceDocument);
  await repoService.commitAndPush(`invoice(${invoice.id}):updated`);
  return invoiceDocument;
}
Enter fullscreen mode Exit fullscreen mode

结论

我必须说,我对结果很满意。就这个特定的用例而言,Git 作为应用程序数据的数据库表现相当不错。当然,与常见的数据库解决方案相比,它的性能要低得多。如果应用程序托管在远程服务器上,整个概念也远非理想,因为这会使处理冲突变得更加困难。但另一方面,它非常简单、便宜,并且使我们能够快速破解文档。

您可以在此处访问发票应用程序的完整代码https://github.com/pruttned/owl-invoice

文章来源:https://dev.to/pruttned/turning-git-into-an-application-database-4b6a
PREV
Implementing Spring Security 6 with Spring Boot 3: A Guide to OAuth and JWT with Nimbus for Authentication
NEXT
构建自定义 React Hooks