yc-acm-client
Version:
aliyun acm client
579 lines (530 loc) • 15.5 kB
JavaScript
'use strict';
const debug = require('debug')('diamond-client');
const co = require('co');
const path = require('path');
const assert = require('assert');
const Base = require('sdk-base');
const is = require('is-type-of');
const utils = require('./utils');
const gather = require('co-gather');
const Constants = require('./const');
const { sleep } = require('mz-modules');
const crypto = require('crypto');
const DEFAULT_OPTIONS = {
serverPort: 8080,
refreshInterval: 30 * 1000, // 30s
requestTimeout: 5000,
unit: Constants.CURRENT_UNIT,
ssl: true,
};
class DiamondEnv extends Base {
/**
* Diamond Client.
*
* @param {Object} options
* - {Number} [refreshInterval] data refresh interval time, default is 30000 ms
* - {Number} [requestTimeout] diamond request timeout, default is 5000 ms
* - {String} [unit] unit name
* - {HttpClient} httpclient http request client
* - {Snapshot} snapshot snapshot instance
* @constructor
*/
constructor(options = {}) {
assert(options.httpclient, '[DiamondEnv] options.httpclient is required');
assert(options.snapshot, '[DiamondEnv] options.snapshot is required');
assert(options.serverMgr, '[DiamondEnv] options.serverMgr is required');
super(Object.assign({}, DEFAULT_OPTIONS, options));
this._isClose = false;
this._isLongPulling = false;
this._subscriptions = new Map(); // key => { }
this._currentServer = null;
// 同一个key可能会被多次订阅,避免不必要的 `warning`
this.setMaxListeners(100);
this.ready(true);
}
get appName() {
return this.options.appName;
}
get appKey() {
return this.options.appKey;
}
get secretKey() {
return this.options.secretKey;
}
get snapshot() {
return this.options.snapshot;
}
get serverMgr() {
return this.options.serverMgr;
}
get unit() {
return this.options.unit;
}
/**
* HTTP 请求客户端
* @property {HttpClient} DiamondEnv#httpclient
*/
get httpclient() {
return this.options.httpclient;
}
close() {
this._isClose = true;
this.removeAllListeners();
}
/**
* 更新 当前服务器
*/
* _updateCurrentServer() {
this._currentServer = yield this.serverMgr.getOne(this.unit);
if (!this._currentServer) {
const err = new Error('[DiamondEnv] Diamond server unavailable');
err.name = 'DiamondServerUnavailableError';
err.unit = this.unit;
throw err;
}
}
/**
* 订阅
* @param {Object} info
* - {String} dataId - id of the data you want to subscribe
* - {String} [group] - group name of the data
* @param {Function} listener - listener
* @return {DiamondEnv} self
*/
subscribe(info, listener) {
const { dataId, group } = info;
const key = this._formatKey(info);
this.on(key, listener);
let item = this._subscriptions.get(key);
if (!item) {
item = {
dataId,
group,
md5: null,
content: null,
};
this._subscriptions.set(key, item);
co(function* () {
yield this._syncConfigs([ item ]);
this._startLongPulling();
}.bind(this)).catch(err => { this._error(err); });
} else if (!is.nullOrUndefined(item.md5)) {
process.nextTick(() => listener(item.content));
}
return this;
}
/**
* 同步配置
* @param {Array} list - 需要同步的配置列表
* @return {void}
*/
* _syncConfigs(list) {
const tasks = list.map(({ dataId, group }) => this.getConfig(dataId, group));
const results = yield gather(tasks, 5);
for (let i = 0, len = results.length; i < len; i++) {
const key = this._formatKey(list[i]);
const item = this._subscriptions.get(key);
const result = results[i];
if (!item) {
debug('item %s not exist', key); // maybe removed by user
continue;
}
if (result.isError) {
const err = new Error(`[DiamondEnv] getConfig failed for dataId: ${item.dataId}, group: ${item.group}, error: ${result.error}`);
err.name = 'DiamondSyncConfigError';
err.dataId = item.dataId;
err.group = item.group;
this._error(err);
continue;
}
const content = result.value;
const md5 = utils.getMD5String(content);
// 防止应用启动时,并发请求,导致同一个 key 重复触发
if (item.md5 !== md5) {
item.md5 = md5;
item.content = content;
// 异步化,避免处理逻辑异常影响到 diamond 内部
setImmediate(() => this.emit(key, content));
}
}
}
/**
* 请求
* @param {String} path - 请求 path
* @param {Object} [options] - 参数
* @return {String} value
*/
* _request(path, options = {}) {
// 默认为当前单元
const unit = this.unit;
const ts = String(Date.now());
const { encode = false, method = 'GET', data, timeout = this.options.requestTimeout, headers = {} } = options;
if (!this._currentServer) {
yield this._updateCurrentServer();
}
let url = `http://${this._currentServer}:${8080}/diamond-server${path}`;
if (this.options.ssl) {
url = `https://${this._currentServer}:${443}/diamond-server${path}`;
}
debug('request unit: [%s] with url: %s', unit, url);
let signStr = data.tenant;
if (data.group && data.tenant) {
signStr = data.tenant + '+' + data.group;
} else if (data.group) {
signStr = data.group;
}
const signature = crypto.createHmac('sha1', this.secretKey)
.update(signStr + '+' + ts).digest()
.toString('base64');
// 携带统一的头部信息
Object.assign(headers, {
'Client-Version': Constants.VERSION,
'Content-Type': 'application/x-www-form-urlencoded; charset=GBK',
'Spas-AccessKey': this.options.accessKey,
timeStamp: ts,
exConfigInfo: 'true',
'Spas-Signature': signature,
});
let requestData = data;
if (encode) {
requestData = utils.encodingParams(data);
}
try {
const res = yield this.httpclient.request(url, {
rejectUnauthorized: false,
httpsAgent: false,
method,
data: requestData,
dataType: 'text',
headers,
timeout,
secureProtocol: 'TLSv1_2_method',
});
let err;
const resData = res.data;
debug('%s %s, got %s, body: %j', method, url, res.status, resData);
switch (res.status) {
case Constants.HTTP_OK:
return resData;
case Constants.HTTP_NOT_FOUND:
return null;
case Constants.HTTP_CONFLICT:
err = new Error(`[DiamondEnv] Diamond server config being modified concurrently, data: ${JSON.stringify(data)}`);
err.name = 'DiamondServerConflictError';
throw err;
default:
err = new Error(`Diamond Server Error Status: ${res.status}, url: ${url}, request data: ${JSON.stringify(data)}, response data: ${resData && resData.toString()}`);
err.name = 'DiamondServerResponseError';
err.body = res.data;
throw err;
}
} catch (err) {
err.url = `${method} ${url}`;
err.data = data;
err.headers = headers;
yield this._updateCurrentServer();
throw err;
}
}
/**
* 开启长轮询
* @return {void}
* @private
*/
_startLongPulling() {
// 防止重入
if (this._isLongPulling) {
return;
}
this._isLongPulling = true;
co(function* () {
while (!this._isClose && this._subscriptions.size > 0) {
try {
yield this._checkServerConfigInfo();
} catch (err) {
err.name = 'DiamondLongPullingError';
this._error(err);
yield sleep(2000);
}
}
}.bind(this)).then(() => {
this._isLongPulling = false;
}).catch(err => {
this._isLongPulling = false;
this._error(err);
});
}
* _checkServerConfigInfo() {
debug('start to check update config list');
if (this._subscriptions.size === 0) {
return;
}
const beginTime = Date.now();
const tenant = this.options.namespace;
const probeUpdate = [];
for (const { dataId, group, md5 } of this._subscriptions.values()) {
probeUpdate.push(dataId, Constants.WORD_SEPARATOR);
probeUpdate.push(group, Constants.WORD_SEPARATOR);
if (tenant) {
probeUpdate.push(md5, Constants.WORD_SEPARATOR);
probeUpdate.push(tenant, Constants.LINE_SEPARATOR);
} else {
probeUpdate.push(md5, Constants.LINE_SEPARATOR);
}
}
const content = yield this._request('/config.co', {
method: 'POST',
data: {
'Probe-Modify-Request': probeUpdate.join(''),
},
headers: {
longPullingTimeout: '30000',
},
timeout: 40000, // 超时时间比longPullingTimeout稍大一点,避免主动超时异常
});
debug('long pulling takes %ds', (Date.now() - beginTime) / 1000);
const updateList = this._parseUpdateDataIdResponse(content);
if (updateList && updateList.length) {
yield this._syncConfigs(updateList);
}
}
// 解析 diamond 返回的 long pulling 结果
_parseUpdateDataIdResponse(content) {
const updateList = [];
decodeURIComponent(content)
.split(Constants.LINE_SEPARATOR)
.forEach(dataIdAndGroup => {
if (dataIdAndGroup) {
const keyArr = dataIdAndGroup.split(Constants.WORD_SEPARATOR);
if (keyArr.length >= 2) {
const dataId = keyArr[0];
const group = keyArr[1];
updateList.push({
dataId,
group,
});
}
}
});
return updateList;
}
/**
* 退订
* @param {Object} info
* - {String} dataId - id of the data you want to subscribe
* - {String} group - group name of the data
* @param {Function} listener - listener
* @return {DiamondEnv} self
*/
unSubscribe(info, listener) {
const key = this._formatKey(info);
if (listener) {
this.removeListener(key, listener);
} else {
this.removeAllListeners(key);
}
// 没有人订阅了,从长轮询里拿掉
if (this.listeners(key).length === 0) {
this._subscriptions.delete(key);
}
return this;
}
/**
* 默认异常处理
* @param {Error} err - 异常
* @return {void}
* @private
*/
_error(err) {
if (err) {
setImmediate(() => this.emit('error', err));
}
}
_formatKey(info) {
return `${info.dataId}@${info.group}@${this.unit}`;
}
_getSnapshotKey(dataId, group, tenant) {
tenant = tenant || this.options.namespace || 'default_tenant';
return path.join('config', this.unit, tenant, group, dataId);
}
/**
* 获取配置
* @param {String} dataId - id of the data
* @param {String} group - group name of the data
* @return {String} value
*/
* getConfig(dataId, group) {
debug('calling getConfig, dataId: %s, group: %s', dataId, group);
let content;
const key = this._getSnapshotKey(dataId, group);
try {
content = yield this._request('/config.co', {
data: {
dataId,
group,
tenant: this.options.namespace,
},
});
} catch (err) {
const cache = yield this.snapshot.get(key);
if (cache) {
this._error(err);
return cache;
}
throw err;
}
yield this.snapshot.save(key, content);
return content;
}
/**
* 查询租户下的所有的配置
* @return {Array} config
*/
* getConfigs() {
const configInfoPage = yield this.getAllConfigInfoByTenantInner(1, 1);
const total = configInfoPage.totalCount;
const pageSize = 200;
let configs = [];
for (let i = 0; i * pageSize < total; i++) {
const configInfo = yield this.getAllConfigInfoByTenantInner(i + 1, pageSize);
configs = configs.concat(configInfo.pageItems);
}
return configs;
}
* getAllConfigInfoByTenantInner(pageNo, pageSize) {
const ret = yield this._request('/basestone.do', {
data: {
pageNo,
pageSize,
method: 'getAllConfigByTenant',
tenant: this.options.namespace,
},
});
return JSON.parse(ret);
}
/**
* 发布配置
* @param {String} dataId - id of the data
* @param {String} group - group name of the data
* @param {String} content - config value
* @return {Boolean} success
*/
* publishSingle(dataId, group, content) {
yield this._request('/basestone.do?method=syncUpdateAll', {
method: 'POST',
encode: true,
data: {
dataId,
group,
content,
tenant: this.options.namespace,
},
});
return true;
}
/**
* 删除配置
* @param {String} dataId - id of the data
* @param {String} group - group name of the data
* @return {Boolean} success
*/
* remove(dataId, group) {
yield this._request('/datum.do?method=deleteAllDatums', {
method: 'POST',
data: {
dataId,
group,
tenant: this.options.namespace,
},
});
return true;
}
* publishAggr(dataId, group, datumId, content) {
const appName = this.appName;
yield this._request('/datum.do?method=addDatum', {
method: 'POST',
data: {
dataId,
group,
datumId,
content,
appName,
tenant: this.options.namespace,
},
});
return true;
}
* removeAggr(dataId, group, datumId) {
yield this._request('/datum.do?method=deleteDatum', {
method: 'POST',
data: {
dataId,
group,
datumId,
tenant: this.options.namespace,
},
});
return true;
}
/**
* 批量获取配置
* @param {Array} dataIds - data id array
* @param {String} group - group name of the data
* @return {Array} result
*/
* batchGetConfig(dataIds, group) {
const dataIdStr = dataIds.join(Constants.WORD_SEPARATOR);
const content = yield this._request('/config.co?method=batchGetConfig', {
method: 'POST',
data: {
dataIds: dataIdStr,
group,
tenant: this.options.namespace,
},
});
try {
/**
* data 结构
* [{status: 1, group: "test-group", dataId: 'test-dataId3', content: 'test-content'}]
*/
const data = JSON.parse(content);
const savedData = data.filter(d => d.status === 1).map(d => {
const r = {};
r.key = path.join(this._getSnapshotKey(d.dataId, d.group));
r.value = d.content;
return r;
});
yield this.snapshot.batchSave(savedData);
return data;
} catch (err) {
err.name = 'DiamondBatchDeserializeError';
err.data = content;
throw err;
}
}
/**
* 批量查询
* @param {Array} dataIds - data id array
* @param {String} group - group name of the data
* @return {Object} result
*/
* batchQuery(dataIds, group) {
const dataIdStr = dataIds.join(Constants.WORD_SEPARATOR);
const content = yield this._request('/admin.do?method=batchQuery', {
method: 'POST',
data: {
dataIds: dataIdStr,
group,
tenant: this.options.namespace,
},
});
try {
return JSON.parse(content);
} catch (err) {
err.name = 'DiamondBatchDeserializeError';
err.data = content;
throw err;
}
}
}
module.exports = DiamondEnv;