Java开发经验——日志治理经验

280 阅读32分钟

摘要

本文主要介绍了Java开发中的日志治理经验,包括系统异常日志、接口摘要日志、详细日志和业务摘要日志的定义和目的,以及错误码规范和异常处理规范。强调了日志治理的重要性和如何通过规范化错误码和日志格式来提高系统可观测性和问题排查效率。

画板

错误码规范

【强制】错误码的制定原则:快速溯源、沟通标准化。

说明: 错误码想得过于完美和复杂,就像康熙字典中的生僻字一样,用词似乎精准,但是字典不容易随身携带并且简单易懂。

正例: 错误码回答的问题是谁的错?错在哪?

  1. 错误码必须能够快速知晓错误来源,可快速判断是谁的问题。
  2. 错误码必须能够进行清晰地比对(代码中容易 equals)。
  3. 错误码有利于团队快速对错误原因达 到一致认知。

【强制】错误码不体现版本号和错误等级信息。

说明:错误码以不断追加的方式进行兼容。错误等级由日志和错误码本身的释义来决定。

【强制】全部正常,但不得不填充错误码时返回五个零:00000。

【强制】错误码为字符串类型,共5位,分成两个部分:错误产生来源+四位数字编号

说明: 错误产生来源分为 A/B/C,A 表示错误来源于用户,比如参数错误,用户安装版本过低,用户支付 超时等问题;B 表示错误来源于当前系统,往往是业务逻辑出错,或程序健壮性差等问题;C 表示错误来源 于第三方服务,比如 CDN 服务出错,消息投递超时等问题;四位数字编号从 0001 到 9999,大类之间的步长间距预留 100。

【强制】编号不与公司业务架构,更不与组织架构挂钩,以先到先得的原则在统一平台上进行,审批生效,编号即被永久固定。

【强制】错误码使用者避免随意定义新的错误码。

说明: 尽可能在原有错误码附表中找到语义相同或者相近的错误码在代码中使用即可。

假设系统已有的错误码表如下:

错误码错误描述
<font style="color:rgb(14.100000%, 16.100000%, 18.000000%);">1001</font>用户名不存在
<font style="color:rgb(14.100000%, 16.100000%, 18.000000%);">1002</font>密码错误
<font style="color:rgb(14.100000%, 16.100000%, 18.000000%);">2001</font>用户未授权
<font style="color:rgb(14.100000%, 16.100000%, 18.000000%);">3001</font>请求参数缺失
<font style="color:rgb(14.100000%, 16.100000%, 18.000000%);">3002</font>参数格式错误

不建议的做法:当遇到一个新的错误情况时,比如“用户输入的邮箱地址格式错误”,如果开发者随意定义新的错误码:

public static final int ERROR_INVALID_EMAIL_FORMAT = 4001; // 随意定义错误码

推荐的做法:首先,检查现有的错误码表中是否有适合的错误码。如果已存在类似的错误码,如<font style="color:rgb(14.100000%, 16.100000%, 18.000000%);">3002</font>(参数格式错误),可以在代码中使用已有的错误码,或者选择一个相关的错误码。

public static final int ERROR_INVALID_EMAIL_FORMAT = 3002; // 采用已有的错误码

如果系统的错误码表没有类似的错误码,可以考虑增加新的错误码,但应该尽量确保其语义清晰,避免误解:

public static final int ERROR_INVALID_EMAIL_FORMAT = 4002; // 增加新的错误码,避免与其他错误码冲突

【强制】错误码不能直接输出给用户作为提示信息使用。

说明: 堆栈(stack_trace)、错误信息(error_message)、错误码(error_code)、提示信息(user_tip) 是一个有效关联并互相转义的和谐整体,但是请勿互相越俎代庖。

错误码不应该直接作为提示信息输出给用户。错误码是开发人员用于定位问题、分析故障的工具,而不是直接与用户交互的内容。错误码应该与用户提示信息分开,避免让用户看到冗长或无意义的错误码,从而影响用户体验。

错误处理的正确做法:

// 错误码定义
public static final int ERROR_INVALID_EMAIL_FORMAT = 4002;

// 错误处理方法
public void handleInvalidEmail(String email) {
    try {
        // 检查邮箱格式是否有效
        if (!isValidEmail(email)) {
            // 如果邮箱格式无效,抛出自定义异常
            throw new CustomException(ERROR_INVALID_EMAIL_FORMAT, "邮箱格式错误");
        }
    } catch (CustomException e) {
        // 输出堆栈信息给开发者
        e.printStackTrace();
        // 记录错误信息
        logError(e.getErrorCode(), e.getErrorMessage());
        // 给用户友好的提示信息
        showUserTip("请输入有效的邮箱地址,例如 user@example.com。");
    }
}

错误信息输出:

  • 开发者:看到 ERROR_INVALID_EMAIL_FORMAT 和详细的错误信息(如 邮箱格式错误)以及堆栈信息,用于定位代码中的问题。
  • 用户:看到简洁的提示信息 "请输入有效的邮箱地址,例如 user@example.com。",这是用户可以理解并采取相应行动的内容。

错误码与提示信息的区分:

  1. 错误码ERROR_INVALID_EMAIL_FORMAT(开发者使用)。
  2. 错误信息邮箱格式错误(开发者记录日志时使用)。
  3. 用户提示信息请输入有效的邮箱地址,例如 user@example.com。(直接展示给用户)。

不推荐的做法:

public void handleInvalidEmail(String email) {
    try {
        if (!isValidEmail(email)) {
            // 错误码直接暴露给用户
            throw new CustomException(4002, "错误码 4002: 邮箱格式错误");
        }
    } catch (CustomException e) {
        // 将错误码直接展示给用户
        showUserTip("错误码 4002: 邮箱格式错误");
    }
}

【推荐】在获取第三方服务错误码时,向上抛出允许本系统转义,由C转为B,并且在错误信息上带上原有的第三方错误码。

在系统集成时,往往需要将第三方服务的错误码转化为本系统的错误码,这样既能让系统的错误码保持一致性,又能确保第三方错误信息不丢失,并提供给用户或开发者必要的上下文信息。为了实现这一目标,可以通过异常机制将第三方错误码捕获后转义为本系统的错误码,并且在错误信息中附带原有的第三方错误码,确保错误的追踪和调试不受影响。

第三方错误码转义

假设我们的系统调用了一个第三方支付服务,而该支付服务返回的错误码格式为 <font style="color:rgb(14.100000%, 16.100000%, 18.000000%);">TS_ERR_1001</font>。我们希望将这个错误码转义为系统内部的错误码 <font style="color:rgb(14.100000%, 16.100000%, 18.000000%);">5001</font>,并且将第三方错误码和错误信息一起传递到本系统。

定义第三方错误码和系统内部错误码

// 第三方错误码
public static final String THIRD_PARTY_ERR_1001 = "TS_ERR_1001";
public static final String THIRD_PARTY_ERR_1002 = "TS_ERR_1002";

// 系统内部错误码
public static final int ERROR_PAYMENT_FAILED = 5001;
public static final int ERROR_INVALID_PAYMENT_METHOD = 5002;

封装第三方错误码的异常类

首先,定义一个自定义异常类,用于封装来自第三方服务的错误信息,并在其中记录第三方错误码。

public class ThirdPartyException extends Exception {
    private final String thirdPartyErrorCode;

    public ThirdPartyException(String message, String thirdPartyErrorCode) {
        super(message);
        this.thirdPartyErrorCode = thirdPartyErrorCode;
    }

    public String getThirdPartyErrorCode() {
        return thirdPartyErrorCode;
    }
}

服务调用和错误处理

在服务调用过程中,如果第三方服务返回错误,我们可以通过异常处理将第三方错误转义为本系统的错误,并在错误信息中附带第三方的错误码。

public class PaymentService {

    public void processPayment(String paymentDetails) throws CustomException {
        try {
            // 调用第三方支付服务并捕获其可能抛出的异常
            String thirdPartyErrorCode = callThirdPartyPaymentService(paymentDetails);

            if (thirdPartyErrorCode != null) {
                // 根据第三方错误码转义为系统内部的错误码
                throw new ThirdPartyException("支付失败,第三方服务错误", thirdPartyErrorCode);
            }

        } catch (ThirdPartyException e) {
            // 转义第三方错误码为本系统的错误码
            if (e.getThirdPartyErrorCode().equals(THIRD_PARTY_ERR_1001)) {
                throw new CustomException(ERROR_PAYMENT_FAILED, "支付失败,错误码: " + e.getThirdPartyErrorCode());
            } else if (e.getThirdPartyErrorCode().equals(THIRD_PARTY_ERR_1002)) {
                throw new CustomException(ERROR_INVALID_PAYMENT_METHOD, "无效支付方式,错误码: " + e.getThirdPartyErrorCode());
            } else {
                // 处理其他未知的错误码
                throw new CustomException(9999, "未知错误,错误码: " + e.getThirdPartyErrorCode());
            }
        }
    }

    private String callThirdPartyPaymentService(String paymentDetails) {
        // 模拟调用第三方支付服务,返回错误码
        return THIRD_PARTY_ERR_1001; // 假设返回的是第三方的错误码
    }
}

自定义异常类 (CustomException)

为了便于处理系统内部的错误,可以创建一个自定义异常类 <font style="color:rgb(14.100000%, 16.100000%, 18.000000%);">CustomException</font>,将错误码和错误信息封装起来。

public class CustomException extends Exception {
    private final int errorCode;
    private final String errorMessage;

    public CustomException(int errorCode, String errorMessage) {
        super(errorMessage);
        this.errorCode = errorCode;
        this.errorMessage = errorMessage;
    }

    public int getErrorCode() {
        return errorCode;
    }

    public String getErrorMessage() {
        return errorMessage;
    }
}

调用并处理错误

调用服务时,通过异常捕获机制可以得到转义后的错误信息。

public class Main {
    public static void main(String[] args) {
        PaymentService paymentService = new PaymentService();
        try {
            paymentService.processPayment("paymentDetails");
        } catch (CustomException e) {
            // 打印系统内部错误码和错误信息
            System.out.println("系统错误码: " + e.getErrorCode());
            System.out.println("错误信息: " + e.getErrorMessage());
        }
    }
}

错误处理流程总结:

  1. 第三方服务错误:当第三方服务返回错误时,抛出 <font style="color:rgb(14.100000%, 16.100000%, 18.000000%);">ThirdPartyException</font> 异常,并带上第三方的错误码。
  2. 错误码转义:系统通过捕获该异常,根据第三方错误码转义为本系统的错误码,同时在错误信息中带上第三方的错误码,帮助开发者调试。
  3. 错误信息反馈给用户:系统向用户返回的错误提示信息不包含技术细节(如第三方错误码),而是使用系统错误码和友好的提示信息。

