http-proxy-middleware 源码解读

概述

http-proxy-middleware 库借助于 node-http-proxy,用于将 node 服务器接收到的请求转发到目标服务器,实现代理服务器的功能。

实现原理

可以推想,使用 node-http-proxy 创建代理服务器 proxyServer 后,通过全局注册的转发规则获取到客户端请求 req 需要发送到的目标地址,再通过调用 proxyServer.web, proxyServer.ws 方法转发请求。

转发规则

从原理层面简单的归纳转发规则,就是客户端请求路径到目标服务器地址的映射关系。node-http-proxy 库将转发规则分为两部分加以配置,context 用于匹配需要转发的客户端请求,options.target 用于设定目标服务器的 host;option.router 根据客户端请求重新设定目标服务器的 host(这样,根据不同的请求,可以设定多个目标服务器);option.pathRewrite 用于辅助将客户端请求路径转化为目标服务器地址。

context

context 表示待转发请求的目录名。当客户端请求以 context 起始时,则该请求将被转发。如 context 设置为 ‘/api’,客户端所有以 ‘/api’ 起始的请求都会被转发;默认值为 ‘/‘,意为客户端发送的所有请求都会被转发。node-http-proxy 库使用 createConfig 解析获得 context;matchContext 函数校验客户端请求是否需要转发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
/**
* 解析获取 context,需要代理的请求 url 前缀
* @param {string|object|function} context 作为 HttpProxyMiddleware 接口传入 createConfig 函数的参数
* @param {undefined|object} opts 作为 HttpProxyMiddleware 接口传入 createConfig 函数的参数
*/
function createConfig (context, opts) {
var config = {
context: undefined,
options: {}
}

// context 作为 opts 传入,config.context 使用默认值
if (isContextless(context, opts)) {
config.context = '/'
config.options = _.assign(config.options, context)

// context 以 url 形式配置,可同时配置 context, options.target
} else if (isStringShortHand(context)) {
var oUrl = url.parse(context)
var target = [oUrl.protocol, '//', oUrl.host].join('')

config.context = oUrl.pathname || '/'
config.options = _.assign(config.options, { target: target }, opts)

if (oUrl.protocol === 'ws:' || oUrl.protocol === 'wss:') {
config.options.ws = true
}

} else {
config.context = context
config.options = _.assign(config.options, opts)
}

// 配置 Logger 实例
configureLogger(config.options)

if (!config.options.target) {
throw new Error(ERRORS.ERR_CONFIG_FACTORY_TARGET_MISSING)
}

return config
}

/**
* 解析获取 context,需要代理的请求 url 前缀
* @param {string|function} context 需要代理的请求 url 前缀
* @param {object} uri 客户端请求的 uri,即 req.originalUrl 或 req.url
* @param {object} req 客户端请求 req
*/
function matchContext (context, uri, req) {
// context 为字符串路径,校验 url 是否以 context 起始
if (isStringPath(context)) {
return matchSingleStringPath(context, uri)
}

// context 为 glob 模式的字符串路径(通过 is-glob 模块判断),校验 url 是否匹配 context(通过 micromatch 判断)
// [glob 介绍](https://blog.csdn.net/Free_Wind22/article/details/78344166)
if (isGlobPath(context)) {
return matchSingleGlobPath(context, uri)
}

// 遍历 context,调用 matchSingleStringPath, matchSingleGlobPath 作校验
if (Array.isArray(context)) {
if (context.every(isStringPath)) {
return matchMultiPath(context, uri)
}
if (context.every(isGlobPath)) {
return matchMultiGlobPath(context, uri)
}

throw new Error(ERRORS.ERR_CONTEXT_MATCHER_INVALID_ARRAY)
}

// context 自定义函数,用于校验客户端请求是否需要转发
if (_.isFunction(context)) {
var pathname = getUrlPathName(uri)
return context(pathname, req)
}

throw new Error(ERRORS.ERR_CONTEXT_MATCHER_GENERIC)
}

target

target 表示目标服务器的 host。在 createConfig 函数的源码中,可以看到,target 即能通过 url 形式的 context 设定,又能通过 options.target 选项设定。当然,这样是作为全局配置设定的;同 node-http-proxy 库,http-proxy-middleware 也可以在具体的请求发生时作特殊处理。这后半分的内容,是通过全局配置 options.router 达成的。在 http-proxy-middleware 处理客户端请求的过程中,getTarget 函数将通过 options.router 获取目标服务器的 host。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* 获取目标服务器的 host
* @param {object} req 客户端请求 req
* @param {object} config 即 HttpProxyMiddleware 接口的 options 参数,获取 options.router
*/
function getTarget (req, config) {
var newTarget
var router = config.router

if (_.isPlainObject(router)) {
newTarget = getTargetFromProxyTable(req, router)
} else if (_.isFunction(router)) {
newTarget = router(req)
}

return newTarget
}

function getTargetFromProxyTable (req, table) {
var result
var host = req.headers.host
var path = req.url

var hostAndPath = host + path// 客户端请求路径

_.forIn(table, function (value, key) {
// containsPath(str) 函数,判断 str 是否包含 '/'
if (containsPath(key)) {
if (hostAndPath.indexOf(key) > -1) {
result = table[key]
logger.debug('[HPM] Router table match: "%s"', key)
return false
}
} else {
if (key === host) {
result = table[key]
logger.debug('[HPM] Router table match: "%s"', host)
return false
}
}
})

return result
}

pathRewrite

在 http-proxy-middleware 库中,options.pathRewrite 用于将客户端请求路径转化为目标服务器的路径(pathname 部分),既可以是 map 映射,也可以函数。createPathRewriter 函数将根据 options.pathRewrite 生成路径转化器 pathRewriter 函数;而 pathRewriter 用于将实际的客户端请求地址转化为目标服务器的路径。当 options.pathRewrite 为对象 key-value 时,pathRewriter 将把匹配 key 的客户端请求转化为 value 路径;当 options.pathRewrite 为函数时,将由客户端请求 req.url 获取目标服务器路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
* 生成 pathRewriter 函数
* @param {object|function} rewriteConfig 即 HttpProxyMiddleware 接口的 options.pathRewrite 配置项
*/
function createPathRewriter (rewriteConfig) {
var rulesCache

// isValidRewriteConfig 函数用于校验 options.pathRewrite
if (!isValidRewriteConfig(rewriteConfig)) {
return
}

if (_.isFunction(rewriteConfig)) {
var customRewriteFn = rewriteConfig
return customRewriteFn
} else {
rulesCache = parsePathRewriteRules(rewriteConfig)
return rewritePath
}

// 参数 path 为 req.url
function rewritePath (path) {
var result = path

_.forEach(rulesCache, function (rule) {
if (rule.regex.test(path)) {
result = result.replace(rule.regex, rule.value)
logger.debug('[HPM] Rewriting path from "%s" to "%s"', path, result)
return false
}
})

return result
}
}

function parsePathRewriteRules (rewriteConfig) {
var rules = []

if (_.isPlainObject(rewriteConfig)) {
_.forIn(rewriteConfig, function (value, key) {
rules.push({
regex: new RegExp(key),
value: rewriteConfig[key]
})
logger.info('[HPM] Proxy rewrite rule created: "%s" ~> "%s"', key, rewriteConfig[key])
})
}

return rules
}

整体流程

这部分将串联转发规则的解析和应用,是为 http-proxy-middleware 库的整体工作流程。

  1. 解析 context,options 配置,获得全局注册的 context, options.target;并配置 Logger 实例。
  2. 使用 node-http-proxy 库常见代理服务器 proxy。
  3. 根据 options.pathRewrite 生成路径转化器 pathRewriter。
  4. 为代理服务器绑定事件。
  5. 创建转发 http, https, websocket 请求的代理中间件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
function HttpProxyMiddleware (context, opts) {
// https://github.com/chimurai/http-proxy-middleware/issues/57
var wsUpgradeDebounced = _.debounce(handleUpgrade)
var wsInitialized = false
var config = configFactory.createConfig(context, opts)
var proxyOptions = config.options

var proxy = httpProxy.createProxyServer({})
logger.info('[HPM] Proxy created:', config.context, ' -> ', proxyOptions.target)

var pathRewriter = PathRewriter.create(proxyOptions.pathRewrite)

// 为代理服务器绑定事件
handlers.init(proxy, proxyOptions)

proxy.on('error', logError)

middleware.upgrade = wsUpgradeDebounced

return middleware

// 实际的代理中间件
function middleware (req, res, next) {
if (shouldProxy(config.context, req)) {
var activeProxyOptions = prepareProxyRequest(req)
proxy.web(req, res, activeProxyOptions)
} else {
next()
}

if (proxyOptions.ws === true) {
catchUpgradeRequest(req.connection.server)
}
}

function catchUpgradeRequest (server) {
if (!wsInitialized) {
server.on('upgrade', wsUpgradeDebounced)
wsInitialized = true
}
}

// 转发 websocket 请求
function handleUpgrade (req, socket, head) {
wsInitialized = true

if (shouldProxy(config.context, req)) {
var activeProxyOptions = prepareProxyRequest(req)
proxy.ws(req, socket, head, activeProxyOptions)
logger.info('[HPM] Upgrading to WebSocket')
}
}

// 判断请求是否需要转发
function shouldProxy (context, req) {
var path = (req.originalUrl || req.url)
return contextMatcher.match(context, path, req)
}

// 转发请求
function prepareProxyRequest (req) {
req.url = (req.originalUrl || req.url)

var originalPath = req.url
var newProxyOptions = _.assign({}, proxyOptions)

__applyRouter(req, newProxyOptions)
__applyPathRewrite(req, pathRewriter)

if (proxyOptions.logLevel === 'debug') {
// getArrow 返回的标识,用于区分 options.router, options.pathRewrite 的作用与否
// '->' options.router, options.pathRewrite 均未作用
// '=>' options.router 作用, options.pathRewrite 未作用
// '~>' options.router 未作用, options.pathRewrite 作用
// '≈>' options.router, options.pathRewrite 均作用
var arrow = getArrow(originalPath, req.url, proxyOptions.target, newProxyOptions.target)
logger.debug('[HPM] %s %s %s %s', req.method, originalPath, arrow, newProxyOptions.target)
}

return newProxyOptions
}

// 根据请求获取目标服务器的 host
function __applyRouter (req, options) {
var newTarget

if (options.router) {
newTarget = Router.getTarget(req, options)

if (newTarget) {
logger.debug('[HPM] Router new target: %s -> "%s"', options.target, newTarget)
options.target = newTarget
}
}
}

// 将目标服务器路径写入 req.url
function __applyPathRewrite (req, pathRewriter) {
if (pathRewriter) {
var path = pathRewriter(req.url, req)

if (typeof path === 'string') {
req.url = path
} else {
logger.info('[HPM] pathRewrite: No rewritten path found. (%s)', req.url)
}
}
}

function logError (err, req, res) {
var hostname = (req.headers && req.headers.host) || (req.hostname || req.host)
var target = proxyOptions.target.host || proxyOptions.target
var errorMessage = '[HPM] Error occurred while trying to proxy request %s from %s to %s (%s) (%s)'
var errReference = 'https://nodejs.org/api/errors.html#errors_common_system_errors'

logger.error(errorMessage, req.url, hostname, target, err.code, errReference)
}
}

其他

logger

http-proxy-middleware 库使用单例模式创建 Logger 实例,并提供 setProvider 函数切换打印日志的 console 函数;setLevel 方法用于设定日志级别;继而由 Logger 实例提供 log, error, info, warn, debug 方法,这些方法在调用过程都会校验当前执行的方法是否符合当前日志级别,同时会允许首参以 %s 设定占位符,以注入次参等。

因日志模块在 node 服务器中广为应用,这里贴出 http-proxy-middleware 库中的代码实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
var loggerInstance

var defaultProvider = {
log: console.log,
debug: console.log,
info: console.info,
warn: console.warn,
error: console.error
}

var LEVELS = {
debug: 10,
info: 20,
warn: 30,
error: 50,
silent: 80
}

