作者:卢泰安,前端工程师,GE数字集团
如今前后端分离盛行,在后端领域,诸多微服务构成复杂业务系统,一个前端页面可能需要往多个不同的服务器发送请求、取得数据并完成页面渲染,进而引入跨域问题、请求过多占据带宽,面临这些问题时,Backend For Frontend (BFF) 就是一种良好的解决方案。本文将带大家一起来构建一个在 Predix 平台上使用的 BFF。
我们的 BFF
将实现一个核心功能:
* /predix-ts
请求该 url 返回 predix timeseries
实例中的数据
首先我们假设你已初始化好一个 express server 的项目并且部署在 Predix 上。(如何初始化一个 express server 请见 http://expressjs.com/en/starter/hello-world.html)
注意事项:在 Predix 上我们应监听的端口号为环境变量中的 PORT
。
首先我们假设你已经拥有一个 Predix Timeseries 实例 my-ts
运行在 Predix 上,通过 cf bind-service 将 my-ts
绑定到我们的 node app 上。
cf bind-service [app-name] my-ts
通过 cf env [app-name]
我们可以读到 app 的环境变量:
System-Provided:
{
"VCAP_SERVICES":{
"predix-timeseries":[
{
"credentials":{
"ingest":{
"uri":"wss://gateway-predix-data-services.run.aws-jp01-pr.ice.predix.io/v1/stream/messages",
"zone-http-header-name":"Predix-Zone-Id",
"zone-http-header-value":"<your-service-zone-id>",
"zone-token-scopes":[
"timeseries.zones.<your-service-zone-id>.user",
"timeseries.zones.<your-service-zone-id>.ingest"
]
},
"query":{
"uri":"https://time-series-store-predix.run.aws-jp01-pr.ice.predix.io/v1/datapoints",
"zone-http-header-name":"Predix-Zone-Id",
"zone-http-header-value":"<your-service-zone-id>",
"zone-token-scopes":[
"timeseries.zones.<your-service-zone-id>.user",
"timeseries.zones.<your-service-zone-id>.query"
]
}
},
"label":"predix-timeseries",
"name":"timeseries-ds",
"plan":"Free",
"provider":null,
"syslog_drain_url":null,
"tags":[
"timeseries",
"time-series",
"time series"
],
"volume_mounts":[
]
}
]
}
}
{
"VCAP_APPLICATION":{
"application_id":"<application_id>",
"application_name":"<app-name>",
"application_uris":[
"<app-name>.run.aws-jp01-pr.ice.predix.io"
],
"application_version":"<version-code>",
"limits":{
"disk":1024,
"fds":16384,
"mem":128
},
"name":"<app-name>",
"space_id":"<space_id>",
"space_name":"<space_name>",
"uris":[
"<app-name>.run.aws-jp01-pr.ice.predix.io"
],
"users":null,
"version":"<version-code>"
}
}
No user-defined env variables have been set
No running env variables have been set
No staging env variables have been set
需要注意的是,在我们的 node app 中访问 process.env 也能访问到 cf env
中列出的 VCAP_SERVICES
和 VCAP_APPLICATION
变量。
为了更无缝地在本地和远程环境运行相同代码,减少代码量,我们希望在本地也能读取跟线上相同的环境变量,因此我们希望模拟这些环境变量。
但是实际上线上环境变量中,包含了很多信息包括 tag、scopes 等,因此我们需要明确我们需要用到的部分。回想我们前面定下的功能,我们需要取回 predix timeseries 数据,因此我们需要的仅为
{
"VCAP_SERVICES":{
"predix-timeseries":[
...
{
"credentials":{
...
"query":{
"uri":"https://time-series-store-predix.run.aws-jp01-pr.ice.predix.io/v1/datapoints",
"zone-http-header-value": "<your-zone-id>"
}
}
}
]
}
}
因此我们只要 app 启动前在环境变量中加上 VCAP_SERVICES
即可。
if (process.env.NODE_ENV === 'development') {
process.env.VCAP_SERVICES = JSON.stringify({
"predix-timeseries": [
{
"credentials": {
"query": {
"uri":"https://time-series-store-predix.run.aws-jp01-pr.ice.predix.io/v1/datapoints",
"zone-http-header-value": "<your-zone-id>"
}
}
}
]
})
}
有了与 Predix 环境相同的环境变量后,我们开始下一步。
书写一个转发请求的中间件:
// proxy to predix ts service
app.use('/predix-ts', proxyMiddleware)
我们知道所有的 Predix Services 都是跟 UAA 绑定的,包括之后我们可能会用的其他 Predix Services 也有不少是需要 UAA 授权才能访问的,所以我们需要增加一个获取授权的中间件。
// proxy to predix ts service
app.use('/predix-ts', authMiddleware , proxyMiddleware)
我们使用 predix-uaa-client
这个包来获取 UAA 的授权和 access token 等信息。通过 npm 安装后,我们开始书写 authMiddleware
。先写一个 getToken
的方法,从 Predix UAA 获取 token 并保存,再次调用时如 token 未过期则继续使用该 token,过期则请求新的 token。
const uaaClient = require('predix-uaa-client')
let existedToken = null
function getToken () {
if (existedToken && existedToken.expire_time > new Date() + 10) {
return Promise.resolve(existedToken)
} else {
return uaaClient.getToken(`${yourUaaURL}/oauth/token`, yourClientId, yourClientSecret)
.then(token => {
existedToken = Object.assign({}, token)
return existedToken
})
.catch((err) => {
console.error('Error getting token', err)
})
}
}
书写 authMiddleware
,调用 getToken 方法获取 token,并为请求对象(req) 设置 header 中 Authorization
字段以供 Predix Service 进行鉴权:
app.use('/predix-ts', (req, res, next) => {
getToken()
.then(token => {
req.headers['Authorization'] = token.access_token
next()
})
} , proxyMiddleware)
下一步我们开始书写 proxyMiddleware
。在这里我们使用 http-proxy-middleware 做请求转发。
const url = require('url')
const proxy = require('http-proxy-middleware')
const VCAP_SERVICES = JSON.parse(process.env.VCAP_SERVICES)
const predixTs = VCAP_SERVICES['predix-timeseries'][0]
const urlObj = url.parse(predixTs.credentials.query.uri) // 使用 node 内置的 url 模块解析出 api 的 endpoint
app.use('/predix-ts', (req, res, next) => {
getToken()
.then(token => {
req.headers['Authorization'] = token.access_token
next()
})
}, proxy({
target: urlObj.protocol + '//' + urlObj.host,
changeOrigin: true,
pathRewrite: path => path.replace('/predix-ts', '/'),
onProxyReq: (proxyReq, req, res) => {
// 拦截请求对象并加上 zone id
proxyReq.setHeader('Predix-Zone-Id', predixTs.credentials.query['zone-http-header-value'])
proxyReq.setHeader('Content-Type', 'application/json')
},
onProxyRes: (proxyRes, req, res) => {
delete proxyRes.headers['access-control-allow-origin']
}
}))
至此我们的中间件功能已经完成,我们可以通过 curl https://[app-name].run.aws-jp01-pr.ice.predix.io/predix-ts/v1/aggregations
获取到 my-ts
中数据了。
我们也可以在我们的前端代码中,访问 my-ts
的数据:
<html>
<head>
<title>bff node</title>
</head>
<body>
Hello World BFF
<script> Promise.all([ fetch('/predix-ts/v1/aggregations'), fetch('/predix-ts/v1/datapoints', { method: 'POST', body: JSON.stringify({ start: '1h-ago', tags: [ { name: '<tag-name>', order: 'asc' } ] }) }) ]) </script>
</body>
</html>
完整代码如下:
const url = require('url')
const express = require('express')
const bodyParser = require('body-parser')
const proxy = require('http-proxy-middleware')
const uaaClient = require('predix-uaa-client')
if (process.env.NODE_ENV === 'development') {
process.env.VCAP_SERVICES = JSON.stringify({
"predix-timeseries": [
{
"credentials": {
"query": {
"uri": "https://time-series-store-predix.run.aws-jp01-pr.ice.predix.io/v1/datapoints",
"zone-http-header-value": "<predix-zone-id>"
}
}
}
]
})
}
let existedToken = null
const app = express()
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: false }))
const VCAP_SERVICES = JSON.parse(process.env.VCAP_SERVICES)
const predixTs = VCAP_SERVICES['predix-timeseries'][0]
const urlObj = url.parse(predixTs.credentials.query.uri)
app.use('/predix-ts', (req, res, next) => {
getToken()
.then(token => {
req.headers['Authorization'] = token.access_token
next()
})
}, proxy({
target: urlObj.protocol + '//' + urlObj.host,
changeOrigin: true,
pathRewrite: path => path.replace('/predix-ts', '/'),
onProxyReq: (proxyReq, req, res) => {
proxyReq.setHeader('Predix-Zone-Id', predixTs.credentials.query['zone-http-header-value'])
proxyReq.setHeader('Content-Type', 'application/json')
},
onProxyRes: (proxyRes, req, res) => {
delete proxyRes.headers['access-control-allow-origin']
}
}))
const port = process.env.PORT || 8765
module.exports = app.listen(port, err => {
if (err) {
console.log(err)
return
}
console.log('Listening at http://localhost:' + port + '\n')
})
const uaaURL = '<uaa url>'
const clientId = '<clientId>'
const clientSecret = '<clientSecret>'
function getToken () {
if (existedToken && existedToken.expire_time > new Date() + 10) {
return Promise.resolve(existedToken)
} else {
return uaaClient.getToken(`${uaaURL}/oauth/token`, clientId, clientSecret)
.then(token => {
existedToken = Object.assign({}, token)
return existedToken
})
.catch((err) => {
console.error('Error getting token', err)
})
}
}