NodeJS 中基于 Redis 的分布式锁解决方案。

NodeJS 中基于 Redis 的分布式锁解决方案。

前言0

在分布式系统中,会经常碰到两个问题:

  • 互斥性问题。
  • 幂等性问题。

分布式锁

互斥性问题的解决方案是 – 分布式锁;
幂等性问题,分布式环境中,有些接口是天然保证幂等性的,如查询操作。其他情况下,所有涉及对数据的修改、状态的变更就都有必要防止重复性操作的发生。通过间接的实现接口的幂等性来防止重复操作所带来的影响,成为了一种有效的解决方案。这个间接方案也可以使用分布式锁来实现。

分布式锁条件

基本的条件:

  • 需要有存储锁的空间,并且锁的空间是可以访问到的。
  • 锁需要被唯一标识。
  • 锁要有至少两种状态。

使用 Redis 完全可以满足上述条件。

解决方案

简单的解决方案

通常在 Node 中一个简单的基于 Redis 的解决方案是这样的:
发送 SETNX lock.orderid 尝试获得锁。
如果锁不存在,则 set 并获得锁。
如果锁存在,则跳过此次操作或者等待一下再重试。

SETNX 是一个原子操作,可以保证只有一个节点会拿到锁。

Redis 推荐的方案

上述的方案中,还是有点问题,在 Redis 出现单点故障,例如 master 节点宕机了,而 Redis 的复制是异步的,可能出现以下情况:

  1. 客户端 A 在 master 节点拿到了锁。
  2. master 节点在把 A 创建的 key 写入 slave 之前宕机了。
  3. slave 变成了 master 节点 4.B 也得到了和 A 还持有的相同的锁(因为原来的 slave 里还没有 A 持有锁的信息)

所以为了避免上述问题,Redis 官方给出了更加可靠的实现 Distributed locks with Redis
中译文版本 用 Redis 构建分布式锁,这个方案提出了一个Redlock 算法,文章里有详细解析,这里就不赘述了。

Redlock 的实现

Redlock 有针对不同语言的实现,NodeJS 的实现是node-redlock
官网给出的一个例子:

// the string identifier for the resource you want to lock
var resource = 'locks:account:322456';

// the maximum amount of time you want the resource locked,
// keeping in mind that you can extend the lock up until
// the point when it expires
var ttl = 1000;
redlock.lock(resource, ttl, function(err, lock) {
// we failed to lock the resource
if(err) {
// ...
}
// we have the lock
else {
// ...do something here...
// unlock your resource when you are done
lock.unlock(function(err) {
// we weren't able to reach redis; your lock will eventually
// expire, but you probably want to log this error
console.error(err);
});
}
});

使用起来还是比较简单的,在操作开始之前 lock 并设置 ttl,操作完成后 unlock 即可。

包装一下 redlock

如果要给某些个接口或者 function 添加一个 lock 的话,直接修改接口是件很麻烦的事情,所以我们可以把 redlock 稍微封装一下。

/**
* 锁操作
* @param user_id
* @param operate
* @param process
* @param callback
* @param options
*/

lockProcess: function (user_id, operate, process, callback, options) {
var lock = null
Thenjs(function (cont) {
var ttl = options ? options.ttl : null;
self.lock(user_id, operate, ttl, function (err, clock) {
// 加锁失败直接退出
if (err) return callback(err);
lock = clock
cont()
});
}).then(function (cont) {
// 实际的业务逻辑
process(cont);
}).then(function (cont, result) {
self.unlock(lock, function (err) {
cont(err, result)
});
}).then(function (cont, result) {
callback(null, result)
}).fail(function (cont, err) {
// 操作失败记得解锁
self.unlock(lock, function (unlockErr) {
callback(err || unlockErr)
});
});
}

这样我们可以直接把之前的代码包装一下就能用,就不需要侵入之前的业务逻辑了。

LockService.lockProcess(order.user_id, 'dsPurchase', function (innerCb) {
self.dsPurchase(order, innerCb); // 业务逻辑
}, function (err, result) {
callback(err, result);
});

假设你要针对一个接口加锁的话,还可以新建一个过滤器,然后给接口配置了过滤器就自动加上锁啦。如果是使用 express 的话,可以参考如何修改sails 的 response来设计过滤器,在 response 的时候解锁即可。

参考

node-redlock
Distributed locks with Redis
用 Redis 构建分布式锁
分布式系统互斥性与幂等性问题的分析与解决

