Build a Backend For Frontend (BFF) app in Predix

作者:卢泰安,前端工程师,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_SERVICESVCAP_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)
    })
  }
}
我想将frontend 也是用volumes,将其映射到/app/frontend目录,在/app/frontend下install以及build,如何实现 docker-compose.yml文件: version: '3' services: frontend: build: context: ./frontend dockerfile: Dockerfile ports: - 8010:80 restart: always backend: build: context: ./backend dockerfile: Dockerfile volumes: - /app/backend:/app environment: - CELERY_BROKER_URL=redis://redis:6379/0 command: python manage.py runserver 0.0.0.0:8000 ports: - 8011:8000 restart: always celery-worker: build: context: ./backend dockerfile: Dockerfile volumes: - /app/backend:/app environment: - CELERY_BROKER_URL=redis://redis:6379/0 command: celery -A server worker -l info --pool=solo --concurrency=1 depends_on: - redis - backend restart: always celery-beat: build: context: ./backend dockerfile: Dockerfile volumes: - /app/backend:/app environment: - CELERY_BROKER_URL=redis://redis:6379/0 command: celery -A server beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler depends_on: - redis - backend restart: always redis: image: redis:latest ports: - 6379:6379 restart: always mysql: image: mysql:latest environment: - MYSQL_ROOT_PASSWORD=sacfxSql258147@ ports: - 8016:3306 volumes: - ./mysql:/var/lib/mysql restart: always frontend:dockerfile文件 FROM node:16.18.1 WORKDIR /app/frontend COPY package*.json ./ RUN npm install COPY . . RUN npm run build:prod FROM nginx:latest COPY --from=0 /app/frontend/dist/ /usr/share/nginx/html EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]
07-14
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值