Vue-cli、devServer + http-proxy + 测试服务 + easy-mock 实现开发环境接口“负载”

穆丁雨
2023-12-01

http-proxy 代理应用;测试服务404或500时转发到Mock服务;http-proxy POST 请求失败,浏览器 pending,终端报错 Err:socket hang up;devServer.proxy 高级配置;Vue-cli 终端日志特色打印

1.实际场景

1. 老系统平台重构,生产环境业务逻辑及接口数据大部分可复用
2. 后端重构较前端更困难,针对接口的开发相对滞后,导致前端交互操作的开发受到比较大的影响;
3. 由于历史原因导致的接口测试服务与生产环境接口服务不同步,无法调通测试环境的部分接口,同样导致前端受到影响;
4. 诸如菜单、权限等前端框架配置相对核心的数据结构,需要前端来定义接口数据结构提供到后端
5. 使用vue-cli脚手架进行开发或重构的项目;
6. 搭建了Mock服务(easy-mock)。

2.实际需求

1. 开发环境需要同时代理到测试服务Mock服务,在开发时最大限度的保证接口可调用;
2. 接口的代理链条是先到测试服务再到Mock服务最终成功或失败提示
3. 接口的代理逻辑是测试服务不可用时转到Mock服务Mock服务不可用时进行错误拦截给予客户端合理的错误响应
4. 根据需要在终端打印有效的代理日志
5. 根据实际需求维护Mock服务(easy-mock)。

