CouchDB,开源 Cloud Firestore 的替代品?

2025-06-07

CouchDB,开源 Cloud Firestore 的替代品?

注意:这篇文章最初发布在marmelab.com上。

在我们上一个客户项目中,我们使用了Google 的后端即服务Firebase作为后端。虽然我们对这个“全包式”套件的整体功能感到满意,但其专有性方面仍让我们感到失望。

Firebase

这就是为什么我主动寻找 Firebase 的开源替代品,它可以满足我们所有的需求,而无需依赖第三方服务。

这一任务的第一步是找到用于网络的Cloud Firestore实时 NoSQL 数据库的替代品。

我们需要什么?

使用 Firestore 而非更传统的数据库并非易事。这通常是因为需要快速开发具有以下功能的应用程序:

  • 离线首先,客户端写入与远程数据库同步的本地数据库
  • 实时远程更改必须与我们的本地数据库同步

有一些解决方案可以满足这一需求,其中大多数基于NoSQL 数据库,例如MongoDBCassandraRethinkDBGun或其他基于 MongoDB 的解决方案,例如MinimongoturtleDBtortoiseDB

在我们的例子中,我们将尝试CouchDB(以及用于前端的PouchDB),因为从我们的角度来看,它是更强大和最知名的解决方案。

CouchDB 和 PouchDB

CouchDB 是一款开源/跨平台的面向文档的数据库软件。它基于面向并发的 Erlang语言开发,因此具有很高的可扩展性。它使用JSON 格式存储数据,并通过HTTP API进行访问。

CouchDB 诞生于 2005 年。自 2008 年起,CouchDB 成为Apache 软件基金会项目,这使其受益于大量支持和庞大的社区。

以下是 CouchDB 的主要功能:

  • 多版本并发控制(让您轻松构建离线优先解决方案)
  • 具有复制功能的分布式架构
  • 文件存储
  • HTTP/REST API

由于CouchDB 在服务器上运行,许多客户端库允许通过它提供的 HTTP 接口与其进行通信。

最著名的 Web CouchDB 客户端库是 PouchDB。PouchDB是一个开源的 JavaScript 数据库,旨在在浏览器中运行。这样,它允许离线本地存储数据,并在用户重新上线时将其与远程 CouchDB 服务器同步。

CouchDB 和 PouchDB 实践

介绍够了,让我们开始实践吧!在本节中,我将逐步描述如何使用 CouchDB 和 PouchDB 作为数据库系统来开发一个ReactJS应用程序。同时,我会尽可能地将 CouchDB 的实现与 Firestore 的实现进行比较。

此外,我还将向您介绍我在 Javascript 库方面的最新爱好:Final-FormElasticUIIndicative

在这个项目中,我将创建一个啤酒登记处,让用户可以跟踪他们的啤酒库存。

项目设置

为了使本教程尽可能简单,我将使用create-react-app创建一个 ReactJS 应用程序。

create-react-app reactive-beers && cd reactive-beers

npm install -S pouchdb
Enter fullscreen mode Exit fullscreen mode

应用程序骨架如下所示:

julien@julien-P553UA:~/Projets/marmelab/reactive-beers$ tree -L 1
.
├── node_modules
├── package.json
├── package-lock.json
├── public
├── README.md
└── src
Enter fullscreen mode Exit fullscreen mode

然后,由于我不想直接在我的计算机上安装 CouchDB,我将使用Docker。因此,第一步是配置docker-compose.yml文件及其相关内容Makefile,以改善开发人员体验。

// ./docker-compose.yml

version: "2.1"

services:
  couchdb:
    image: couchdb:2.3.0
    ports:
      - "5984:5984"

  node:
    image: node:10
    command: npm start
    working_dir: "/app"
    volumes:
      - ".:/app"
    ports:
      - "4242:3000"
    depends_on:
      - couchdb
Enter fullscreen mode Exit fullscreen mode
# ./Makefile

USER_ID = $(shell id -u)
GROUP_ID = $(shell id -g)

export UID = $(USER_ID)
export GID = $(GROUP_ID)

DOCKER_COMPOSE_DEV = docker-compose -p reactive-beers

help: ## Display available commands
    @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

install: ## Install docker stack
    $(DOCKER_COMPOSE_DEV) run --rm node bash -ci 'npm install'

start: ## Start all the stack
    $(DOCKER_COMPOSE_DEV) up -d

stop: ## Stop all the containers
    $(DOCKER_COMPOSE_DEV) down

log: ## Show logs
    $(DOCKER_COMPOSE_DEV) logs -f node
Enter fullscreen mode Exit fullscreen mode

因此,我们现在准备开始使用我们的完整堆栈make install start

julien@julien-P553UA:~/Projets/marmelab/reactive-beers$ docker ps
CONTAINER ID        IMAGE            COMMAND                  CREATED       STATUS       PORTS                                        NAMES
6884f92c5341        node:10          "npm start"              3 hours ago   Up 3 hours   0.0.0.0:4242->3000/tcp                       reactive-beers_node_1
21897f166ce4        couchdb:2.3.0    "tini -- /docker-ent…"   3 hours ago   Up 3 hours   4369/tcp, 9100/tcp, 0.0.0.0:5984->5984/tcp   reactive-beers_couchdb_1
Enter fullscreen mode Exit fullscreen mode

一切已启动。您可能已经注意到,5984我们的文件中暴露了端口docker-compose.yml,它是 CouchDB API。然后,如果您localhost:5984在浏览器中打开它,您将看到类似以下内容。

{
    "couchdb": "Welcome",
    "version": "2.3.0",
    "git_sha": "07ea0c7",
    "uuid": "49f4e7520f0e110687dcbc8fbbb5409c",
    "features": ["pluggable-storage-engines", "scheduler"],
    "vendor": {
        "name": "The Apache Software Foundation"
    }
}
Enter fullscreen mode Exit fullscreen mode

访问文档存储

好的,我们的服务器已启动并运行。但是,是否有像 Firestore 一样的界面来可视化/监控CouchDB 呢?答案是肯定的!CouchDB 已经包含一个名为 的管理界面Fauxton。我们可以在 上浏览它http://localhost:5984/_utils/

Firestore 界面


Firestore 管理界面

Fauxton 接口


Fauxton管理界面

Fauxton界面允许访问数据库、设置节点和集群、配置复制、设置权限等。虽然实用,但最好还是使用专用脚本自动执行这些管理任务

React 开始发挥作用

现在,我们可以开始开发我们的第一个 PouchDB 驱动的界面了。下面是我们的App.js入口点和Home.js启动屏幕。

// ./src/App.js

import React from 'react';
import { Home } from './screens/Home';

const App = () => <Home />;

export default App;
Enter fullscreen mode Exit fullscreen mode

App.js文件目前没什么用。将来我们需要添加更多路线和屏幕时,它肯定会派上用场。

// ./src/screens/Home.js

import React, { useState, useEffect } from 'react';
import { addBeer, getBeers, onBeersChange } from '../api/beers';

export const Home = () => {
  const [beers, setBeers] = useState([]);

  const refreshBeers = () => getBeers().then(setBeers);

  useEffect(() => {
    // We fetch beers the first time (at mounting)
    refreshBeers();

    // Each change in our beers database will call refreshBeers
    const observer = onBeersChange(refreshBeers);
    return () => {
        // Don't forget to unsubscribe our listener at unmounting
        observer.cancel();
    };
  }, []);

  return (
    <div>
      <button onClick={() => addBeer({ title: 'Beer X' })}>Add a beer</button>
      <ul>
        {/* beer._id is an unique id generated by CouchDB */}
        {beers.map(beer => <li key={beer._id}>{beer.title}</li>)}
      </ul>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

CouchDB 需要比 Firestore 更多的请求

如您所见,在这个例子中,我们结合使用监听器(onBeersChange)和查询(getBeers)来获取初始啤酒列表,并在数据库发生更改时刷新它。

与 Firestore 提供的操作相比,此操作并非最佳。事实上,虽然Pouchdb 无法同时返回更改和数据,但 Firestore 凭借系统机制能够做到这一点QuerySnapshot,从而减少了服务器之间的往返。您可以参考以下 Firestore 示例自行验证:

  db.collection("anything")
    .onSnapshot(function(querySnapshot) {
        querySnapshot.forEach(function(doc) {
          // This forEach loop is executed at first execution
          // And executed each time the query result changes
        });
    });
Enter fullscreen mode Exit fullscreen mode

因此,如果我们改用 Firestore,它看起来会是这样的:

  //...

  const [beers, setBeers] = useState([]);

  useEffect(() => {
    const unsubscribe =  db.collection("beers")
      .onSnapshot(function(querySnapshot) {
          const snapBeers = [];
          querySnapshot.forEach(function(doc) {
              snapBeers.push(doc.data());
          });

          setBeers(snapBeers);
      });

    return () => {
        unsubscribe();
    };
  }, []);

  // ...
Enter fullscreen mode Exit fullscreen mode

也可以使用.map属性querySnapshot.docs以“非命令式”的方式检索所有文档。遗憾的是,官方文档中没有充分涵盖此功能。

模型

就像后端开发一样,我喜欢在前端应用中将模型逻辑与视图逻辑分离。以下是我们的 Beer 的 API 文件:

// ./src/api/beers.js

import PouchDB from 'pouchdb';

// We declare a PouchDB instance that is "remote only"
// There's no "offline" capability for the moment, everything is sync
export const beersDatabase = new PouchDB('http://localhost:5984/beers');

// If the beers database does not already exist
// => The database is automatically created when an object is added to it
export const addBeer = beer => beersDatabase.post(beer);

// Here, we list all the documents from our beers database
// A lot of options exists. Eg: we can paginate using "startKey", "endKey" or "limit"
export const getBeers = () =>
  beersDatabase
    .allDocs({
      include_docs: true,
      descending: true,
    })
    .then(doc => doc.rows.map(row => row.doc));

// We listen all the changes that happen since now
// We can also apply a "limit" option to this method
export const onBeersChange = callback => beersDatabase
    .changes({ since: 'now', live: true })
    .on('change', callback);
Enter fullscreen mode Exit fullscreen mode

这是我们第一个 CouchDB 应用程序的运行结果。如您所见,所有内容在多个窗口之间同步。

PouchDB 同步

离线同步

遗憾的是,我们的实际版本仅在互联网连接正常且正常运行时有效。在其他情况下,例如网络拥堵或数据包丢失,由于“仅限远程”同步,啤酒永远不会(或缓慢地……)添加到啤酒列表中。

避免这个问题的正确方法是保持本地优先。这意味着我们必须在本地数据库上实现所有数据库操作,然后在恢复网络访问后再将其与远程数据库同步。

互联网离线

因此,第一步是声明一个新的 PouchDB 实例,并使用数据库名称而不是远程数据库 URL。这样,PouchDB 会自动检测到我们想要实例化本地数据库。

import PouchDB from 'pouchdb';

// Declare local database
const beersDatabase = new PouchDB('beers');

// Declare remote database
const remoteBeersDatabase = new PouchDB(`http://localhost:5984/beers`);

// Keep local and remote databases in sync
PouchDB.sync(beersDatabase, remoteBeersDatabase, {
  live: true, // replicate changes in live
  timeout: false, // disable timeout
  retry: true, // retry sync if fail
});
Enter fullscreen mode Exit fullscreen mode

PouchDB.sync指令相当于PouchDB.replicate本地和远程数据库之间的双向指令。

PouchDB.replicate(beersDatabase, remoteBeersDatabase);
PouchDB.replicate(remoteBeersDatabase, beersDatabase);
Enter fullscreen mode Exit fullscreen mode

默认情况下,PouchDB 使用IndexedDB作为本地数据库(顺便说一下,就像 Firestore 一样)。现在设置已完成,我们可以使用 Chrome 控制台查看本地数据库。

索引数据库

如您所见,我们找到了已创建的完整啤酒列表。每个啤酒都由一个key_idCouchDB_rev属性唯一标识。

{
  "_id": "0c2738a3-d363-405f-b9bb-0ab6f5ec9655",
  "_rev": "3-b90bd9d62fbe04e36fe262a267efbd42",
  "title": "Beer X"
}
Enter fullscreen mode Exit fullscreen mode

表示_id一个唯一的文档,而_rev表示该文档的修订标识符。实际上,文档的每次修改都意味着一个新版本的出现,这使得管理冲突成为可能。

与 CouchDB 不同,Firestore 文档没有修订ID。因此,避免使用 Firestore 冲突的唯一方法是使用事务

此外,由于 CouchDB 记录了每个提交的更改,因此可以再次返回或解决冲突,这对于避免丢失数据至关重要。

有关使用 PouchDB 进行冲突管理的更多信息,请查看PouchDB 冲突文档

现在我们能够与本地和远程数据库通信,可以专注于业务逻辑和用户界面。此外,它还能让我们受益于乐观渲染,同时让我们的应用程序在处理网络问题时更加灵活

表格与验证

在本节中,我们将实现一个表单来添加新的啤酒。为此,我将使用final-form(以及react-final-formReactJS 的一个适配器)。

npm install -S final-form react-final-form
Enter fullscreen mode Exit fullscreen mode

因此,我们可以创建一个简单的表单来处理用户输入。

// ./src/components/BeerForm.js

import React from 'react';
import { Form, Field } from 'react-final-form';

export const BeerForm = ({ onSubmit }) => (
  <Form
    validate={() => ({})}
    onSubmit={onSubmit}
    render={({
      handleSubmit,
      hasValidationErrors,
      pristine,
      invalid,
      submitErrors,
      submitting,
      form,
    }) => (
        <form onSubmit={handleSubmit}>
         <div>
            <label>Title</label>
            <Field name="title" component="input" />
          </div>
          <div>
            <label>Description</label>
            <Field
              name="description"
              component="textarea"
              rows={2}
              placeholder="Tape your description here..."
            />
          <div/>
          <button type="submit" disabled={pristine || hasValidationErrors || submitting}>
            Submit
          </button>
          {submitErrors && submitErrors.global && (
            <p>{submitErrors.global}</p>
          )}
        </form>
      )
    }
  />
);
Enter fullscreen mode Exit fullscreen mode

然后,我们可以用主屏幕中的表单替换操作按钮。

// ./src/screens/Home.js

import React, { useState, useEffect } from 'react';
import { addBeer, getBeers, onBeersChange } from '../api/beers';

export const Home = () => {
  const [beers, setBeers] = useState([]);

  /* ... */

  return (
    <div>
      <BeerForm onSubmit={beer => queries.addBeer(beer)} />
      <ul>
        {/* beer._id is an unique id generated by CouchDB */}
        {beers.map(beer => <li key={beer._id}>{beer.title}</li>)}
      </ul>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

指示性数据验证

我们现在有了一个表单,但目前还没有数据验证。用户现在可以发送任何他们想要的内容。因此,我们要使用 (indicative我刚刚发现的一个库,我想尝试一下)来设置一个数据验证器。

npm install -S indicative
Enter fullscreen mode Exit fullscreen mode

Indicative API 非常简单。它由一个Validator使用一组验证规则的对象和一个组成formatter。以下是一个使用示例:

import Validator from 'indicative/builds/validator';
import { Vanilla as VanillaFormatter } from 'indicative/builds/formatters';
import { required, email } from 'indicative/builds/validations';

const validator = Validator({ required, email }, VanillaFormatter);

const rules = {
  name: 'required',
  email: 'required|email',
};

const messages = {
  'required': '{{ field }} field is required', // This message works for all required rules
  'email.required': 'You must provide an email!', // This message is specific for required email
  'email.email': 'The email adress is invalid',
};

const values = {
  email: 'bad email',
};

// Validator.validate is async

validator
  .validate(values, rules, messages)
  .then(() => /* everything is ok! */)
  .catch((errors) => {
    /*
      [
        { field: 'name', message: 'name field is required!' },
        { field: 'email', message: 'The email adress is invalid' },
      ]
    */
  });
Enter fullscreen mode Exit fullscreen mode

以下是我们的自定义实现BeerForm.js

// ./src/components/BeerForm.js

import React from 'react';
import { Form, Field } from 'react-final-form';
import { Vanilla } from 'indicative/builds/formatters';
import Validator from 'indicative/builds/validator';
import { required } from 'indicative/builds/validations';

const validator = Validator({ required }, Vanilla);

const rules = {
  title: 'required',
  description: 'required',
};

const messages = {
  'title.required': 'Beer title is required',
  'description.required': 'Beer description is required',
};

const validate = async values =>
  validator
    .validate(values, rules, messages)
    .then(() => ({}))
    .catch(errors => {
      return errors.reduce((acc, error) => {
        acc[error.field] = error.message;
        return acc;
      }, {});
    });
Enter fullscreen mode Exit fullscreen mode

Final Form 需要一个对象作为错误模型,因此我们使用 来格式化错误catchreduce或者,也可以使用自定义指示性格式化程序

所以,现在我们有了自定义验证函数,我们可以替换空的验证函数。

export const BeerForm = ({ onSubmit }) => (
  <Form
-  validate={() => ({})}
+  validate={validate}
Enter fullscreen mode Exit fullscreen mode

好了!我们的验证表单已启动并运行,我们可以开始使用了。

最终形式指示

让我们让它变得美丽!

总而言之,我们可以显示啤酒,可以添加啤酒,所有功能都可以离线运行,并与远程服务器同步。但是现在,它不太美观,我不敢把它展示给我岳母。那么,让它更漂亮一点怎么样?

在本节中,我将使用Elastic(开发 ElasticSearch 的公司)正在使用的Elastic UI框架(又名)。eui

我想我们都同意,必须移除这个讨厌的列表,用一个美观的网格来代替。幸运的是,Eui 轻松实现了这一点。

啤酒网格

如您所见,我们借此机会添加了直接从网格编辑和删除啤酒的功能。我们还将表单放在页面右侧的滑动面板中。这样,我们可以直接从导航栏中的“+”按钮添加啤酒,或者直接从网格编辑啤酒,而无需更改页面。

处理图像附件

我不知道你是怎么想的,但看到这些灰色的啤酒罐,我的心都碎了。所以,是时候在表单中允许上传图片了。

// ./src/components/BeerForm.js

const handleIllustration = async files => {
  if (files.length === 0) {
    form.change('_image', undefined);
    return;
  }

  const file = files[0];

  form.change('_image', {
    data: file,
    type: file.type,
  });
};

<EuiFormRow label="Beer Illustration">
  <EuiFilePicker onChange={handleIllustration} />
</EuiFormRow>
Enter fullscreen mode Exit fullscreen mode

我刚刚添加到 beer 对象的这个自定义_image属性随后由我们的 beer api 处理,并被视为PouchDB 附件

// ./src/api/queries.js

const saveBeer = async ({ _image, ...beer }) =>
  store
    .collection('beers')
    .post(beer)
    .then(
      ({ id, rev }) =>
        // if an "_image" attribute is present, we put an attachement to the document
        _image &&
        store
          .collection('beers')
          .putAttachment(id, 'image', rev, _image.data, _image.type)
    );

const getBeers = () =>
  store
    .collection('beers')
    .allDocs({
      include_docs: true,
      descending: true,
      attachments: true, // We include images in the output, so we can display them
    })
    .then(doc => doc.rows.map(row => row.doc));
};
Enter fullscreen mode Exit fullscreen mode

在 CouchDB 中,每个文件都可以直接作为 附加到其对应的文档attachement。Firestore 中不存在此概念。因此,最好使用Firebase 存储(Google Cloud Storage)通过其存储桶系统来存储文件,并在 Firestore 中存储路径。

最终申请

结论

我的啤酒注册应用程序的最终成果可以在 GitHub 上找到,地址如下:github.com/marmelab/reactive-beers。欢迎大家提出意见和改进!

虽然我一开始对 CouchDB 的功能有所怀疑,但我很快就被它的稳定性和 API 的易用性所征服。

由于我尚未在生产环境中部署此类应用程序,因此无法评价此类数据库的维护难易程度。尽管如此,我还是建议首先使用Firestore 进行 POC 测试,并首先使用CouchbaseIBM Cloudant等第三方服务来处理关键应用程序。

虽然这次经历让我能够平衡每个数据库主要功能的优缺点,但我并没有能够达到我预期的程度。

确实,我没有时间涵盖许多关键点,例如文档访问安全性权限管理服务器端文档验证数据分页部署。但无论如何,我决心就这些主题撰写更多文章。

所以,请继续关注!

文章来源:https://dev.to/juliendemageon/couchdb-the-open-source-cloud-firestore-alternative-2gc0
PREV
在 Android 上安装官方 VS Code
NEXT
使用 Express 在 Typescript 中进行 JWT 身份验证