kaola-fed / blog

kaola blog
722 stars 56 forks source link

社区后台数据mock解决方案 #269

Open FridaS opened 6 years ago

FridaS commented 6 years ago

2018年06月29日

NEI常用功能介绍

NEI(Netease Easy Interface) 是一个为我们提供接口约定、维护的接口管理平台,它同时提供了自动化构建工具

简单介绍下NEI常用的几个功能:

社区后台原有数据mock方案

// package.json
"scripts": {
    "dev": "node build/dev-server.js"
}
// build/dev-server.js
var express = require('express')
var app = express()
app.use(require('./../mock')
// /mock/index.js
var fs = require('fs')
var path = require('path')
var stripJsonComments = require('strip-json-comments')

var resolveMockDataPath = function(mockDir, filePath) {
    if (filePath.indexOf('/') === 0) {
        filePath = filePath.slice(1, filePath.length)
    }
    return path.resolve(mockDir, filePath)
}

var readFile = function(extname) {
    extname = extname || '.json'
    return function(filePath) {
        filePath += extname
        let exists = fs.existsSync(filePath)
        if (exists) {
            return fs.readFileSync(filePath, 'UTF-8')
        }
        return exists
    }
}

var readJSONFile = readFile()
var readMockData = function(filePath) {
    return readJSONFile(filePath)
}
var mockDir = path.resolve(__dirname, '../mock')

var getFilePath = require('./mockRouterMap').getFilePath

var initMockMiddleware = function(request, response, next) {
    var requestPath = request.path
    var method = request.method.toLowerCase()
    let mockDataPath = getFilePath(requestPath, method, request.xhr)
    if (mockDataPath) {
        let content = readMockData(resolveMockDataPath(mockDir, mockDataPath))
        if (content) {
            response.status(200).json(JSON.parse(stripJsonComments(content)))
        } else {
            var NO_FOUND_CODE = 404
            response.json(NO_FOUND_CODE, {
                code: NO_FOUND_CODE,
                msg: '接口数据未定义'
            })
        }
    } else {
        next()
    }
}

module.exports = initMockMiddleware
// mockRouterMap.js
const path2Regexp = require('path-to-regexp')
const MOCK_DATA_DIR = './data'

const initMockRouterReg = function (map) {
    var regMap = new Map()
    for (var pathReg in map) {
        var keyArr = map[pathReg].split(/\s/)
        var pathInfo = {}, urlReg
        if (keyArr.length > 1) {
            urlReg = keyArr[1]
            pathInfo.method = keyArr[0].toLowerCase()
        } else {
            urlReg = keyArr[0]
        }
        pathInfo.mockFile = MOCK_DATA_DIR + map[pathReg]
        regMap.set(path2Regexp(urlReg), pathInfo)
    }
    return regMap
}
var routeMap = {
    'get /api/user/userInfo': '/api/user/userInfo', // 用户信息
    'post /api/novel/list': '/api/novel/list', // 长文-已发布列表
    'post /api/novel/listDraft': '/api/novel/listDraft', // 长文-草稿列表
    'post /api/novel/edit/*': '/api/novel/edit', // 长文-编辑
    'post /api/novel/edit/10086/1': '/api/novel/edit/10086/1', // 长文-编辑-草稿
    'post /api/novel/edit/10086/2': '/api/novel/edit/10086/2', // 长文-编辑-长文
    'post /api/novel/delete': '/api/novel/delete', // 长文-删除草稿
    'post /api/novel/save': '/api/novel/save', // 长文-保存草稿
    'post /api/img/upload': '/api/img/upload', // 上传图片
    'post /api/article/goodsInfo': '/api/article/goodsInfo', // 长文-获取商品信息
    'post /api/novel/user': '/api/novel/user', // 长文-获取用户信息
    'post /api/novel/article': '/api/novel/article', // 长文-获取用户信息
    'get /api/novel/cell/permission': '/api/novel/cell/permission' // 长文-获取用户权限
}
const pathRegMap = initMockRouterReg(routeMap)
module.exports = {
    getFilePath (requestPath, method, isXhr) {
        var filePath = false
        pathRegMap.forEach(function (pathInfo, urlReg) {
            var limitMethod = pathInfo.method
            if (urlReg.test(requestPath)) {
                filePath = pathInfo.mockFile
                if (limitMethod && limitMethod !== method && isXhr) {
                    filePath = false
                }
            }
        })
        return filePath
    }
}

从上面代码可以看出,该方案使用本地mock文件存放接口的返回数据。其缺点非常明显:

  1. 需要手动维护接口和mock文件的对应关系;
  2. 需要手动添加mock文件和数据;
  3. 与nei脱离(没有把已有的nei mock数据用起来);
  4. mock方式单一,只能使用本地mock,而不能使用nei线上提供的mock数据、也不能使用线上或测试环境数据。

原有数据mock方案与NEI有机结合

NEI作为一个定义、维护接口的平台,使用方便、非常便于接口管理。另外,QA使用的接口测试平台gotest与NEI对接,这就要求开发必须在NEI上维护接口约定。

那么如何把NEI与原有mock方案有机地结合起来呢?

社区后台的解决方案是:使用原有中间件,利用NEI提供的mock数据和自动化构建方案替换原来的手动mock(包括手动创建mock文件和数据、手动维护接口和mock文件的对应关系)方式,并增加使用线上NEI提供的mock数据功能 和 代理到线上/测试环境的功能。

// /mock/proxy.config.js
const proxy = require('http-proxy-middleware')
const NO_NEED_PROXY = process.env.NO_NEED_PROXY
const proxyTarget = 'http://content-kl.netease.com'
const proxyTable = NO_NEED_PROXY ? [] : [
    proxy('/api', {
        target: proxyTarget,
        changeOrigin: true,
    }),
    proxy('/community', {
        target: proxyTarget,
        changeOrigin: true,
    })
]

module.exports = {
    // 项目的nei唯一标识
    key: '07841b89b63b942b1bb0abcfd090685d',
    // 是否使用 nei 提供的在线 mock 数据
    neiOnline: true,
    // 是否代理到测试/线上环境,只有当neiOnline为false时才有效:true - 代理到proxy target,false - 使用本地mock数据
    useProxy: false,
    // 代理环境配置
    proxyTable
}
// build/dev-server.js
const { neiOnline, useProxy, proxyTable } = require(path.resolve(__dirname, './../mock/proxy.config.js'))
var express = require('express')
var app = express()
if (neiOnline) {
    console.log('use nei mock data online')
    app.use(require('./../mock/nei-online.js'))
} else if (useProxy && proxyTable.length >= 0) {
    console.log('use proxy')
    app.use(proxyTable)
} else {
    console.log('user local mock')
    app.use(require('./../mock'))
}

1. 使用nei提供的在线mock数据

nei本身提供了使用nei在线mock数据的方法:nei server可以启动本地模拟容器,设置 server.config.js 文件的 online: true就可以使用nei提供的在线mock数据了。

那么不使用nei server,该怎么实时拿到nei线上mock数据呢?剖析nei-toolkit源码,发现nei上定义的每个接口都可以通过https://nei.netease.com/api/mockdata?path=${requestPath}&type=3&key=${项目key}&method=${method}请求来返回结果数据。(其中requestPath是接口url,type为3表示api接口、1表示页面接口,key是项目唯一标识码,method是请求方法如get或post)

所以我们方案是:

nei-online.js代码略。

2. 本地mock

原有的本地mock方案,是根据请求和mock文件的对应关系去取/mock/data下的相应mock文件,那么我们可以根据nei提供的mock文件替换掉/mock/data下的文件根据server.config.js自动生成接口和mock文件对应关系routeMap,从而将原有本地mock中间件与NEI有机结合起来。

// package.json
"scripts": {
    "mock": "NO_NEED_PROXY=true node mock/nei-mock.js"
}
// /mock/nei-mock.js
const exec = require('child_process').exec
const fs = require('fs')
const path = require('path')
const os = require('os')
const globule = require('globule')
const yargs = require('yargs')
const rimraf = require('rimraf')
const async = require('async')
const { key } = require('./proxy.config')

// 命令行参数
let argv = yargs
    .option('f', {
        alias: 'force',
        describe: 'force to pull data from nei',
        boolean: true,
        default: false
    })
    .help('h')
    .alias('h', 'help')
    .alias('v', 'version')
    .version('0.0.1')
    .usage('Usage: hello [options]')
    .example('npm run mock, npm run mock -- -f, npm run mock -- --force')
    .argv

const neiBaseDir = path.resolve(os.homedir(), 'localMock', key)
const copyTar = path.join(__dirname, './../mock/data')

// 判断文件/文件夹是否已存在
function fsExistsSync (path) {
    try {
        fs.accessSync(path, fs.F_OK)
    } catch (e){
        return false
    }
    return true
}

// 复制文件
let copyFile = (src, tar, cb) => {
    console.log('file update:', tar)
    let rs = fs.createReadStream(src)
    rs.on('error', (error) => {
        if (error) {
            console.log('file read error:', src)
        }
        cb && cb(error)
    })

    let ws = fs.createWriteStream(tar)
    ws.on('error', (error) => {
        if (error) {
            console.log('file write error:', tar)
        }
        cb && cb(error)
    })
    ws.on('close', (ex) => {
        cb && cb(ex)
    })

    rs.pipe(ws)
}

// 复制文件夹
let copyFolder = (srcDir, tarDir, cb) => {
    fs.readdir(srcDir, (error, files) => {
        if (error) {
            console.log('readdir error:', error)
            cb && cb(error)
            return
        }
        files.forEach((file) => {
            let srcPath = path.join(srcDir, file)
            let tarPath = path.join(tarDir, file)
            fs.stat(srcPath, (error, stats) => {
                if (error) {
                    console.log('stat error:', error)
                    return
                }
                if (stats.isDirectory()) {
                    console.log('mkdir:', tarPath)
                    fs.mkdir(tarPath, (error) => {
                        if (error && error.code !== 'EEXIST') {
                            console.log('mrdir error:', error)
                            return
                        }
                        // 无异常 或 已经存在的文件夹(error.code === 'EEXIST'),复制文件夹内容
                        copyFolder(srcPath, tarPath, cb)
                    })
                } else if (file === 'data.json') {
                    // 是文件,且文件名是 data.json
                    let newTarDir = tarDir + '.json'

                    if (!fsExistsSync(newTarDir)) {
                        copyFile(srcPath, newTarDir, cb)
                    } else {
                        console.log('file exist:', newTarDir)
                    }

                    // 删除data.json的上一级目录
                    rimraf(tarDir, (error) => {
                        if (error) {
                            console.log('rmdir error:', error)
                            return
                        }
                    })
                }
            })
        })
        // 为空时直接回调
        files.length === 0 && cb && cb('files is empty')
    })
}

let createMockData = (neiBaseDir) => {
    const copySrcGET = path.join(neiBaseDir, './mock/get')
    const copySrcPOST = path.join(neiBaseDir, './mock/post')
    copyFolder(copySrcGET, copyTar, (error) => {
        if (error) {
            console.log('copy get error:', error)
            return
        }
    })
    copyFolder(copySrcPOST, copyTar, (error) => {
        if (error) {
            console.log('copy post error:', error)
            return
        }
    })
}

// 从nei的server.config.js提取route map
let routeMap = (folderPath) => {
    let srcPath = path.resolve(folderPath, './server.config.js')
    let tarPath = path.join(__dirname, './routeMap.json')

    let serverContent = require(srcPath)
    let { routes } = serverContent

    // 将格式化后的数据写入tarPath所在文件
    fs.writeFile(tarPath, formatRoutes(routes), (error) => {
        if (error) {
            console.log('write file error:', error)
            return
        }
        console.log('update route map: success')
    })
}

// format server.config.js 的 routes,返回格式化后的对象
let formatRoutes = (routes) => {
    let result = {}
    for (let key in routes) {
        result[key] = key.split(' ')[1]
    }
    // JSON.stringify后两个参数可以让json文件换行、4空格缩进 格式化显示
    return JSON.stringify(result, null, 4)
}

let softUpdate = (cb) => {
    const neiServerConfig = path.resolve(neiBaseDir, './nei**')
    let configPathArr = globule.find(neiServerConfig)

    // 从nei拉取mock数据
    const neiBuild = `nei build -k ${key} -o ${neiBaseDir}`
    // nei update: 更新接口文件,但本地已存在的不覆盖;
    // nei update -w: 覆盖已存在的文件,但本地已存在、nei已删除的文件不处理(需要用户手动删除)。
    // const neiUpdate = `cd ~/localMock/${key} && nei update -w`
    const neiUpdate = `cd ~/localMock/${key} && nei update`
    const cmdStr = (configPathArr && configPathArr.length) ? neiUpdate : neiBuild
    console.log('nei exec start:', cmdStr)

    // 每次执行命令,总是先 nei build 或 nei update,然后更新本地的数据
    exec(cmdStr, (error, stdout, stderr) => {
        console.log('nei exec end')
        if (error) {
            cb && cb('cmd exec error')
            console.log('cmd exec error:', error)
            console.log('cmd exec stdout:', stdout)
            console.log('cmd exec stderr:', stderr)
            return
        }

        !configPathArr[0] && (configPathArr = globule.find(neiServerConfig))
        routeMap(configPathArr[0])
        createMockData(neiBaseDir)

        cb && cb()
    })
}

// 删除 ~/localMock/${key}文件
let removeLocalMock = (cb) => {
    console.log('remove localMock start:', neiBaseDir)
    rimraf(neiBaseDir, (error) => {
        if (error) {
            cb && cb('remove localMock error')
            console.log('remove localMock error:', error)
            return
        }
        console.log('remove localMock end')
        cb && cb()
    })
}

// 删除本工程mock/data下的文件
let removeProjectMockData = (cb) => {
    console.log('remove project mock data start')
    fs.readdir(copyTar, (error, files) => {
        if (error) {
            cb && cb('remove project mock data readdir error')
            console.log('readdir error:', error)
            return
        }
        files.forEach((file) => {
            let theFolder = path.join(copyTar, file)
            rimraf(theFolder, error => {
                if (error) {
                    cb && cb('remove project mock data error')
                    console.log('remove project mock data error:', error)
                    return
                }
                console.log('remove project mock data end')
            })
        })
        // 为空时直接回调
        files.length === 0 && console.log('project mock data is empty')
        cb && cb()
    })
}

let hardUpdate = () => {
    async.series([
        removeLocalMock, // 删除 ~/localMock/${key}文件
        removeProjectMockData, // 删除本工程mock/data下的文件
        softUpdate // 重新拉取
    ],
    (err, results) => {
        if (err) {
            console.log('async series error:', err)
        }
    })
}

let main = () => {
    if (argv.f) {
        // 强制从nei拉取数据、覆盖本地mock数据
        hardUpdate()
    } else {
        // 更新nei新增接口、保留本地mock数据
        softUpdate()
    }
}

main()

注意:nei拉取到本地的文件结构是在nei工程规范中定义的。

mockRouterMap.js文件修改(只贴出修改的代码):

// mockRouterMap.js
const fs = require('fs')
const path = require('path')
const ROUTE_MAP = './routeMap.json'

// 删除原先的routeMap
let routeMapPath = path.join(__dirname, ROUTE_MAP)
let routeMap = JSON.parse(fs.readFileSync(routeMapPath))

3. 代理到线上/测试环境

通过http-proxy-middleware把请求代理转发到其他服务器,从而响应得到其他服务器上该请求的返回数据。

关键代码:

// mock/proxy.config.js
const proxy = require('http-proxy-middleware')

const proxyTarget = 'http://content-kl.netease.com'
const proxyTable = [
    proxy('/api', {
        target: proxyTarget,
        changeOrigin: true,
    }),
    proxy('/community', {
        target: proxyTarget,
        changeOrigin: true,
    })
]

module.exports = {
    // 代理环境配置
    proxyTable
}
// build/dev-server.js
const { proxyTable } = require(path.resolve(__dirname, './../mock/proxy.config.js'))
app.use(proxyTable)

线上/测试环境代理到本地debug

当我们需要定位线上或测试环境的问题时,通常的做法是拦截资源请求、使其走本地资源(如使用Fiddler),这样就可以在本地定位问题了。社区组娄涛同学写了个proxy-localgithub),可以在不使用代理工具的情况下让线上/测试环境请求本地资源。

下一步

总结

我们拿到一个需求之后,为了达到前后端分离的高效开发方式,开发各阶段都需要不同的数据mock方式。

参考

  1. https://note.youdao.com/group/#/12651257/(full:collab/113122602)?gid=12651257&filterState=true&noPush=true
  2. https://github.com/NEYouFan/nei-toolkit
  3. https://github.com/NEYouFan/nei-toolkit/blob/master/README.md
  4. https://note.youdao.com/group/#/42540264/(full:md/190946293)?gid=42540264&filterState=true
  5. https://www.cnblogs.com/zhoujie/p/nodejs2.html
  6. https://www.liaoxuefeng.com/wiki/001434446689867b27157e896e74d51a89c25cc8b43bdb3000/001434501497361a4e77c055f5c4a8da2d5a1868df36ad1000
  7. http://javascript.ruanyifeng.com/nodejs/fs.html
  8. http://nodejs.cn/api/
  9. http://www.cnblogs.com/rubylouvre/archive/2011/11/28/2264717.html
  10. https://itbilu.com/nodejs/core/E1Abosjbe.html
  11. http://ourjs.com/detail/59a53a1ff1239006149617c6
  12. https://itbilu.com/nodejs/core/4JGAlesbl.html
  13. http://www.ruanyifeng.com/blog/2016/10/npm_scripts.html
  14. http://www.webmxx.com/2017/06/13/package-json-script/
  15. http://www.ruanyifeng.com/blog/2015/05/command-line-with-node.html

by Fridas