ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

JavaEE项目全局异常处理实战:从Filter到ExceptionMapper的演进

JavaEE项目全局异常处理实战:从Filter到ExceptionMapper的演进 我发现很多同学做JavaEE项目时异常处理还停留在“每个Service方法都try-catch每个Controller方法都自己处理异常”的阶段。代码里充斥着重复的错误处理逻辑一旦业务规则调整所有方法的异常响应格式都要跟着改一遍维护起来苦不堪言。更麻烦的是DAO层抛出的异常、Service层封装后的异常、Controller层拦截到的异常它们的处理方式各不相同返回给前端的结构很难统一测试同学每次都要针对不同情况写不同的断言。全局异常处理解决的就是这个问题把“捕获异常”和“响应异常”的职责从业务代码里剥离出来集中在一个地方统一处理。JavaEE生态和流行的Spring全家桶不同它没有Spring Boot里那种RestControllerAdvice的便捷注解需要我们基于Servlet规范和JAX-RS规范自己动手。我梳理了一条从传统Filter拦截到ExceptionMapper映射的演进路径每一步都踩过坑今天把实战过程完整分享一下。1. 内容整体设计与思路拆解1.1 为什么需要独立的全局异常处理层先看一个典型的传统写法。假设现在要设计一个用户注册接口没有全局异常处理时代码大概长这样WebServlet(/api/user/register) public class RegisterServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) { try { String username req.getParameter(username); UserService service new UserService(); service.register(username); resp.setStatus(HttpServletResponse.SC_OK); resp.getWriter().write({\code\: 0, \msg\: \success\}); } catch (UserAlreadyExistsException e) { resp.setStatus(HttpServletResponse.SC_CONFLICT); resp.getWriter().write({\code\: 1001, \msg\: \用户已存在\}); } catch (IllegalArgumentException e) { resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); resp.getWriter().write({\code\: 1002, \msg\: e.getMessage()}); } catch (Exception e) { resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); resp.getWriter().write({\code\: 500, \msg\: \系统异常\}); } } }这段代码的问题一眼就能看出来第一每个Servlet都要重复写这一套catch逻辑一个系统如果有几十个接口就是几十份重复代码。第二业务异常和系统异常混在同一个catch块里日志打点散落各处排障时只能靠人工搜索关键词。第三一旦前端要求把响应结构从{code:0,msg:success}改成{code:OK,message:success,data:null}所有Servlet都得改一遍漏改一个地方联调就出问题。把异常处理抽成独立层次之后业务代码里不再有任何try-catch异常向上抛出由统一的地方决定“怎么响应、记什么日志、返回什么状态码”。这符合单一职责原则业务方法只关心业务逻辑异常处理层只关心异常翻译和响应输出。结合它的演进过程来看不同的实现方案带来的收益差别也很明显。1.2 三条技术路线的取舍分析针对JavaEE项目全局异常处理有三种常见实现路线方案适用场景侵入性是否推荐Filter包裹所有请求try-catch全链路老项目快速改造Servlet与JAX-RS混用低过渡可用JAX-RS ExceptionMapper纯RESTful接口项目使用JAX-RS开发低首选容器级配置与web.xml错误页传统JSP项目页面级错误展示中页面场景这三条路线的核心差别在异常捕获的位置和粒度。Filter的方案是在最外层包裹全部请求好处是不管底层是Servlet、Filter还是JAX-RS资源方法只要异常冒泡到Filter就能统一处理。坏处是它拿不到被调用方法的具体元数据比如某个资源方法上打的业务注解、方法参数等信息想要针对特定异常做差异化处理时比较吃力。JAX-RS的ExceptionMapper则是注册在JAX-RS运行时内部的映射器当资源方法抛出的异常会被运行时捕获然后按照异常类型匹配最合适的ExceptionMapper来处理。这种方式能够拿到完整的资源方法上下文可以精准定制“某类异常返回什么样的响应结构、什么样的HTTP状态码”在REST接口场景下是最优雅的。至于web.xml错误页配置定位在“页面级”的错误展示适合用户直接访问JSP页面报错时给出一个友好的错误页不适合做JSON接口的异常响应。基于这个对比我的做法是线上项目如果是纯REST接口直接用ExceptionMapper如果老项目里面既有Servlet也有JAX-RS资源先用Filter兜底再用ExceptionMapper精细化处理两种结合。接下来详细拆解每一步的细节。2. 核心细节解析与实操要点2.1 设计一套统一的异常响应结构不管用什么方案先要定义一套统一的异常响应结构让前端不管是登录失败、参数校验失败还是服务器内部错误都能用同一套格式解析。我习惯用下面这个结构{ code: 1001, message: 用户已存在, path: /api/user/register, timestamp: 1733456789012, traceId: 8f3a2b1c-6d4e-4f0a-9c2b-5e6d7f8a9b0c }字段含义code业务错误码用于前端逻辑分支判断。0表示成功非0表示各种业务异常。不要直接用HTTP状态码当业务码因为HTTP状态码只有几十个标准值业务错误码需要更精细。message给用户看的提示文案。注意这里不能直接把异常堆栈返回给前端否则会暴露内部实现细节属于安全隐患。path当前请求的URI便于前端定位是哪个接口报错。timestamp毫秒时间戳。traceId链路追踪ID这个字段非常重要后面排查问题全靠它。有了这个结构还要配套一个自定义异常基类public class BusinessException extends RuntimeException { private final int code; private final String message; public BusinessException(int code, String message) { super(message); this.code code; this.message message; } public int getCode() { return code; } Override public String getMessage() { return message; } }所有业务异常都继承BusinessException例如public class UserAlreadyExistsException extends BusinessException { public UserAlreadyExistsException() { super(1001, 用户已存在); } } public class InvalidParamException extends BusinessException { public InvalidParamException(String message) { super(1002, message); } }2.2 设计错误码枚举与异常映射表错误码不建议散落在一个个魔法数字里而是统一维护在一张枚举里。我见过很多项目错误码定义在各自的常量类中1001在A模块表示“用户已存在”在B模块又表示“订单已取消”排查问题时跨模块沟通成本极高。维护一张全局错误码映射表是更靠谱的做法错误码含义HTTP状态码所属模块1001用户已存在409用户模块1002参数不合法400通用1003认证失败401通用1004权限不足403通用1005资源不存在404通用1006资源已锁定423订单模块1007操作过于频繁429通用5000系统内部异常500通用错误码分段的规则可以是通用错误码用1000~1999用户模块用2000~2999订单模块用3000~3999支付模块用4000~4999系统级错误用5000~5999。规范一旦确定前后端都要遵守新增错误码必须走评审避免重复和歧义。对应这个映射关系可以设计一个枚举类public enum ErrorCode { USER_EXISTS(1001, 用户已存在, 409), INVALID_PARAM(1002, 参数不合法, 400), UNAUTHORIZED(1003, 认证失败, 401), FORBIDDEN(1004, 权限不足, 403), NOT_FOUND(1005, 资源不存在, 404), RATE_LIMITED(1007, 操作过于频繁, 429), INTERNAL_ERROR(5000, 系统内部异常, 500); private final int code; private final String message; private final int httpStatus; ErrorCode(int code, String message, int httpStatus) { this.code code; this.message message; this.httpStatus httpStatus; } // getter... }有了这个枚举ExceptionMapper里做异常映射时就不用每个异常单独写一坨代码。2.3 基于业务场景的日志与监控埋点全局异常处理不只是返回一个JSON响应更重要的是在捕获异常的同时记录足够的日志上下文。我见过太多线上事故报错信息只有一行NullPointerException连是哪个接口、哪个参数为空都看不出来。要做好异常处理第一部分就是日志埋点。建议在全局异常处理层统一完成以下日志工作打印完整的异常堆栈级别为ERROR。记录请求路径、请求方法、请求参数注意脱敏密码、令牌等敏感字段不要打。记录当前登录用户ID如果有。记录traceId。记录耗时。基于这个思路日志输出格式可以统一成这样ERROR [http-nio-8080-exec-8] [8f3a2b1c-6d4e-4f0a-9c2b-5e6d7f8a9b0c] 【全局异常】uri/api/user/register methodPOST userId10086 cost35ms 业务异常: code1001, msg用户已存在 java.lang.RuntimeException: 用户已存在 at com.example.service.UserService.register(UserService.java:58) ...这样的日志在排查问题时价值极大一行就能定位到接口、用户、耗时和异常类型。3. 实操过程与核心环节实现3.1 方案一Filter方式——最外层兜底先说一下Filter方案的实现。Filter是Servlet规范里的标准组件在请求进入Servlet之前执行在响应返回客户端之前也可以拦截。用Filter做全局异常处理的思路是用一个Filter包裹整个请求链路在doFilter方法外层的try-catch捕获所有异常。一个基础版本的实现长这样WebFilter(/*) public class ExceptionFilter implements Filter { private static final ObjectMapper MAPPER new ObjectMapper(); Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; long start System.currentTimeMillis(); try { chain.doFilter(req, resp); } catch (BusinessException e) { handleBusinessException(req, resp, e, start); } catch (Exception e) { handleException(req, resp, e, start); } } private void handleBusinessException(HttpServletRequest req, HttpServletResponse resp, BusinessException e, long start) throws IOException { writeErrorResponse(req, resp, e.getCode(), e.getMessage(), e, start); } private void handleException(HttpServletRequest req, HttpServletResponse resp, Exception e, long start) throws IOException { writeErrorResponse(req, resp, 5000, 系统内部异常, e, start); } private void writeErrorResponse(HttpServletRequest req, HttpServletResponse resp, int code, String message, Exception e, long start) throws IOException { // 关键保证响应编码统一避免中文乱码 resp.setCharacterEncoding(UTF-8); resp.setContentType(application/json;charsetUTF-8); resp.setStatus(resolveHttpStatus(code)); String traceId UUID.randomUUID().toString(); ErrorResponse body new ErrorResponse(code, message, req.getRequestURI(), System.currentTimeMillis(), traceId); LOGGER.error(【全局异常】uri{} method{} cost{}ms业务code{}msg{}, req.getRequestURI(), req.getMethod(), System.currentTimeMillis() - start, code, message, e); if (!resp.isCommitted()) { resp.getWriter().write(MAPPER.writeValueAsString(body)); } } }这里有几个细节需要注意。Filter方式能捕获到Servlet和Service层抛出的异常但有个坑如果异常发生在RESTEasy或Jersey这类JAX-RS框架内部而Filter在JAX-RS的前置链之外需要确认Filter的执行顺序。如果Filter注册在JAX-RS的DispatcherServlet之前那么它包裹的是整个Servlet可以捕获所有异常。但如果某个JAX-RS框架内部自己处理了异常Filter可能拿不到。这种情况下单用Filter不够需要配合ExceptionMapper。另外resp.isCommitted()这个判断很重要。如果响应已经提交给客户端了这时候再写数据会报IllegalStateException。在高并发下响应流已经部分写出时全局异常处理层要做的只是记录日志不要再试图修改响应。Filter方案还可以做一件事统一给响应头注入CORS跨域配置、XSS防护头等安全响应头。顺手把这些通用逻辑也放在这里这个Filter的价值就更大了。3.2 方案二ExceptionMapper——精细化异常映射如果项目使用JAX-RS开发REST接口比如基于Jersey或RESTEasy那么更优雅的方案是使用ExceptionMapper。这是JAX-RS规范专门用来处理异常映射的机制。它的原理是JAX-RS运行时捕获到资源方法抛出的异常后会根据异常类型找到与之匹配的ExceptionMapper实现调用其toResponse方法生成响应。先看一个最简单的mapperProvider public class BusinessExceptionMapper implements ExceptionMapperBusinessException { Override public Response toResponse(BusinessException exception) { ErrorResponse body new ErrorResponse( exception.getCode(), exception.getMessage(), /api/unknown, System.currentTimeMillis(), TraceIdHolder.get() ); return Response.status(exception.getHttpStatus()) .entity(body) .type(MediaType.APPLICATION_JSON_TYPE) .build(); } }这里有一个关键点ExceptionMapperBusinessException中的泛型参数决定了它能处理哪些异常。运行时匹配时如果有多个mapper都能处理同一个异常JAX-RS会找最具体的那个。比如UserAlreadyExistsException继承自BusinessException同时为这两个异常都定义了mapper那么抛UserAlreadyExistsException时会优先使用针对该异常类的mapper。再看一个处理通用异常的mapperProvider public class GenericExceptionMapper implements ExceptionMapperThrowable { private static final Logger LOGGER LoggerFactory.getLogger(GenericExceptionMapper.class); Override public Response toResponse(Throwable exception) { // 由于某些框架异常可能会被包装需要先解开最内层的异常 Throwable cause exception; while (cause.getCause() ! null cause.getCause() ! cause) { cause cause.getCause(); } LOGGER.error(【全局异常】未捕获异常: , exception); ErrorResponse body new ErrorResponse( 5000, 系统内部异常, UriHolder.getRequestUri(), System.currentTimeMillis(), TraceIdHolder.get() ); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) .entity(body) .type(MediaType.APPLICATION_JSON_TYPE) .build(); } }注意Throwable这个泛型它的含义是“兜底mapper”任何没有匹配到更具体mapper的异常都会落到这里。所以一个成熟的JAX-RS项目必须至少有一个ExceptionMapperThrowable否则会有大量异常逃出JAX-RS运行时以一种不可控的方式返回给客户端。在JAX-RS的toResponse方法里拿到的是异常对象但默认拿不到当前请求的URI、Header、用户信息等上下文。为了在异常处理层记录这些信息需要借助JAX-RS的Context注入不过不同版本的JAX-RS注入方式有差异。Jersey 2.x中可以用Context UriInfo但ExceptionMapper本身是否能注入UriInfo取决于实现。所以在实际架构中我更倾向用一套拦截器把请求上下文放进ThreadLocal和MDC里然后ExceptionMapper从ThreadLocal中取。3.3 请求上下文统一维护ThreadLocal与TraceId前面已经两处提到TraceIdHolder和UriHolder这个设计很关键。实现思路是在Filter或JAX-RS的ContainerRequestFilter中为每个请求生成或透传traceId并把它绑定到当前线程的MDC中。日志框架的MDC自动把traceId附带在每行日志里排障效率会大幅提升。先看traceId的来源规则如果前端传了X-Request-Id请求头沿用这个值做traceId便于前后端日志关联。如果前端没传后端生成一个UUID。在响应头里把traceId带回去前端把traceId展示在错误提示里用户上报问题时就能直接定位。核心实现WebFilter(/*) public class TraceIdFilter implements Filter { private static final String TRACE_ID_HEADER X-Request-Id; private static final String TRACE_ID_MDC_KEY traceId; Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; String traceId req.getHeader(TRACE_ID_HEADER); if (StringUtils.isBlank(traceId) || traceId.length() 64) { traceId UUID.randomUUID().toString().replace(-, ); } MDC.put(TRACE_ID_MDC_KEY, traceId); resp.setHeader(TRACE_ID_HEADER, traceId); TraceIdHolder.set(traceId); try { chain.doFilter(req, resp); } finally { MDC.remove(TRACE_ID_MDC_KEY); TraceIdHolder.clear(); } } }MDC的操作必须在finally中清理否则线程池复用时traceId会串到下一个请求上日志就全乱了。这个坑我踩过排查了半天才发现是MDC没清理导致的“串线程”问题。除了traceId请求URI、用户ID等信息也要放进一个ThreadLocal对象里统一管理public class RequestContext { private static final ThreadLocalContext HOLDER new ThreadLocal(); public static void init(String uri, String method, Long userId) { Context ctx new Context(uri, method, userId); HOLDER.set(ctx); } public static Context get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } public static class Context { private final String uri; private final String method; private final Long userId; // constructor, getter... } }这里用的是ThreadLocal但要注意在异步Servlet、线程池、异步JAX-RS场景下ThreadLocal变量是无法跨线程传递的。如果需要支持异步处理就得考虑使用TransmittableThreadLocal或者把traceId显式传递到异步线程中。这是我这几年遇到的最隐蔽的问题之一希望各位尽量提前注意。3.4 配合Filter链式处理与链上优先级控制实际项目里一个请求会经过多个Filter每个Filter关注的点不同。以我最近接手的一个支付网关项目为例它的Filter链顺序是这样的TraceIdFilter生成/透传traceIdCorsFilter处理跨域AuthFilter登录态校验RateLimitFilter限流ExceptionFilter异常兜底关键点ExceptionFilter要在所有Filter的最外层或者放在所有可能抛出异常的业务型Filter后面。如果在AuthFilter里抛出了未登录异常而ExceptionFilter放在AuthFilter前面异常就无法被捕获到。所以我在项目里把ExceptionFilter放在Filter链的最后一个位置即最早执行确保它能包裹住后面所有Filter。但这样又带来另一个问题如果ExceptionFilter在链条最外层它如何知道某个异常是哪个Filter抛出的其实不需要知道因为Filter抛出的异常会向上冒泡在ExceptionFilter里捕获的是整个链路中冒泡上来的第一个异常。这个位置靠前一点反而能捕获更多类型的异常。还有一个细节某些Filter可能自己内部处理了异常没有往上层抛这时ExceptionFilter永远捕获不到。比如AuthFilter如果自己catch了异常并直接返回401 JSON那ExceptionFilter只需要处理真正的系统异常业务异常已经由AuthFilter处理了。这不算错误设计但要注意保持一致性不要让“模块内处理一部分、全局处理一部分”的边界模糊。在设计Filter链时建议明确列出每个Filter的职责边界Filter名称职责抛出异常时由谁处理TraceIdFilter生成traceId、设置响应头不抛异常或异常向上抛给异常过滤器CorsFilter添加CORS响应头异常向上抛给异常过滤器AuthFilter登录态校验抛401异常由异常过滤器统一转JSONRateLimitFilter限流抛429异常由异常过滤器统一转JSONExceptionFilter全局兜底自身处理所有冒泡上来的异常3.5 集成RESTEasy/Jersey时的注册配置细节如果你用的是JAX-RS实现框架要注意注册ExceptionMapper的方式。不同框架的注册方式略有不同RESTEasy中使用resteasy-jaxrs时可以通过Provider注解自动发现或者通过Application子类手动注册ApplicationPath(/api) public class RestApplication extends Application { Override public SetClass? getClasses() { SetClass? classes new HashSet(); classes.add(BusinessExceptionMapper.class); classes.add(GenericExceptionMapper.class); classes.add(ValidationExceptionMapper.class); classes.add(UserResource.class); return classes; } }Jersey中类似也是通过ResourceConfig扫描或注册public class JerseyConfig extends ResourceConfig { public JerseyConfig() { register(BusinessExceptionMapper.class); register(GenericExceptionMapper.class); register(ValidationExceptionMapper.class); packages(com.example.api); } }这里要特别提醒用packages(com.example.api)自动扫描时Provider注解的类会被自动注册。但有时会因为包扫描范围不够导致某个Mapper没被扫描到运行时异常直接以默认HTML格式返回前端拿到一个HTML页面会懵掉。所以改成显式注册会更可控——注册了哪些Mapper一目了然出问题时也容易排查。4. 常见问题与排查技巧实录4.1 异常被JAX-RS框架“吞掉”没进你的Mapper这个问题是我在集成Jersey时遇到的。如果某个异常在进入你的ExceptionMapper之前已经被JAX-RS运行时内部当作“不可映射异常”处理了你的Mapper可能根本没机会执行。尤其当异常类型是WebApplicationException时JAX-RS允许不经过任何mapper就直接生成响应。解决办法给WebApplicationException也注册一个mapper。虽然JAX-RS自带默认处理但默认处理返回的结构不是你自定义的JSON格式。注册一个自定义mapper把WebApplicationException也纳管Provider public class WebApplicationExceptionMapper implements ExceptionMapperWebApplicationException { Override public Response toResponse(WebApplicationException exception) { Response original exception.getResponse(); int status original.getStatus(); Object entity original.getEntity(); // 构造统一格式的响应保留原始status ErrorResponse body new ErrorResponse( mapStatusToCode(status), resolveMessage(entity, status), UriHolder.getRequestUri(), System.currentTimeMillis(), TraceIdHolder.get() ); return Response.status(status) .entity(body) .type(MediaType.APPLICATION_JSON_TYPE) .build(); } }mapStatusToCode是把HTTP状态码映射到业务错误码比如404映射到1005401映射到1003。resolveMessage是提取原始响应实体里的消息优先用原始消息原始实体不再打印的时候用默认文案兜底。4.2 异步线程池里的异常无法被ExceptionMapper捕获上面提到过ExceptionMapper默认是同步处理的如果你在资源方法里用了异步线程池比如ExecutorService子线程里抛出的异常不会回到JAX-RS运行时更不会进入ExceptionMapper。实际表现是接口返回成功但后台日志里躺着一条未被处理的异常。场景示例GET Path(/async-job/{id}) public Response submitJob(PathParam(id) Long id) { executorService.submit(() - { // 这里抛异常无法被全局异常处理器捕获 process(id); }); return Response.accepted().build(); }办法有两种。一种是把异常捕获范围放到异步任务内部手动包一层异常发生时记录日志并发送告警另一种是使用JAX-RS的Suspended和AsyncResponse把异步结果回调给JAX-RS运行时处理。第二种做法可以让异常回到JAX-RS的异常处理链路前提是你把AsyncResponse.resume(exception)调用的地方放到try-catch外面。GET Path(/async-job/{id}) public void submitJob(PathParam(id) Long id, Suspended AsyncResponse asyncResponse) { executorService.submit(() - { try { String result process(id); asyncResponse.resume(result); } catch (Exception e) { asyncResponse.resume(e); // 异常回到JAX-RS运行时交给ExceptionMapper } }); }4.3 返回结构“串格式”JSON和HTML互串在Filter方式里如果异常过滤器在某个Filter已经设置过响应的ContentType为HTML之后触发那么响应头里的ContentType可能还是HTML即使body里写的是JSON前端解析也会报错。这个问题出现的频率比我预想的高得多。原因在于resp.setContentType(application/json;charsetUTF-8)只在响应未提交时有效一旦响应提交了再设置ContentType不会生效。所以全局异常过滤器通常放在最外层能拿到还没提交的响应。如果发现响应已经提交那么不建议继续写数据只记录日志即可。还有一种情况Servlet容器里的web.xml配置了error-page重定向异常发生时会走容器错误页逻辑导致自定义的JSON响应被容器的错误页替换。解决办法是确保全局异常过滤器把异常全部吞掉后再返回让容器认为没有异常发生或者把error-page的location指定为一个JSON接口。4.4 参数校验异常没有统一返回结构JAX-RS里参数校验Bean Validation抛出的异常类型是ConstraintViolationException它不会自动映射到你的BusinessExceptionMapper或GenericExceptionMapper如果没有专门处理则可能返回400但响应体是框架默认的格式。现象是业务错误码规则失效body里是ConstraintViolationExceptionImpl之类的堆栈片段。解决方法是注册一个专门的ConstraintViolationExceptionMapperProvider public class ConstraintViolationExceptionMapper implements ExceptionMapperConstraintViolationException { Override public Response toResponse(ConstraintViolationException exception) { SetConstraintViolation? violations exception.getConstraintViolations(); ListString messages violations.stream() .map(v - v.getPropertyPath() : v.getMessage()) .collect(Collectors.toList()); String message messages.isEmpty() ? 参数校验失败 : String.join(, , messages); ErrorResponse body new ErrorResponse( 1002, message, UriHolder.getRequestUri(), System.currentTimeMillis(), TraceIdHolder.get() ); return Response.status(Response.Status.BAD_REQUEST) .entity(body) .type(MediaType.APPLICATION_JSON_TYPE) .build(); } }4.5 常见问题速查表现象可能原因排查方法解决方案异常格式不是自定义JSONMapper未注册或未生效检查Application类显式注册看启动日志有无扫描到类显式注册避免仅靠包扫描返回了HTML错误页面容器默认error-page拦截了响应查看web.xml检查Filter是否吞掉异常自定义error-page或让Filter吞掉异常traceId在多线程下线ThreadLocal跨线程失效检查异步场景使用TransmittableThreadLocal或显式传递响应已经提交后还试图写bodyresponse被提前提交检查CORS、Auth等Filter是否先写了响应在异常过滤器最外层处理或仅记录日志参数校验异常格式不统一缺ConstraintViolationExceptionMapper打印异常类型添加对应Mapper日志中找不到请求上下文ThreadLocal未在finally清理检查是否存在串线程清理或使用委托包装清理逻辑4.6 分环境差异化处理线上环境和本地开发环境的异常处理策略应该不同。本地开发时全局异常处理层可以把完整异常堆栈直接放在响应体的detail字段里方便前端联调和后端排查。线上环境则不要返回堆栈只返回message和traceId。实现方式是根据环境配置判断private ErrorResponse buildErrorResponse(int code, String message, Exception e) { ErrorResponse response new ErrorResponse(code, message, uri, timestamp, traceId); if (ENV.isDev()) { response.setDetail(e.toString()); } return response; }生产环境里不要试图把堆栈返回客户端也别用printStackTrace全部走日志框架否则大量异常堆栈打在控制台日志收集时会被刷屏真正需要关注的信息反而被淹没。5. 进阶实践从单体异常处理到分布式异常追踪5.1 全局异常处理与链路追踪的配合JavaEE单体应用发展到分布式体系时全局异常处理层除了返回统一格式的JSON还要承担“把traceId透传到下游服务”的职责。这块在网关层、服务提供方、服务消费方各有一份实现目标只有一个一个请求从网关到下游多个服务整条链路上使用的traceId是同一个。在服务消费方请求外发之前要取出MDC里的traceId设置到HTTP头里String traceId MDC.get(traceId); if (StringUtils.isNotBlank(traceId)) { httpRequest.setHeader(X-Request-Id, traceId); }在服务提供方就是前面TraceIdFilter里的逻辑优先读取X-Request-Id头沿用而不是另生成。一旦全链路都在透传同一个traceId全局异常处理层打出的日志日志里就带着这条traceId跳板机上输入traceId就能把整个链路的日志拉出来排障效率天差地别。5.2 告警策略与关键指标全局异常处理层除了记录日志还应该做指标统计。常见的统计维度包括每分钟异常捕获总数各错误码占比各类异常的发生趋势异常发生时对应接口的耗时用这些指标配合监控告警可以对“异常突增”做出快速响应。比如平时每分钟异常捕获数在个位数突然涨到几百不需要等待用户反馈告警已经发出来了。这一点在生产环境有巨大价值。统计做法不复杂用一个环形的计数器或直接在日志里打点LOGGER.info(【异常指标】uri{} code{} type{} cost{}ms, uri, code, exception.getClass().getSimpleName(), cost);监控系统收集日志后按关键字聚合就能画出异常趋势图。5.3 万能兜底最后一道防线在设计全局异常处理时有一个原则必须坚持前端展示的友好错误信息可以缺失但服务端绝对不能因为没有正确处理异常而崩溃或丢失日志。换句话说全局异常处理层本身绝不能抛出异常。一旦它在处理异常时抛了新异常原始异常信息可能丢失响应也可能变成500空页面。所以建议在所有Mapper和Filter的异常处理分支里再加一个最终保底catch住所有Throwable只记日志返回最基础的错误结构。try { writeErrorResponse(req, resp, code, message, e, start); } catch (Exception ex) { LOGGER.error(全局异常处理自身失败, ex); if (!resp.isCommitted()) { resp.setStatus(500); resp.setContentType(text/plain;charsetUTF-8); resp.getWriter().write({\code\: 5000, \message\: \系统繁忙\}); } }有人可能会说写响应怎么可能失败在真实运行环境下连接被客户端断开、缓冲区溢出、字符编码不支持等原因都可能触发IO异常不要让兜底逻辑变成“二次炸弹”。5.4 一个完整的配置清单项目里落地这套东西建议按下面的清单自查检查项是否完成统一了ErrorResponse结构是/否定义了全局错误码枚举是/否业务异常继承统一基类是/否注册了业务异常Mapper是/否注册了通用异常Mapper(Throwable)是/否注册了参数校验异常Mapper是/否生成并透传traceId是/否MDC在finally中清理是/否响应统一为UTF-8编码是/否生产环境不返回堆栈是/否异步场景有异常处理策略是/否全局异常处理自身有兜底是/否6. 个人经验总结与扩展思路实操做下来我最深的感受是全局异常处理不只是写几个Mapper类那么简单它是一个系统性设计。体系一旦搭好后续每新增一种异常类型都只是加一个异常类和一个Mapper的问题业务代码的整洁度会大幅提升。有一个比较实用的扩展思路如果项目已经使用Swagger/OpenAPI可以让全局异常处理层把ErrorResponse结构自动注入到OpenAPI文档中。这样前端生成的SDK里所有接口的错误响应结构就是同一个类型联调时前端基本不会问“这个接口报错返回什么格式”这种问题。另外一个方向是按微服务拆分错误码段。每个微服务模块有自己独立的错误码区间网关做一次统一聚合将内部错误码翻译成对客户端合适的错误码。这个工作做与不做直接决定BFF层对接成本的高低。多写一句不要想着一口气引入过多“优雅”特性。先让Filter兜底跑起来把统一响应结构固化下来再引入ExceptionMapper精细控制逐步演进。一个能跑、能排查、能告警的处理体系比文档里再优美的设计都更有价值。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表