记录一个 NPM 包冲突的问题

记录一个 NPM 包冲突的问题

问题

项目中使用 sails 这个 Web 框架,sails 集成的 orm 是 waterline ,waterline 针对 mongo 的适配器是 sails-mongo,最近线上爆了一个 BUG,sails-mongo 在执行 native 方法时报错 :

TypeError: Argument must be a string
at TypeError (native)
at Buffer.write (buffer.js:791:21)
at serializeObjectId (node_modules/sails-mongo/node_modules/bson/lib/bson/parser/serializer.js:242:10)
at serializeInto (node_modules/sails-mongo/node_modules/bson/lib/bson/parser/serializer.js:699:17)
at serialize (node_modules/sails-mongo/node_modules/bson/lib/bson/bson.js:47:27)
at Query.toBin (node_modules/sails-mongo/node_modules/mongodb-core/lib/connection/commands.js:143:25)
at Cursor._find (node_modules/sails-mongo/node_modules/mongodb-core/lib/cursor.js:339:36)
at proto.(anonymous function) (node_modules/sails-mongo/node_modules/mongodb/lib/apm.js:547:16)
at proto.(anonymous function) (node_modules/sails-mongo/node_modules/mongodb/lib/apm.js:547:16)
at proto.(anonymous function) (node_modules/sails-mongo/node_modules/mongodb/lib/apm.js:547:16)
at proto.(anonymous function) (node_modules/sails-mongo/node_modules/mongodb/lib/apm.js:547:16)
at proto.(anonymous function) (node_modules/sails-mongo/node_modules/mongodb/lib/apm.js:547:16)
at proto.(anonymous function) (node_modules/sails-mongo/node_modules/mongodb/lib/apm.js:547:16)
at proto.(anonymous function) [as _find] (node_modules/sails-mongo/node_modules/mongodb/lib/apm.js:547:16)
……

解决问题

针对这个问题 Google 了一下,已经有人发现了这个问题并在 sails-mongo 的 github 项目里提了 issue 了, https://github.com/balderdashy/sails-mongo/issues/415
查看这个 issue 可以知道,问题原因是项目中同时使用了 sails-mongo 和 mongo 两个包,在 sails-mong 的版本为 0.12.0 的情况下, mongodb 这个包升级到 2.2.0 以上就会导致报错。
issue 里给出的解决方案是:

分析

OK, 问题解决后分析下两个包出现冲突的原因。
业务代码是这样写的:

var ObjectId = require('mongodb').ObjectID;

...

query.push({
$match: {
$or: _.map(posts, function(post) {
return {
_id: { $eq: ObjectId(post.id) },
status: { $ne: 'trash' }
}
})
}
});

这里把 mongodb(2.2.x) 的 ObjectId 对象传给了 sails-mongo 的处理逻辑中,导致了最终的报错。
sails-mongo 里也引用了 mongodb 这个库,版本是 2.1.3

这个版本与外部的 mongodb 库 2.2.x 版本有冲突,看来 mongodb 这个库在 2.2.x 升级了某些东西,我们进一步追查,根据报错信息可以定位报错的代码

at serializeObjectId
(node_modules/sails-mongo/node_modules/bson/lib/bson/parser/serializer.js:242:10)

看来是 bson 这个库里有问题,这个是 sails-mongo 里的 bson(0.4.23) 的代码的报错位置

在 242 行加上断点,可以看到 value 这个变量的情况:

而 mongodb 里引用的 bson(0.5.6) 的此处代码是这样的

加断点看到的变量情况:

看来是 ObjectID 的类型变了, 由之前的 String 变成了 Uint8Array ,这也就契合了报错信息里的 “TypeError: Argument must be a string”, 这就是问题的根源。

总结

这个问题初看好像是 npm 包版本导致的冲突(NPM 其实已经处理好了不同版本的依赖问题),但是实际上是代码应用层使用不当导致的问题,根据 issue 提供的解决方案,要获取 ObjectID 对象,应该使用 sails-mongo 来提供这个对象:

const ObjectId = require('sails-mongo').mongo.objectId

而不是从外部获取:

var ObjectId = require('mongodb').ObjectID;

其实是不是可以说是 sails-mogno 坑?


11/18日更新:
哈哈哈, sails-mogno 果然是坑,看看它提供的 objectId 的实现:

如果你想要生成一个新的 ObjectID,这样写的话

var id = ObejctId()