【推荐】在获取第三方服务错误码时,向上抛出允许本系统转义,由C转为B,并且在错误信 息上带上原有的第三方错误码。

【参考】错误码分为一级宏观错误码、二级宏观错误码、三级宏观错误码。 说明:在无法更加具体确定的错误场景中,可以直接使用一级宏观错误码,分别是:A0001(用户端错误)、 B0001(系统执行出错)、C0001(调用第三方服务出错)。

正例: 调用第三方服务出错是一级,中间件错误是二级,消息服务出错是三级。

【参考】错误码的后三位编号与 HTTP 状态码没有任何关系。

【参考】错误码有利于不同文化背景的开发者进行交流与代码协作。

说明:英文单词形式的错误码不利于非英语母语国家(如阿拉伯语、希伯来语、俄罗斯语等)之间的开发 者互相协作。

【参考】错误码即人性,感性认知+口口相传,使用纯数字来进行错误码编排不利于感性记忆和分类。

说明: 数字是一个整体,每位数字的地位和含义是相同的。\ 反例: 一个五位数字 12345,第 1 位是错误等级,第 2 位是错误来源,345 是编号,人的大脑不会主动地 拆开并分辨每位数字的不同含义。

异常处理规范

【强制】Java 类库中定义的可以通过预检查方式规避的 RuntimeException 异常不应该通过****catch 的方式来处理,比如:NullPointerException,IndexOutOfBoundsException 等等。

说明:无法通过预检查的异常除外,比如,在解析字符串形式的数字时,可能存在数字格式错误,不得不

通过catch NumberFormatException来实现。

正例:if(obj!=nul){}
反例:try{  obj.method(); } catch ( NullPointerException e){ . }				

错误的做法:使用异常进行流程控制

public class OrderService {
    
    public void processOrder(int orderId) {
        try {
            // 用异常捕获条件控制的流程
            if (orderId <= 0) {
                throw new IllegalArgumentException("订单ID无效");
            }
            // 正常的订单处理逻辑
            System.out.println("处理订单 " + orderId);
        } catch (IllegalArgumentException e) {
            // 处理异常:用异常控制流程,这种做法是错误的
            System.out.println("捕获异常:订单ID无效");
        }
    }
}

问题:在上面的代码中,使用 throwcatch 来控制流程判断 orderId 是否有效,这明显是不合适的。orderId <= 0 只是一个普通的条件检查,完全可以使用条件判断来处理,而不需要抛出异常。

正确的做法:使用条件判断来控制流程

public class OrderService {
    
    public void processOrder(int orderId) {
        // 使用条件判断处理正常的逻辑流程
        if (orderId <= 0) {
            System.out.println("订单ID无效");
            return; // 直接返回,不继续处理
        }
        // 正常的订单处理逻辑
        System.out.println("处理订单 " + orderId);
    }
}

错误的异常用法会导致性能问题

假设我们在一个循环中每次都要检查条件,并且抛出异常:

public void processOrders(List<Integer> orderIds) {
    for (int orderId : orderIds) {
        try {
            // 通过异常进行流程控制
            if (orderId <= 0) {
                throw new IllegalArgumentException("订单ID无效");
            }
            // 处理订单
            System.out.println("处理订单 " + orderId);
        } catch (IllegalArgumentException e) {
            System.out.println("捕获异常:订单ID无效");
        }
    }
}

这种写法会导致:

  • 每次循环都会进行异常处理,严重影响性能。
  • 异常捕获会增加系统的资源消耗,特别是在异常发生频繁时。
public void processOrders(List<Integer> orderIds) {
    for (int orderId : orderIds) {
        // 使用条件判断进行流程控制
        if (orderId <= 0) {
            System.out.println("订单ID无效");
            continue; // 直接跳过这个订单,继续下一个
        }
        // 处理有效订单
        System.out.println("处理订单 " + orderId);
    }
}

【强制】catch时请分清稳定代码和非稳定代码,稳定代码指的是无论如何不会出错的代码。 对于非稳定代码的 catch 尽可能进行区分异常类型,再做对应的异常处理。

说明:对大段代码进行 try-catch,使程序无法根据不同的异常做出正确的应激反应,也不利于定位问题,这是一种不负责任的表现。

正例: 用户注册的场景中,如果用户输入非法字符,或用户名称已存在,或用户输入密码过于简单,在程序上作出分门别类的判断,并提示给用户。

【强制】捕获异常是为了处理它,不要捕获了却什么都不处理而抛弃之,如果不想处理它,请 将该异常抛给它的调用者。最外层的业务使用者,必须处理异常,将其转化为用户可以理解的内容。

【强制】事务场景中,抛出异常被catch后,如果需要回滚,一定要注意手动回滚事务。

在实际项目中,通常会使用 Spring 框架的事务管理来简化事务的控制。Spring 提供了 <font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">@Transactional</font> 注解,它会自动处理事务的开始、提交、回滚等过程。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class BankService {

    @Autowired
    private AccountRepository accountRepository;

    @Transactional
    public void transferFunds(int fromAccountId, int toAccountId, double amount) {
        // 减少账户余额
        Account fromAccount = accountRepository.findById(fromAccountId);
        if (fromAccount.getBalance() < amount) {
            throw new IllegalArgumentException("余额不足");
        }
        fromAccount.setBalance(fromAccount.getBalance() - amount);
        accountRepository.save(fromAccount);

        // 增加目标账户余额
        Account toAccount = accountRepository.findById(toAccountId);
        if (toAccount == null) {
            throw new IllegalArgumentException("目标账户不存在");
        }
        toAccount.setBalance(toAccount.getBalance() + amount);
        accountRepository.save(toAccount);

        // 如果这里抛出异常,Spring 会自动回滚事务
    }
}

