ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

代码评审必看:识别高风险写法与坏味道,守护系统稳定性

代码评审必看:识别高风险写法与坏味道,守护系统稳定性 “Are you sure this is a good idea?~”这句话很多开发者在代码评审Code Review时都默默在心里问过。看到一个临时修复、一段跳过规范的核心逻辑、一个为了“先跑起来”而留下的 todo第一反应往往不是“能不能跑”而是“这个方案会不会在三个月后变成事故”。这篇文章不讨论具体某个框架的 API而是围绕代码评审中最常见的“高风险写法”展开。我们会一起拆解那些表面能运行、长期会埋雷的代码模式给出可落地的修改建议并整理一份可以直接用于日常评审的排查清单。如果你正负责项目质量、带新人或者想让自己提交的代码更经得起推敲这篇内容会比较适合你。1. 背景与核心概念1.1 代码评审中的“隐形雷区”代码评审的目的不是证明“代码能跑”而是确认“代码在真实环境中长期运行时仍然能保持正确、稳定、可维护”。很多问题在本地跑通一次并不会暴露例如接口返回 null 时下一个调用方直接空指针。定时任务处理到一半突然重启数据被重复处理。异常被吞掉线上日志里什么都看不到。某段逻辑用了魔法数字后续调整时根本不知道这个数字代表什么。每当你看到类似代码心里冒出“Are you sure this is a good idea?~”的时候说明这段代码正在触碰某种风险边界。我们需要做的不是凭感觉反对同事的提交而是把模糊的“不放心”转化成具体的、可以被修改的评审意见。1.2 什么是代码坏味道“坏味道”Code Smell并不是指 bug而是指代码中存在的一些结构性信号暗示当前设计可能脆弱、难以维护或容易引发线上故障。坏味道的特点是现在不报错但未来大概率出问题。常见的坏味道包括魔法值代码中直接出现的数字、字符串没有语义。重复代码同一个逻辑复制粘贴到多个地方修改时容易漏改。过长的函数一个方法几百行职责混乱。参数过多调用方很难看清每个参数含义。吞异常catch 之后什么都不做。隐藏副作用一个简单函数内部偷偷改了外部状态。理解坏味道不能在评审时给出有说服力的理由。接下来我们重点看几种最容易被忽略、又最容易引发线上问题的坏味道。1.3 本文的核心思路整篇文章会围绕一条主线展开当一个看似“可以工作”的方案出现时我们应该从正确性、健壮性、可维护性三个角度去问三个问题。正确性任何边界条件下都不会算错吗健壮性外部依赖不可用、数据异常、并发冲突时系统能兜住吗可维护性三个月后的自己或者刚接手的新同学能看懂并安全修改吗如果某一个问题的答案不确定那大概率需要停下来讨论一句“Are you sure this is a good idea?~”2. 环境准备与评审流程定位2.1 本文示例的运行环境本文主要以 Java 代码举例因为 Java 在企业级后端项目中的空指针、事务、异常处理问题非常典型。不过其中思路同样适用于 Python、Go、C# 等语言。版本方面说明一下Java 8 足以运行本文示例Spring Boot 相关示例需要根据你的实际项目版本调整本文重点演示的是设计思路和代码模式而不是某个版本的专属 API。如果你本机还没有 Java 开发环境可以准备JDK 8 或更高版本。Maven 或 Gradle用于构建项目。一个 IDE如 IntelliJ IDEA、Eclipse 或 VS Code。2.2 代码评审流程的定位代码评审并不是“代码写完后贴到群里等大家吐槽”而是一个有节奏的协作流程。通常可以分成四个阶段自测阶段提交代码前开发者在本地跑通测试用例至少保证主流程可用。提交阶段提交 Merge RequestMR/ Pull RequestPR填写清晰的描述说明改动原因和影响范围。评审阶段评审人检查代码提出意见开发者和评审人讨论并修改。合入阶段所有阻塞性问题关闭后代码才能合并。本文讨论的主要内容集中在评审阶段。我们需要一套方法把“我不太放心”转化为“这里建议改因为存在具体风险”。3. 典型高风险写法盘点3.1 魔法值与语义缺失先看一个最简单的例子// 危险示例魔法值 if (order.getStatus() 1) { // 发货 }这段代码看似没问题但1代表什么如果有一天状态枚举发生变化或者不同模块对1的定义不一样很容易改错。推荐改成常量或枚举// 推荐示例使用枚举 if (OrderStatus.SHIPPED.equals(order.getStatus())) { // 发货 }使用枚举之后语义清晰编译期也能帮忙校验。如果只是常量也可以private static final int STATUS_SHIPPED 1;代码评审时看到裸数字、裸字符串都可以建议提取为常量或枚举。不要小看这种改动它能显著降低后续维护成本。3.2 空值处理过于随意空指针NullPointerExceptionNPE是 Java 开发中最常见的线上异常之一。很多空指针并不是因为代码写错而是因为对返回值的假设过于乐观。// 危险示例直接调用可能为空的对象 String cityName user.getAddress().getCity();如果getAddress()返回 null这里就会抛出空指针。正确的做法是提前判空或者使用Optional// 推荐示例使用 Optional String cityName Optional.ofNullable(user.getAddress()) .map(Address::getCity) .orElse(未知城市);这里要注意一个原则能保证非空的场景直接返回对象可能为空的场景尽早明确说明并让调用方感知到空值概率。最怕的是代码里没有一句说明却默认所有对象都不为 null。3.3 吞异常与裸 catch异常处理是代码评审的重点区域。最常见的坏味道是“吞异常”// 危险示例吞掉异常 try { paymentService.pay(orderId); } catch (Exception e) { // 这里什么都没做 }吞掉异常后支付失败没有任何记录用户以为成功后续对账时才发现问题。这种代码比抛异常更危险因为它把问题隐藏到了不可感知的地方。至少应该记录日志// 改进示例记录日志并抛出业务异常 try { paymentService.pay(orderId); } catch (Exception e) { log.error(支付失败, orderId{}, orderId, e); throw new BizException(支付失败请稍后重试); }如果当前方法确实允许失败也需要明确说明失败后的兜底逻辑而不是静默吞掉。3.4 数据库操作缺少事务与边界涉及多条数据更新的操作如果没有事务保护很容易出现“一个操作成功、另一个操作失败”的数据不一致问题。// 危险示例多条更新没有事务 public void transfer(Long fromId, Long toId, BigDecimal amount) { accountMapper.reduceBalance(fromId, amount); accountMapper.increaseBalance(toId, amount); }如果increaseBalance失败reduceBalance已经执行钱就凭空消失了。正确做法是包在事务中// 推荐示例Spring 事务 Transactional(rollbackFor Exception.class) public void transfer(Long fromId, Long toId, BigDecimal amount) { accountMapper.reduceBalance(fromId, amount); accountMapper.increaseBalance(toId, amount); }评审时如果发现一个方法内有多条写操作第一反应就应该是它们是否处于同一个事务边界内如果不在需要明确数据不一致的影响范围。3.5 复制粘贴式编程复制粘贴代码是维护成本最高的行为之一。当同一个逻辑出现在多个文件里后续一旦需要修改很容易漏掉其中一处。// 危险示例多处复制同一个状态判断 if (PAID.equals(order.getStatus())) { // do something }推荐把公共逻辑抽成方法public boolean isPaid(Order order) { return OrderStatus.PAID.equals(order.getStatus()); }代码评审时看到重复代码可以提醒“这个逻辑可以抽取到公共方法或工具类中”。需要注意的是不要为了消除重复而强行引入复杂的抽象先从小方法开始。4. 实战案例危险代码与推荐写法4.1 案例一用户列表查询的 NPE 风险假设有一个简单的用户查询接口// 文件路径src/main/java/com/example/demo/service/UserService.java public UserVO getUserVO(Long userId) { User user userMapper.selectById(userId); UserVO vo new UserVO(); vo.setId(user.getId()); vo.setName(user.getName()); return vo; }如果selectById没有查到数据返回 null第二行的user.getId()会直接空指针。推荐写法public UserVO getUserVO(Long userId) { User user userMapper.selectById(userId); if (user null) { throw new BizException(用户不存在); } UserVO vo new UserVO(); vo.setId(user.getId()); vo.setName(user.getName()); return vo; }或者使用Optionalpublic UserVO getUserVO(Long userId) { return Optional.ofNullable(userMapper.selectById(userId)) .map(user - new UserVO(user.getId(), user.getName())) .orElseThrow(() - new BizException(用户不存在)); }评审关注点调用链上每一层返回的对象是否明确约定过“可以返回 null”如果有调用方必须感知并处理。4.2 案例二定时任务缺少幂等保护定时任务常常用来同步数据、发通知、清理过期记录。如果一个任务在上一轮还没跑完时又被触发或者同一任务部署在多台机器上并发执行很容易产生重复数据。// 危险示例没有幂等保护 Scheduled(cron 0 0/5 * * * ?) public void syncOrders() { ListOrder orders orderMapper.selectNeedSyncOrders(); for (Order order : orders) { orderSyncService.sync(order); } }一旦某次同步超时任务再次触发同一个订单可能被同步两次。常见改造方式有以下几种数据库唯一索引防止重复数据。分布式锁保证同一时间只有一个实例在执行任务。处理记录表记录每个订单的同步状态。// 改进示例增加幂等表检查 Scheduled(cron 0 0/5 * * * ?) public void syncOrders() { ListOrder orders orderMapper.selectNeedSyncOrders(); for (Order order : orders) { if (orderSyncRecordMapper.exists(order.getId())) { continue; } try { orderSyncService.sync(order); orderSyncRecordMapper.insert(order.getId()); } catch (Exception e) { log.error(订单同步失败, orderId{}, order.getId(), e); } } }代码评审时看到定时任务需要重点关注“重复执行”和“任务中断”两个场景。可以主动问一下如果这个任务被同时执行了两次结果是否一致4.3 案例三接口重试逻辑导致重复扣款假设一个对接第三方支付的下单接口网络超时后前端自动重试// 危险示例重试时未做幂等 public void createOrderAndPay(Long userId, Long productId) { Order order createOrder(userId, productId); paymentService.pay(order.getOrderNo()); }如果第一次支付成功后响应超时客户端重试就会再创建一笔新订单并再次扣款。推荐做法是引入“幂等键”public void createOrderAndPay(String idempotentKey, Long userId, Long productId) { if (idempotentService.isProcessed(idempotentKey)) { return; } Order order createOrder(userId, productId); paymentService.pay(order.getOrderNo()); idempotentService.markProcessed(idempotentKey); }实际项目中幂等键可以放在请求头中也可以是业务流水号。核心原则是同一请求重复执行多次结果应该与执行一次相同。这类问题在代码评审时非常值得认真讨论。因为“网络超时重试”在测试环境几乎复现不出来但到了线上高并发环境会真实发生。5. 常见问题与排查思路5.1 评审意见冲突怎么办代码评审中开发者和评审人经常会意见不一致。比如开发者认为“临时方案先上线后续再优化”评审人认为“这个隐患必须现在解决”。建议处理方式明确风险等级。如果问题会导致资金损失、数据丢失、安全隐患那属于必须立即修复的阻塞性问题。如果只是代码风格或优化建议可以记录为技术债但需要指定责任人。讨论时聚焦具体场景不要说“这个写法不好”而是说“当 XX 场景发生时这里可能会 XX”。5.2 时间紧要不要先合并后重构这是团队里最常见的灵魂拷问。我的建议是先区分“临时方案”和“危险方案”。临时方案功能不受影响只是未来可维护性差可以带技术债合并但要立刻记录。危险方案存在明显的数据不一致风险、空指针风险、安全问题无论时间多紧都不建议合并上线。因为线上故障修复的成本往往比多写两天代码的成本高得多。代码评审的意义就是在上线前把危险方案拦下来。5.3 如何判断是否存在“过度设计”评审时也要避免另一种极端为了“优雅”而引入复杂的设计把简单问题复杂化。判断标准有三个当前需求是否真的需要这个抽象如果未来需求扩展重构成本大概是多少引入的新概念团队其他人是否能快速理解如果一个简单场景被包装成策略模式 工厂模式 模板方法模式但实际只有两个分支那大概率是过度设计。此时评审意见应该是“简化设计”而不是“继续加抽象”。5.4 代码评审排查清单检查项常见风险评审时怎么检查空指针调用链中某层返回 null检查返回值是否可能为 null调用方是否有判空魔法值状态/类型判断不清晰搜索裸数字、裸字符串建议替换为常量或枚举异常吞噬失败无感知检查 catch 块中是否有日志、是否抛出业务异常事务边界多条写操作不在同一事务看方法是否标注事务注解或手动事务幂等性重复请求或重复任务导致脏数据询问定时任务、支付回调、重试场景是否做幂等日志输出日志缺乏上下文检查异常日志是否包含关键业务 ID并发安全共享变量在多线程下被修改检查静态变量、缓存、集合类是否线程安全安全漏洞SQL 注入、越权访问检查 SQL 拼接、权限校验是否到位这张清单可以直接打印出来贴在评审页面旁边每次评审时逐项过一遍。6. 最佳实践与工程建议6.1 建立可执行的“质量红线”团队里可以有几条硬性的质量红线一旦违反代码不能合并禁止吞异常。禁止在写操作中暴露 ID 等敏感参数直接拼接 SQL。禁止多条数据库写操作不在事务内。禁止修改线上配置却不经过评审。质量红线必须具体、可检查。不要写“保证代码质量”这种模糊要求。6.2 提交代码前先做自我评审很多评审意见如果提交者自己多检查一遍是可以避免的。提交 MR/PR 前可以问自己几个问题这段代码在空数据、超时、并发情况下会怎样我是否留下了无用的调试代码或注释是否有复制粘贴的逻辑变量名、方法名是否能准确表达含义我是否清楚这段代码上线后对现有功能的影响自我评审不是走形式而是把容易发现的问题先解决掉把评审人的精力留给更复杂的设计问题。6.3 用单元测试约束“临时方案”有些临时方案不能立刻重构但可以通过单元测试把它锁住防止后续被误改坏。// 文件路径src/test/java/com/example/demo/service/OrderServiceTest.java Test void testCreateOrderWhenUserNotFound() { // 模拟 userMapper.selectById 返回 null assertThrows(BizException.class, () - orderService.getUserVO(999L)); }一旦测试存在后续任何人改动逻辑测试就会提醒他这里可能存在空指针风险。单元测试不一定能发现所有问题但能把关键的业务分支稳定住。6.4 渐进式重构策略面对历史代码中的坏味道不建议一次性重新设计。更稳妥的策略是“每一次修改都顺手改善一点”当需要修改一个方法时顺手把方法中的魔法值替换为常量。当发现一个函数超过 50 行时拆成两三个小函数。当看到相同逻辑第二次出现时提炼公共方法。当某个异常日志长期为空时补上日志上下文。这种“沿途修路”的方式虽然每一次改动都不大但长期积累下来代码质量会稳步提升而且不需要专门安排一个“重构大版本”。6.5 评审口吻建议代码评审不是为了证明谁对谁错而是为了共同把风险降到最低。给出意见时可以尝试下面的表达方式把“你这里写错了”换成“这里似乎存在空指针风险建议确认一下”。把“这个设计太烂了”换成“这个方案在 XX 场景下可能遇到问题我们是否可以考虑 XX 方案”。把“你去改一下”换成“这个点建议你调整一下有问题我们可以再讨论”。评审氛围越安全开发者在提交代码时越愿意暴露真实问题团队的整体质量也会更好。7. 总结与后续学习方向本文围绕“Are you sure this is a good idea?~”这句代码评审时常见的内心独白分析了它背后对应的真实风险类别魔法值、空指针、吞异常、缺少事务、缺少幂等、复制粘贴代码等。我们通过实际代码示例展示了如何把这些“模糊的不放心”转化为清晰的评审意见并整理了一份可以直接使用的代码评审排查清单。如果继续深入你可以从以下方向持续学习系统学习常见设计模式理解“什么时候该抽象什么时候不该抽象”。掌握单元测试与集成测试用自动化手段提前发现问题。阅读团队的历史事故复盘了解线上问题大多数发生在哪些环节。关注代码质量工具例如 Checkstyle、SpotBugs、SonarQube让工具自动拦截一部分坏味道。在实际项目里最优先关注的还是“数据正确性”和“系统可用性”这两条主线。一个看似能跑的方案如果上线后需要人工对账才能发现错误那无论当时节省了多少时间最终都会以更大的成本还回去。下次再看到某个值得怀疑的写法时不妨把那句“Are you sure this is a good idea?~”说出口——但记得接下来要给出一段能落地修改的评审意见。
返回列表