ctrip-apollo-ex
Version:
The most delightful and handy Node.js client for ctrip apollo configuration service
591 lines (467 loc) • 10.3 kB
JavaScript
const path = require('path')
const EventEmitter = require('events')
const log = require('util').debuglog('ctrip-apollo')
const req = require('request')
const {request: reqSync} = require('urllib-sync')
const fs = require('fs-extra')
const {diff} = require('diff-sorted-array')
const {createKey} = require('./util')
const {error, composeError} = require('./error')
const {queryConfigAsJson, queryConfig} = require('./url')
const request = (url, timeout) => new Promise((resolve, reject) => {
let timedout = false
let timer = timeout
? setTimeout(() => {
timedout = true
reject(error('FETCH_TIMEOUT', timeout))
}, timeout)
: null
req(url, (err, response) => {
if (timedout) {
return
}
if (timeout) {
clearTimeout(timer)
timer = null
}
if (err) {
return reject(error('FETCH_REQUEST_ERROR', err))
}
const {
body,
statusCode: status
} = response
if (status === 304) {
return resolve({
noChange: true
})
}
if (status !== 200) {
return reject(error('FETCH_STATUS_ERROR', status))
}
try {
resolve({
config: JSON.parse(body)
})
} catch (parseError) {
reject(error('JSON_PARSE_ERROR', parseError))
}
})
})
const requestSync = (url, timeout) => {
const response = reqSync(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json;charset=UTF-8',
},
rejectUnauthorized: true,
contentType: 'json',
dataType: 'json',
})
const {
data,
status
} = response
if (status === 304) {
return {
noChange: true
}
}
if (status !== 200) {
throw error('FETCH_STATUS_ERROR', status)
}
return {
config: data
}
}
const CONVERTER = {
CACHE: json => json,
NO_CACHE: json => json.configurations
}
const NOOP = () => {}
class ApolloNamespace extends EventEmitter {
constructor (options, type) {
super()
this._options = options
this._type = type
this._config = null
this._releaseKey = null
this._cacheFile = this._createCacheFile()
this._fetchTimer = null
this._ready = false
}
get namespace () {
return this._options.namespace
}
get cluster () {
return this._options.cluster
}
_createCacheFile () {
const {
cachePath
} = this._options
if (!cachePath) {
return false
}
const {
host,
appId,
cluster,
namespace,
} = this._options
const filename = createKey(
host,
appId,
cluster,
namespace
)
return path.join(cachePath, filename)
}
async _load (url, converter) {
const {
noChange,
config
} = await request(url, this._options.fetchTimeout)
if (noChange) {
return {
noChange
}
}
const {
releaseKey
} = config
if (releaseKey) {
this._releaseKey = releaseKey
}
return {
config: converter(config)
}
}
_loadSync (url, converter) {
const {
noChange,
config
} = requestSync(url, this._options.fetchTimeout)
if (noChange) {
return {
noChange
}
}
const {
releaseKey
} = config
if (releaseKey) {
this._releaseKey = releaseKey
}
return {
config: converter(config)
}
}
_loadWithNoCache () {
const url = queryConfig({
...this._options,
releaseKey: this._releaseKey
})
log('client: load with no cache: %s', url)
return this._load(url, CONVERTER.NO_CACHE)
}
_loadWithNoCacheSync () {
const url = queryConfig({
...this._options,
releaseKey: this._releaseKey
})
log('client: load with no cache: %s', url)
return this._loadSync(url, CONVERTER.NO_CACHE)
}
_loadWithCache () {
const url = queryConfigAsJson(this._options)
log('client: load with cache: %s', url)
return this._load(url, CONVERTER.CACHE)
}
_save (config) {
const cacheFile = this._cacheFile
if (!cacheFile) {
return
}
// Save asynchronously
fs.outputJson(cacheFile, config, err => {
if (err) {
log('client: save error, stack: %s', err.stack)
this.emit('save-error', err)
return
}
this.emit('saved')
log('client: save success')
})
}
_diffAndSave ({
noChange,
config
}) {
if (noChange) {
return
}
const oldKeys = Object.keys(this._config)
const newKeys = Object.keys(config)
const {
unchanged,
added,
deleted
} = diff(oldKeys, newKeys)
unchanged.forEach(key => {
const oldValue = this._config[key]
const newValue = config[key]
if (oldValue === newValue) {
return
}
this._config[key] = newValue
this.emit('change', {
oldValue,
newValue,
key
})
})
added.forEach(key => {
const value = config[key]
this._config[key] = value
this.emit('add', {
value,
key
})
})
deleted.forEach(key => {
const value = this._config[key]
delete this._config[key]
this.emit('delete', {
value,
key
})
})
this.emit('updated')
this._save(this._config)
}
async fetch (withCache) {
if (!this._ready) {
return
}
let result
try {
result = withCache
? await this._loadWithCache()
: await this._loadWithNoCache()
} catch (err) {
this.emit('fetch-error', err)
return
}
log('client: start diff: %j', result)
this._diffAndSave(result)
}
async ready () {
const config = this._options.skipInitFetchIfCacheFound
? await this._readOrFetch()
: await this._fetchOrFallback()
this._config = config
this._checkReady = NOOP
this._ready = true
if (this._options.enableFetch) {
this.enableFetch(true)
}
this.emit('ready')
return this
}
readySync() {
const config = this._options.skipInitFetchIfCacheFound
? this._readOrFetchSync()
: this._fetchOrFallbackSync()
this._config = config
this._checkReady = NOOP
this._ready = true
if (this._options.enableFetch) {
this.enableFetch(true)
}
this.emit('ready')
return this
}
async _readCache () {
const cacheFile = this._cacheFile
if (!cacheFile) {
return null
}
try {
await fs.access(cacheFile, fs.constants.R_OK)
} catch (err) {
throw error('NO_LOCAL_CACHE_FOUND', err, cacheFile)
}
try {
return await fs.readJson(cacheFile)
} catch (err) {
throw error('READ_LOCAL_CACHE_FAILS', err, cacheFile)
}
}
_readCacheSync () {
const cacheFile = this._cacheFile
if (!cacheFile) {
return null
}
try {
fs.accessSync(cacheFile, fs.constants.R_OK)
} catch (err) {
throw error('NO_LOCAL_CACHE_FOUND', err, cacheFile)
}
try {
return fs.readJsonSync(cacheFile)
} catch (err) {
throw error('READ_LOCAL_CACHE_FAILS', err, cacheFile)
}
}
async _firstFetch () {
const {
config
} = await this._loadWithNoCache()
this._save(config)
return config
}
_firstFetchSync () {
const {
config
} = this._loadWithNoCacheSync()
this._save(config)
return config
}
async _readOrFetch () {
let readError
let config
try {
config = await this._readCache()
} catch (err) {
readError = err
}
if (config) {
log('cache found, skip fetching')
return config
}
try {
return await this._firstFetch()
} catch (err) {
throw composeError(
readError || error('NO_CACHE_SPECIFIED'),
err
)
}
}
_readOrFetchSync () {
let readError
let config
try {
config = this._readCacheSync()
} catch (err) {
readError = err
}
if (config) {
log('cache found, skip fetching')
return config
}
try {
return this._firstFetchSync()
} catch (err) {
throw composeError(
readError || error('NO_CACHE_SPECIFIED'),
err
)
}
}
async _fetchOrFallback () {
let fetchError
try {
return await this._firstFetch()
} catch (err) {
this.emit('fetch-error', err)
fetchError = err
}
// If fails to fetch configurations from apollo,
// then fallback to local cache file
let config
try {
config = await this._readCache()
} catch (err) {
throw composeError(fetchError, err)
}
if (config) {
return config
}
throw composeError(
fetchError,
error('NO_CACHE_SPECIFIED')
)
}
_fetchOrFallbackSync () {
let fetchError
try {
return this._firstFetchSync()
} catch (err) {
this.emit('fetch-error', err)
fetchError = err
}
// If fails to fetch configurations from apollo,
// then fallback to local cache file
let config
try {
config = this._readCacheSync()
} catch (err) {
throw composeError(fetchError, err)
}
if (config) {
return config
}
throw composeError(
fetchError,
error('NO_CACHE_SPECIFIED')
)
}
_checkReady (name) {
throw error('NOT_READY', name)
}
config () {
this._checkReady('config')
return {
...this._config
}
}
get (key) {
this._checkReady('get')
return this._config[key]
}
has (key) {
this._checkReady('has')
return key in this._config
}
enableFetch (enable) {
enable
? this._enableFetch()
: this._disableFetch()
return this
}
_enableFetch () {
const options = this._options
options.enableFetch = true
if (!this._ready) {
return
}
this._fetchTimer = setInterval(() => {
// if (!options.enableFetch) { // <- never happen
// return
// }
this.fetch(options.fetchCachedConfig)
}, options.fetchInterval)
}
_disableFetch () {
this._options.enableFetch = false
if (this._fetchTimer) {
clearInterval(this._fetchTimer)
this._fetchTimer = null
}
}
}
module.exports = {
ApolloNamespace
}