Spring 事务管理的特点:

  1. 自动管理事务:使用 <font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">@Transactional</font> 注解时,Spring 会自动开启事务,并且在方法执行结束后自动提交或回滚事务。
  2. 异常回滚:如果方法执行过程中抛出了异常,Spring 会自动回滚事务。默认情况下,<font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">RuntimeException</font><font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">Error</font> 会导致回滚,而 <font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">checked exception</font> 默认不会回滚(可以通过 <font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">@Transactional(rollbackFor = Exception.class)</font> 指定回滚条件)。
  3. 简化代码:无需手动管理事务的开始、提交和回滚,极大简化了代码。

【强制】finally块必须对资源对象、流对象进行关闭,有异常也要做try-catch****。

说明:如果 JDK7 及以上,可以使用 try-with-resources 方式。

**finally**** 块关闭资源(传统做法)**

import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;

public class ResourceManagement {

    public void readFile(String filePath) {
        InputStream inputStream = null;

        try {
            inputStream = new FileInputStream(filePath);
            // 执行文件读取操作
            System.out.println("读取文件内容");
        } catch (IOException e) {
            // 处理异常
            System.out.println("文件读取异常:" + e.getMessage());
        } finally {
            // 确保资源被关闭
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    System.out.println("关闭流时发生异常:" + e.getMessage());
                }
            }
        }
    }

    public static void main(String[] args) {
        ResourceManagement rm = new ResourceManagement();
        rm.readFile("test.txt");
    }
}

问题分析:

  • finally 块用于确保资源被关闭,即使在 trycatch 块中发生异常。
  • 但在 finally 块中,关闭流时仍然可能抛出异常,这时需要在 finally 块内再嵌套一个 try-catch 来捕获异常。
  • 代码冗长,且资源关闭的逻辑分散在不同的地方,可能增加出错的风险。

JDK 7 及以上的 **try-with-resources**(推荐做法)

从 JDK 7 开始,引入了 try-with-resources 语法,能够自动关闭实现了 AutoCloseable 接口(如 InputStreamOutputStreamConnection 等)的资源。它通过实现 AutoCloseable 接口的 close() 方法来确保资源的关闭,因此我们不再需要显式地在 finally 块中关闭资源。

import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;

public class ResourceManagement {

    public void readFile(String filePath) {
        // 使用 try-with-resources 语法自动关闭资源
        try (InputStream inputStream = new FileInputStream(filePath)) {
            // 执行文件读取操作
            System.out.println("读取文件内容");
        } catch (IOException e) {
            // 处理异常
            System.out.println("文件读取异常:" + e.getMessage());
        }
        // 无需显式关闭流,资源会自动关闭
    }

    public static void main(String[] args) {
        ResourceManagement rm = new ResourceManagement();
        rm.readFile("test.txt");
    }
}

try-with-resources 的优势:

  1. 自动关闭资源:当 try-with-resources 块执行完毕时,JVM 会自动调用资源的 close() 方法,无需显式地调用 close(),避免了资源泄漏。
  2. 代码更简洁:代码更加简洁且易读,避免了冗长的 finally 块。
  3. 异常处理更简便:如果资源在关闭过程中抛出异常,它会被添加到原有异常的 suppressed 异常中,而不需要额外的 try-catch

【强制】不要在 finally 块中使用 return。

说明:try 块中的 return 语句执行成功后,并不马上返回,而是继续执行 finally 块中的语句,如果此处存 在return 语句,则在此直接返回,无情丢弃掉 try 块中的返回点。

为什么不能在 **<font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">finally</font>** 块中使用 **<font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">return</font>**

  1. 覆盖返回值<font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">finally</font> 中的 <font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">return</font> 会覆盖 <font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">try</font><font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">catch</font> 中的返回值,导致原本应该返回的值被替换。
  2. 影响程序逻辑<font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">finally</font> 中的 <font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">return</font> 会导致无法清晰判断哪个返回值应该被执行。
  3. 可维护性差:其他开发者阅读代码时,会对 <font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">finally</font> 中的 <font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">return</font> 产生困惑,不知道程序到底返回了什么。

**错误示例:在 **finally** 块中使用 ****return**

public class ReturnInFinallyExample {

    public int exampleMethod() {
        int result = 0;
        
        try {
            result = 10;
            // 模拟某种异常
            if (result == 10) {
                throw new Exception("Exception in try block");
            }
        } catch (Exception e) {
            result = 20;
        } finally {
            // 错误:在 finally 块中使用 return
            return 30;  // 这会覆盖 try 或 catch 中的返回值
        }
        
        // 这个返回值永远不会被执行
        return result;  // 这个返回值将被覆盖
    }

    public static void main(String[] args) {
        ReturnInFinallyExample example = new ReturnInFinallyExample();
        System.out.println(example.exampleMethod());  // 输出 30,而不是 20 或 10
    }
}

**正确的做法:避免在 **finally** 块中使用 ****return**