function Logger () {
var logLevel
var provider

var api = {
log: log,
debug: debug,
info: info,
warn: warn,
error: error,
setLevel: function (v) {
if (isValidLevel(v)) {// isValidLevel 校验 v 是否 debug, info, warn, error, slient 中的一个
logLevel = v
}
},
setProvider: function (fn) {
if (fn && isValidProvider(fn)) {// isValidProvider 校验 fn 是否函数
provider = fn(defaultProvider)
}
}
}

init()

return api

function init () {
api.setLevel('info')
api.setProvider(function () {
return defaultProvider
})
}

function log () {
provider.log(_interpolate.apply(null, arguments))
}

function debug () {
if (_showLevel('debug')) {
provider.debug(_interpolate.apply(null, arguments))
}
}

function info () {
if (_showLevel('info')) {
provider.info(_interpolate.apply(null, arguments))
}
}

function warn () {
if (_showLevel('warn')) {
provider.warn(_interpolate.apply(null, arguments))
}
}

function error () {
if (_showLevel('error')) {
provider.error(_interpolate.apply(null, arguments))
}
}

// 校验调用的方法是否在当前允许的日志级别下
function _showLevel (showLevel) {
var result = false
var currentLogLevel = LEVELS[logLevel]

if (currentLogLevel && (currentLogLevel <= LEVELS[showLevel])) {
result = true
}

return result
}

// 参数转化,允许首参以 %s 形式设置占位符
function _interpolate () {
var fn = _.spread(util.format)
var result = fn(_.slice(arguments))

return result
}
}

module.exports = {
getInstance: function () {
if (!loggerInstance) {
loggerInstance = new Logger()
}

return loggerInstance
}
}

事件绑定

解析 options.onError, options.onProxyReq, options.onProxyReqWs, options.onProxyRes, options.onOpen, options.onClose,并绑定为代理服务器的事件。

默认的绑定函数为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// onError 绑定函数
function defaultErrorHandler (err, req, res) {
var host = (req.headers && req.headers.host)
var code = err.code

if (res.writeHead && !res.headersSent) {
if (/HPE_INVALID/.test(code)) {
res.writeHead(502)
} else {
switch (code) {
case 'ECONNRESET':
case 'ENOTFOUND':
case 'ECONNREFUSED':
res.writeHead(504)
break
default: res.writeHead(500)
}
}
}

res.end('Error occured while trying to proxy to: ' + host + req.url)
}

// onClose 绑定函数
function logClose (req, socket, head) {
logger.info('[HPM] Client disconnected')
}

应用

webpack-dev-server

webpack-dev-server 库会创建 express 服务器,其服务器代理的实现就是解析配置项,并挂载 http-proxy-middleware 中间件。主要逻辑在 features.proxy 方法中实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
const features = {
// ...
proxy: () => {
if (options.proxy) {
// 解析配置
if (!Array.isArray(options.proxy)) {
options.proxy = Object.keys(options.proxy).map((context) => {
let proxyOptions;
const correctedContext = context
.replace(/^\*$/, '**')
.replace(/\/\*$/, '');

if (typeof options.proxy[context] === 'string') {
proxyOptions = {
context: correctedContext,
target: options.proxy[context]
};
} else {
proxyOptions = Object.assign({}, options.proxy[context]);
proxyOptions.context = correctedContext;
}

// 日志级别
proxyOptions.logLevel = proxyOptions.logLevel || 'warn';

return proxyOptions;
});
}

// 获得 proxy 中间件
const getProxyMiddleware = (proxyConfig) => {
const context = proxyConfig.context || proxyConfig.path;
if (proxyConfig.target) {
return httpProxyMiddleware(context, proxyConfig);
}
};

// 以配置项挂载多个中间件
options.proxy.forEach((proxyConfigOrCallback) => {
let proxyConfig;
let proxyMiddleware;

if (typeof proxyConfigOrCallback === 'function') {
proxyConfig = proxyConfigOrCallback();
} else {
proxyConfig = proxyConfigOrCallback;
}

proxyMiddleware = getProxyMiddleware(proxyConfig);

if (proxyConfig.ws) {
websocketProxies.push(proxyMiddleware);
}

app.use((req, res, next) => {
if (typeof proxyConfigOrCallback === 'function') {
const newProxyConfig = proxyConfigOrCallback();

if (newProxyConfig !== proxyConfig) {
proxyConfig = newProxyConfig;
proxyMiddleware = getProxyMiddleware(proxyConfig);
}
}

const bypass = typeof proxyConfig.bypass === 'function';

const bypassUrl = (bypass && proxyConfig.bypass(req, res, proxyConfig)) || false;

// 转发到本地服务器
if (bypassUrl) {
req.url = bypassUrl;

next();
} else if (proxyMiddleware) {
return proxyMiddleware(req, res, next);
} else {
next();
}
});
});
}
}
}