做后端这些年,几乎每个项目都逃不开“跨域、日志、认证”这三件套。前端说跨域报错,运维说日志不够,安全说认证有漏洞,改来改去最后都指向同一个地方——中间件。花点时间把中间件这层窗户纸捅破,比反复搜“Spring Boot 解决跨域问题”要划算得多。这篇文章我不会只贴配置,而是把跨域、日志、认证这三类中间件从原理讲到落地,再带大家走一遍自定义中间件从设计到上线的完整路径。
先说清楚一个容易混淆的点:标题里的“中间件”和“消息中间件”不是一回事。消息中间件指的是 Kafka、RabbitMQ 这类异步消息系统,而我们要讨论的是 Web 框架里的 HTTP 中间件,比如 Spring Boot 的 Filter/Interceptor、Express 的 Middleware,它们工作在每一次请求进入业务代码之前或之后。这个区别搞清楚了,后面所有代码才不会看迷糊。
1. 中间件在请求链路里的真实位置
1.1 中间件、过滤器、拦截器,别被三个名字绕晕
我见过不少同事把“过滤器”和“拦截器”混着用,其实它们虽然都在请求进业务之前干活,但层级和时机不一样。以 Java 生态为例:
| 组件 | 所属框架 | 执行时机 | 典型用途 |
|---|---|---|---|
| Filter | Servlet 容器 | 请求进入 DispatcherServlet 之前 | 跨域、编码、日志、压测标识 |
| HandlerInterceptor | Spring MVC | HandlerMapping 定位到 handler 之后、执行 handler 之前 | 登录校验、权限校验、接口幂等 |
| AOP | Spring | 业务方法调用前后 | 埋点、事务、缓存 |
| Middleware | Node.js Express/Koa | 请求路由匹配前后 | 同 Filter,但写法是函数式 |
Filter 是最外层的保安,请求还没到 Spring MVC 它就开始工作了;Interceptor 是进入具体业务方法前的第二道闸门,它能拿到“这次请求将要调用哪个 Controller 方法”这样的信息;AOP 则更贴近业务方法本身。很多人喜欢用 Interceptor 做跨域,不是不行,但面对某些老版本的 Web 容器或特殊 URL 路径时,Filter 的兼容性更稳。我个人的习惯是:跨域用 Filter,认证用 Interceptor,日志可以两者配合。
1.2 为什么说中间件是一个“洋葱”
如果你写过 Express,一定见过 next()。它其实是中间件链条的核心逻辑:请求会一层层穿进去,响应会一层层弹出来。用一张抽象图表示就是这样:
text复制请求进来 -> 中间件A -> 中间件B -> 业务接口 -> 中间件B -> 中间件A -> 响应出去
这个模型叫洋葱模型。中间件A在业务执行前能做前置处理,业务执行后还能做后置处理。Koa 里用 await next() 可以方便地控制这个流程。Spring Boot 的 Filter 也有 chain.doFilter(),Interceptor 有 preHandle 和 afterCompletion,本质上都在表达同一个思想:想要在所有请求前后插入通用逻辑,不需要改动任何业务代码,只需要在中间件层做文章。
理解了这个位置和模型,后面跨域、日志、认证的所有代码就都有了清晰的方向。每个中间件只解决一个问题,靠执行顺序把它们串起来,这就是中间件设计的核心。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 跨域中间件:CORS 的坑基本都在预检和凭证这两件事上
2.1 CORS 真正的工作原理
跨域不是后端限制的,是浏览器安全策略限制的。浏览器发现页面域名和接口域名不一致时,会在正式请求之前发一个 OPTIONS 请求来探路,这个请求叫“预检”。预检通过后,浏览器才会发出真实的 GET、POST 请求。
所以后端解决跨域,本质上就是告诉浏览器:“这些请求我允许,你可以放行。”具体来说就是响应头里加这些内容:
text复制Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Max-Age: 3600
其中 Access-Control-Allow-Origin 最容易出问题。前端岗位的同学经常跑过来说“帮我配跨域”,结果一查,我们后端明明配的 *,但前端还是报错。原因多半是请求里带了 Authorization 头或者 Cookie,也就是“凭证请求”。对于凭证请求,浏览器强制要求 Access-Control-Allow-Origin 不能是 *,必须是具体的源地址,同时还要加 Access-Control-Allow-Credentials: true。这三者缺一个都会被浏览器拦截。
2.2 Spring Boot 里两种跨域实现
如果你用 Spring Boot,常见的做法有两种:一种是实现 WebMvcConfigurer 的 addCorsMappings,另一种是注册一个 CorsFilter。
先看第一种,适合绝大多数单体项目:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("https://admin.example.com", "http://localhost:8080")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
注意我写的是 allowedOriginPatterns 而不是 allowedOrigins。这是因为一旦开启 allowCredentials(true),Spring 会自动拒绝 allowedOrigins("*"),而 allowedOriginPatterns 支持模式匹配,能解决一些前后端端口不固定的场景。不过它本质上是把允许的来源动态写到响应头里,而不是简单写成 *。
第二种是用 CorsFilter,适合需要更灵活控制跨域规则的场景,尤其当项目里多个过滤器并存时:
java复制@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.setAllowedOriginPatterns(List.of("https://*.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
这里有一个容易忽略的小细节:CorsFilter 需要放在其他过滤器前面,否则请求还没走到跨域处理就被拦截了,前端看到的还是跨域错误。Spring Boot 的 FilterRegistrationBean 里可以设置 setOrder(Ordered.HIGHEST_PRECEDENCE),我建议把跨域过滤器放到最前面,这样认证等其他逻辑收到的请求都已经完成跨域预检,不会出现“明明是认证拦截器报的 401,前端控制台却显示跨域报错”的迷惑现场。
2.3 前端也会遇到的 iframe 跨域
热搜词里出现“iframe跨域问题”,这里值得提一句。iframe 跨域和 XHR/Fetch 跨域不完全一样。iframe 嵌入的是另一个页面,常见问题是你无法直接操作 iframe 内部 DOM,所以需要靠 postMessage 通信。后端能做的,主要是给 iframe 页面返回时加上 X-Frame-Options 或 Content-Security-Policy 的 frame-ancestors 指令,来允许指定站点嵌入页面。它不完全是 CORS 那一套,但属于同一个“跨域”排查清单。排查的时候先确认是接口跨域还是页面嵌入受限,不要一股脑去改后端跨域配置。
2.4 多环境跨域配置容易踩的雷
开发环境 localhost:8080、测试环境 test.example.com、生产环境 admin.example.com 往往都不一样。如果把允许来源写死在代码里,每次部署都要改代码,很容易引发事故。我的做法是放到配置文件里,用一个字符串列表直接注入:
yaml复制cors:
allowed-origins:
- http://localhost:8080
- https://admin.example.com
然后读取配置:
java复制@Value("${cors.allowed-origins}")
private String[] allowedOrigins;
再动态构建 CorsRegistration。需要注意配置文件里如果有一个环境忘了配,或者配错了协议(https 写成了 http),前端怎么调试都过不了。所以每次环境切换后,我习惯先用 curl -H "Origin: https://admin.example.com" -H "Access-Control-Request-Method: GET" -X OPTIONS -v 去验证预检响应,确认响应头里出现了正确的 Access-Control-Allow-Origin,再让前端联调。
3. 日志中间件:把请求变成长链路里的一条完整时间线
3.1 日志中间件需要记录什么
日志中间件是后端排查问题的第一现场。生产环境没有 debugger,遇到问题只能靠日志反推。一个合格的“请求日志中间件”,至少要记录下面这些信息:
- 请求ID / traceId,用来串联同一次请求的所有日志
- 请求方法、URI、查询参数
- 请求体内容
- 用户身份(如果有)
- 处理耗时
- 响应状态码
- 异常堆栈(如果有)
有些人会问,打印请求体和响应体会不会有性能问题?确实有,特别是大文件上传或大响应体时。所以我通常的做法是:对请求体大小做限制,超过 1MB 就不打印完整内容,只打印大小和摘要;响应体可以不打全量,只打印状态码和耗时。日志中间件最重要的是稳定,不能因为它自己抛异常把业务接口搞挂了。
3.2 RequestBody 读取一次就没了?流包装
用 Spring Boot 写日志中间件的时候,第一次写的人都会踩一个坑:在 Filter 里读了 request.getInputStream(),进入 Controller 后再也拿不到请求体了。因为 Servlet 的 InputStream 是单向流,读一次就到底了,不能重新读。
解决办法是包装一个重复可读的 Request:
java复制public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
private final byte[] cachedBody;
public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
super(request);
this.cachedBody = StreamUtils.copyToByteArray(request.getInputStream());
}
@Override
public ServletInputStream getInputStream() {
return new CachedServletInputStream(this.cachedBody);
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
}
}
然后在日志 Filter 里这么用:
java复制@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (request.getContentType() != null && request.getContentType().contains("application/json")) {
request = new CachedBodyHttpServletRequest(request);
}
// 在这里读取 request.getInputStream() 记录请求体
filterChain.doFilter(request, response);
}
这个包装类本身不算中间件,但它是日志中间件落地的前提。我见过有人图省事,直接在拦截器里 getParameterMap(),结果 POST 的 JSON 请求体还是什么都拿不到。原因就是 JSON 是放在 body 里的,不是普通表单参数。
3.3 用 MDC 把 traceId 贯穿整个调用链
如果只打印请求 URL 和耗时,日志中间件的价值有限。真正让日志好用的是“把同一次请求的所有日志串起来”。Java 的 org.slf4j.MDC 就是干这个的。它本质是一个线程本地变量,在日志 Pattern 里加上 %X{traceId},就能在每个线程的日志里自动输出 traceId。
示例:
java复制@Log4j2
@Component
public class TraceIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String traceId = request.getHeader("X-Trace-Id");
if (StringUtils.isBlank(traceId)) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put("traceId", traceId);
response.setHeader("X-Trace-Id", traceId);
try {
filterChain.doFilter(request, response);
} finally {
MDC.remove("traceId");
}
}
}
然后在 logback 配置里:
xml复制<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] [%X{traceId}] %logger{36} - %msg%n</pattern>
这样 grep traceId 就能把一次请求在 Controller、Service、Mapper 里留下的所有日志捞出来。这个能力在排查慢请求或者链路异常时是救命级的。
另外要注意,新版 logback 扫描日志时经常出现“日志堆栈简化”的需求,比如只想打印异常的第一行类名和错误信息,不打印那 20 行调用栈。这可以通过自定 ThrowableProxyConverter 或直接控制异常日志的长度实现。不过这不是中间件层面的事,我建议先在本地把日志格式想清楚,再写中间件,不要等日志量大了才优化。
3.4 从日志中间件到 ELK 的统一日志体系
单机日志再全,一旦服务多了,查找问题还是要靠集中式日志。热词里出现的“ELK 搭建日志系统”就是这个方向。日志中间件负责把每条请求日志变成统一的 JSON 格式,然后输出到 stdout 或文件,再由 Filebeat 采集进 Elasticsearch,最后在 Kibana 里查询。
日志中间件此时要做的事是“结构化输出”。比如不要print成:
text复制request come from 192.168.1.1 url=/api/user
而是:
json复制{"timestamp":"2025-01-01T10:00:00.000Z","traceId":"abc123","method":"GET","path":"/api/user","status":200,"duration":12}
统一格式之后,ELK 里就能直接按 traceId 做聚合查询。所以日志中间件不仅仅是为了现在console里看得舒服,更是为了给后续日志基础设施喂数据。这一步做得早,后期上观测平台会轻松很多。
4. 认证中间件:把“你是谁、你能干什么”从业务里剥离开
4.1 Token 认证中间件的最小实现
认证逻辑如果不抽成中间件,最常见的结局就是每个 Controller 里都有一段“取 Header -> 解析 Token -> 判断是否过期 -> 返回码”的重复代码。我的建议是:认证只做校验和身份注入,不做具体业务逻辑。
以 Spring Boot 为例,认证拦截器一般这样写:
java复制@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 从 Header 取 Token
String token = request.getHeader("Authorization");
if (StringUtils.isBlank(token) || !token.startsWith("Bearer ")) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return false;
}
// 2. 解析并验证 Token
try {
JwtClaims claims = JwtParser.parse(token.substring(7));
// 3. 把用户信息放进请求上下文
request.setAttribute("userId", claims.getUserId());
request.setAttribute("userRole", claims.getRole());
} catch (Exception e) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return false;
}
return true;
}
}
这里的核心思路是:验证通过后,把 userId、userRole 这类信息放到 request 属性里,后面 Controller 通过 @RequestAttribute("userId") 或 RequestContextHolder 获取即可。这样业务代码就不需要再关心 Token 从哪来、怎么解析、怎么判空。
4.2 白名单与权限标记具体怎么写
认证中间件不能对每个接口都生效,比如登录接口、注册接口、健康检查接口必须放行。常见的做法是维护一个白名单:
java复制private static final Set<String> WHITE_LIST = Set.of(
"/api/auth/login",
"/api/auth/register",
"/api/health"
);
在 preHandle 开头判断:
java复制if (WHITE_LIST.contains(request.getRequestURI())) {
return true;
}
不过白名单用字符串精确匹配在路径参数面前会失效,比如 /api/user/123/profile。这时候可以改成 AntPathMatcher 或 PathPattern 来判断:
java复制private final PathMatcher pathMatcher = new AntPathMatcher();
if (WHITE_LIST.stream().anyMatch(pattern -> pathMatcher.match(pattern, request.getRequestURI()))) {
return true;
}
再进一步,如果你需要做角色权限控制,可以在自定义注解上做文章。比如定义 @RequireRole("admin"),然后在拦截器里看 HandlerMethod 上有没有这个注解:
java复制if (handler instanceof HandlerMethod handlerMethod) {
RequireRole requireRole = handlerMethod.getMethodAnnotation(RequireRole.class);
if (requireRole != null) {
String role = (String) request.getAttribute("userRole");
if (!requireRole.value().equals(role)) {
response.setStatus(HttpStatus.FORBIDDEN.value());
return false;
}
}
}
这样业务方法只需要写一行注解,权限判断和 Token 解析的代码都收拢到了认证中间件里。
4.3 认证中间件和跨域的相爱相杀
很多项目认证中间件和跨域中间件同时存在时,会出现一个典型的连锁问题:前端发来一个带 Authorization 头的 OPTIONS 预检请求,认证中间件不认识 OPTIONS,直接返回 401,浏览器就报跨域。然后前端同事跑过来说跨域没配好,其实是认证拦截器把预检给拦了。
解决办法很简单,认证中间件放行所有 OPTIONS 请求:
java复制if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
否则就要保证跨域 Filter 确实在认证拦截器之前,并且 OPTIONS 请求能直接返回 200。这个顺序和特判,是我排查过最多的问题之一。
还有一点容易被忽略:如果使用了单点登录或者第三方认证,中间件里通常不应该直接把用户信息塞进 session,而是只负责“验证票据是否有效”,然后依赖更上层的业务服务去拿用户资料。中间件不能越做越重,否则后续想替换认证方案时会非常痛苦。
5. 自定义中间件开发:一个通用限流中间件的完整落地过程
5.1 为什么要自己写而不是找现成组件
跨域、日志、认证都有现成框架可以做,但真正让中间件区别于“工具类”的是自定义中间件。比如限流、接口幂等、响应包装、灰度标识,这些场景往往需要定制。拿限流来说,Spring Cloud Gateway 里有 RequestRateLimiter,但如果你用的是传统单体服务,或者只想给某个接口加个简单限流,自己写一个小中间件比引一整套组件成本更低。
自定义中间件的真正价值是:它能把横切逻辑变成“一行接入”,新接口只需要加个注解或者走某个路径,通用逻辑自动生效。我从第一行代码开始带大家走完这个限流中间件的实现。
5.2 限流中间件的设计要点
限流算法有很多,常见的有固定窗口、滑动窗口、令牌桶、漏桶。自定义中间件不需要一开始就做得特别复杂,用“固定窗口 + 客户端IP”就足够覆盖大部分内部系统需求。设计要点有三个:
- 维度:按 IP、按用户、按接口,还是组合
- 阈值:每秒/每分钟允许多少次
- 超限后的行为:直接 429 或者排队等待
固定窗口的缺点很明显:窗口切换的瞬间可能出现双倍流量。但作为内部系统防护,可以先用它验证业务效果,之后要升级成滑动窗口或令牌桶,也只是替换算法模块的问题。
5.3 代码实现与接入
我以 Spring Boot 的 HandlerInterceptor 为例,写一个基于 Caffeine 缓存实现的最简版限流:
java复制@Component
public class RateLimitInterceptor implements HandlerInterceptor {
private final Cache<String, AtomicInteger> counterCache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.build();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String clientIp = request.getRemoteAddr();
String key = clientIp + ":" + request.getRequestURI();
AtomicInteger counter = counterCache.get(key, k -> new AtomicInteger(0));
int count = counter.incrementAndGet();
if (count > 10) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.getWriter().write("{\"code\":429,\"message\":\"request too many\"}");
return false;
}
return true;
}
}
然后把它注册到拦截器链里:
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Resource
private RateLimitInterceptor rateLimitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/login");
}
}
虽然只是几十行代码,但接入后所有 /api/** 接口都有了每分钟同一个 IP 最多 10 次请求的限制。Caffeine 的过期时间天然承担了“窗口重置”的作用,不用自己再去维护定时任务。
这个例子的灵感来自真实系统上线时被刷接口的问题。当时我们把限流放在 Nginx 层,但团队总是要喊运维帮忙加规则,非常不方便。后来把简单的 IP 限流下沉到应用层中间件,运维无需介入,开发也能快速调整阈值。对比之下,临时写一个限流中间件的成本远低于改基础设施。
5.4 通用中间件的配置化与开关
自定义中间件如果要长期维护,一定要“可配置、可开关”。哪怕团队里只有你一个人用,过三个月你也很难记得阈值写在哪里。我习惯用配置文件:
yaml复制custom:
middleware:
rate-limit:
enabled: true
limit-per-minute: 10
excludes:
- /api/auth/login
然后在中间件里读取 @Value("${custom.middleware.rate-limit.limit-per-minute:10}")。enabled 字段可以用来动态决定要不要注册这个拦截器。这样不同环境可以差异化配置,测试环境可以关掉,生产环境开严格一点。
另一个重要经验是:中间件不要硬编码返回格式。 比如上面限流返回的 JSON 写死了 {"code":429},如果系统响应结构是 {"success":false,"message":"..."},这个中间件就得跟着改。更好的做法是让中间件只负责“拒绝”,具体响应体由全局异常处理器或统一响应包装器生成,中间件内部只设置状态码和跳转位置。否则中间件写多了,每个中间件的返回结构不一致,前端最遭殃。
6. 注册顺序、重复执行与线上排错:中间件后半程才是真正考验
6.1 注册顺序能决定一次请求是否被“错误拦下”
跨域 Filter、认证拦截器、日志 Filter、限流拦截器全都注册以后,顺序就不只是代码洁癖问题了,它会直接决定功能是否正常。比如:
- 日志 Filter 如果放在认证拦截器后面,未认证请求的日志就丢了
- 跨域 Filter 如果放在认证拦截器后面,预检请求会被 401 拦截
- 限流拦截器如果放在认证拦截器后面,拿到
userId才能做用户维度限流;如果放在前面就只能按 IP 限流
在 Spring Boot 里,Filter 的注册顺序通过 @Order 或 FilterRegistrationBean.setOrder 控制,数字越小优先级越高。Interceptor 的注册顺序则是按 addInterceptor 的先后顺序。我一般定的顺序是:跨域 Filter(最高)-> 日志 Filter -> 认证拦截器 -> 限流拦截器 -> 业务 Controller。
6.2 一次请求中间件执行两次?OncePerRequestFilter
Servlet 世界里有时会出现过滤器被调用两次的诡异现象,尤其是在项目里配置了多个 Servlet 容器过滤器链,或者 Spring Boot 的某些代理配置下。解决办法很简单:继承 OncePerRequestFilter,它保证了一次请求只执行一次 doFilterInternal。
我在日志 Middleware 里给出的例子就继承了 OncePerRequestFilter。这不是随便选的,因为日志中间件如果执行两次,会打印重复日志,traceId 也会被重置。这类问题在本地开发时通常很难复现,部署到特殊环境后才会冒出来,提前用 OncePerRequestFilter 可以省掉很多排查时间。
6.3 线上排查三连问
中间件相关的问题,线上排查路径基本固定。我总结了一个“三连问”:
- 这个请求到底有没有走到我的中间件?——看日志。如果日志 Filter 在最前面都没打日志,说明请求根本没进入应用,去查网关或负载均衡。
- 这个请求是被哪个中间件拦下来的?——在关键拦截点加上不同标识,或者在公共兜底异常里打印
HandlerMethod信息。 - 中间件和业务代码的执行顺序是否符合预期?——在日志里打印每个中间件的执行时间戳,用 traceId 串联。
很多跨域和认证的问题,跟着这三步走完,基本都能定位到是配置问题还是代码顺序问题。最怕的是在没有任何日志输出的情况下靠猜,那会越猜越偏。
6.4 关于中间件性能损耗的真实数据
最后聊一下性能。中间件是横在每个请求前面的必经之路,如果写得重,确实会拖慢接口。但如果只是做跨域、日志、认证判断,单次开销一般在 1ms 以内。真正需要警惕的是下面这些操作:
- 在中间件里查数据库或访问 Redis
- 在中间件里对大的 RequestBody 做反序列化
- 在中间件里同步调用外部 HTTP 接口
- 日志打印太多大对象的
toString()
我见过一个生产事故,认证中间件里为了提高安全等级,把 Token 换成了每次访问都调一次用户中心的 HTTP 接口,结果高峰期下游用户中心被拖垮,所有接口跟着变慢。后来优化成“中间件只做签名校验 + 缓存用户信息”,耗时从平均 80ms 降到 5ms。这个经验说明:中间件适合做轻量级判断,不适合做重量级业务聚合。如果确实需要复杂数据,尽量把数据的获取延迟到业务层,或者用缓存。
中间件设计的好不好,不在于它功能多强大,而在于它够不够薄、够不够稳定、够不够好排查。把跨域、日志、认证这层逻辑彻底理解透,再动手写自定义中间件时,你会少踩很多隐形坑。上面这些经验都是我在项目里一步一步试出来的,希望这篇记录对你有实际帮助。
