@cvb740
2019-07-27T08:04:08.000000Z
字数 28427
阅读 2472
nodejs
在开发阶段我们常常面临着接口对接的问题,常见的需求就是接口代理、配置跨域以及mock来模拟接口,接下来我们就来深入剖析一下背后的机制,在这个过程中,我们可以思考下如何才能做的更好。
常见的可以实现接口代理和跨域的方式如下:
我们都知道,开发阶段webapck-dev-server有一个proxy字段,可以将某些接口代理到后台服务器上去,也可以用来解决开发环境的跨域问题。
一个典型的proxy配置:
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://192.168.1.238:8076',
changeOrigin: true,
// 是否重写路径
pathRewrite: { '^/api': '' },
ws: true, // enable websocket proxy
// 不检查安全问题,可以接受运行在 HTTPS 上,使用无效证书的后端服务器
secure: false,
// 定义请求头
header: {
Cookie: 'JSESSIONID=9848e0b7-efdd-4cdf-a310-40ad20368a31'
// Authorization: 'JSESSIONID=9848e0b7-efdd-4cdf-a310-40ad20368a31',
}
}
}
}
}
webpack-dev-server 背后使用了非常强大的 http-proxy-middleware,常见的代理配置如下:
target:将使用url模块进行解析的url字符串
forward:将使用url模块进行解析的url字符串
agent:将传递给http(s).request的对象(请参阅Node的https代理和http代理对象)
ssl:将传递给https.createServer()的对象
ws:true / false,是否代理websockets
xfwd:true / false,添加x-forward标头
secure:true / false,是否验证SSL Certs
toProxy:true / false,传递绝对URL作为路径(对代理代理很有用)
prependPath:true / false,默认值:true - 指定是否要将目标的路径添加到代理路径
ignorePath:true / false,默认值:false - 指定是否要忽略传入请求的代理路径(注意:如果需要,您必须附加/手动)。
localAddress:要为传出连接绑定的本地接口字符串
changeOrigin:true / false,默认值:false - 将主机标头的原点更改为目标URL
http-proxy-middleware 库又借助于 node-http-proxy,将 node 服务器接收到的请求转发到目标服务器,实现代理服务器的功能。
node-http-proxy 可以帮助我们转发 http 请求,其实现的原理是利用 http 或 https 模块创建一个 node 代理服务器,将客户端发送的请求数据转发到目标服务器,再将响应输送到客户端。
示例:node-http-proxy 创建一个基础的代理服务器
const http = require('http')
const httpProxy = require('http-proxy')
//
// Create a proxy server with custom application logic
//
const proxy = httpProxy.createProxyServer({})
//
// Create your custom server and just call `proxy.web()` to proxy
// a web request to the target passed in the options
// also you can use `proxy.ws()` to proxy a websockets request
//
const server = http.createServer(function (req, res) {
// You can define here your custom logic to handle the request
// and then proxy the request.
proxy.web(req, res, { target: 'http://127.0.0.1:5060' })
})
server.listen(5050)
在这里,http-proxy-middleware 利用 node-http-proxy 创建代理服务器 proxyServer 后,通过全局注册的转发规则获取到客户端请求 req 需要发送到的目标地址,再通过调用 proxyServer.web, proxyServer.ws 方法转发请求。这是为了兼容webSocket的代理。
http-proxy-middleware 创建代理的过程:
1. 解析 context,options 配置,获得全局注册的 context, options.target;
2. 使用 node-http-proxy 库创建代理服务器 proxyServer。
3. 根据 options.pathRewrite 生成路径转化器 pathRewriter。
4. 为代理服务器绑定事件。
5. 创建转发 http, https, websocket 请求的代理中间件。
webpack-dev-server实际上是启动了一个express服务器,原理就是解析上述的配置项,并挂载 http-proxy-middleware 中间件。具体可以查看setupProxyFeature
setupProxyFeature() {
/**
* Assume a proxy configuration specified as:
* proxy: {
* 'context': { options }
* }
* OR
* proxy: {
* 'context': 'target'
* }
*/
// 解析一下配置
if (!Array.isArray(this.options.proxy)) {
if (Object.prototype.hasOwnProperty.call(this.options.proxy, 'target')) {
this.options.proxy = [this.options.proxy];
} else {
this.options.proxy = Object.keys(this.options.proxy).map((context) => {
let proxyOptions;
// For backwards compatibility reasons.
const correctedContext = context
.replace(/^\*$/, '**')
.replace(/\/\*$/, '');
if (typeof this.options.proxy[context] === 'string') {
proxyOptions = {
context: correctedContext,
target: this.options.proxy[context],
};
} else {
proxyOptions = Object.assign({}, this.options.proxy[context]);
proxyOptions.context = correctedContext;
}
proxyOptions.logLevel = proxyOptions.logLevel || 'warn';
return proxyOptions;
});
}
}
// ...
当然,上述的实现是较为复杂的,如果我们只是想通过简单的路径匹配来实现接口代理,可以通过express配合http-proxy-middleware中间件配合实现。
先从一个简单的示例开始:
const express = require('express')
const request = require('request')
const app = express()
app.use('/', (req, res) => {
const url = 'https://www.baidu.com/' + req.url
req.pipe(request(url)).pipe(res)
})
app.listen(process.env.PORT || 3000)
核心是这一句:
req.pipe(request(url)).pipe(res)
下面我来解释一下:
request是一个node端进行http请求的工具,可以很简单的实现下载文件、爬虫、代理等功能。
request.get('http://google.com/img.png').pipe(request.put('http://mysite.com/img.png'))
不过之前 request 作者 Mikeal Rogers 提了一个issue:Request's Past, Present and Future, 表示 request 已进入维护模式,并停止考虑添加新功能或发布主要版本。核心原因其实是随着现在javascript的发展,request 的局限性愈发体现出来,核心模式也稍显过时。作者表示也曾尝试通过改变来适应变化,但后来发现可行性非常低 —— 兼容性是很大的问题。尽管其做了一些尝试,包括对promise的支持:request-promise
所以或许我们可以试试 axios,axios也是前后端通用的请求库,得益于vue和react的强大控制力,axios得到了最大化的扩张。实际上利用axios我们也可以同样实现request的功能。这里分享一个我之前简单写的一个利用axios来下载最新的iconfont文件夹到本地的脚本:
// config.js
module.exports= {
// 最后要放置iconfont的文件夹
outputDir: '../../../public/static/iconfont/',
// 在iconfont项目的下载页,点击下载按钮,在控制台的network面板即可看到项目id和cookie
// 项目project id
pid: '1224431',
// 下载时的cookie,这里的cookie只是示例
cookie:
'ctoken=e8zxMOeDY24gAcQxvoy2kjwE;'
}
// index.js
const fs = require('fs')
const path = require('path')
const axios = require('axios')
const unzip = require('unzip2')
function resolve(...dir) {
return path.join(__dirname, ...dir)
}
const filePath = resolve('zip/iconfont.zip')
const unzipPath = resolve('./temp')
const { outputDir, cookie, pid } = require('./config.js')
// 最后要放置iconfont的文件夹
const lastPath = resolve(outputDir)
function downloadIconfont() {
axios({
method: 'GET',
url: 'https://www.iconfont.cn/api/project/download.zip',
responseType: 'stream',
params: {
pid
},
headers: {
cookie
}
})
.then(res => {
const rs = res.data
const ws = fs.createWriteStream(filePath)
rs.on('end', () => {
deleteDir(unzipPath)
const unzipWs = fs.createReadStream(filePath)
const extractWs = unzip.Extract({ path: unzipPath })
extractWs.on('close', () => {
travelDirSync(unzipPath, filePath => {
const baseName = path.basename(filePath)
const target = path.join(lastPath, baseName)
const source = filePath
copyFile(source, target)
})
console.log('下载iconfont完成')
})
unzipWs.pipe(extractWs)
})
rs.pipe(ws)
})
.catch(err => {
console.log('err: ', err)
console.log(
'下载iconfont失败,请重新去iconfont官网的项目页复制新的cookie替换当前cookie'
)
})
}
downloadIconfont()
function deleteDir(dirPath) {
var files = []
if (fs.existsSync(dirPath)) {
files = fs.readdirSync(dirPath)
files.forEach(function(file, index) {
var curPath = path.join(dirPath, file)
if (fs.statSync(curPath).isDirectory()) {
deleteDir(curPath)
} else {
fs.unlinkSync(curPath)
}
})
fs.rmdirSync(dirPath)
} else {
console.log('要删除的目录不存在')
}
}
function travelDirSync(dirPath, callback) {
if (typeof callback !== 'function') {
console.log('传入的回调应该是一个函数')
return false
}
if (!fs.existsSync(dirPath)) {
console.log('文件夹不存在: ', dirPath)
return false
}
const files = fs.readdirSync(dirPath)
files.forEach(filename => {
const filePath = path.join(dirPath, filename)
const stats = fs.statSync(filePath)
if (stats.isDirectory()) {
travelDirSync(filePath, callback)
} else {
callback(filePath)
}
})
}
function copyFile(source, target) {
const rs = fs.createReadStream(source)
const ws = fs.createWriteStream(target)
rs.pipe(ws)
}
这里的回调有点多,我暂时没有去使用对应插件的promise封装。
你可以将其配置在package.json的scripts字段中,这样每次运行npm run download:iconfont
即可下载最新的iconfont到本地文件夹下
这里的pipe来自于node中的stream,stream是一种处理流数据的抽象接口,常见的如http.IncomingMessage类、fs.createReadStream类等都继承流的私有属性和公有方法。
此处的 req 和 res 也属于 Stream 的消费接口(前者为 Readable Stream,后者为 Writable Stream)。我们以管道的方式把一个可读流req的输出连接到了一个可写流res的输入。在这里request就充当了代理请求的作用。
我们可以看到Linux中也存在着流的机制,流带给我们组合代码的能力,就像我们可以通过管道连接几个简单的Linux命令组合出强大的功能一样。
举个例子:在shell中使用管道处理数据
#!/bin/bash
# 获取package.json中的版本号
grep '"version"' package.json | cut -d'"' -f4
# 查看系统中所有的用户名称,并按字母排序
awk -F: '{print $1}' /etc/passwd | sort
# 查看当前目录的子目录个数
ls -l | cut -c 1 | grep "d" | wc -l
# 合并两个文件的内容
cat 1.txt | paste -d: 2.txt -
其实你们会发现gulp中也有类似于pipe的机制:
const gulp = require('gulp')
const babel = require('gulp-babel')
gulp.task('babel', () => {
gulp
.src('src/app.js')
.pipe(
babel({
presets: ['es2015']
})
)
.pipe(gulp.dest('dist/js'))
})
const less = require('gulp-less')
const autoprefix = require('gulp-autoprefixer')
gulp.task('css', () => {
gulp
.src('styles/app.less')
.pipe(less())
.pipe(autoprefix('last 2 version'))
.pipe(gulp.dest('dist/css'))
})
其实gulp也是基于nodejs stream的,gulp通过各种 Transform Stream 来实现文件的处理,再进行输出。Transform Streams 是 nodejs stream 的一种,是可读又可写的,它会对传给它的对象做一些转换的操作。运行过程:
文件输入 → Gulp 插件处理 → 文件输出
在 gulp 的任务中,gulp.src 将匹配到的文件转化为可读(或 Duplex/Transform)流,通过 pipe 流经各插件进行处理,最终推送给 gulp.dest 所生成的可写(或 Duplex/Transform)流并生成文件。
前面我们简单讲了一下利用request来进行简单的路径匹配的代理,但这样我们很难实现复杂的代理配置,同时onProxyReq和onProxyRes也并未实现,接下来我们来实现一下创建自己的复杂配置的代理服务器。
先从一个简单的例子开始:
// include dependencies
const express = require('express')
const proxy = require('http-proxy-middleware')
// proxy middleware options
const options = {
target: 'http://www.example.org', // target host
changeOrigin: true, // needed for virtual hosted sites
ws: true, // proxy websockets
pathRewrite: {
'^/api/old-path': '/api/new-path', // rewrite path
'^/api/remove/path': '/path' // remove base path
},
router: {
// when request.headers.host == 'dev.localhost:3000',
// override target 'http://www.example.org' to 'http://localhost:8000'
'dev.localhost:3000': 'http://localhost:8000'
}
}
// mount `exampleProxy` in web server
const app = express()
app.use('/api', proxy(options))
app.listen(3000)
这里已经可以实现将/api的接口代理到www.example.org,想要实现一个代理配置表也非常容易:
const merge = require('deepmerge')
// 全局的通用默认代理配置
const commonOpts = {
changeOrigin: true,
ws: false, // enable websocket proxy
secure: false,
xfwd: true,
logLevel: 'debug',
pathRewrite: {
[`^proxy-api`]: ''
},
onProxyReq (proxyReq, req, res) {
// add custom header to request
proxyReq.setHeader('X-Forwarded-To', 'swagger-koa')
},
onProxyRes (proxyRes, req, res) {
const key = 'set-cookie'
let val = proxyRes.headers[key]
if (val) {
const cookies = val.join('').split(' ')
// 切割掉一些严格的安全校验,只保留了第一项和Path,这样secure、domain都被忽略了。
val = [cookies[0], 'Path=/'].join(' ')
}
proxyRes.headers['X-Proxy-From'] = 'swagger-koa'
}
}
// 配置代理表
const proxyTableArr = [
{
// 代理规则的名字,用于识别该条代理规则
name: '对报表的接口进行代理',
// 根据路径名pathname或请求req参数来过滤,返回true就会使用下面的代理选项options
filter (pathname, req) {
return pathname.match('/report/')
},
// https://github.com/chimurai/http-proxy-middleware#options
// 代理的选项,该选项与http-proxy-middleware选项一致
options: {
// 代理到的目标地址
target: 'http://192.168.1.238:8000'
}
},
{
name: '对平台相关的接口进行代理',
filter (pathname, req) {
return pathname.match('/platform/')
},
options: {
target: 'http://192.168.1.238:8080'
}
},
{
name: '其余带有/easy-mock前缀的接口代理到easy-mock官网',
filter (pathname, req) {
return pathname.match('/easy-mock/')
},
options: {
target: 'https://www.easy-mock.com/mock/5c1cb192281d50211bd8549d'
}
}
]
// 初始化代理的配置
function initProxy (app, defaultOpts = {}, proxyTableArr = []) {
proxyTableArr.forEach(item => {
let { filter, options } = item
// options = { ...commonOpts, ...defaultOpts, ...options }
// 这里要采用深合并
options = merge.all([{}, commonOpts, defaultOpts, options])
app.use(proxy(filter, options))
})
}
这里暂时没有做对函数的合并策略,将来有空研究下再做上去。这里其实也不支持自定义选项合并策略,可以参照:或者你也可以使用webpack-merge
config.optionMergeStrategies.myOption = function (toVal, fromVal) {
// 返回合并后的值
}
在这里我们可以看一下vue的源码,里头有很清楚的解释:
MergeStrategies
你可以看到vue是怎么实现巧妙的实现合并的,以及vue对函数和生命周期是如何实现mixin的,还有如何让开发者自定义合并策略的。
代理存在一些事件,我们还没有处理,类似下面的事件能提供代理源或代理日志的功能将来会提供支持,尤其是有多个同时存在的情况下:
//
// Listen for the `proxyRes` event on `proxy`.
//
proxy.on('proxyRes', function (proxyRes, req, res) {
console.log('RAW Response from the target', JSON.stringify(proxyRes.headers, true, 2));
});
实际上,以上的这些代理配置其实非常复杂,尤其是每改一次配置,webpack都需要重启,尤其是在我当时较大的PC端同时包含五个子系统项目下,重启几乎需要60秒,而且较难验证代理配置是否正确,尤其在部分接口走测试接口服务器,一部分走后台人员开发机上,还有一部分要走easy-mock的mock服务器的情况下,多个小组成员的proxy代理配置是比较容易产生冲突的,有时候你根本不知道接口来自哪里,于是便希望能公用一台代理服务器来随时同步更新配置,同时可以可视化的编辑和调试。
接下来我们来看一个轻量级反向代理服务器Nginx
在现在前后端分离的背景下,前后端通常是独立部署的,利用Nginx可以很好的动静分离,将静态资源放到Nginx上,由Nginx管理,动态请求则转发给后端。
关于Nginx的介绍什么的就不多说了,我们就直接来配置一下nginx,这里我们就用CentOS Linux release 7.6.1810 (Core),nginx/1.12.2来实验一下。配置Nginx其实是在配置nginx.conf,nginx.conf是典型的分段配置文件,我们来看一个典型的nginx配置:
cd /etc/nginx
vim nginx.conf
http {
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 以下是gzip的配置
gzip on; # 启用 gzip 压缩功能
gzip_static on;
gzip_vary on;
gzip_min_length 1k; # 最小压缩的页面,如果页面过于小,可能会越压越大,这里规定大于1K的页面才启用压缩
gzip_buffers 4 16k; # 设置系统获取几个单位的缓存用于存储gzip的压缩结果数据流
gzip_http_version 1.1; # 默认值是1.1,就是说对HTTP/1.1协议的请求才会进行gzip压缩
gzip_comp_level 9; # 压缩级别,1压缩比最小处理速度最快,9压缩比最大但处理最慢,同时也最消耗CPU,一般>设置为3就可以了
#要压缩的mine类型,即什么类型的页面或文档启用压缩
gzip_proxied expired no-cache no-store private auth; # nginx 做前端代理时启用该选项,表示无论后端
#服务器的headers头返回什么信息,都无条件启用压缩
gzip_disable "MSIE [1-6]\."; # IE6不支持gzip
# gzip配置结束
# Load modular configuration files from the /etc/nginx/conf.d directory.
# See http://nginx.org/en/docs/ngx_core_module.html#include
# for more information.
include /etc/nginx/conf.d/*.conf;
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
root /usr/share/nginx/html;
# Load configuration files for the default server block.
include /etc/nginx/default.d/*.conf;
location / {
root html;
index index.html index.htm;
}
error_page 404 /404.html;
location = /40x.html {
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
}
}
# Settings for a TLS enabled server.
#
# server {
# listen 443 ssl http2 default_server;
# listen [::]:443 ssl http2 default_server;
# server_name _;
# root /usr/share/nginx/html;
#
# ssl_certificate "/etc/pki/nginx/server.crt";
# ssl_certificate_key "/etc/pki/nginx/private/server.key";
# ssl_session_cache shared:SSL:1m;
# ssl_session_timeout 10m;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;
#
# # Load configuration files for the default server block.
# include /etc/nginx/default.d/*.conf;
#
# location / {
# }
#
# error_page 404 /404.html;
# location = /40x.html {
# }
#
# error_page 500 502 503 504 /50x.html;
# location = /50x.html {
# }
# }
}
我们主要会关心虚拟主机的server字段:
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
root /usr/share/nginx/html;
# Load configuration files for the default server block.
include /etc/nginx/default.d/*.conf;
location / {
root html;
index index.html index.htm;
}
error_page 404 /404.html;
location = /40x.html {
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
}
}
解释一下:
server 配置虚拟主机的相关参数,可以有多个
server_name 通过请求中的host值 找到对应的虚拟主机的配置
location 配置请求路由,处理相关页面情况,location可以进行正则匹配,需要注意正则的几种形式以及优先级
root 查找资源的路径
我们简单来配置一个:
server {
listen 60001;
server_name _;
location / {
# nginx适配pc和app设备
#if ($http_user_agent ~* '(Android|webOS|iPhone|iPod|BlackBerry)') {
# root /data/iportal/app/dist; #app
#}
# 服务默认启动目录
root /data/iportal/dist; #pc
index index.html; #默认访问文件
# 用于使用vue-router或react-router的history模式
try_files $uri $uri/ /index.html
expires 30d;
}
error_page 404 /404.html;
location = /404.html {
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
}
}
尝试一下更改:
# 检查nginx配置是否有错误
nginx -t
# 更新Nginx配置文件
nginx -s reload
ok啦,以上只是部署静态服务,接下来我们来看看怎么通过反向代理做接口转发以及配置跨域:
所谓反向代理,其实就是在location这一段配置中的root替换成proxy_pass即可。root说明是静态资源,可以由Nginx进行返回;而proxy_pass说明是动态请求,需要进行转发,比如代理到Tomcat上。
location /api {
# 请求host传给后端
proxy_set_header Host $http_host;
# 请求ip 传给后端
proxy_set_header X-Real-IP $remote_addr;
# 请求协议传给后端
proxy_set_header X-Scheme $scheme;
# 这里重写了请求,将正则匹配中的第一个()中$1的path,拼接到真正的请求后面,并用break停止后续匹配
rewrite /api/(.*) /$1 break;
# 代理服务器
proxy_pass http://localhost:9000;
}
说明:
/api 拦截路径, 可以通过正则匹配。
proxy_set_header 允许重新定义或添加字段传递给代理服务器的请求头。
$http_host、$remote_addr、$scheme 为Nginx内置变量。
rewrite 根据rewrite后的请求URI,将路径重写,如:接口路径为 /user, 我们可以请求 /api/user。(为什么需要重写uri?因为在使用Nginx做反向代理的时候,需要匹配到跨域的接口再做转发,为了方便匹配,会人为的在原接口中添加一段路径(或标示, 如例子中的api),因此需要在匹配之后、转发之前把添加的那段去掉,因此需要rewrite。)
break 继续本次请求后面的处理 ,停止匹配下面的location。需要注意的是与之类似的last执行过程则是停止当前这个请求,并根据rewrite匹配的规则重新发起一个请求,从上到下依次匹配location后面的规则。
proxy_pass 代理服务器。
这里在nginx中配置proxy_pass代理转发时,如果在proxy_pass后面的url加/,表示绝对根路径;如果没有/,表示相对路径,把匹配的路径部分也给代理走。
假设下面四种情况分别用 http://192.168.1.44/api/getUserInfo 进行访问。
// 第一种:
location /api/ {
proxy_pass http://127.0.0.1/;
}
// 代理到URL:http://127.0.0.1/getUserInfo
// 第二种:(相对于第一种,最后少一个 / )
location /api/ {
proxy_pass http://127.0.0.1;
}
// 代理到URL:http://127.0.0.1/api/getUserInfo
// 第三种:
location /api/ {
proxy_pass http://127.0.0.1/aaa/;
}
// 代理到URL:http://127.0.0.1/aaa/getUserInfo
// 第四种:(相对于第三种,最后少一个 / )
location /api/ {
proxy_pass http://127.0.0.1/aaa;
}
// 代理到URL:http://127.0.0.1/aaagetUserInfo
这样我们就做到了接口转发,另外nginx是可以自定义http头的,所以我们也可以用于配置跨域:
location / {
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Methods GET,POST,OPTIONS;
add_header Access-Control-Allow-Credentials true;
add_header Access-Control-Allow-Headers DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type;
add_header Access-Control-Max-Age 1728000;
if ($request_method = 'OPTIONS') {
return 204;
}
}
这样我们就加上了CORS响应头。
除此之外,nginx还支持负载均衡:
# srever模块配置是http模块中的一个子模块,用来定义一个虚拟访问主机
server {
listen 80; # 监听80端口
server_name localhost;
# 根路径指到index.html
location / {
root html;
index index.html index.htm;
}
# /api会被分发到myserver
location /api {
proxy_pass http://myserver; # 负载均衡名
proxy_set_header X-real-ip $remote_addr;
proxy_set_header Host $http_host;
}
# 重定向错误页面到/50x.html
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
#负载均衡
upstream myserver {
ip_hash;
server 192.168.1.44:8080 weight=1 max_fails=2 fail_timeout=20s;
server 192.168.1.45:8081 weight=1 max_fails=2 fail_timeout=20s;
}
实际上nginx也是支持windows的,所以你可以前往nginx download来下载nginx进行尝试。如果你想了解更多,可以参考:Nginx
前面介绍了两种最简单轻巧的实现接口代理和跨域的方式,除此之外,还有一些浏览器代理插件,或者直接让后台或运维配置一下web服务器等方式也可以实现,不过通用性不太强或者比较耗时,这里就不再多说了。
我们想想能不能以前端易于接受的方式来结合多方的优点?
前面的两种方案都能很好的实现功能,但有一定的局限性,webpack-dev-server每次修改了配置都需要重启,也很难共享配置,nginx可以无感知的重启,但难以做到复杂的规则匹配,nginx配置语法也有一定复杂度,所以我们可不可以考虑一台公共的服务器来可视化的使用options方式来配置接口代理规则?
我们设想一下,它应该长成下面这样:
点击编辑按钮,即可在线编辑代理规则:
编辑好后,点击确定即可实时更新代理规则。你只需要将axios的baseUrl配置成192.168.1.44:4000即可使用这里的代理规则。
想法有了,那么下面我们来看看如何实现:
既然要搭建一个好用简单的代理服务器,咱们可以试试足够轻量的koa,至于为什么不用express,主要是考虑到koa足够轻量,洋葱模型的设计非常优秀,很好的利用了IOC的思想,配合async和await可以更好的做异步和出错处理。
咱们先来启动一下:
const Koa = require('koa')
const app = new Koa()
// 添加了对接口的可视化
const restc = require('restc')
app.use(restc.koa2())
// 监听的端口
const { host, port, isAutoOpen, isTrustProxy } = require('./config/server.js')
app.proxy = isTrustProxy
function listenCallback () {
const url = `http://${host}:${port}/`
if (isAutoOpen) {
const open = require('open')
open(url)
}
const chalk = require('chalk')
console.log(`\nServer is listen at: ${chalk.green(url)}\n`)
// 按Ctrl+C退出前的代码:
process.stdin.resume()
process.on('SIGINT', () => {
console.log(`\nGood Day!\n`)
process.exit(2)
})
}
if (module.parent) {
// 用来hack一下回调
app.$myListenCallback = listenCallback
} else {
app.listen(port, listenCallback)
}
对所有流经此处的接口配置跨域头:
const CORS = require('koa2-cors')
app.use(
CORS({
// origin: ctx => {
// // if (ctx.url === '/test') {
// // // return false
// // return ctx.url
// // }
// // return '*'
// // return ctx.url
// const { origin, Origin, referer, Referer } = ctx.headers
// const allowOrigin =
// ctx.origin || origin || Origin || referer || Referer || '*'
// return allowOrigin
// },
exposeHeaders: [
'Content-Type',
'WWW-Authenticate',
'Server-Authorization'
],
maxAge: 30 * 86400,
credentials: true,
allowMethods: [
'OPTIONS',
'GET',
'POST',
'PUT',
'PATCH',
'DELETE',
'HEAD'
],
allowHeaders: [
'Content-Type',
'X-Token',
'token',
'Authorization',
'Accept'
]
})
)
koa的源码实现的非常巧妙,就是单纯的中间件数组,具体可以看看koa的源码:use,这里似乎与react的hook:useState有异曲同工之妙:“没有魔法,就是纯粹的js”
关于context的实现也相当巧妙,巧妙的利用委托模式(Delegation Pattern),将request和response的一些属性或方法委托给内部的ctx对象,这里采用了大名鼎鼎的TJ写的delegates,将内部对象的变量或者函数绑定在暴露在外层的变量上,具体实现可以看看源码delegate。
不扯远了,咱们接着说。
对于接口代理的配置,我们当然希望保持与http-proxy-middleware options保持一致,这样可以最大化的复用之前的配置,以及降低所有开发人员的学习使用成本。
但这里存在一个问题:
function onError(err, req, res) {
res.writeHead(500, {
'Content-Type': 'text/plain'
});
res.end(
'Something went wrong. And we are reporting a custom error message.'
);
}
function onProxyRes(proxyRes, req, res) {
proxyRes.headers['x-added'] = 'foobar'; // add new header to response
delete proxyRes.headers['x-removed']; // remove header from response
}
我们可以看出,其实http-proxy-middleware本质上是只支持express的,虽说koa也存在一些proxy框架:koa-proxy、koa-better-http-proxy等,但与http-proxy-middleware相比都存在某些功能缺失,某些配置也不够友好,所以这里我们需要另辟蹊径。
我们先来对比一下:
// koa
router.get('/bar', function (ctx, next) {
ctx.body = 'this is a users/bar response'
})
// express
app.get('/', function(req, res) {
res.send('hello world');
});
我们可以看到两者在回调参数上存在较大不一致的情况,这里我们可以采用适配器模式:
const koaConnect = require('koa2-connect')
const httpProxy = require('http-proxy-middleware')
module.exports = (context, options) => {
if (typeof options === 'string') {
options = { target: options }
}
const proxy = httpProxy(context, options)
return async (ctx, next) => {
await koaConnect(proxy)(ctx, next)
}
}
到这里适配似乎是解决了(这里可能存在问题,之后再说),于是我们就可以配置代理表了:
// 配置代理表
const proxyTableArr = [
{
// 代理规则的名字,用于识别该条代理规则
name: '对报表的接口进行代理',
// 根据路径名pathname或请求req参数来过滤,返回true就会使用下面的代理选项options
filter (pathname, req) {
return pathname.match('/report/')
},
// https://github.com/chimurai/http-proxy-middleware#options
// 代理的选项,该选项与http-proxy-middleware选项一致
options: {
// 代理到的目标地址,返回true的值即可转发到对应的target地址
target: 'http://192.168.1.238:8000'
}
},
{
name: '对平台相关的接口进行代理',
filter (pathname, req) {
return pathname.match('/platform/')
},
options: {
target: 'http://192.168.1.238:8080'
}
},
{
name: '其余带有/easy-mock前缀的接口代理到easy-mock官网',
filter (pathname, req) {
return pathname.match('/easy-mock/')
},
options: {
target: 'https://www.easy-mock.com/mock/5c1cb192281d50211bd8549d'
}
}
]
// 初始化代理的配置
function initProxy (app, defaultOpts = {}, proxyTableArr = []) {
proxyTableArr.forEach(item => {
let { filter, options } = item
// options = { ...commonOpts, ...defaultOpts, ...options }
// 这里要采用深合并
options = merge.all([{}, commonOpts, defaultOpts, options])
app.use(proxy(filter, options))
})
}
注意这里要采用深合并:
// options = { ...commonOpts, ...defaultOpts, ...options }
// 这里要采用深合并
options = merge.all([{}, commonOpts, defaultOpts, options])
深合并可以采用deepmerge,它也支持自定义数组的合并策略,如:
import merge from 'deepmerge'
const overwriteMerge = (destinationArray, sourceArray, options) => sourceArray
// 获取单个报表的卡片信息
export function getReportCard(options) {
return request(
merge(
{
url: `/report/${options.pathParams.id}`,
method: 'post',
data: {
select: {
sum_month: [201906]
},
start: 0,
size: 20
}
},
options,
// 对数组则采用覆盖模式
{ arrayMerge: overwriteMerge }
)
)
}
此外webpack实现了自己的merge规则,同时vue的mixin也是。
然后我们需要做一个在线编辑器,让用户可以编辑这个代理表,这里我们可以采用一个ace编辑器插件,npm上为brace,你可以试用一下
import * as ace from 'brace';
import 'brace/mode/javascript';
import 'brace/theme/monokai';
const editor = ace.edit('javascript-editor');
editor.getSession().setMode('ace/mode/javascript');
editor.setTheme('ace/theme/monokai');
或许你也可以试试monaco-editor,它支持多种语法格式,强大的智能输入提示和报错提示,diff editor等功能。
现在我们的编辑器效果如下:
之后再优化一下用户的输入体验,提供typescript智能提示即可。
这里要注意一下函数的编辑转换的问题,实际上JSON是不支持传输这里的filter函数的,所以filter函数要采用自定义的stringify方法来进行保存和输出,这里需要进一步考虑。vue源码当中有对toString方法作一些处理。或许我们可以参考。
安全问题
接下来的事情就是将这些规则保存至mongodb中,另外,这里需要考虑预防SQL注入。
除此之外,你们会发现这里还存在一个大问题:
这里要采用深合并:
// options = { ...commonOpts, ...defaultOpts, ...options }
// 这里要采用深合并
options = merge.all([{}, commonOpts, defaultOpts, options])
不要相信任何用户的输入,这是后台开发的基本原则,尤其是在我们的js的任何元素都可以被重写的情况下(可能有特殊情况),这里是可能存在__proto__
漏洞的,过去$.extend也存在这个问题,具体修复措施,lodash已经修复了,我们可以看看lodash源码:baseAssignValue、proto-property-bugs
最后一个大问题,这里如何实现这些代理规则的实时无感知更新,尤其是在代理服务器已经在处理大量的代理请求的情况下。
我们来看看nginx是如何做到热部署的:
所谓热部署,就是配置文件nginx.conf修改后,不需要stop Nginx,不需要中断请求,就能让配置文件生效。
我们已经知道nginx中的worker进程负责处理具体的请求,那么如果想达到热部署的效果,可以想象:
方案一:
修改配置文件nginx.conf后,主进程master负责推送给woker进程更新配置信息,woker进程收到信息后,更新进程内部的线程信息。(有点valatile的味道)
方案二:
修改配置文件nginx.conf后,重新生成新的worker进程,当然会以新的配置进行处理请求,而且新的请求必须都交给新的worker进程,至于老的worker进程,等把那些以前的请求处理完毕后,kill掉即可。
Nginx其实就是通过方案二来达到热部署的。
这里我没有过多的研究,目前我只是简单通过pm2来实现的,或许我们可以采用新的更好的方式:Proxy(memory-fs)或WebSocket(hot-update.json)
Todo...
接口代理和跨域就先告一段落了,接下来我们看看另一个常见需求,如何实现Mock模拟接口
常见的实现mock模拟接口的方式:
const taskData = [
{ value: 1548, name: '成功' },
{ value: 679, name: '运行' },
{ value: 128, name: '失败' },
{ value: 335, name: '挂起' }
]
export default {
getTask: config => {
return taskData
}
}
利用 Charles 、Fiddler等代理工具,将 URL 映射到本地文件;这样配置非常多,公共的部分没法共享。
本地启动 Mock Server,包含mockjs,有点麻烦每次修改了后还要重启服务,nodemon能解决,但开的东西多了,电脑卡出翔,维护也麻烦;或者启动一个JSON Server来watch一段json,虽说可以实时更新,不过小组成员比较难共享。
更好的办法是将json server与webpack搭配起来,组成一个mock-server,根据环境变量来切换不同服务器地址,开发者写不同的json文件,然后提交到git仓库,这样大家便可以共享一些写好的mock数据。细节可以看看mock-server
export default {
// 支持值为 Object 和 Array
'GET /api/users': { users: [1, 2] },
// GET POST 可省略
'/api/users/1': { id: 1 },
// 支持自定义函数,API 参考 express@4
'POST /api/users/create': (req, res) => { res.end('OK'); },
// 使用 mockjs 等三方库
'GET /api/tags': mockjs.mock({
'list|100': [{ name: '@city', 'value|1-100': 50, 'type|0-2': 1 }],
}),
'POST /api/users/create': (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
},
};
我们来看看easy-mock的优点:
easy-mock是一个可视化,并且能快速生成 模拟数据 的持久化服务,
Easy Mock 支持基于 Swagger 创建项目,以节省手动创建接口的时间;
简单点说:Easy Mock就是一个在线创建mock的服务平台,帮你省去你 配置、安装、起服务、维护、多人协作Mock数据不互通等一系列繁琐的操作。
它也支持mockjs的所有语法,你不需要写复杂的筛选函数,便可以模拟随机数据:
@ip 随机输出一个ip;
@id 随机输出长度18的字符,不接受参数;
"array|1-10" 随机输出1-10长度的数组,也可以直接是固定长度;
"object|2" 输出一个两个key值的对象,
"@image()" 返回一个占位图url,支持size, background, foreground, format, text等等
除此之外,easy-mock的一个更好用的地方是它可以任意模拟http响应头和状态码,尤其是你想测试后台返回401状态码时,页面是否会去跳转至权限验证页,这点是其它mock方法都做不到的。
这里要用到easy-mock的高阶用法:Function
{
"code": "200",
"desc": "操作成功",
"msg": "ok",
"data": function({
Mock,
_req
}) {
var arrData = [];
var n = _req.query.pageSize || 10;
for (var i = 0; i < n; i++) {
var ip = Mock.mock('@ip');
var port = Mock.mock('@natural(3000, 10000)')
var createDate = Mock.mock('@now(yyyy-MM-dd)') + "T" + Mock.mock('@now(HH:mm:ss)') + "Z";
var rawData = {};
arrData.push(rawData)
};
return arrData;
},
}
//简单模拟登录,根据用户传入的参数,返回不同逻辑数据
{
defaultName:function({_req}){
return _req.query.name;
},
code: function({_req}){
return this.defaultName ? 0 : -97;
},
message: function({_req}) {
return this.defaultName ? "登录成功" : "参数错误";
},
data: function({_req,Mock}){
return this.defaultName ? {
token: Mock.mock("@guid()"),
userId: Mock.mock("@id(5)"),
cname: Mock.mock("@cname()"),
name: Mock.mock("@name()"),
avatar: Mock.mock("@image(200x100, #FF6600)"),
other:"easy-mock"
}:{}
}
}
常见的可以获取到的req参数如下:
参数 描述
req.url 获得请求 url 地址
req.method 获取请求方法
req.params 获取 url 参数对象
req.querystring 获取查询参数字符串(url中?后面的部分),不包含 ?
req.query 将查询参数字符串进行解析并以对象的形式返回,如果没有查询参数字字符串则返回一个空对象
req.body 当 post 请求以 x-www-form-urlencoded 方式提交时,我们可以拿到请求的参数对象
req.path 获取请求路径名
req.header 获取请求头对象
req.originalUrl 获取请求原始地址
req.search 获取查询参数字符串,包含 ?
req.host 获取 host (hostname:port)
req.hostname 获取 hostname
req.type 获取请求 Content-Type,不包含像 "charset" 这样的参数
req.protocol 返回请求协议
req.ip 请求远程地址
req.get(field) 获取请求 header 中对应 field 的值
req.cookies(field) 获取请求 cookies 中对应 field 的值
easy-mock也支持在线查看和调试接口,示例类似于这样:在线调试链接
easy-mock还可以支持swagger文档导入来自动生成所有mock,更加解放了前端来编辑mock的成本, swagger 是一个 REST APIs 文档生成工具,它可以从代码注释中自动生成文档,可以跨平台,开源,支持大部分语言,社区支持也是非常完善的。
我们来思考一个问题,后台改了接口文档,我们前台怎么能自动察觉到呢?
swagger配合vscode插件pont可以做到更疯狂的事情,如果后台改了swagger接口文档,pont插件会去同步这份swagger接口文档,自动生成对应的typescript定义文件,比如getUserInfo请求时id应为字符串,但你传的是一个数字类型,typescript就会提示报错,这样的话,后台任何时候改了接口文档,前台都可以实时察觉到,而且绝对不存在接口传送的参数类型错误问题。尤其是后台的response中哪些字段是数组,哪些是字符串,通过typescript智能提示一目了然,几乎不存在接口接错的可能。
之后有空我会做这方面的探索,然后会补充在这里。
Todo...
easy-mock官网的服务实际上被太多人使用了,所以尽量使用docker部署:easy-mock docker
之前easy-mock导入swagger文档存在一些解析的问题,我就发了邮件给芋头大大和easy-mock的维护者,他们最终解决了这个问题,不过这个docker可能用到是比较老的easy-mock版本,可能仍然存在这个解析问题。(我之前用docker部署的easy-mock就仍存在这个问题,不过后台这份docker已经更新过,我并没有去重新验证)
关于easy-mock详细的使用介绍可以看看easy-mock 官方文档
实际上,当我看到easy-mock是能支持自定义响应函数,同时支持mockjs函数运行时,我就知道这又是骚操作了,想一想它是如何控制变量的作用域的,如何将req和res侵入其中,这都是很骚气的操作。
我们都知道常见的能将字符串当做js代码执行的方法有:eval, new Function, setTimeout, setInterval, setImmediate,在node中想要创建一个沙盒机制,vm本身是可以做到的,但实际上vm是存在很大漏洞的,几乎没办法控制字符串代码对process和环境变量等的访问,导致很容易就能使宿主机受到控制,所以使用docker部署是必要的。关于如何构建更安全的沙箱环境的这块安全问题我下回有时间再讲一讲。
我们来看一下easy-mock的源码:
vm
vm2实际上解决了上面说的安全问题,它更好的创建了一个隔离的沙箱环境,它基于 vm 模块,来建立基础的沙箱环境,然后同时使用 ES6 的 Proxy 技术来防止沙箱脚本逃逸。
这里还将相关的mock函数,req,res独立的注入进去,使得你可以自定义任何响应。
不过vm2但仍存在一定问题,可能导致easy-mock官网受到攻击,不过这里也就够用了。
关于如何支持mockjs的写法?
easy-mock直接覆盖了Mock.handler,不过这里直接覆写掉Mock函数的handler本身,我感到有点疑惑,或许有更好的办法来做这个事,不过easy-mock可能有它自己的考量。
除此之外,你也可以看到easy-mock也是很简单粗暴的通过axios来实现url的接口代理的。
除此之外,Yapi也是一个极好的在线mock平台,它可以帮助开发者轻松创建、发布、维护 API,特别是拥有接口测试的功能。支持导入 swagger, postman, har 数据格式,方便迁移旧项目。基于 websocket 的多人协作接口编辑功能和类 postman 测试工具,让多人协作成倍提升开发效率。
由于我使用Yapi较少,这里我就不做太多介绍了。Yapi的自动化接口测试倒是个很好的功能,可以完善的对接口自动化测试,保证数据的正确性。可视化的编辑接口文档也是个很好的,或许swagger也有可视化的编辑工具?
看到easy-mock能支持mockjs,我们就来顺便讲讲mockjs,mockjs可以拦截匹配到的请求并代理到本地,然后进行数据模拟。你会发现被mockjs拦截到的请求,几乎不会出现在network选项面板中,这样导致直接用mockjs来模拟请求会导致调试起来极为困难。
mockjs最大的问题是就是它的实现机制。它会重写浏览器的XMLHttpRequest对象,从而才能拦截所有请求,代理到本地。大部分情况下用起来还是蛮方便的,但就因为它重写了XMLHttpRequest对象,所以比如progress方法,或者一些底层依赖XMLHttpRequest的库都会和它发生不兼容。
我们来看一下mockjs源码是如何实现的:
我开始以为是利用Proxy实现的,结果不是,可能开发者写mockjs的时候es6并未完善这块吧。
你会看到,mockjs蛋碎的覆盖掉了原生的XHR对象,替代成自己模拟出的xhr对象,这样才实现了拦截规则,这也导致了mockjs极大的侵入性。
mockjs强制覆盖XHR对象的真正原因其实是因为XMLHttpRequest上的关键属性readyState、status、statusText、response、responseText、responseXML 都是 readonly的,所以,试图通过修改这些状态,来模拟响应是不可行的。
因此,唯一的办法是模拟整个 XMLHttpRequest,就像 jQuery 对事件模型的封装。
详情你可以看看MDN的解释:XMLHttpRequest
看一下mockjs的源码,我们可以发现mockjs的bug点还蛮多的:
最明显的就是在你import Mock from 'mockjs'
之后,你在axios或者jquery中设置的withCredentials: true全都失效了,这导致跨域访问时后台无法接收到cookie,核心原因是由于mockjs在模拟的时候将withCredentials设为了false,完了最后居然忘记按照用户的定义设置回来,于是withCredentials就一直为false了,你可以看看源码withCredentials
最坑爹的是mockjs最近一次更新是2016年4月7日 GMT+8 下午3:04,基本上可以确定开发者跑路了,所以这个bug也一直没有修复。
不过既然js上的任何方法其实都是可以重写的,我们可以尝试下面的方法来重写回来:
import Mock from 'mockjs'
// 修复在使用 MockJS 情况下,设置 withCredentials = true,且未被拦截的跨域请求丢失 Cookies 的问题
// https://github.com/nuysoft/Mock/issues/300
Mock.XHR.prototype.proxy_send = Mock.XHR.prototype.send
Mock.XHR.prototype.send = function() {
if (this.custom.xhr) {
this.custom.xhr.withCredentials = this.withCredentials || false
}
// console.log(this.custom.url, this.custom);
if (this.custom.template) {
console.log(`Local Mock: `, this.custom.url, this.custom)
}
this.proxy_send(...arguments)
}
这里我额外添加了打印的功能,可以更方便让我们看到被拦截的请求的状况,如果你不需要可以注释掉。
除此之外,mockjs也不支持什么http状态码的模拟,这种情况下还是使用easy-mock吧。
那讲了这么多,我也搭建了一个swagger-koa项目,在我以前的小组中大量使用,配合了easy-mock和swagger文档,同时加上了自动认证的功能,使得接口测试可以随时随地。swagger文档可以直接点try it out 按钮发送请求,就可以直接在页面看到响应,也可以直接编辑参数,大多数情况下比postman方便。swagger-koa也可以可视化的编辑公有的代理配置,后台开发好的接口走他们的服务,没开发好的继续走easy-mock。
不过唯一的两个问题是没有key值,难以多项目使用,koa与http-proxy-middleware的适配器会导致打印日志来做代理调试存在问题,所以最终逃不过真香定律,所以只会我会用express重写一遍,预览接口代理的编辑器也会采用monaco editor来添加代码补全和错误提示。
前面我们说了这么多,从webpack-dev-server、http-proxy-middleware、express、koa,在这个过程中我们也逐个逐个看了不少源码:vue、lodash、koa、mockjs、easy-mock等,了解了不少概念:pipe/管道、热部署、深合并、一些相关的漏洞等,希望大家能有所收获,这里面还有一些点我还没有完全探索完,待我有更多探索经验后,我会逐步逐步补充出来。
在之前的公司我产出了不少供整个前端团队甚至是测试团队使用的提高效率的工具,我也希望能创造出更多效率工具提供给咱们前端团队使用,也希望你们能多给我提一些建议。
之后有机会我会讲一讲cr(createReactCompontent)命令行工具如何编写,来实现React和Vue组件的规范化的生成,保障我们页面级别的规范化,同时也可以当做项目初始化的脚手架工具来使用,以及open in vscode,如何所见即所得的打开组件文件、另外可能还有airtest的e2e测试等。
未完待续...