得墨忒耳定律
不要链式调用, 如 a.getB().getC().doSomething()
。
直接获取对象调用方法
数据传输对象(DTOs)
DTO (Data Transfer Object): 数据传输对象。这是一种典型的数据结构 。 里面没有任何业务逻辑代码。它的唯一作用就是在不同的软件层次之间(比如从数据库层到服务层,或者从服务层到外部接口)传输数据。
第7章 错误处理
核心是用异常来清晰、强制、有上下文地处理"异常"情况 ,将错误处理代码与正常业务逻辑代码分离。同时,应尽量避免使用 null 和返回错误码,让代码更加健壮和易于理解。
使用异常替代返回错误码
- 思想: 错误是"异常"情况,它阻碍了程序的正常流程。错误处理代码(如何应对问题)应该与正常业务逻辑代码(顺利时该怎么做)分开。
- 为什么: 异常机制 (
try-catch-finally
) 将正常流程代码(try 块)和错误处理代码(catch 块)清晰地分开了。相比返回错误码(比如返回 -1 或 null),异常更难被忽略,能强制调用者处理潜在错误,并且携带更丰富的错误信息。
给出足够异常的上下文
抛出异常时,不要只抛一个泛泛的异常类型 , 应该提供清晰的、有业务意义 的错误消息,并且如果捕获了下层抛出的异常,应该将其包含在你向上层抛出的新异常中 。
举例说明
java
// 1. DAO 层 (底层)
class UserRepository {
// 这个方法负责将用户对象保存到数据库,可能因为数据库连接问题、SQL语法错误等抛出 SQLException
public void save(User user) throws SQLException {
// ... JDBC 代码或 ORM 代码 ...
// 假设这里发生了数据库错误,抛出了一个 SQLException
throw new SQLException("Duplicate entry for primary key 'users.PK_users'");
}
}
// 2. Service 层 (中层)
// 定义一个 Service 层知道的业务异常类型
class UserSaveException extends Exception {
public UserSaveException(String message, Throwable cause) {
super(message, cause); // 调用父类 Exception 的构造函数,保存消息和原因
}
}
class UserService {
private UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
// 这个方法负责保存用户业务逻辑,它调用 DAO 层
public void registerUser(User user) throws UserSaveException { // Service 层向上抛出自己定义的业务异常
// ... Service 层的其他业务逻辑 ...
try {
// 尝试调用 DAO 层的功能
userRepository.save(user);
} catch (SQLException e) {
// !!! 在这里捕获底层的 SQLException !!!
// !!! 在这里包装成新的业务异常,并包含上下文 !!!
// 提供业务层面的错误描述,包含用户 ID 或其他关键信息
String businessMessage = "注册用户失败,用户名为: " + user.getUsername();
// 创建一个新的 UserSaveException 异常
// 第一个参数是业务错误消息,第二个参数是捕获到的原始底层异常 (SQLException)
throw new UserSaveException(businessMessage, e);
}
// ... Service 层的其他业务逻辑 ...
}
}
// 3. Controller 层 (顶层)
class UserController {
private UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
// 这个方法处理用户注册请求
public Response handleUserRegistrationRequest(Request request) {
User user = // ... 从请求中解析用户数据 ...
try {
// 调用 Service 层的功能
userService.registerUser(user);
// 如果上面没抛异常,说明成功了
return Response.ok("用户注册成功!");
} catch (UserSaveException e) {
// !!! 在这里捕获 Service 层抛出的业务异常 !!!
// !!! 在这里进行最终的错误处理或日志记录 !!!
// 打印业务错误消息 (来自 UserSaveException 的消息)
System.err.println("处理注册请求失败: " + e.getMessage());
// !!! 更重要的是,可以通过 getCause() 获取导致这个业务错误的技术原因 !!!
Throwable rootCause = e.getCause();
if (rootCause != null) {
System.err.println("底层原因: " + rootCause.getMessage());
// 可以进一步打印堆栈信息帮助调试
// rootCause.printStackTrace();
}
// 返回一个用户友好的错误响应
return Response.badRequest("注册失败,请稍后再试或联系管理员。");
} catch (Exception e) {
// 捕获其他非预期的错误
System.err.println("处理注册请求时发生未知错误: " + e.getMessage());
return Response.internalServerError("未知错误。");
}
}
}
这个例子说明了:
- 底层的错误 (
SQLException
) 发生在它最相关的层次 (DAO)。 - Service 层捕获了这个底层的技术错误,但它知道当前的业务上下文是"正在注册用户"。
- Service 层将技术错误包装 成了具有业务意义的
UserSaveException
,并添加了业务相关的错误消息(比如包含了用户名)。 - Controller 层捕获了
UserSaveException
,它不需要知道底层是SQLException
还是其他什么错误,它只知道"用户保存失败了"。它可以根据这个业务异常类型给用户一个通用的提示。 - 当程序员或运维人员查看错误日志时,他们会看到
UserSaveException
的业务消息,快速了解"出错了什么业务"。如果需要深入排查,他们可以查看这个异常的cause
(原因),找到原始的SQLException
,从而了解具体是哪个数据库问题导致了业务失败。
一次只记录一处日志
- 什么意思: 当同一个异常沿着调用链向上层抛出,并在多个
catch
块中被捕获时,只在异常被"最终处理"或程序决定无法恢复并终止流程的那一层记录日志 。不要在每一个catch
块里都记录一次日志。 - 为什么: 如果每个捕获层都记录日志,当一个异常发生时,日志里会出现大量重复的、表示同一个错误的日志条目,信息非常嘈杂,很难找到原始的错误发生点和关键信息。
封装第三方 API
- 什么意思: 当你的代码调用了第三方库或框架(比如一个数据库连接库、一个 HTTP 客户端库)的方法,而这些方法会抛出第三方库特有的异常时,不要让这些第三方异常直接传播到你的应用代码的各个角落。你应该在调用第三方库的地方捕获它们的异常,然后包装成你自己定义的、带有业务含义的异常再向上抛。
- 为什么:
-
- 降低耦合: 如果将来你更换了第三方库(比如从一个 HTTP 客户端库换到另一个),你只需要修改封装它们的那个地方的代码和异常包装逻辑,调用你的应用的其它部分代码不需要改动,因为它们只依赖于你定义的异常类型。
- 提升语义: 你定义的异常(比如
UserCreationFailedException
)比第三方的技术异常(比如HttpClientTimeoutException
)更能清晰地表达业务上的错误原因。
举例说明
假如封装腾讯云 COS
- 引入 COS 的 SDK
- 定义一个自己的 COS 业务异常类,继承 Exception
java
// --- 我们的应用代码 ---
package com.myproject.storage.domain.exception; // 我们的应用自己的异常包
// 定义我们自己的文件存储相关的业务异常基类
public class FileStorageException extends Exception {
public FileStorageException(String message) { super(message); }
public FileStorageException(String message, Throwable cause) { super(message, cause); }
}
// 可以定义更具体的子类,但这里为了简洁,只用一个基类演示
// public class FileUploadFailedException extends FileStorageException { ... }
// public class FileNotFoundError extends FileStorageException { ... }
-
封装自己的 COS 类,对自己项目更通用友好,抛出异常也不再是 COS 的异常,而是自定义的 COS 异常。
javapackage com.myproject.storage.infra; // 基础设施层,处理外部依赖 import com.myproject.storage.domain.exception.FileStorageException; // 导入我们自己的异常 // 导入腾讯云 COS SDK 的相关类和异常 import com.qcloud.cos.auth.BasicCOSCredentials; import com.qcloud.cos.client.COSClient; import com.qcloud.cos.client.COSClient.PutObjectRequest; // 导入内部类需要完整路径 import com.qcloud.cos.client.COSClient.CosClientException; // 导入内部类需要完整路径 import com.qcloud.cos.client.COSClient.CosServiceException; // 导入内部类需要完整路径 import java.io.File; // 需要用到 File 类 // 这个类封装了腾讯云 COS 的文件存储功能 public class CosStorageService { private final COSClient cosClient; private final String bucketName; // 构造函数,初始化 COSClient,隐藏 SDK 的初始化细节 public CosStorageService(String secretId, String secretKey, String bucketName) { // 这里直接使用 BasicCOSCredentials,如果需要更复杂的认证,可以在这里处理 BasicCOSCredentials cred = new BasicCOSCredentials(secretId, secretKey); // 这里的初始化细节,只有 CosStorageService 需要知道 this.cosClient = new COSClient(cred); this.bucketName = bucketName; // 封装桶名称 } /** * 上传文件到 COS。 * 只向上层抛出我们自己的 FileStorageException。 * * @param key 文件在桶内的路径 (例如 "uploads/my-document.pdf") * @param localFile 需要上传的本地文件 * @throws FileStorageException 如果上传过程中发生任何错误 */ public void uploadFile(String key, File localFile) throws FileStorageException { // 只声明抛出我们自己的异常 // 可以在这里进行一些我们自己的业务校验,不属于 COS SDK 的 if (localFile == null || !localFile.exists()) { throw new FileStorageException("要上传的本地文件不存在或无效: " + (localFile != null ? localFile.getPath() : "null")); } try { // 1. (如果需要)将我们自己的文件对象转换为第三方 SDK 需要的格式 PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, key, localFile); // 2. 调用腾讯云 COS SDK 的上传 API cosClient.putObject(putObjectRequest); // 3. 如果 SDK 方法成功返回,说明上传成功 } catch (CosServiceException e) { // !!! 捕获 COS 服务端异常 !!! // !!! 包装成我们自己的文件存储异常,包含业务上下文和原始异常 !!! String errorMsg = String.format("上传文件失败,COS 服务端错误。键: %s, 桶: %s. 错误码: %s, 状态码: %d", key, bucketName, e.getErrorCode(), e.getStatusCode()); throw new FileStorageException(errorMsg, e); // 包装并传递原始异常 e 作为 cause } catch (CosClientException e) { // !!! 捕获 COS 客户端异常 (网络、参数等) !!! // !!! 包装成我们自己的文件存储异常,包含业务上下文和原始异常 !!! String errorMsg = String.format("上传文件失败,COS 客户端错误。键: %s, 桶: %s. 消息: %s", key, bucketName, e.getMessage()); throw new FileStorageException(errorMsg, e); // 包装并传递原始异常 e 作为 cause } catch (Exception e) { // !!! 捕获其他任何非 COS SDK 的意外运行时异常 (虽然可能性低,但安全起见) !!! // !!! 包装成我们自己的文件存储异常 !!! String errorMsg = String.format("上传文件时发生意外错误。键: %s, 桶: %s.", key, bucketName); throw new FileStorageException(errorMsg, e); // 包装并传递原始异常 e 作为 cause } } // 提供关闭客户端的方法,隐藏 SDK 的关闭细节 public void shutdown() { cosClient.shutdown(); } }
使用特殊情况对象
这个原则主要用在那些**"找不到"或者"为空"是一种常见且预期内的结果,而不是真正的错误或异常**的场景。在这种情况下,相比返回
null
或抛出异常,返回一个实现了相同接口的"特殊情况对象"可能让代码更清晰。场景: 根据用户 ID 查找用户。在某些系统中,查找某个 ID 的用户可能找不到,这是一个正常的操作结果(比如用户 ID 不存在),而不是一个程序错误。 这样就能省去
if (user != null)
判断
java
// 定义用户接口
interface User {
String getId();
String getName();
boolean isLoggedIn();
// ... 其他用户行为方法 ...
// 添加一个方法来判断是否为特殊情况对象
boolean isNull(); // 或 isSpecialCase();
}
// 普通用户类实现 User 接口 (与上面反例中的 User 类类似,但实现了接口)
class RealUser implements User {
private String id;
private String name;
private boolean loggedIn;
public RealUser(String id, String name, boolean loggedIn) {
this.id = id;
this.name = name;
this.loggedIn = loggedIn;
}
@Override public String getId() { return id; }
@Override public String getName() { return name; }
@Override public boolean isLoggedIn() { return loggedIn; }
@Override public boolean isNull() { return false; } // 这是一个真正的用户
}
// !!! 特殊情况对象:表示"没有找到"的用户 !!!
class NullUser implements User {
// 可以是单例,因为所有"没有找到"的情况都用这同一个对象表示
private static final NullUser INSTANCE = new NullUser();
private NullUser() {}
public static NullUser getInstance() { return INSTANCE; }
// !!! 实现 User 接口的方法,提供"默认"或"空"的行为 !!!
@Override public String getId() { return "null"; } // 或 "",表示没有实际 ID
@Override public String getName() { return "Guest"; } // 或 "未知用户",提供一个默认名称
@Override public boolean isLoggedIn() { return false; } // 没有找到的用户当然没有登录
@Override public boolean isNull() { return true; } // 明确表示这是一个特殊情况对象
}
// 查找用户的服务,找不到返回特殊情况对象
class UserService {
private Map<String, User> users = new HashMap<>();
public UserService() {
// 添加一些模拟用户 (使用 RealUser)
users.put("user1", new RealUser("user1", "Alice", true));
users.put("user2", new RealUser("user2", "Bob", false));
}
/**
* 根据 ID 查找用户。如果找不到,返回 NullUser 特殊情况对象。
* (这是推荐的方式,用于非异常的"找不到"情况)
*/
public User findUserById(String userId) {
// Map.get() 找不到时返回 null
User user = users.get(userId);
// 如果找到了返回真正的用户,如果没找到返回 NullUser 单例对象
return user != null ? user : NullUser.getInstance();
}
}
// 调用 findUserById 的代码 (不再需要繁琐的 null 检查)
class UserProcessor {
private UserService userService;
public UserProcessor(UserService userService) {
this.userService = userService;
}
public void processUserInfo(String userId) {
User user = userService.findUserById(userId); // 返回的永远是一个 User 对象 (RealUser 或 NullUser)
// !!! 不需要 null 检查了 !!! 可以直接调用 User 接口的方法
System.out.println("找到用户: " + user.getName()); // 如果是 NullUser,会打印 "Guest"
// 如果需要区分"真的用户"和"特殊情况对象",可以使用 isNull() 方法
if (!user.isNull()) { // 判断是否不是特殊情况对象 (即是 RealUser)
if (user.isLoggedIn()) { // 可以直接调用方法
System.out.println("用户 " + user.getName() + " 已登录。");
} else {
System.out.println("用户 " + user.getName() + " 未登录。");
}
// ... 其他使用 user 对象的方法调用 ...
} else {
// 处理没找到用户(特殊情况对象)的逻辑(如果需要一些特殊处理)
System.out.println("这是一个特殊情况用户,不需要处理登录状态等细节。");
}
}
}
// 反例类
// 调用 findUserById 的代码 (必须进行 null 检查)
class UserProcessor {
private UserService userService;
public UserProcessor(UserService userService) {
this.userService = userService;
}
// 反例方法
public void processUserInfo(String userId) {
User user = userService.findUserById(userId);
//反例
// !!! 每次使用 user 对象之前,都必须进行 null 检查 !!!
if (user != null) {
// 处理找到用户的情况
System.out.println("找到用户: " + user.getName());
if (user.isLoggedIn()) {
System.out.println("用户 " + user.getName() + " 已登录。");
} else {
System.out.println("用户 " + user.getName() + " 未登录。");
}
// ... 其他使用 user 对象的方法调用 ...
} else {
// 处理没找到用户的情况
System.out.println("没有找到用户 ID 为 " + userId + " 的用户。");
}
}
}
不要返回 Null / 不要传递 Null
这是两个非常坚决的原则
- 不要从函数返回 null: 返回 null 强制调用者在每次使用函数返回值时都要进行 null 检查,这既啰嗦又容易遗漏,是导致
NullPointerException
的主要原因。替代方案: 抛出异常、返回特殊情况对象、返回空集合(如Collections.emptyList()
)。 - 不要传递 null 作为参数: 函数接收 null 参数,意味着函数内部必须增加额外的逻辑来检查参数是否为 null,这增加了函数的复杂性,违反了"只做一件事"和"函数参数越少越好"的原则。它也通常表明调用代码的逻辑有问题。