通常,我们应该在 finally 块中执行必要的清理工作,而不是返回值。如果确实需要返回值,可以将返回值的处理放在 trycatch 中,确保 finally 块中不会有返回语句。

public class ReturnInFinallyExample {

    public int exampleMethod() {
        int result = 0;
        
        try {
            result = 10;
            // 模拟某种异常
            if (result == 10) {
                throw new Exception("Exception in try block");
            }
        } catch (Exception e) {
            result = 20;
        } finally {
            // 在 finally 中进行清理工作,但不返回值
            System.out.println("Finally block executed");
        }
        
        // 返回结果
        return result;
    }

    public static void main(String[] args) {
        ReturnInFinallyExample example = new ReturnInFinallyExample();
        System.out.println(example.exampleMethod());  // 输出 20
    }
}

【强制】捕获异常与抛异常,必须是完全匹配,或者捕获异常是抛异常的父类。

说明: 如果预期对方抛的是绣球,实际接到的是铅球,就会产生意外情况。

【强制】在调用 RPC、二方包、或动态生成类的相关方法时,捕捉异常必须使用 Throwable类来进行拦截。

说明: 通过反射机制来调用方法,如果找不到方法,抛出 NoSuchMethodException。什么情况会抛出 NoSuchMethodError 呢?二方包在类冲突时,仲裁机制可能导致引入非预期的版本使类的方法签名不匹配, 或者在字节码修改框架(比如:ASM)动态创建或修改类时,修改了相应的方法签名。这些情况,即使代 码编译期是正确的,但在代码运行期时,会抛出 NoSuchMethodError。

**在调用 RPC (Remote Procedure Call)、二方包(即第三方库)或 动态生成类 的相关方法时,捕捉异常需要使用 **Throwable** 类来进行拦截,而不仅仅是捕捉 **Exception**。**原因是,Throwable 是 Java 中所有错误和异常的父类,包含了两种类型的对象:

  • **Exception**:通常用于表示程序中能够预料到的异常情况。
  • **Error**:表示 JVM 或底层运行环境发生的严重错误(例如 OutOfMemoryErrorStackOverflowError 等),通常不可恢复。

在调用这些方法时,尤其是在网络请求、动态类加载或不受控制的第三方库调用中,可能会遇到 Error 类型的错误,导致系统崩溃。因此,**捕获 ****Throwable** 可以确保所有可能的错误(无论是 Exception 还是 Error)都能被捕获,并做出相应处理。

**捕捉 **Throwable** 类的示例:**假设我们有一个 RPC 调用或二方包的调用,可能会抛出未知的 ErrorException,我们希望在捕获异常时处理所有情况。

捕捉 **Throwable** 类来拦截所有异常和错误

public class RpcClient {

    public void makeRpcCall() {
        try {
            // 假设这是一个 RPC 调用,可能会抛出异常或错误
            callRemoteService();
        } catch (Throwable t) {
            // 捕捉所有异常和错误
            System.out.println("捕获到异常或错误: " + t.getMessage());
            // 这里可以做异常处理或者记录日志等
        }
    }

    private void callRemoteService() throws Exception {
        // 模拟远程调用抛出异常
        throw new Exception("远程服务调用失败");
    }

    public static void main(String[] args) {
        RpcClient client = new RpcClient();
        client.makeRpcCall();
    }
}

为什么需要捕捉 **Throwable**

  • **RPC 调用、二方包、动态生成类的操作可能引发 ****Error**:这些操作可能会遇到网络中断、服务不可用等导致的 Error,比如 OutOfMemoryErrorUnknownHostException,这些是我们通常不预期的严重错误,但它们仍然会影响程序的正常运行。
  • 无法预见的异常和错误:调用外部服务时,可能会遇到一些底层错误(例如,JVM 内存问题等),这些错误是 Exception 类无法捕获的。
  • 保证稳定性:通过捕获 Throwable,可以确保程序能够稳健地处理异常和错误,而不至于因为未处理的 Error 导致程序崩溃。

**动态生成类的调用:**假设我们使用反射或动态代理来调用一些方法,这些方法可能会抛出异常或错误,我们同样需要使用 Throwable 来捕获所有可能的异常和错误。

通过反射调用动态生成的类方法时捕捉 Throwable

import java.lang.reflect.Method;

public class DynamicMethodInvocation {

    public void invokeMethod() {
        try {
            // 动态生成或反射调用某个类的方法
            Class<?> clazz = Class.forName("com.example.SomeService");
            Object instance = clazz.getDeclaredConstructor().newInstance();
            Method method = clazz.getMethod("someMethod");
            method.invoke(instance);
        } catch (Throwable t) {
            // 捕捉所有异常和错误
            System.out.println("捕获到异常或错误: " + t.getMessage());
            // 可以进一步处理或者记录错误
        }
    }

    public static void main(String[] args) {
        DynamicMethodInvocation invocation = new DynamicMethodInvocation();
        invocation.invokeMethod();
    }
}

**二方包调用时捕捉 ****Throwable**

假设我们使用第三方库来发送消息或执行某些操作,这些操作可能会抛出 ExceptionError

捕捉二方包的异常和错误

import org.apache.commons.lang3.StringUtils;

public class ThirdPartyLibraryUsage {

    public void useLibraryMethod() {
        try {
            // 使用一个第三方库(例如 Apache Commons Lang)
            String input = null;
            String result = StringUtils.capitalize(input);  // 可能会抛出异常
            System.out.println(result);
        } catch (Throwable t) {
            // 捕捉所有异常和错误
            System.out.println("捕获到异常或错误: " + t.getMessage());
            // 进行适当的错误处理
        }
    }

    public static void main(String[] args) {
        ThirdPartyLibraryUsage usage = new ThirdPartyLibraryUsage();
        usage.useLibraryMethod();
    }
}

捕捉 Throwable 的优缺点:

优点:

  1. 全局捕获异常和错误:可以保证程序不会因为未处理的 Error 崩溃,能够适当地记录、处理或者上报这些错误。
  2. 稳定性:通过捕获所有 Throwable 类型的对象,程序能够尽量保证即使出现无法预见的错误,也能稳定运行。

缺点:

  1. 过度捕获:捕获 Throwable 会捕获所有错误类型,包括 Error,例如 OutOfMemoryError 等严重错误。如果这些错误发生时,程序可能已经处于无法恢复的状态,捕获并继续执行可能并不合适。对于严重错误,通常应该让程序终止并进行适当的资源释放。
  2. 异常处理不够精确:捕获 Throwable 可能会掩盖一些具体的异常类型,导致代码的异常处理逻辑不够明确。

【推荐】方法的返回值可以为 null,不强制返回空集合,或者空对象等,必须添加注释充分说 明什么情况下会返回 null 值。

说明: 本手册明确防止 NPE 是调用者的责任。即使被调用方法返回空集合或者空对象,对调用者来说,也 并非高枕无忧,必须考虑到远程调用失败、序列化失败、运行时异常等场景返回 null 的情况。

【推荐】防止 NPE,是程序员的基本修养,注意 NPE 产生的场景:

  1. 返回类型为基本数据类型,return 包装数据类型的对象时,自动拆箱有可能产生 NPE。反例: public int f() { return Integer 对象}, 如果为 null,自动解箱抛 NPE。
  2. 数据库的查询结果可能为 null。
  3. 集合里的元素即使 isNotEmpty,取出的数据元素也可能为 null。
  4. 远程调用返回对象时,一律要求进行空指针判断,防止 NPE。
  5. 对于 Session 中获取的数据,建议进行 NPE 检查,避免空指针。
  6. 级联调用 obj.getA().getB().getC();一连串调用,易产生 NPE。

正例: 使用 JDK8 的 Optional 类来防止 NPE 问题。

常见的导致 NPE 的场景

  1. 调用 null 对象的方法
  2. 访问 null 对象的属性
  3. 尝试对 null 数组元素进行操作
  4. 集合对象为 null 时进行操作
  5. 反射调用 null 对象

防止 NPE 的最佳实践:

  1. 空值检查:在操作对象之前总是进行空值检查。
  2. **Optional**** 使用**:在不确定对象是否为 null 时,可以使用 Optional 来避免直接操作 null
  3. 使用工具类:使用 Objects.requireNonNull() 来验证传入对象不为 null,从而避免出现 NPE。
  4. **尽量避免返回 ****null**:如果可能,避免返回 null,使用空对象或者 Optional 类型来代替。
  5. 使用现代 API:Java 8 引入的 StreamOptional 等 API 提供了更多处理空值的优雅方式,避免了直接与 null 打交道。
  • NullPointerException (NPE) 常发生在访问 null 对象的方法、属性、数组元素时。避免 NPE 是编程的基本修养。
  • 预防 NPE 的方法:检查对象是否为 null,使用 Optional 类型,使用 Java 8 的流式操作,避免返回 null,以及使用 Objects.requireNonNull() 来明确断言非空。
  • 使用现代工具和 API:如 OptionalStreamObjects 等工具类,可以大大减少由于 null 导致的问题。

【推荐】定义时区分 unchecked / checked 异常,避免直接抛出 new RuntimeException(), 更不允许抛出 Exception 或者 Throwable,应使用有业务含义的自定义异常。

推荐业界已定义过的自定义异常,如:DAOException/ServiceException等。

【参考】对于公司外的 http/api 开放接口必须使用errorCode; 而应用内部推荐异常抛出; 跨应用间 RPC 调用优先考虑使用 Result方式,封装 isSuccess()方法、errorCode、 errorMessage;而应用内部直接抛出异常即可。\ 说明: 关于 RPC 方法返回方式使用 Result 方式的理由:

  1. 使用抛异常返回方式,调用方如果没有捕获到就会产生运行时错误。
  2. 如果不加栈信息,只是 new 自定义异常,加入自己的理解的 error message,对于调用端解决问题 的帮助不会太多。如果加了栈信息,在频繁调用出错的情况下,数据序列化和传输的性能损耗也是问题。

日志规约

【强制】应用中不可直接使用日志系统(Log4j、Logback)中的 API,而应依赖使用日志框架 (SLF4J、JCL--Jakarta Commons Logging)中的 API,使用门面模式的日志框架,有利于维护和 各个类的日志处理方式统一。

说明:日志框架(SLF4J、JCL--Jakarta Commons Logging)的使用方式(推荐使用 SLF4J)

// 使用 SLF4J: 					
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
private static final Logger logger = LoggerFactory.getLogger(Test.class); 

// 使用 JCL:
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
private static final Log log = LogFactory.getLog(Test.class);

