ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

写给Java开发者的异常处理规范建议

写给Java开发者的异常处理规范建议 异常处理是Java开发者绕不开的战场。有人把异常当敌人用try-catch把代码裹成粽子有人把异常当摆设捕获后打个日志就继续裸奔。这两种极端都在日常Code Review里反复上演。我想说的第一句话是异常不是Bug它是程序在说“我遇到了无法按原计划继续执行的情况”。倾听它、分类它、传递它才是工程化的态度。本文不打算罗列教科书上的异常体系而是给你一套可落地的规范建议——它们来自真实项目中的痛苦教训而非Java语言规范。先把“捕获”和“声明”的边界画清楚Java的受检异常Checked Exception设计初衷是强制调用者处理可预见的失败。但现实是很多开发者用“捕获所有异常并包装成RuntimeException”来逃避思考。我见过太多代码try { orderService.create(order); } catch (Exception e) { throw new RuntimeException(创建订单失败, e); }这种写法不是错但它是“懒惰”的代名词。你连异常的具体类型都没看就急着把它扔到上层这等于告诉别人“我不关心发生了什么”。规范的第一条捕获异常时要捕获你能处理的最小粒度声明异常时要声明你能表达的最准确类型。比如调用远程接口可能超时、可能连接拒绝、可能响应解析失败你至少应该区分TimeoutException和IOException而不是一股脑catch(Exception)。你能处理的就地处理并恢复你不能处理的明确地重新抛出并说明原因。反过来也有一种过度设计把每个方法都声明为throws Exception。这比捕获所有异常更糟糕。throws Exception是异常处理上的“放弃治疗”它让所有调用者被迫处理一个模糊的、可能包含任何问题的怪物。规范建议方法签名上只声明该方法的调用者真正需要关心的、并且可能发生的受检异常。如果内部有不受检异常如NullPointerException不要声明而是通过参数校验和防御性编程去消除。不要吞掉异常除非你有完美的理由“吞掉”指catch块里什么都不做或者只打个logger.info。最常见的理由是“这个异常不影响主流程比如记录日志失败”。但真的不影响吗吞掉一个异常等于向未来的排查者关上了最重要的一扇门。我见过生产事故因为某个catch块里只是// ignore导致数据同步线程卡死三天没人发现。如果你确实要忽略某个异常必须满足三条一你已经确认该异常在业务上完全可忽略二你至少打了debug或trace级别的日志三你在代码注释里写明“为什么忽略”。否则请你至少做一件事logger.warn(..., e)把堆栈留下。更隐蔽的吞异常方式是“捕获后返回null”。比如try { return config.getValue(key); } catch (MissingConfigException e) { return null; }如果调用方没有处理null后续NPE就会在别处爆发。用null替代异常是把错误状态推迟到不可控的时点。规范的替代做法有两种要么抛出异常并让调用方处理要么返回OptionalT把“值不存在”变成类型系统的一部分。异常包装别让根因消失当你把异常从底层抛向高层常常需要包装成更具业务语义的异常。比如底层的SQLException在上层包装成OrderPersistenceException。这是合理的。但包装时最容易犯的错是丢失原始异常的堆栈和原因。如果你不把原始异常作为cause传给新异常你就是在故意抹除犯罪现场的指纹。正确的姿势throw new OrderPersistenceException(保存订单失败订单号 orderId, e);这里务必注意字符串拼接。很多开发者会写“保存订单失败”这样没有上下文的话等排查时根本不知道是哪个订单。异常消息必须携带足够的上下文信息入参的关键业务标识、当前操作的对象ID、甚至当时的重试次数。规范可以定义为生产环境下的异常消息必须能让一个不了解代码背景的运维人员初步判断出问题范围。另外不要在包装时层次过深。三层以内的包装还可以接受五层以上就说明你的抽象边界出了问题。每一层包装都是对根因的一次稀释你需要确保每次稀释都增加了价值更贴近业务语义而不是重复表述。自定义异常需要但别泛滥很多人喜欢自定义异常类仿佛不建几个自定义异常就不够专业。结果是项目里冒出UserNotFoundEx、UserNotExistException、EdewtUserMissingException之类的重复定义。自定义异常的核心价值是“让调用方可以根据类型做差异化处理”。如果两个异常在调用方的处理方式上没有任何区别那么它们应该合并。规范建议一个业务模块的自定义异常数量控制在个位数优先使用Java标准异常比如IllegalArgumentException、IllegalStateException、UnsupportedOperationException。当你发现标准异常无法表达语义时再考虑自定义。自定义异常应该继承什么领域层建议继承RuntimeException。理由很实际受检异常在Java中是历史包袱主流框架Spring等都倾向运行时异常。但注意如果你的项目有严格的架构分层且明确要求DAO层抛出受检异常那也请保持一致。一致性比“最佳实践”更重要因为团队沟通成本和维护成本会因混杂风格暴增。自定义异常还要提供多个构造函数至少包含仅消息、消息原因、原因。另外如果需要序列化请保留serialVersionUID——虽然很多开发者不记得这事但分布式场景下异常对象可能跨进程传输。资源关闭try-with-resources是底线在Java 7之前写finally里关闭连接是必修课。但今天如果你还在手写finally { if (in ! null) { try { in.close(); } catch ... } }那你的代码属于上个世纪。用try-with-resources不仅是简洁更是安全——它保证了close()在异常扔出时也会隐含执行而且能把close()自身的异常与主体异常合并。规范强制凡是实现了AutoCloseable的资源流、连接、锁、监听器一律放在try的括号里禁止在finally中手动关闭。一个常见的误区try-with-resources里若先声明了A再声明B关闭顺序是反的B先关闭。这在某些依赖关系下会出问题。比如先开Connection再开Statement关闭时要先关Statement再关Connection。try-with-resources的自动顺序恰好是后声明的先关这符合大多数场景。但如果你遇到反依赖就别硬套改用传统的try-finally并保持清晰结构。任何规范都不是教条理解背后的原因更重要。异常与性能别用异常控制流程Exception的开销主要在填充堆栈。哪怕你创建异常后不抛仅new一个异常对象也会记录堆栈信息成本高昂。因此严禁用异常来实现常规业务分支比如循环内用NumberFormatException判断字符串是否数字。这类代码可能在低并发时无感一到高并发就会成为CPU杀手。正确做法是用正则、工具类、或者明确的条件判断。还有一种性能杀手是“记录异常时的字符串拼接”。比如log.error(order: order err: e)如果日志级别是ERROR拼接总会执行如果改成log.error(order:{}, order, e)SLF4J只有在需要输出时才会调用toString。这一点对异常路径尤其重要因为异常路径本来性能就差别再雪上加霜。在继承和多态中抛出异常的约束如果你重写父类方法或实现接口方法子类方法不能抛出比父类更宽泛的受检异常——这是编译期规则。但比规则更重要的是设计原则父类方法声明了throws IOException子类实现如果不需要抛任何异常也不应该为了“对齐”而额外抛出。反过来子类如果抛出了父类未声明的受检异常编译器会拦你但你可能会用RuntimeException绕过。用运行时异常绕过接口签名约束是一种对接口契约的破坏请务必在方法注释中明确说明可能抛出的运行时异常。更微妙的是当你调用一个声明为throws Exception的第三方方法时不要试图捕获所有异常后裸抛。你应该在那一层就将异常翻译成你的领域语义。“翻译异常”是分层架构中的重要工作Controller层应该把异常翻译成HTTP状态码和错误响应体Service层应该把异常翻译成业务错误码Repository层应该把异常翻译成数据访问异常的领域变体。避免底层的DataAccessException一路冒泡到前端JSON里。空指针最好的处理是不让它发生NullPointerException是Java的万恶之王。很多规范建议“多用Optional”但Optional不是包治百病的万灵丹它主要用于返回值不应作为方法参数和字段类型。一个更实用的规范是除非有明确表示“可能不存在”的语义否则所有参数和返回值都视为非null。然后通过Objects.requireNonNull在方法入口做快速失败。这比在方法体内隐式抛出NPE要清晰得多。另一个好习惯是使用Optional时避免orElseThrow(() - new RuntimeException())这种空泛的写法。请抛出带有具体业务语义的异常比如OrderNotFoundException。你每写一个魔法异常都在给未来的自己埋雷。日志与异常别把堆栈打成多行当异常被捕获后需要记录日志。规范上你要在日志中记录异常消息和堆栈但没必要记录整个异常对象的toString很多toString不包含堆栈。最推荐的写法是log.error(上下文, e)其中第二个参数直接传异常对象日志框架会自动输出完整堆栈。千万不要e.printStackTrace()它会打印到标准错误输出在服务器上根本找不到。也不要将异常的消息与堆栈分开记录成两行比如先打message再打stackTrace否则在日志聚合工具里这两行会被拆散给检索带来巨大困难。一条日志关联一个异常这是底线。另外要注意日志级别。捕获后恢复的异常用warn业务的预期失败比如用户确实传了一个不存在的ID用info或debug真正的系统故障用error。级别用错要么淹没机房监控要么掩盖真实故障。规范建议error级日志必须对应一个需要人工介入的事件否则就用warn或info。分布式与异步异常无处安放微服务架构下异常不再只是JVM内部的事。RPC调用、消息队列、异步线程池都有异常的跨边界问题。当异常穿越网络边界堆栈信息往往被压缩成一行错误码所以你在服务内部抛什么异常、消息如何构造直接影响排障效率。规范建议对外暴露的API接口应该定义统一的错误响应结构包含错误码、错误消息、请求追踪ID。内部异常不要直接序列化给客户端而是映射为业务错误码。底层异常信息是内部机密泄露给客户端可能暴露数据库表名或SQL片段安全上也需小心。异步任务中Runnable的run方法无法声明受检异常也没有调用方能catch。因此异步任务内必须自己兜底捕获一切异常并记录日志否则线程池会让异常悄悄消失。正确的做法是在异步方法入口统一设置try-catch将异常包裹在带上下文信息的自定义异常后交给全局异常处理器比如Spring的Async配AsyncUncaughtExceptionHandler。同时你在异步回调CompletionStage里也要显式处理exceptionally或whenComplete不能让失败像一个掉进下水道的声音。全局异常处理器最后一道防线在Spring Boot等框架中用ControllerAdvice或RestControllerAdvice统一处理异常是主流做法。但规范上要注意全局处理器只应该处理“面向外部响应”的异常它不应该吞掉那些需要业务层自己处理的异常。全局处理器的存在是为了防止异常泄漏到Web容器而不是为了取代业务try-catch。你能在controller层通过条件判断避免的异常不要故意抛出来让全局处理器接那是一种借刀杀人式的坏风格。同时全局处理器里要对异常进行分级已知业务异常乱码转成友好提示、系统异常记录error并返回“系统繁忙”、未预知异常必须打印全堆栈。未预知异常是最有价值的故障信号不要用美观的响应体把它掩盖。你要保留原始的异常类型和堆栈用于监控告警。从组织视角看异常规范需要代码评审守门规范如果只是写在文档里很快就会被遗忘。你需要一个自动化或者半自动化的守门机制。比如引入静态检查规则禁止捕获Throwable除了main入口和线程池任务兜底禁止打印堆栈禁止在循环内try-catch除非有明确要求禁止用异常控制流程。代码评审中异常处理相关的意见应该是最高优先级的反馈之一。因为异常处理的质量决定了系统在失败时是优雅降级还是灾难崩溃。我见过太多项目平时功能正常一旦数据库连接池耗尽错误日志被无效的catch吞掉导致所有人都不知道发生了什么。异常处理不是写happy path时顺带补的边角料它是软件健壮性的核心骨架。每次你写catch、throw、try-with-resources都在决策这个系统的失败行为。作为Java开发者一个有用的思维转换是不要问“这段代码会不会抛异常”而要问“这段代码如果抛异常我的系统该怎么办”。前者是被动防御后者是主动设计。把异常当作流程的一部分而不是意外。这样你的代码才能在大规模、高并发、长运行的真实世界里站得住脚。最后我想说一句没有一条规范是永恒的但“保持意图清晰”这条原则是永恒的。无论你选受检异常还是运行时异常无论你写自定义异常还是复用标准异常只要你的团队能通过阅读异常路径理解系统的失败模式那你的规范就是有效的。剩下的就是不断在Code Review中打磨让每一个catch都有意义每一个throw都有理由。
返回列表