3.配置代码(vue.config.js

'use strict'
const path = require('path')
const ScriptExtHtmlWebpackPlugin = require('script-ext-html-webpack-plugin')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
const httpProxy = require('http-proxy')
const { info, warn, done } = require('@vue/cli-shared-utils')

function resolve(dir) {
  return path.join(__dirname, dir)
}

const isProd = process.env.NODE_ENV === 'production'
const port = process.env.port || process.env.npm_config_port || 80 // 端口
const publicPath = isProd ? '/' : '/'

/* Mock Mapping */
const router = {
  'xxx-server': 'mockid'
}
function pathRewrite(path) {
  return path.replace(/(.*?)([^\/]+-server)/, function (_, $1, $2) {
    if (/-server/.test($2)) {
      return router[$2] + '/' + $2
    }
    return ''
  })
}
const proxy = httpProxy.createProxyServer()
/* Mock请求实例设置POST请求体 */
proxy.on('proxyReq', function (proxyReq, req, res, options) {
  const rb = req.bodybuffer
  if (req.bodybuffer) {
    proxyReq.setHeader('content-type', 'application/json; charset=utf-8')
    proxyReq.setHeader('content-length', Buffer.byteLength(rb))
    proxyReq.write(rb.toString('utf-8'))
    proxyReq.end()
  }
})
/* Mock响应打印 */
proxy.on('proxyRes', function (proxyRes, req, res, options) {
  let buffer = Buffer.from('', 'utf8')
  proxyRes.on('data', (chunk) => (buffer = Buffer.concat([buffer, chunk])))
  proxyRes.on('end', () => {
    done(`[MR - ${req.timestamp}]:${buffer.toString('utf8').replace(/(.{100})(.*)/, '$1...')}`/* MOCK响应 */)
  })
})
/* 代理服务错误拦截 */
proxy.on('error', function (err, req, res, targeterr) {
  res.setHeader('Content-Type', 'application/json; charset=utf-8')
  res.write(JSON.stringify({ code: 500, info: `Mock代理请求超时[${req.path}]` }))
  res.end()
})
/* 转递到Mock服务 */
function nextHPM(req, res) {
  req.url = pathRewrite(req.url)
  proxy.web(req, res, {
    target: 'http://xxx.xx.xxx.xx:7300/mock/',
    changeOrigin: true,
    xfwd: true,
    preserveHeaderKeyCase: true,
    proxyTimeout: 5 * 1000 /* 代理未收到目标(target)的响应时超时(毫秒)。 */
  })
}
module.exports = {
  publicPath,
  outputDir: 'dist',
  assetsDir: 'static',
  lintOnSave: process.env.NODE_ENV === 'development',
  productionSourceMap: false,
  transpileDependencies: [],
  devServer: {
    port: port,
    open: true,
    overlay: {
      warnings: false,
      errors: false
    },
    proxy: {
        '/web': {
        target: `http://xxx.xxx.xxx.xx:9080`,
        changeOrigin: true,
        // 设置为 true 则需要手动进行请求响应:res.end()
        selfHandleResponse: true,
        pathRewrite: {
          '^/': ''
        },
        onProxyReq(proxyReq, req) {
          const date = new Date()
          date.setMinutes(date.getMinutes() - date.getTimezoneOffset())
          req.timestamp = date.toJSON()
          info(`[NP - ${req.timestamp}]:${req.path}`/* 原始路径 */)
          info(`[PP - ${req.timestamp}]:${proxyReq.path}`/* 代理路径 */)
          /* 向原始请求中缓存请求体 */
          let bodybuffer = Buffer.from('', 'utf8')
          req.on('data', (chunk) => (bodybuffer = Buffer.concat([bodybuffer, chunk])))
          req.on('end', function () {
            req.bodybuffer = bodybuffer
          })
        },
        onProxyRes(proxyRes, req, res) {
          /* 校验测试服务接口是否可用,不可用将转递到Mock服务 */
          let buffer = Buffer.from('', 'utf8')
          proxyRes.on('data', (chunk) => (buffer = Buffer.concat([buffer, chunk])))
          proxyRes.on('end', () => {
            const result = JSON.parse(buffer.toString('utf8'))
            if (result.code === 500 || result.statusCode === 404 || result.status === 404) {
              warn(`[PR - ${req.timestamp}]:${buffer.toString('utf8')}`/* 代理响应 */)
              nextHPM(req, res, result)
            } else {
              done(`[PR - ${req.timestamp}]:${buffer.toString('utf8')}`/* 代理响应 */)
              res.write(buffer)
              res.end()
            }
          })
        }
      }
    },
    disableHostCheck: true
  },
  configureWebpack: config => {
    return {
      resolve: {
        alias: {
          '@': resolve('src'),
          '@crud': resolve('src/components/Crud')
        }
      },
      plugins: [
        ...(process.env.npm_config_analysis ? [new BundleAnalyzerPlugin({ // 打包分析图
          analyzerMode: 'disabled',
          generateStatsFile: true,
          statsOptions: { source: false }
        })] : []),
        new ScriptExtHtmlWebpackPlugin({
          inline: /runtime\..*\.js$/
        })
      ]
    }
  },
  parallel: false,
  chainWebpack(config) {
    config.plugins.delete('preload-index')
    config.plugins.delete('prefetch-index')

    const oneOfsMap = config.module.rule('scss').oneOfs.store
    oneOfsMap.forEach(item => {
      item
        .use('sass-resources-loader')
        .loader('sass-resources-loader')
        .options({
          // scss 全局变量
          resources: ['src/assets/styles/variables.scss', 'src/assets/styles/mixin.scss']
        })
        .end()
    })

    // set svg-sprite-loader
    config.module
      .rule('svg')
      .exclude.add(resolve('src/assets/icons'))
      .end()
    config.module
      .rule('icons')
      .test(/\.svg$/)
      .include.add(resolve('src/assets/icons'))
      .end()
      .use('svg-sprite-loader')
      .loader('svg-sprite-loader')
      .options({
        symbolId: 'icon-[name]'
      })
      .end()

    /** *** worker-loader Start *****/
    config.module
      .rule('worker-loader')
      .test(/\.worker\.js$/)
      .use('worker-loader')
      .loader('worker-loader')
      .options({ filename: 'WorkerName.[hash].js' })
      .end()

    config.output.globalObject('this')
    /* worker 热更新 */
    config.module.rule('js').exclude.add(/\.worker\.js$/)
    /** *** worker-loader End *****/

    config
      .when(process.env.NODE_ENV !== 'development',
        config => { /* production */
          /* 代码分割缓存组 */
          config
            .optimization.splitChunks({
              chunks: 'all',
              cacheGroups: {
                libs: {
                  name: 'chunk-libs',
                  test: /[\\/]node_modules[\\/]/,
                  priority: 10,
                  chunks: 'initial'
                  // enforce: true
                },
                elementUI: {
                  name: 'chunk-elementUI',
                  priority: 20,
                  test: /[\\/]node_modules[\\/]_?element-ui(.*)/
                },
                quillCSS: {
                  name: 'chunk-quillCSS',
                  priority: 20,
                  test: /[\\/]node_modules[\\/]_?quill(.*)(core|snow)\.css$/
                },
                commons: {
                  name: 'chunk-commons',
                  test: resolve('src/components'),
                  minChunks: 3,
                  priority: 5,
                  reuseExistingChunk: true
                }
              }
            })
          config.optimization.runtimeChunk('single')
        }
      )
  }
}

4.问题点

1. 上述配置未处理devServer.proxy.target服务不可用的情况。

3. 以下报错是由于Mock服务不可用(服务宕机)导致的,它会直接使程序退出

/xxx/node_modules/http-proxy/lib/http-proxy/index.js:120
    throw err;
    ^

Error: connect ECONNREFUSED xxx.xx.xxx.xx:7300
    at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1141:16) {
  errno: -61,
  code: 'ECONNREFUSED',
  syscall: 'connect',
  address: 'xxx.xx.xxx.xx',
  port: 7301
}
npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! vue-admin-template@4.4.0 dev: `vue-cli-service serve`
npm ERR! Exit status 1
npm ERR! 
npm ERR! Failed at the vue-admin-template@4.4.0 dev script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.
# 通过注册error事件: proxy.on('error', function (err, req, res, targeterr) { console.log(err) })
# 进行错误拦截,可将错误打印到控制台并且不会导致程序直接退出(参考上述 vue.config.js 配置)
Error: connect ECONNREFUSED xxx.xx.xxx.xx:7300
    at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1141:16) {
  errno: -61,
  code: 'ECONNREFUSED',
  syscall: 'connect',
  address: 'xxx.xx.xxx.xx',
  port: 7301
}

