1、前言
经过一段时间的探索和研究,以及反复调整验证,终于把函子模块写完了。下面一起探索一下前端中间件(Middleware)的应用和实现。写中间件模块的初衷,基于想对函数组合模块的反思和补充。原本打算在函数组合那篇文章的基础上丰富完善,但整体篇幅略长,内容略显繁杂,索性重新写一篇。
2、概念
先看一下百度百科的定义:
中间件是一种独立的系统软件服务程序,分布式应用软件借助这种软件在不同的技术之间共享资源,中间件位于客户机服务器的操作系统之上,管理计算资源和网络通信。从这个意义上可以用一个等式来表示中间件:中间件=平台+通信。
简而言之,中间件是介于应用系统和系统软件之间的一类软件。用途是衔接网络上应用系统的各个部分或不同的应用,实现资源共享、功能共享。
我们讨论的基础范畴是函数式编程,这里的中间件不是真正意义上的中间件。准确点说,它是一种设计模式、编程思想、是函数与函数之间的组合方式。这种中间件设计模式,应用最广泛的场景是express和koa框架中,数据请求Request和数据响应Response的应用模型,通常叫做洋葱模型。
洋葱模型的执行顺序是由外及里,然后再由里及外。下面我们举一个简单的例子,看一下执行过程:
const fn1 = (data)=>{
console.log('enter fn1')
data.step1 = 'step1'
let rst = fn2(data)
console.log('exit fn1')
return rst
}
const fn2 = (data)=>{
console.log('enter fn2')
data.step2 = 'step2'
const rst = fn3(data)
console.log('exit fn2')
return rst
}
const fn3 = (data)=>{
console.log('enter fn3')
data.step3 = 'step3'
console.log(data)
console.log('exit fn3')
return data
}
fn1({name:'Lucy'})
执行结果
enter fn1
enter fn2
enter fn3
{ name: 'Lucy', step1: 'step1', step2: 'step2', step3: 'step3' }
exit fn3
exit fn2
exit fn1
简单分析一下执行处理过程:
- fn1接收数据data,处理后交给fn2,进入fn2的执行流程
- fn2接收数据data,处理后交给fn3,进入fn3的执行流程
- fn3接受数据data,处理后返给fn2,并把执行权还给fn2
- fn2拿到fn3的处理结果,返给fn1,并把执行权还给fn1
- fn1拿到fn2的处理结果,并继续执行完毕
上面例子非常简略,目的是为了把中间件的设计模型直观清晰的描述清楚。然而,实践中面对的问题会很复杂,不会采用这种彼此耦合调用的编程方式。下面我们一起看一下koa处理此类问题的方式,参照对比一下,就能发现中间件带来的便利和优势:
const app = new Koa()
app.use(async (ctx, next)=>{
console.log('enter 1')
await next()
console.log('exit 1')
})
app.use(async (ctx, next)=>{
console.log('enter 2')
await next()
console.log('exit 2')
})
app.use(async (ctx, next)=>{
console.log('enter 3')
await next()
console.log('exit 3')
})
koa的中间件核心模块是koa-compose,有关koa-compose的源码分析有很多,下面我们也会涉及到,但并不是本文讨论的核心。我们的目的是,搞清楚Middleware的实现原理,以便更好的掌握使用它。接下来我们一起探索讨论几种Middleware的实现方式。
3、中间件的实现
再说函数组合(Function Composition)
按照前面的实例和应用分析,洋葱模型的调用过程,跟函数组合(Function Composition)或者管道(Pipeline)基本类似。都是串联组合函数,控制数据流动的方式。不同之处是,中间件可以主动调用next,而函数组合的next是自动调用。主动调用更加灵活,可以控制next的调用时机,可以拦截修饰输入数据以及输出数据。换而言之,中间件可以控制双向数据流,而函数组合只能控制单向数据流。下面我们再回顾一下前面讨论过的函数组合的实现,并在其基础上,讨论一下中间件的实现原理。
函数组合的实现原理及应用示例
function compose(...funcs) {
return function (input) {
return funcs.reverse().reduce((result, next) => next.call(this, result), input)
}
}
const fn1 = (d) => d + 1
const fn2 = (d) => d * 2
const fn3 = (d) => Math.pow(d, 2)
compose(fn3, fn2, fn1)(2) // 36
compose的原理是基于reduce循环迭代自动执行,整个过程数据单向流动,数据2从fn1流入,并流经fn2、fn3,经过三次函数处理,最终输出结果36。
整个执行过程可以简化为:
f3(f2(f1(input)))
在compose基础上,实现中间件需要改动的地方并不多。只需把next执行用函数包裹,暂时缓存,传递给下一个函数,等待主动调用。原理不难理解,把传递执行结果,变成了传递执行过程。 需要注意的是,compose的执行顺序是从里到外,middleware的执行顺序是从外到里,然后再从里到外。应用时需要注意传参顺序。
下面调整一下compose函数,看一下具体的实现原理。
function middleware(...funcs) {
return funcs.reverse().reduce((result, next) => (arg) => next.call(this, arg, result), () => { })
}
const fn1 = (data, next) => {
console.log('enter 1')
data.step1 = 'step1'
next(data)
console.log(data)
console.log('exit 1')
}
const fn2 = (data,next) => {
console.log('enter 2')
data.step2 = 'step2'
next(data)
console.log('exit 2')
}
const fn3 = (data,next) => {
console.log('enter 3')
data.step3 = 'step3'
next()
console.log('exit 3')
}
middleware(fn1,fn2,fn3)
输出结果
enter 1
enter 2
enter 3
exit 3
exit 2
{ name: 'Lucy', step1: 'step1', step2: 'step2', step3: 'step3' }
exit 1
为了便于理解,我们拆解一下reduce执行过程:
// 第一次
(arg1)=>f3(arg1, (arg2)={})
// 第二次
(arg1)=>f2(arg1, (arg2)=>f3(arg2, (arg3)={}))
// 第三次
(arg1)=>f1(arg1,(arg2)=>f2(arg2,(arg3)=>f3(arg3,(arg4)={})))
实践中,中间件模式的应用,多数都涉及到异步场景。上面的middleware还缺乏异步处理的支持。接下来,我继续调整,对middleware添加异步的支持。添加异步支持的思路有两种,一种是把执行结果转换成Promise;一种是把next函数变成async...await。
我们先看一下基于Promise的思路:
function middleware(...funcs) {
return funcs.reverse().reduce((result, next) => (arg) => Promise.resolve(next.call(this, arg, result)), () => Promise.resolve())
}
const getData = () => new Promise((resolve) => setTimeout(() => resolve(), 2000))
const fn1 = async (data, next) => {
console.log('enter 1')
data.step1 = 'step1'
await next(data)
console.log(data)
console.log('exit 1')
}
const fn2 = async (data, next) => {
console.log('enter 2')
data.step2 = 'step2'
await getData()
await next(data)
console.log('exit 2')
}
const fn3 = async (data, next) => {
console.log('enter 3')
data.step3 = 'step3'
await next(data)
console.log('exit 3')
}
middleware(fn1, fn2, fn3)({ name: 'Lucy' })
执行结果
enter 1
enter 2
enter 3
exit 3
exit 2
{ name: 'Lucy', step1: 'step1', step2: 'step2', step3: 'step3' }
exit 1
下面再看一下借助async...await实现支持异步的思路:
function middleware(...funcs) {
return funcs.reverse().reduce((result, next) => {
return async (arg) => {
await next.call(this, arg, await result)
}
}, async () => { })
}
实现compose的方式有很多,在compose的基础上实现middleware的方式也有很多,比如基于for...of的middleware。
function middleware(...funcs) {
return (input) => {
let result = arg => arg
for (let next of funcs.reverse()) {
result = ((next, result) => async (arg) => await next(arg, result))(next, result)
}
return result(input)
}
}
总而言之,实现middleware的基本原理都是基于函数组合,把传递执行结果,转换成传递执行过程函数;把自动执行,转换成手动执行next;把控制单向数据流,转换成了可以控制双向数据流。middleware同样具备函数组合可拆分、可组合、易于管理、可读性强的特性。
换个角度看compose
我们通过数据传输角度,讨论compose基础上实现middleware。基本原理是,把传递执行结果转换成传递执行过程函数。我们可以个角度,思考一个问题:如果组合函数执行结果就是函数呢?
const fn1 = (next) => (data) => {
console.log('enter fn1')
next(data)
console.log('exit fn1')
}
const fn2 = (next) => (data) => {
console.log('enter fn2')
next(data)
console.log('exit fn2')
}
const fn3 = (next) => (data) => {
console.log('enter fn3')
console.log(next(data))
console.log('exit fn3')
}
let cm = compose(fn1, fn2, fn3)
let fc = cm(() => {})
fc({ name: 'Lucy' })
执行过程:
// 第一步compose(fn1,fn2,fn3)得到
cm = (input)=>f1(f2(f3(input)))
// 第二步输入cm(()=>{})
fc = function rst1(data){
~(function rst2(data){
~(function rst3(data){
input()
})()
})()
}
// 第三步
fc({name:'Lucy'})
结果
enter fn1
enter fn2
enter fn3
{ name: 'Lucy' }
exit fn3
exit fn2
exit fn1
koa-compose
node.js开发应用最广泛的是koa框架,它的基础是middleware,核心模块是koa-compose。koa-compose的实现代码很精炼,有很多地方值得我们学习和参考。
下面看一下koa-compose的实现代码。
function compose(middleware) {
return function (context, next) {
let index = -1
return dispatch(0)
function dispatch(i) {
// 避免重复调用
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn = middleware[i]
// compose最后的回调函数
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
try {
// 核心执行代码,缓存执行next,并支持异步
return Promise.resolve(fn(context, function next() {
return dispatch(i + 1)
}))
} catch (err) {
return Promise.reject(err)
}
}
}
}
koa-compose的实现原理,跟我们前面讨论的middleware实现原理基本相同。思路都是把传递执行结果,转换成了传递执行过程。区别在于,koa-compose基于递归循环迭代,而我们是基于reduce或for..of。koa-compose细节处理比较完善,比如参数验证和多次调用next的问题。
简单讨论一下关于next重复调用的问题。理论上,一个执行流程中,一个中间件应该只执行一次。换句话说,同一个next,只允许调用一次,如果多次调用,应该抛出异常。
我们把上面的fn2调整一下
const fn2 = async (data, next) => {
console.log('enter 2')
data.step2 = 'step2'
await getData()
await next(data)
await next(data)
console.log('exit 2')
}
compose([fn1, fn2, fn3])({ name: 'Lucy' }, (data)=>{
console.log(data)
})
执行结果,报UnhandledPromiseRejectionWarning: Error: next() called multiple times
enter 1
enter 2
enter 3
exit 3
(node:52325) UnhandledPromiseRejectionWarning: Error: next() called multiple times
完整的Middleware
前面我们讨论了基于compose实现middleware的原理,下面我们一起实现一个完整的Middleware。首先,Middleware需要具备一个可以管理所有middlewares的属性;其次,还需要具备一个类似于app.use,可以添加middlewares的方法;另外,还需要一个触发执行middlewares流程的方法。
function Middleware(...middlewares) {
const stack = middlewares
const push = (...middlewares) => {
stack.push(...middlewares)
return this
}
const execute = async (context, callback) => {
let prevIndex = -1
const runner = (index) => {
if (index === prevIndex) {
throw new Error('next() called multiple times')
}
prevIndex = index
let middleware = stack[index]
if (prevIndex === stack.length) middleware = callback
if (!middleware) return Promise.resolve()
try {
return Promise.resolve(middleware(context, () => {
return runner(index + 1)
}))
} catch (err) {
return Promise.reject(err)
}
}
return runner(0)
}
return { push, execute }
}
let middleware = Middleware()
middleware.push(fn1)
middleware.push(fn2)
middleware.push(fn3)
middleware.execute({ name: 'Lucy' }, (ctx) => console.log(ctx))
下面给出Middleware的typescript版本以及Middleware类版本
typescript版本Middleware
type Next = () => Promise<void> | void
type TMiddleware<T> = (context: T, next: Next) => Promise<void> | void
type IMiddleware<T> = {
push: (...middlewares: TMiddleware<T>[]) => void
execute: (context: T, callback: Next) => Promise<void>
}
function Middleware<T>(...middlewares: TMiddleware<T>[]): IMiddleware<T> {
const stack: TMiddleware<T>[] = middlewares
const push: IMiddleware<T>['push'] = (...middlewares) => {
stack.push(...middlewares)
return this
}
const execute: IMiddleware<T>['execute'] = (context, callback) => {
let prevIndex = -1
const runner = async (index: number): Promise<void> => {
if (index === prevIndex) {
throw new Error('next() called multiple times')
}
prevIndex = index
let middleware = stack[index]
if (prevIndex === stack.length) middleware = callback
if (!middleware) return Promise.resolve()
try {
return Promise.resolve(middleware(context, () => {
return runner(index + 1)
}))
} catch (err) {
return Promise.reject(err)
}
}
return runner(0)
}
return { push, execute }
}
Middleware类
type Next = () => Promise<void> | void
type TMiddleware<T> = (context: T, next: Next) => Promise<void> | void
type IMiddleware<T> = {
push: (...middlewares: TMiddleware<T>[]) => void
execute: (context: T, callback: Next) => Promise<void>
}
class Middleware<T> implements IMiddleware<T>{
stack: TMiddleware<T>[] = []
static create<T>(...middlewares: TMiddleware<T>[]) {
return new Middleware(...middlewares)
}
constructor(...middlewares: TMiddleware<T>[]) {
this.stack = middlewares
}
public push(...middlewares: TMiddleware<T>[]) {
this.stack.push(...middlewares)
return this
}
public execute(context?, callback?) {
let prevIndex: number = -1
const runner = async (index: number): Promise<void> => {
if (index === prevIndex) {
throw new Error('next() called multiple times')
}
prevIndex = index
let middleware = this.stack[index]
if (prevIndex === this.stack.length) middleware = callback
if (!middleware) return Promise.resolve()
try {
return Promise.resolve(middleware(context, () => {
return runner(index + 1)
}))
} catch (err) {
return Promise.reject(err)
}
}
return runner(0)
}
}
4、实践应用
中间件在web服务开发领域的应用,最为广泛,最为大家熟知的是node.js开发。比较典型也是应用最多的是koa框架。Koa是由Express幕后的原班人马打造,新的web框架。基础核心模块是中间件,用于控制请求数据流Request和响应数据流Response。
const Koa = require('koa')
const app = new Koa()
app.use(async ctx => {
ctx.body = 'Hello World'
})
app.use(async (ctx, next) => {
const start = Date.now()
await next()
const ms = Date.now() - start
ctx.set('X-Response-Time', `${ms}ms`)
})
app.use(async (ctx, next) => {
await getData()
next()
})
app.listen(3000)
在前端开发领域,中间件应用广泛。网络数据请求日志、性能监控、用户行为埋点信息都是中间价典型应用场景。这些辅助业务信息非常重要,但并不是主业务流程。而且这些辅助业务信息散乱耦合在项目主业务流程的各个环节,不易管理,项目越大,越难以维护。可以利用中间件,把这些辅助业务从主业务流程中分离解耦,作为独立的模块开发和维护。这符合模块设计的功能单一性,同时也能提高模块的可维护性和可移植性。
下面我们通过一个具体的示例,演示一下中间件在前端的应用。
service.js
export const getData = (url, param) => new Promise((resolve) => setTimeout(() => resolve({
name: '张三',
age: 28,
work: '司机'
}), 2000))
index.js
import { Middleware } from './middleware.js'
import { log } from './log-middleware.js'
import { getData } from './service.js'
export const fetchPerson = (url, params) => {
let mw = Middleware()
mw.push(log)
mw.push(async (ctx, next) => {
let person = await getData(url, params)
ctx.res = person
next()
})
mw.execute({ url, params }, ctx => {
console.log(ctx.res)
})
}
export default {
start() {
fetchPerson('http://www.abc.com/api/v1/person', {
id: '123456'
})
}
}
log-middleware.js
export const log = async (ctx, next) => {
let { url, params } = ctx
let log = {
startTime: Date.now(),
request: {
url,
params
}
}
await next()
log.response = ctx.res
log.endTime = Date.now()
console.log(`send log info:\n`, log)
}
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0,maximum-scale=1.0, user-scalable=0">
<title>Document</title>
</head>
<body>
<script type="module">
import app from './index.js'
app.start()
</script>
</body>
</html>
结果: