背景
用户在网站上进行操作时,会产生一些重要的临时数据,如编辑了一半的文章块、输入中的评论等。一般来说,这些数据的生产成本是比较高的,当出现意外导致中断时,能尽可能的恢复这些临时数据可以大幅度提高用户体验。另外考虑用户多端登录的情况,可以进行“接力”操作也是一个用户体验提升点。
解决方案
目前的需要主要是高频写入,低频读取,文档写入时最好有冲突检测以避免错误覆盖,如有文档变更通知可以更好的实现多端同步功能。考虑到某些场景下文档可能较大,可较为便利的实现文档拆分,减小写入压力。
综合各方面考虑,选用CouchDB作为文档存储服务。
身份认证:CouchDB支持Proxy Authentication模式,外部服务可以根据一定的规则生成Http Headers用于登录。前期由前端直接调用身份认证服务的CouchDB token接口获取Http Headers,后期可考虑在服务端自动进行转换。
数据隔离:为每个Feat用户创建一个私有数据库,用户只能访问自己的数据库,用户数据中可以根据需要创建不同的JSON文档。
数据大小限制:目前限制一个JSON文档的大小是8M。
CouchDB部署
安装
参考 1. Installation — Apache CouchDB® 3.2 Documentation 进行安装
CouchDB服务需要向外暴露5984端口。
单节点模式请参考2.1. Single Node Setup — Apache CouchDB® 3.2 Documentation。
多节点部署模式请参考2.2. Cluster Set Up — Apache CouchDB® 3.2 Documentation。
下面的配置文件范例:
[chttpd_auth]
; proxy authentication secret. shared by CouchDB and feat accounts service.
secret = 609e42e49def19dcb2428027d95b9db7
require_valid_user = true
proxy_use_secret = true
[cors]
; cors origins. * is bad, set all frontend domain names here.
origins = *
headers = accept, authorization, content-type, origin, referer, X-Auth-CouchDB-UserName, X-Auth-CouchDB-Token
methods = GET, PUT, POST, HEAD, DELETE
credentials = true
[httpd]
enable_cors = true
[couch_peruser]
enable = true
[chttpd]
authentication_handlers = {chttpd_auth, cookie_authentication_handler},{chttpd_auth, proxy_authentication_handler},{chttpd_auth, default_authentication_handler}
放到相应的配置目录后重启服务。
反向代理
CouchDB 3做了一些安全方面的限制,导致Proxy Authentication登录的用户没有创建对应的用户账号。我们在CouchDB接口上做一些tricky的处理以便实现用户可以自建账号和数据库。
map $request_method $proxy_method {
POST PUT;
default $request_method;
}
server {
# port for elb
listen 5984;
# server name. maybe couchdb.feat.com
server_name localhost;
set $couchdb_base "http://localhost:5984";
error_page 418 = @normal;
error_page 420 = @newuser;
recursive_error_pages on;
location @normal {
# proxy to couchdb
proxy_pass $couchdb_base;
proxy_redirect off;
proxy_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Auth-CouchDB-Roles "";
}
# HACK: set roles = _admin when create user
location @newuser {
set $the_username "$http_x_auth_couchdb_username";
proxy_pass "$couchdb_base/_users/org.couchdb.user:$the_username";
proxy_redirect off;
proxy_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Auth-CouchDB-Roles _admin;
proxy_method $proxy_method;
set $body '{"name": "${the_username}", "roles": [], "type": "user"}';
proxy_set_body $body;
}
location / {
return 418;
}
location ~ /_users/org.couchdb.user:(?<username>.+) {
set $the_username "$http_x_auth_couchdb_username";
if ($the_username != $username) {
return 418;
}
if ($request_method != PUT) {
return 418;
}
return 420;
}
location /_users/create-new-user {
if ($request_method !~* (POST|OPTIONS)) {
return 405;
}
return 420;
}
}
在elb上建议配置https和http2支持,可以使用标准443端口。
环境
Feat | Dev | |
ACCOUNT_SERVICE_HOST | https://accounts.feat.com | https://117.local.feat.com |
COUCH_DB_HOST | https://couchdb.feat.com | https://10.0.10.117:5984 |
使用流程
- 调用
https://accounts.feat.com/couchdb/token/
获取 CouchDB 身份认证信息,以及分配的用户数据库名称 - 用户首次使用 CouchDB 时,需要调用
https://couchdb.feat.com/_users/org.couchdb.user:{uid}
,以完成用户账号和用户数据库的创建 - 调用 CouchDB API,如
GET https://couchdb.feat.com/{dbname}
获取数据库信息
API
获取Token
GET https://accounts.feat.com/couchdb/token/
Headers: Authorization
Response
200 获取成功
{
"data": {
"headers": {
"X-Auth-CouchDB-UserName": "4222704093994",
"X-Auth-CouchDB-Token": "2197693d9ab1c56bf45633f6326d0078d670fd1d"
},
"dbname": "userdb-34323232373034303933393934"
}
}
401 未授权
创建账号和数据库
PUT https://couchdb.feat.com/_users/org.couchdb.user:{uid}
Headers:
// 从 https://accounts.feat.com/couchdb/token/ 获取
{
"X-Auth-CouchDB-UserName": "4222704093994",
"X-Auth-CouchDB-Token": "2197693d9ab1c56bf45633f6326d0078d670fd1d"
}
Body: 可参考 CouchDB 的接口文档
说明:目前这个接口的请求会在反向代理 中被覆盖,以下为 body 示例:
{
"name": "4222704093994",
"type": "user",
"roles": []
}
Response
201 创建成功
401 未授权,headers错误
409 用户已存在
Alternative
POST https://couchdb.feat.com/_users/create-new-user
Headers:同上
Body:无
Response:同上
文档的增删改查
GET/PUT/DELETE https://couchdb.feat.com/{dbname}/{docid}
参考文档 1.4. Documents — Apache CouchDB® 3.2 Documentation
示例代码
JS示例
可以使用CouchDB客户端进行连接,比如下面是PouchDB的例子:
var PouchDB = require('pouchdb');
class MyPouchDB extends PouchDB {
constructor (db, opts) {
opts.fetch = function(url, options) {
options.credentials = 'omit';
options.headers = {...options.headers, ...(opts.headers || {})};
return PouchDB.fetch(url, options);
}
opts.skip_setup = true;
super(db, opts);
}
setupPromise = undefined;
ensure_user_and_db () {
// 这个函数只支持http、https adapter,如果要同时使用PouchDB的其他adapter需要做兼容处理。
if (this.setupPromise) {
return this.setupPromise;
}
this.setupPromise = this.info().then(res => {
if (res && !res.error) {
return Promise.resolve(res);
}
const err = res;
if (err && err.error && err.error === 'not_found') {
return this.signUp();
} else {
return Promise.reject(err);
}
}).catch(function (err) {
if (err && err.status && err.status === 412) {
return true;
}
return Promise.reject(err);
})
this.setupPromise.catch(() => {
this.setupPromise = null;
});
return this.setupPromise;
}
signUp () {
const username = this.__opts.headers['X-Auth-CouchDB-UserName'];
const user = {
_id: `org.couchdb.user:${username}`,
name: username,
roles: [],
type: 'user',
};
const getBaseUrl = function (db) {
const prebase = db.substr(-1, 1) === '/' ? db.substr(0, -1) : db;
return prebase.split('/').slice(0, -1).join('/');
}
const usersDb = new this.constructor(getBaseUrl(this.name) + '/_users', this.__opts);
return usersDb.put(user)
}
}
class PouchDBStorage {
constructor(db, options = {}) {
if (typeof db !== "string" && options == {}) {
this.db = db;
} else {
this.db = new MyPouchDB(db, options);
}
this.docRevs = {};
}
async getItem(key) {
await this.db.ensure_user_and_db();
try {
const doc = await this.db.get(key);
this.docRevs[key] = doc._rev;
return JSON.stringify(doc.doc);
} catch (err) {
if (err && err.status && err.status === 404) {
return undefined;
} else {
throw err;
}
}
}
async setItem(key, value) {
await this.db.ensure_user_and_db();
const doc = JSON.parse(value);
const _rev = this.docRevs[key];
try {
const result = await this.db.put({ _id: key, _rev, doc });
this.docRevs[key] = result.rev;
return result;
} catch (err) {
if (err && err.status && err.status === 409) {
// TODO: 版本冲突,需要重新获取最新版本并进行处理。这里的例子是覆盖远端修改。
const new_value = await this.getItem(key);
// value = merge(value, new_value)
return await this.setItem(key, value);
} else {
throw err;
}
}
}
async removeItem(key) {
await this.db.ensure_user_and_db();
if (!this.docRevs[key]) {
// 删除一个本地没有的key,也许是搞错了什么?这里只是简单获取最新版本并删除。
const v = await this.getItem(key);
if (!v) {
return;
}
}
// TODO: 跟setItem类似,这里也可能出现版本冲突,需要进行处理。
await this.db.remove({ _id: key, _rev: this.docRevs[key] });
delete this.docRevs[key];
}
}
const getDbToken = async function (auth) {
const ret = await fetch('http://127.0.0.1:8123/couchdb/token/', {headers: new Headers({"Authorization": auth})})
return await ret.json()
}
const setupStorage = async function (opts) {
const {data} = await getDbToken(opts);
if (!data) {
// TODO
return;
}
const {headers, dbname} = data;
const storage = new PouchDBStorage('http://127.0.0.1:5984/'+dbname, {headers});
return storage;
}
const storage = await setupStorage("Bearer qAg3jdtuCUB4dWuc8d5pD81PnpCqUX");
await storage.getItem('test') === undefined;
await storage.setItem('test', '{"test": 1}');
await storage.getItem('test') === '{"test": 1}';
await storage.removeItem('test');
await storage.getItem('test') === undefined;
拆分对象
更进一步的,如果想更优雅的处理多个对象的数据,减少每次提交的数据量和冲突,或者当一个对象大于8MB的时候,可以把一个cache key下的多个对象分别存储:
- 使用key:subkey作为文档id
- 使用find接口(pouchdb-find)或allDocs接口(匹配id前缀)进行批量获取
- 单独管理每个子对象的版本
TBD
Redux Persist
通过类似 Redux Persist 的库插入state的持久化处理,使业务代码不需要考虑持久化的问题。
多端同步
通过 MyPouchDB.changes 接口可以监听其他端的文档变更,实现多端同步功能。
通过 MyPouchDB.sync 可实现local first开发。
TBD