你将得到一个 null !
无语呢,所以呢,还是要这样引用才行:

var ObjectID = require('sails-mongo/node_modules/mongodb').ObjectID;

使用 Winston 处理 Node 应用 Log

使用 Winston 处理 Node 应用 Log

问题

随着 Node 应用节点增多,应用部署在多台机器上,查询 Node 的 log 变得非常麻烦。业内通常的解决方案是使用 ELK 套件来收集并处理 log,可以方便地查询。关于 ELK,可以看看这篇文章:https://www.ibm.com/developerworks/cn/opensource/os-cn-elk/,本文不作介绍~。
在推动使用 ELK 的过程中碰到一些问题:

  1. 之前的代码中很多地方直接是使用 console.log 输出 log 的。这些 log 之前是依赖于 pm2 处理并写入到文件中。
  2. 如果 log 一个 object 对象的话,默认的输出效果是多行的,ELK 在收集会比较麻烦。

amount: 23, products: [ { product_id: ‘55c31e936f227ed922c508aa’,
num: 23,
rebuyType: 1,
price: 1 } ],

初步的解决方案

问题1:全局搜索并替换 console.log 的内容,使用新的 log 工具是可以的。
问题2:ELK 中的 Logstash 提供了一个多行文件的过滤器,https://www.elastic.co/guide/en/logstash/5.0/plugins-filters-multiline.html ,这个过滤器可以把多行 log 合并为一条记录,但是它要求使用正则来判断哪些多行 log 是属于一行的内容,这是个难点。

最终解决方案

上述的方案实施起来都不怎么好看,可以考虑使用 Winston 这个Node 的 log 库。

引入 Winston

WinstonLog.js


var winston = require('winston');
var customLogger = new winston.Logger();

// A console transport logging debug and above.
customLogger.add(winston.transports.Console, {
timestamp: function() {
return Date.now();
},
level: 'verbose',
colorize: true
});

// A file based transport logging only errors formatted as json.
customLogger.add(winston.transports.File, {
level: 'verbose',
filename: 'winston.log',
json: true
});
module.exports = customLogger

这是个 Winston 配置的 demo,要找到合适你项目的 Winston 的配置方式可以查看 Winston 文档。

回到这个配置文件,重点在 winston.transports.File 的配置:
json: true
这样写入 log 文件里的内容都是 json 对象了,winston.log 的部分内容是这样的:

{“level”:”info”,”message”:”getRateMap”,”timestamp”:”2016-11-16T04:31:55.882Z”}
{“level”:”info”,”message”:”defaultRateUp get
map”,”timestamp”:”2016-11-16T04:31:55.883Z”}
{“level”:”info”,”message”:”each products { quota: 20000,n name:
”,n term: 1,n type: 2,n description: ”,n id:
‘549922452238c54e98b750bc’,n rate_year: null,n product_id:
‘549922452238c54e98b750bc’,n _id: ‘549922452238c54e98b750bc’,n
rate: null,n rate_up: 0 }”,”timestamp”:”2016-11-16T04:31:55.884Z”}

可以看到,每一行都是一个 json,结构是:

{
“level” : “”,
“message” : “****
}

这样ELK 在收集 log 就会很方便了。

而配置中 winston.transports.Console 里是没有 json: true 的配置项的,所以我们在命令行里看到的 log 还是换行的,比较适合阅读。

替换项目中的 log

console.log

搞定了log 文件格式的问题,我们还需要把项目里的 console.log 替换为 Winston 的 log。

可以在应用的启动位置 app.js 添加如下内容:

var winston = require('./api/lib/WinstonLog')
console.log = winston.info
console.error = winston.error

这样就把 console.log 的实现替换了。

web 框架默认的 log

因为这边使用的是 sails 这个 web 框架,所以这里讲一下 sails 里如何替换 log 的实现。
sails 默认集成的 log 库是 captains-log ,那如何替换成 Winston 呢?sails 的官方文档其实已经给了方法了~~~, 见http://sailsjs.org/documentation/reference/configuration/sails-config-log

修改 log 配置

// config/log.js
var customLogger = require('../api/lib/WinstonLog')
module.exports.log = {
// Pass in our custom logger, and pass all log levels through.
custom: customLogger,
level: 'verbose',

// Disable captain's log so it doesn't prefix or stringify our meta data.
inspect: false
};

用刚刚引入的 Winston 对象注入到 sails 的 log 配置里就可以啦。

用 sails、jade 和 angular 写个 CRUD

用 sails、jade 和 angular 写个 CRUD

前言

在做运营活动的时候,要提供一个管理界面给运营使用,经常要用到一些 CRUD 的功能,所以就想着写个通用的模板,目标是:开发填好一个 model 的配置,一个管理页面就出来了。极大地提高生产力。

怎么配置

  1. 新建一个 sails 的 model,就叫 Foo.js 按照规则定义好每个一个字段。见 Demo
  2. 新建一个与 model 名字对应 sails 的 controller: FooController

    你也可以使用 sails 的生成器命令 sails generate api Foo 这样可以一次生成 Model 和 Controller 文件。

  3. 访问 http://localhost:8080/foo/crud,一个管理界面就出来。访问 http://localhost:8080/foo/crud_s 则是另一种交互风格的管理页面。

  4. 长这样

原理

后端使用 jade 模板来渲染 html,访问 http://localhost:8080/foo/crud 其实就是访问 crud.jade 这个 view 文件。后端读取了对应 model 的配置,并把属性配置注入到 jade 模板,然后生成对应的 table,和编辑框。

前端 html 的数据绑定工作是借助 angular 来实现的。

一个 Model 的Demo

/**
* 电商化产品
*/

module.exports = {
title: '电商产品',
sort: 'no',
attributes: {
no: {type: 'string', name: '编号', required: true},
name: {type: 'string', name: '名字', required: true},
name_detail: {type: 'string', name: '详细名字', required: true},
ram: {type: 'integer', name: '内存'},
icon_src: {type: 'string', image: true, name: '缩略图', required: true},
list_src: {type: 'string', image: true, name: '列表图', required: true},
detail_src: {
type: 'array', name: '细节图', attributes: {
name: {type: 'string', image: true, name: '细节图'}
}
},
description: {type: 'string', textarea: true, name: ' 描述'},
tags: {
type: 'array', name: '标签', required: true, attributes: {
name: {type: 'string', name: '标签'}
}
},
prices: {
type: 'array', name: '套餐信息', required: true, attributes: {
color: {type: 'string', name: '颜色'},
month: {type: 'integer', name: '月份'},
price: {type: 'string', name: '市场价'},
trans_amount: {type: 'string', name: '转入金额'},
earning: {type: 'string', name: '另享收益'},
earning_real: {type: 'string', name: '实质收益'}
}
}
}
};

module.exports = require('../lib/crud').fixModel(module.exports)

title 是这个页面的 title
sort 决定列表数据的排序方式
最后记得用 crud 的 fixModel 方法来处理这个 model(原因是 sails 不支持你这样随便修改 model)

一个 Control 的 Demo

var crud = require('../lib/crud')
module.exports = {};
module.exports = require('../lib/crud').extendController(module.exports)

这里引入了 crud 的方法,用来给这个 controller 注入页面渲染的功能。

Model 的定义

可以给 model 的字段定义以下类型,在前端会被翻译成对应的控件

  • string
    文本类型 前端对应的是文本输入框
  • integer/float
    数字类型 前端是 html5的文本输入框 number 类型
  • 长文本
    添加一个 textarea:true 属性即可,前端会展示成一个文本框。
  • 日期类型
    type=date 前端是 html5的 date 类型的文本输入框
  • 选择框
    需要添加一个 options 属性,描述每个 option 的 key and value
    这样前端就能构造一个 选择框了。
compare: {
name: '', type: 'string', options: [
{value: '-2', name: '小于等于'
},
{value: '-1', name: '小于'},
{value: '0', name: '等于'},
{value: '1', name: '大于'},
{value: '2', name: '大于等于'}
]
},
  • 图片地址
    某个字段如果是保存图片地址的话,配置一个属性 image:true
    在编辑和展示这个字段都能看到图片预览

  • object 需要继续定义 object 里有哪些类型的数据
    在前端会把 object 的子内容铺开。

price: {
name: '套餐', type: 'object', readOnly: true, attributes: {
"month": {type: 'string', name: '月份'},
"color": {type: 'string', name: '颜色'}
}
},
  • array 同 object 需要继续定义数据类型。
    在前端会以一个子 table 的形式来展开。

编辑的时候是这样的:

prices: {
type: 'array', name: '套餐信息', required: true, attributes: {
color: {type: 'string', name: '颜色'
},
month: {type: 'integer', name: '月份'},
price: {type: 'string', name: '市场价'},
trans_amount: {type: 'string', name: '转入金额'},
earning: {type: 'string', name: '另享收益'},
earning_real: {type: 'string', name: '实质收益'}
}
}

代码

crud.jade 里的 extends layout2 里引用了 css 和 js 文件,这里不便公开。
https://github.com/myfjdthink/CodeNode/tree/master/crud

TODO

分页功能
导出 Excel 功能
在 model 字段比较多的时候提供一个更好的编辑交互。

总结

本文只是提供一个实现 CRUD 思路,你可以根据你的实际项目来替换使用。
REST 接口可以很多 Web 框架都会提供。
视图模板除了 Jade 还有 ejs 等。
前端的数据绑定也可以考虑使用 Vue。

JS 里怎么给数组填充默认值?

JS 里怎么给数组填充默认值?

今天看到一段代码:

Array.apply(null, Array(30)).map(() => 4)

这代码的写法无法让人一下理解它的意图。
Google 之后知道它的作用是构造一个长度为 30 的数组,默认值是 4。

解析

但是为什么要写得这么别扭呢?我们来分解下它每一步在做什么:

Array.apply(null, Array(30)) 

这一段代码生成一个长度为30的数组,里面的值都是 undefined
之后的 .map(() => 4) :负责填充默认值

但是为什么构造一个空值数组需要这么麻烦呢?还要用上 apply 方法,尝试用 Array(30).map(() => 4) 来生成数组的话,你会得到这样的一个结果,根本就没有值。

[ , , , , , , , , , , , , , , , , , , , , , , , , , , , , , ]

查看文档 可以看到 Array 的构造函数语法,可以得知 Array 支持两种构造方式。使用参数形式给定 N 个数组元素,或者给定一个数组长度。


不过比较重要的一点文档里没提到,使用 new Array(arrayLength) 方式构造的数组是一个稀疏数组,里面是没有任何值的,只有长度。所以这个方式构造出来的数组是无法遍历的,也就无法用 map 遍历填充值了。

知道了上述的原因,我们就能理解:

Array.apply(null, Array(30))
其实等于
Array.apply(null, [, , , , , , , , , , , , , , , , , , , , , , , , , , , , ,]))

然后我们要继续了解 apply 方法,在这里可以看 apply 的作用 文档解释, 这里不作介绍。apply 方法会把生成的稀疏数组展开并当做参数再次传给 Array 的构造函数,就是这样子:

Array(null,null,null......))

这样最终就会得到一个数组,这样就不是稀疏数组了,里面是有值的,虽然是 undefined。

[ undefined, undefined, undefined ……]

结语

总结下,其实就是 js 的 Array 的默认构造函数生成的是稀疏数组,是无法用 map 遍历填充的。所以才写得这么绕。

不过,说了这么多,要实现原本的需求,其实有更简单的方法啦:

Array(30).fill(4)

fill 方法的说明

在 Node 里使用使用 this 的问题

在 Node 里使用使用 this 的问题

this 指向不正确

Node 在 ES2016 里引入了 Class,终于不用使用 function 来模拟 class 了,最近在使用 Class 的时候碰到个关于 this 指向的小问题,记录下。
来看一段代码,两个文件:
test_a.js

const test_b = require('./test_b')
const funcs = {
func_a: test_b.func_a
}
funcs.func_a()

test_b.js

'use strict';
class B {
func_a() {
console.log("I'm func_a");
this.func_b()
}

func_b() {
console.log("I'm func_b");
}
}
module.exports = new B()

执行 test_a,js,会报错

/usr/local/bin/node test_a.js I’m func_a
/Users/nick/nodePro/fin_base/api/models/test_b.js:5
this.func_b()
^

TypeError: this.func_b is not a function
at Object.func_a (/Users/nick/nodePro/fin_base/api/models/test_b.js:5:10)
at Object. (/Users/nick/nodePro/fin_base/api/models/test_a.js:6:7)
at Module._compile (module.js:409:26)

原因分析

因为在把 func_a 赋值给 funcs 后,func_a 里的 this 指向的的是 test_a 文件里的 funcs 对象,而不是我们要预期的执行 test_b 文件里的 class B 实例。

所以我们把赋值的代码稍微改一下:

func_a: function () {
return test_b.func_a()
}

这样就可以了,如果要要传递参数,可以写成这样:

func_a: function () {
return test_b.func_a.apply(test_b, arguments)
}