职责链模式是什么?中间件和请求拦截器是怎样用它的?
职责链把多个处理者串成一条链,请求沿链传递,每个处理者决定自己处理、加工后交给下一个,或者直接中断。前端常见的是 Koa 中间件、Express 中间件和 axios 拦截器。文中写一个审批链和一个洋葱式中间件,并讲 axios 请求拦截器后加先执行的顺序。
简答
请假审批是最直观的例子:一天以内组长批,五天以内经理批,再长交给总监。申请人不用知道最后谁来批,只要交给第一个人,不归自己管的就往上传。职责链模式就是把多个处理者串成一条链,请求从头往后传,每个处理者自己决定处理、交给下一个,还是直接拦下。
这样做的好处是发送方和处理方解耦了。新增一个处理环节、调整顺序,只要改链的组成,不用改每个处理者内部的代码。
前端最常见的就是中间件和拦截器。Koa 的中间件调用
await next()把控制权交给下一个,下游执行完再回到自己,可以在前后各做一件事,比如记录耗时;某个中间件不调用next(),链就在这里断开,比如鉴权失败直接返回 401。axios 的拦截器在请求发出前统一加 token,在响应回来后统一处理错误。要注意两点。一是顺序很重要,鉴权要放在业务处理之前,日志放在最外层才能统计完整耗时。二是链太长时,一个请求最后落到哪里不容易看出来,出问题要在每一环加日志排查。
先写一个最简单的职责链
审批链里,每个处理者要么返回结果,要么返回 undefined 表示“不归我管”,交给下一个。
// 职责链:每个处理者决定自己处理,还是交给下一个
function createChain(handlers) {
return function handle(request) {
for (const handler of handlers) {
const result = handler(request);
if (result !== undefined) return result;
}
return '没有人处理';
};
}
const approve = createChain([
(days) => (days <= 1 ? '组长审批' : undefined),
(days) => (days <= 5 ? '经理审批' : undefined),
(days) => (days <= 15 ? '总监审批' : undefined),
]);
console.log(approve(1)); // 组长审批
console.log(approve(3)); // 经理审批
console.log(approve(30)); // 没有人处理
请 3 天假,组长那一环判断不归自己,交给经理,经理处理了,链就停在这里。30 天谁都不处理,走到链尾返回兜底结果。
如果以后要在经理和总监之间加一个“部门负责人”,只要往数组里插一项,其他处理者都不用改。
中间件为什么能在前后都做事
上面的链是“找到一个处理者就停”。中间件更进一步:每一层拿到一个 next 函数,调用它就进入下一层,下一层执行完再回到当前层继续往下走。所以一层可以在 next() 前做准备,在 next() 后做收尾,这就是 Koa 文档说的先向下游执行、再把控制流交回上游,常被叫作洋葱模型。
// 洋葱式中间件:每一层可以在 next() 前后各做一件事
function compose(middlewares) {
return function run(context) {
function dispatch(index) {
const middleware = middlewares[index];
if (!middleware) return Promise.resolve();
return Promise.resolve(middleware(context, () => dispatch(index + 1)));
}
return dispatch(0);
};
}
const logs = [];
const run = compose([
async (ctx, next) => {
logs.push('日志:开始');
await next();
logs.push('日志:结束');
},
async (ctx, next) => {
if (!ctx.token) {
ctx.status = 401;
logs.push('鉴权:拦截');
return; // 不调用 next,后面的中间件都不会执行
}
await next();
},
async (ctx) => {
ctx.status = 200;
logs.push('业务:处理请求');
},
]);
run({ token: 'abc' })
.then(() => {
console.log(logs.join(' > ')); // 日志:开始 > 业务:处理请求 > 日志:结束
logs.length = 0;
return run({});
})
.then(() => {
console.log(logs.join(' > ')); // 日志:开始 > 鉴权:拦截 > 日志:结束
});
带 token 时,请求一路走到业务层,再一层层退回来,所以“日志:结束”在最后。没有 token 时,鉴权层不调用 next,业务层根本没执行,可是日志层的“结束”仍然会打印,因为鉴权层返回以后,控制权照样回到了日志层。
这也说明了为什么日志、计时要放在最外层:只有最外层能包住后面所有环节。这个简化版省略了 Koa 的错误处理和防止重复调用 next 的检查,完整实现见《Koa 和 Express 有什么区别?中间件洋葱模型是怎样实现的?》。
axios 拦截器也是一条链
axios 用 interceptors.request.use() 注册请求拦截器,用 interceptors.response.use() 注册响应拦截器,use 返回一个 id,可以传给 eject() 移除。
const api = axios.create({ baseURL: '/api' });
api.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${getToken()}`;
return config; // 必须把 config 返回,下一环才能拿到
});
api.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) redirectToLogin();
return Promise.reject(error); // 继续往下传,让调用方也能感知失败
},
);
顺序上有一个容易记错的地方。axios 文档写明,请求拦截器是后添加的先执行,响应拦截器是先添加的先执行。注册了请求拦截器 1、2、3 和响应拦截器 1、2、3,实际顺序是请求 3、2、1,发出请求,响应 1、2、3。所以要依赖前一个请求拦截器结果的逻辑,应该先注册。
常见追问
职责链和装饰器模式有什么区别?
装饰器是一层包一层地给同一个对象加功能,每一层都会执行。职责链里的处理者可以决定不往下传,请求可能在中途就被处理或拦下。中间件兼有两者的特点:大多数层会调用
next继续往下,但鉴权这类中间件可以中断。装饰器见《装饰器模式是什么?和高阶函数、高阶组件有什么关系?》。
中间件里忘了 await next() 会怎样?
在 Koa 里,如果只写
next()不await,当前中间件不会等下游执行完就继续往下走,next()之后的代码可能在业务处理完成之前就执行了,统计的耗时、设置的响应头都会不对。完全不调用next(),后面的中间件就不会执行。