【强制】所有日志文件至少保存15天,因为有些异常具备以“周”为频次发生的特点。对于当天日志,以“应用名.log”来保存,保存在/home/admin/应用名/logs/目录下,过往日志 格式为: {logname}.log.{保存日期},日期格式:yyyy-MM-dd\ 正例: 以 aap 应用为例,日志保存在/home/admin/aapserver/logs/aap.log,历史日志名称为 app.log.2016-08-01

【强制】根据国家法律,网络运行状态、网络安全事件、个人敏感信息操作等相关记录,留存 的日志不少于六个月,并且进行网络多机备份。

【强制】应用中的扩展日志(如打点、临时监控、访问日志等)命名方式:appName_logType_logName.log。logType:日志类型,如 stats/monitor/access 等;logName:日志描 述。这种命名的好处:通过文件名就可知道日志文件属于什么应用,什么类型,什么目的,也有利于归类查找。

说明: 推荐对日志进行分类,如将错误日志和业务日志分开存放,便于开发人员查看,也便于通过日志对系统进行及时监控。\ 正例: mppserver 应用中单独监控时区转换异常,如:mppserver_monitor_timeZoneConvert.log

【强制】在日志输出时,字符串变量之间的拼接使用占位符的方式。

说明: 因为 String 字符串的拼接会使用 StringBuilder 的 append()方式,有一定的性能损耗。使用占位符仅是替换动作,可以有效提升性能。

正例: logger.debug("Processing trade with id: {} and symbol: {}", id, symbol); 

【强制】对于 trace/debug/info 级别的日志输出,必须进行日志级别的开关判断。

说明:虽然在 debug(参数)的方法体内第一行代码 isDisabled(Level.DEBUG_INT)为真时(Slf4j 的常见实现

  1. Log4j 和 Logback),就直接 return,但是参数可能会进行字符串拼接运算。此外,如果 debug(getName())
  2. 这种参数内有 getName()方法调用,无谓浪费方法调用的开销。
正例: 							
// 如果判断为真,那么可以输出 trace 和 debug 级别的日志 							
if (logger.isDebugEnabled()) {
    logger.debug("Current ID is: {} and name is: {}", id, getName()); 							
} 

【强制】避免重复打印日志,浪费磁盘空间,务必在日志配置文件中设置 additivity=false。 正例:

【强制】生产环境禁止直接使用 System.out 或 System.err 输出日志或使用 e.printStackTrace()打印异常堆栈。\ 说明: 标准日志输出与标准错误输出文件每次 Jboss 重启时才滚动,如果大量输出送往这两个文件,容易 造成文件大小超过操作系统大小限制。

错误示例:直接使用 <font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">System.out</font><font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">System.err</font>

public class BadLoggingExample {

    public static void main(String[] args) {
        try {
            // 模拟一些代码,可能会抛出异常
            int result = 10 / 0;
        } catch (Exception e) {
            // 错误做法:直接使用 System.err 打印异常堆栈
            System.err.println("An error occurred: " + e.getMessage());
            e.printStackTrace();  // 这会将堆栈信息打印到控制台
        }

        // 错误做法:直接使用 System.out 打印日志
        System.out.println("This is a log message");
    }
}

正确示例:使用日志框架(如 Logback)

<dependencies>
    <!-- SLF4J API -->
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-api</artifactId>
        <version>2.0.0</version>
    </dependency>
    <!-- Logback 实现 -->
    <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
        <version>1.4.7</version>
    </dependency>
</dependencies>

配置 <font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">logback.xml</font>

然后在项目的 <font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">src/main/resources</font> 目录下创建 <font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">logback.xml</font> 文件,配置日志输出到控制台和文件:

<configuration>

    <!-- 控制台输出 -->
    <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- 文件输出 -->
    <appender name="file" class="ch.qos.logback.core.FileAppender">
        <file>logs/app.log</file>
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- 根日志配置 -->
    <root level="INFO">
        <appender-ref ref="console" />
        <appender-ref ref="file" />
    </root>

</configuration>

使用 <font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">SLF4J</font> 记录日志

在代码中使用 SLF4J API 来记录日志,替换掉 <font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">System.out</font><font style="color:rgb(20.000000%, 20.000000%, 20.000000%);">System.err</font>

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class GoodLoggingExample {

    // 创建日志记录器
    private static final Logger logger = LoggerFactory.getLogger(GoodLoggingExample.class);

    public static void main(String[] args) {
        try {
            // 模拟一些代码,可能会抛出异常
            int result = 10 / 0;
        } catch (Exception e) {
            // 使用 SLF4J 记录异常信息
            logger.error("An error occurred: {}", e.getMessage(), e);  // 记录异常及堆栈信息
        }

        // 使用 SLF4J 记录普通日志
        logger.info("This is a log message");
    }
}

【强制】异常信息应该包括两类信息:案发现场信息和异常堆栈信息。如果不处理,那么通过关键字 throws 往上抛出。\ 正例: logger.error("inputParams:{} and errorMessage:{}", 各类参数或者对象 toString(), e.getMessage(), e);

【强制】日志打印时禁止直接用 JSON 工具将对象转换成 String。\ 说明: 如果对象里某些 get 方法被覆写,存在抛出异常的情况,则可能会因为打印日志而影响正常业务流 程的执行。\ 正例: 打印日志时仅打印出业务相关属性值或者调用其对象的 toString()方法。