3. 以下报错是由于http-proxy转发POST请求时导致的,Mock服务处理请求体失败未给予http-proxy响应,导致响应超时http-proxy将错误直接抛出,要求开发者注册错误拦截 proxy.on('error', handler) 自行处理;
设置 proxyTimeout 缩短超时时间,避免客户端接口调用长时间处于 pending 状态。

/xxx/node_modules/http-proxy/lib/http-proxy/index.js:120
    throw err;
    ^

Error: socket hang up
    at connResetException (internal/errors.js:614:14)
    at Socket.socketOnEnd (_http_client.js:456:23)
    at Socket.emit (events.js:327:22)
    at endReadableNT (_stream_readable.js:1201:12)
    at processTicksAndRejections (internal/process/task_queues.js:84:21) {
  code: 'ECONNRESET'
}
npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! vue-admin-template@4.4.0 dev: `vue-cli-service serve`
npm ERR! Exit status 1
npm ERR! 
npm ERR! Failed at the vue-admin-template@4.4.0 dev script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.
/* 代理服务错误拦截,避免错误导致程序直接退出 */
proxy.on('error', function (err, req, res, targeterr) {
  /* 给予客户端友好提示 */
  res.setHeader('Content-Type', 'application/json; charset=utf-8')
  res.write(JSON.stringify({ code: 500, info: `Mock代理请求超时[${req.path}]` }))
  res.end()
})
function nextHPM(req, res) {
  req.url = pathRewrite(req.url)
  proxy.web(req, res, {
    target: 'http://xxx.xx.xxx.xx:7300/mock/',
    changeOrigin: true,
    xfwd: true,
    preserveHeaderKeyCase: true,
    /* nodejs Server 默认超时时间为2分钟,设置 proxyTimeout 缩短超时时间,避免客户端接口调用长时间处于 pending 状态 */
    proxyTimeout: 5 * 1000 /* 代理未收到目标(target)的响应时超时(毫秒)。 */
  })
}

4. devServer.proxy 转递到 easy-mock 时,POST 携带请求体请求失败(客户端现象就是接口 pending 状态直到代理失败 failed 状态;服务端则程序抛出错误:Error: socket hang up),GETPOST 不携带请求体可正常响应。

排查错误“原因”:

devServer.proxy 代理处理到 easy-mock 时,request的请求体被篡改(?)或数据包破损(?),导致代理到 easy-mock 服务端时,请求体不能被正确解析(不在常规的content-type 解析范围内(?)),因此报错导致响应超时,客户端代理服务报错Error: socket hang up

问题解决:

/* devServer.proxy.onProxyReq */
onProxyReq(proxyReq, req) {
  const date = new Date()
  date.setMinutes(date.getMinutes() - date.getTimezoneOffset())
  req.timestamp = date.toJSON()
  info(`[NP - ${req.timestamp}]:${req.path}`/* 原始路径 */)
  info(`[PP - ${req.timestamp}]:${proxyReq.path}`/* 代理路径 */)
  /* 向原始请求中缓存请求体 */
  /* 注意:req 注册的以下 data 和 end 事件,只会在这个阶段(onProxyReq)内生效 */
  let bodybuffer = Buffer.from('', 'utf8')
  req.on('data', (chunk) => (bodybuffer = Buffer.concat([bodybuffer, chunk])))
  req.on('end', function () {
    req.bodybuffer = bodybuffer
  })
},

/* httpProxy.createProxyServer().on('proxyReq', handler) */
proxy.on('proxyReq', function (proxyReq, req, res, options) {
  /* Mock请求实例设置POST请求体 */
  /* 注意:此处为针对请求体进行重新设置 */
  const rb = req.bodybuffer
  if (req.bodybuffer) {
    proxyReq.setHeader('content-type', 'application/json; charset=utf-8')
    proxyReq.setHeader('content-length', Buffer.byteLength(rb))
    proxyReq.write(rb.toString('utf-8'))
    proxyReq.end()
  }
})

5.相关文档

Vue-cli 官方文档
Github源码 Vue-cli
NPM vue-cli文档
Github源码 http-proxy-middleware
NPM http-proxy-middleware(devServer.proxy)文档
Github源码 http-proxy(node-http-proxy)
NPM http-proxy(http-proxy-middleware的基础依赖)文档
Github源码 easy-mock

 类似资料: