CouchDB,开源 Cloud Firestore 的替代品?
注意:这篇文章最初发布在marmelab.com上。
在我们上一个客户项目中,我们使用了Google 的后端即服务Firebase作为后端。虽然我们对这个“全包式”套件的整体功能感到满意,但其专有性方面仍让我们感到失望。
这就是为什么我主动寻找 Firebase 的开源替代品,它可以满足我们所有的需求,而无需依赖第三方服务。
这一任务的第一步是找到用于网络的Cloud Firestore实时 NoSQL 数据库的替代品。
我们需要什么?
使用 Firestore 而非更传统的数据库并非易事。这通常是因为需要快速开发具有以下功能的应用程序:
- 离线首先,客户端写入与远程数据库同步的本地数据库
- 实时远程更改必须与我们的本地数据库同步
有一些解决方案可以满足这一需求,其中大多数基于NoSQL 数据库,例如MongoDB、Cassandra、RethinkDB、Gun或其他基于 MongoDB 的解决方案,例如Minimongo、turtleDB或tortoiseDB。
在我们的例子中,我们将尝试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-Form、ElasticUI和Indicative。
在这个项目中,我将创建一个啤酒登记处,让用户可以跟踪他们的啤酒库存。
项目设置
为了使本教程尽可能简单,我将使用create-react-app创建一个 ReactJS 应用程序。
create-react-app reactive-beers && cd reactive-beers
npm install -S pouchdb
应用程序骨架如下所示:
julien@julien-P553UA:~/Projets/marmelab/reactive-beers$ tree -L 1
.
├── node_modules
├── package.json
├── package-lock.json
├── public
├── README.md
└── src
然后,由于我不想直接在我的计算机上安装 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
# ./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
因此,我们现在准备开始使用我们的完整堆栈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
一切已启动。您可能已经注意到,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"
}
}
访问文档存储
好的,我们的服务器已启动并运行。但是,是否有像 Firestore 一样的界面来可视化/监控CouchDB 呢?答案是肯定的!CouchDB 已经包含一个名为 的管理界面Fauxton
。我们可以在 上浏览它http://localhost:5984/_utils/
。
该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;
该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>
);
};
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
});
});
因此,如果我们改用 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();
};
}, []);
// ...
也可以使用.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);
这是我们第一个 CouchDB 应用程序的运行结果。如您所见,所有内容在多个窗口之间同步。
离线同步
遗憾的是,我们的实际版本仅在互联网连接正常且正常运行时有效。在其他情况下,例如网络拥堵或数据包丢失,由于“仅限远程”同步,啤酒永远不会(或缓慢地……)添加到啤酒列表中。
避免这个问题的正确方法是保持本地优先。这意味着我们必须在本地数据库上实现所有数据库操作,然后在恢复网络访问后再将其与远程数据库同步。
因此,第一步是声明一个新的 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
});
该PouchDB.sync
指令相当于PouchDB.replicate
本地和远程数据库之间的双向指令。
PouchDB.replicate(beersDatabase, remoteBeersDatabase);
PouchDB.replicate(remoteBeersDatabase, beersDatabase);
默认情况下,PouchDB 使用IndexedDB作为本地数据库(顺便说一下,就像 Firestore 一样)。现在设置已完成,我们可以使用 Chrome 控制台查看本地数据库。
如您所见,我们找到了已创建的完整啤酒列表。每个啤酒都由一个key
和_id
CouchDB_rev
属性唯一标识。
{
"_id": "0c2738a3-d363-405f-b9bb-0ab6f5ec9655",
"_rev": "3-b90bd9d62fbe04e36fe262a267efbd42",
"title": "Beer X"
}
表示_id
一个唯一的文档,而_rev
表示该文档的修订标识符。实际上,文档的每次修改都意味着一个新版本的出现,这使得管理冲突成为可能。
与 CouchDB 不同,Firestore 文档没有修订ID。因此,避免使用 Firestore 冲突的唯一方法是使用事务。
此外,由于 CouchDB 记录了每个提交的更改,因此可以再次返回或解决冲突,这对于避免丢失数据至关重要。
有关使用 PouchDB 进行冲突管理的更多信息,请查看PouchDB 冲突文档。
现在我们能够与本地和远程数据库通信,可以专注于业务逻辑和用户界面。此外,它还能让我们受益于乐观渲染,同时让我们的应用程序在处理网络问题时更加灵活。
表格与验证
在本节中,我们将实现一个表单来添加新的啤酒。为此,我将使用final-form
(以及react-final-form
ReactJS 的一个适配器)。
npm install -S final-form react-final-form
因此,我们可以创建一个简单的表单来处理用户输入。
// ./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>
)
}
/>
);
然后,我们可以用主屏幕中的表单替换操作按钮。
// ./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>
);
};
指示性数据验证
我们现在有了一个表单,但目前还没有数据验证。用户现在可以发送任何他们想要的内容。因此,我们要使用 (indicative
我刚刚发现的一个库,我想尝试一下)来设置一个数据验证器。
npm install -S indicative
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' },
]
*/
});
以下是我们的自定义实现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;
}, {});
});
Final Form 需要一个对象作为错误模型,因此我们使用 来格式化错误catch
。reduce
或者,也可以使用自定义指示性格式化程序。
所以,现在我们有了自定义验证函数,我们可以替换空的验证函数。
export const BeerForm = ({ onSubmit }) => (
<Form
- validate={() => ({})}
+ validate={validate}
好了!我们的验证表单已启动并运行,我们可以开始使用了。
让我们让它变得美丽!
总而言之,我们可以显示啤酒,可以添加啤酒,所有功能都可以离线运行,并与远程服务器同步。但是现在,它不太美观,我不敢把它展示给我岳母。那么,让它更漂亮一点怎么样?
在本节中,我将使用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>
我刚刚添加到 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));
};
在 CouchDB 中,每个文件都可以直接作为 附加到其对应的文档attachement
。Firestore 中不存在此概念。因此,最好使用Firebase 存储(Google Cloud Storage)通过其存储桶系统来存储文件,并在 Firestore 中存储路径。
结论
我的啤酒注册应用程序的最终成果可以在 GitHub 上找到,地址如下:github.com/marmelab/reactive-beers。欢迎大家提出意见和改进!
虽然我一开始对 CouchDB 的功能有所怀疑,但我很快就被它的稳定性和 API 的易用性所征服。
由于我尚未在生产环境中部署此类应用程序,因此无法评价此类数据库的维护难易程度。尽管如此,我还是建议首先使用Firestore 进行 POC 测试,并首先使用Couchbase或IBM Cloudant等第三方服务来处理关键应用程序。
虽然这次经历让我能够平衡每个数据库主要功能的优缺点,但我并没有能够达到我预期的程度。
确实,我没有时间涵盖许多关键点,例如文档访问安全性、权限管理、服务器端文档验证、数据分页或部署。但无论如何,我决心就这些主题撰写更多文章。
所以,请继续关注!
文章来源:https://dev.to/juliendemageon/couchdb-the-open-source-cloud-firestore-alternative-2gc0