在日志打印时,**直接使用 JSON 工具将对象转换成 ****String**(例如,使用 JSON.toJSONString()ObjectMapper.writeValueAsString() 等)并不推荐。原因如下:

  1. 性能问题:将对象转换为 JSON 字符串可能会导致性能问题,尤其是在高并发环境下,频繁进行对象的序列化会占用不必要的资源。
  2. 日志污染:如果对象包含大量字段或者嵌套对象,直接将整个对象序列化为 JSON 字符串会导致日志输出过长,增加阅读和排查问题的难度。
  3. 敏感信息泄露:直接序列化整个对象可能会泄露敏感信息,尤其是当对象包含密码、身份标识符或其他敏感数据时。

错误做法:直接使用 JSON 工具将对象转换成 String

错误做法 - 直接使用 JSON.toJSONString()ObjectMapper.writeValueAsString()

import com.alibaba.fastjson.JSON;

public class BadLoggingExample {
    public static void main(String[] args) {
        MyObject obj = new MyObject("Alice", 30, "Engineer");
        // 错误做法:直接使用 JSON.toJSONString 打印整个对象的 JSON 字符串
        System.out.println(JSON.toJSONString(obj));  // 直接打印整个对象的 JSON 字符串
    }
}

class MyObject {
    private String name;
    private int age;
    private String job;

    public MyObject(String name, int age, String job) {
        this.name = name;
        this.age = age;
        this.job = job;
    }

    // getters and setters
}

正确做法:通过日志框架打印日志,并记录必要的字段

<dependencies>
    <!-- SLF4J API -->
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-api</artifactId>
        <version>2.0.0</version>
    </dependency>
    <!-- Logback 实现 -->
    <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
        <version>1.4.7</version>
    </dependency>
</dependencies>

**配置 Logback: **在 src/main/resources/logback.xml 中配置日志输出格式:

<configuration>

    <!-- 控制台输出 -->
    <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- 根日志配置 -->
    <root level="INFO">
        <appender-ref ref="console" />
    </root>

</configuration>

通过 SLF4J 记录日志,避免直接将对象转换为 JSON 字符串,输出必要的字段:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class GoodLoggingExample {

    // 创建日志记录器
    private static final Logger logger = LoggerFactory.getLogger(GoodLoggingExample.class);

    public static void main(String[] args) {
        MyObject obj = new MyObject("Alice", 30, "Engineer");

        // 正确做法:只记录对象的必要信息
        logger.info("User Info - Name: {}, Age: {}, Job: {}", obj.getName(), obj.getAge(), obj.getJob());
    }
}

class MyObject {
    private String name;
    private int age;
    private String job;

    public MyObject(String name, int age, String job) {
        this.name = name;
        this.age = age;
        this.job = job;
    }

    // getters
    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public String getJob() {
        return job;
    }
}

防止敏感信息泄露

为了防止将敏感信息泄露到日志中,应该确保:

  • 只记录对象的必要信息:在日志中记录关键信息,如用户的用户名、请求类型等,而非整个对象或敏感字段(如密码、Token 等)。
  • 自定义日志输出格式:通过日志框架定制日志输出,避免将整个对象序列化为字符串输出。
  • 脱敏处理:如果必须记录一些敏感信息,确保通过脱敏方法将敏感数据遮蔽或加密。
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class SecureLoggingExample {

    private static final Logger logger = LoggerFactory.getLogger(SecureLoggingExample.class);

    public static void main(String[] args) {
        User user = new User("Alice", "secretPassword", 30);

        // 只记录用户的非敏感信息
        logger.info("User Info - Name: {}, Age: {}", user.getName(), user.getAge());

        // 如果必须记录敏感信息,进行脱敏处理
        logger.info("User Info with sensitive data: Name: {}, Password: {}", user.getName(), "[PROTECTED]");
    }
}

class User {
    private String name;
    private String password;
    private int age;

    public User(String name, String password, int age) {
        this.name = name;
        this.password = password;
        this.age = age;
    }

    // getters
    public String getName() {
        return name;
    }

    public String getPassword() {
        return password;
    }

    public int getAge() {
        return age;
    }
}

总结:

  • **避免直接使用 JSON 工具将对象转换为 ****String**。直接将对象序列化为 JSON 字符串会导致日志冗长、性能下降并可能泄露敏感信息。
  • 使用日志框架(如 SLF4JLogback),并只记录必要的字段或关键信息,而不是整个对象的 JSON 字符串。
  • 敏感信息保护:避免在日志中记录敏感信息,如密码、Token 等,必要时可以进行脱敏处理。

【推荐】谨慎地记录日志。生产环境禁止输出 debug 日志;有选择地输出 info 日志;如果使用 warn 来记录刚上线时的业务行为信息,一定要注意日志输出量的问题,避免把服务器磁盘撑 爆,并记得及时删除这些观察日志。

说明: 大量地输出无效日志,不利于系统性能提升,也不利于快速定位错误点。记录日志时请思考:这些 日志真的有人看吗?看到这条日志你能做什么?能不能给问题排查带来好处?

【推荐】可以使用 warn 日志级别来记录用户输入参数错误的情况,避免用户投诉时,无所适从。如非必要,请不要在此场景打出 error 级别,避免频繁报警。

说明: 注意日志输出的级别,error 级别只记录系统逻辑出错、异常或者重要的错误信息。

【推荐】尽量用英文来描述日志错误信息,如果日志中的错误信息用英文描述不清楚的话使用中文描述即可,否则容易产生歧义。国际化团队或海外部署的服务器由于字符集问题,使用全英文来注释和描述日志错误信息。

博文参考

《阿里巴巴java开发规范》