⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 juejin.cn/post/7594817135128248354 「一只叫煤球的猫」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

前言

昨天在Code Review时,我发现阿城在Service层直接返回了Result对象。

指出这个问题后,阿城有些不解,反问我为什么不能这样写。

于是我们展开了一场技术讨论(battle 🤣)。

讨论过程中,我发现这个看似简单的设计问题,背后其实涉及分层架构、职责划分、代码复用等多个重要概念。

与其让这次讨论的内容随风而去,不如整理成文,帮助更多遇到同样困惑的朋友理解原因。

知其然,更知其所以然。

耐心看完,你一定有所收获。

正文

职责分离原则

在传统的MVC架构中,Service层和Controller层各自承担着不同的职责。

Service层负责业务逻辑的处理,而Controller层负责HTTP请求的处理和响应格式的封装。

当我们将数据包装成 Result 对象的任务交给 Service 层时,意味着 Service 层不再单纯地处理业务逻辑,而是牵涉到了数据处理和响应的部分。

这样会导致业务逻辑与表现逻辑的耦合,降低了代码的清晰度和可维护性。

看一个不推荐的写法:

@Service
publicclass UserService {
public Result<User> getUserById(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
return Result.error(404, 用户不存在);
}
return Result.success(user);
}
}

@RestController
publicclass UserController {
@Autowired
private UserService userService;

@GetMapping("/user/{id}")
public Result<User> getUser(@PathVariable Long id) {
return userService.getUserById(id);
}
}

上面代码中,Service 层不仅负责从数据库获取用户信息,还直接处理了返回的结果。

如果我们需要改变返回的格式,或者进行错误信息的标准化,所有 Service 层的方法都需要修改。这样会导致代码的高耦合。

相比之下,以下做法将展示逻辑留给 Controller 层,保证了业务逻辑的纯粹性:

@Service
publicclass UserService {
public User getUserById(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
thrownew BusinessException(用户不存在);
}
return user;
}
}

@RestController
publicclass UserController {
@Autowired
private UserService userService;

@GetMapping("/user/{id}")
public Result<User> getUser(@PathVariable Long id) {
User user = userService.getUserById(id);
return Result.success(user);
}
}

让每一层都专注于自己的职责。

可复用性问题

当Service层返回Result时,会严重影响方法的可复用性。

假设我们有一个订单服务需要调用用户服务:

@Service
publicclass OrderService {
@Autowired
private UserService userService;

public void createOrder(Long userId, OrderDTO orderDTO) {
// 不推荐的方式:需要解包Result
Result<User> userResult = userService.getUserById(userId);
if (!userResult.isSuccess()) {
thrownew BusinessException(userResult.getMessage());
}
User user = userResult.getData();

// 后续业务逻辑
validateUserStatus(user);
// ...
}
}

这种写法有个很明显的问题。

OrderService 作为另一个业务服务,业务之间的调用本来应该简单直接,但使用 Result 带来了两个问题:

  • 不知道 Result 里到底包含什么,还得去查看代码里面的实现,写起来麻烦。
  • 还需要额外判断 Result 的状态,增加了不必要的复杂度。

如果是调用第三方外部服务,需要这种包装还能理解,但在自己业务之间互相调用时,完全没必要这样做。

如果Service返回纯业务对象:

@Service
public class OrderService {
@Autowired
private UserService userService;

public void createOrder(Long userId, OrderDTO orderDTO) {
// 推荐的方式:直接获取业务对象
User user = userService.getUserById(userId);

// 后续业务逻辑
validateUserStatus(user);
// ...
}
}

代码变得简洁且符合直觉。

业务层之间直接传递业务对象,保持简单和清晰。

异常处理机制

有些 Service 层在业务判断失败后,会直接返回 Result.fail(xxx) 这样的代码,例如:

public Result<Void> createOrder(Long userId, OrderDTO orderDTO) {
if (userId == null) {
return Result.fail("用户ID不能为空");
}
// 后续业务逻辑
return Result.success();
}

这种做法有几个问题:

  • 重复的错误处理: 每个方法都得写一大堆类似的错误判断代码,增加了代码量。
  • 错误分散: 错误处理分散在每个方法里,如果需要改进错误逻辑,要在多个地方修改,麻烦且容易出错。

而如果我们通过抛出异常并结合全局异常处理来统一处理错误,例如:

public void createOrder(Long userId, OrderDTO orderDTO) {
if (userId == null) {
throw new BusinessException("用户ID不能为空");
}
// 后续业务逻辑
}

再通过全局异常捕获来转换为 Result:

@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
return Result.error(400, e.getMessage());
}

@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常", e); // 这里可以查看堆栈信息
return Result.error(500, "系统繁忙");
}
}

这样做的好处是:

减少重复代码:业务方法不再需要写重复的错误判断,代码更简洁。

  • 集中错误处理: 错误处理集中在一个地方,修改时只需修改全局异常处理器,不用改动每个 Service 层方法。
  • 业务与错误分离: 业务逻辑专注处理核心功能,错误处理交给统一的机制,代码更加清晰易懂。

而且异常可以携带更丰富的上下文信息,如果业务侧需要时,可以带上堆栈信息,便于一些问题的定位。

测试便利性

Service层返回业务对象而不是Result时,能够大大提升单元测试的便利性:

@SpringBootTest
publicclass UserServiceTest {

@Autowired
private UserService userService;

@Test
public void testGetUserById() {
// 推荐的方式:直接断言业务对象
User user = userService.getUserById(1L);
assertNotNull(user);
assertEquals(张三, user.getName());
}

@Test
public void testGetUserById_NotFound() {
// 推荐的方式:断言抛出异常
assertThrows(BusinessException.class, () -> {
userService.getUserById(999L);
});
}
}

如果Service返回Result,测试代码则需要写得更复杂:

@Test
public void testGetUserById() {
// 不推荐的方式:需要解包Result
Result<User> result = userService.getUserById(1L);
assertTrue(result.isSuccess());
assertNotNull(result.getData());
assertEquals(张三, result.getData().getName());
}

测试代码变得莫名冗长,还得去关注响应结构,这并不是Service层测试的关注点。

Service 层本应专注于业务逻辑,测试也应该直接验证业务数据。

领域驱动设计角度

再换个角度。

从领域驱动设计(DDD)的角度来看,Service 层属于应用层或领域层,应该使用领域语言来表达业务逻辑。

而 Result 是基础设施层的概念,代表 HTTP 响应格式,不应该污染领域层。

例如,考虑转账业务:

@Service
publicclass TransferService {

public TransferResult transfer(Long fromAccountId, Long toAccountId, BigDecimal amount) {
Account fromAccount = accountRepository.findById(fromAccountId);
Account toAccount = accountRepository.findById(toAccountId);

fromAccount.deduct(amount);
toAccount.deposit(amount);

accountRepository.save(fromAccount);
accountRepository.save(toAccount);

returnnew TransferResult(fromAccount, toAccount, amount);
}
}

在这个例子中,TransferResult 是一个领域对象,代表了转账的结果,包含了与业务相关的意义,而不是一个通用的 HTTP 响应封装 Result。

这种做法更符合领域模型的表达,体现了领域层的职责——处理业务逻辑,而不是涉及 HTTP 响应格式的细节。

接口适配的灵活性

当 Service 层返回纯粹的业务对象时,Controller 层可以根据不同的接口需求灵活封装响应:

@RestController
@RequestMapping("/api")
publicclass UserController {

@Autowired
private UserService userService;

// REST接口返回Result
@GetMapping("/user/{id}")
public Result<User> getUser(@PathVariable Long id) {
User user = userService.getUserById(id);
return Result.success(user);
}

// GraphQL接口直接返回对象
@QueryMapping
public User user(@Argument Long id) {
return userService.getUserById(id);
}

// RPC接口返回自定义格式
@DubboService
publicclass UserRpcServiceImpl implements UserRpcService {
public UserDTO getUserById(Long id) {
User user = userService.getUserById(id);
return convertToDTO(user);
}
}
}

同一个Service方法可以被不同类型的接口复用,每个接口根据自己的协议要求封装响应。

强行使用 Result 会导致接口的适配性变差,无法根据不同协议的需求灵活定制响应格式。

灵活性反而丢失了。

事务边界清晰

Service 层通常是事务边界所在,当 Service 返回业务对象时,事务的语义更加清晰:

@Service
publicclass OrderService {

@Transactional
public Order createOrder(OrderDTO orderDTO) {
Order order = new Order();
// 设置订单属性
orderMapper.insert(order);

// 扣减库存
inventoryService.deduct(orderDTO.getProductId(), orderDTO.getQuantity());

return order;
}
}

在这个例子中,事务是围绕 Service 层的方法展开的,@Transactional 注解确保在业务逻辑执行失败时,事务会回滚。因为方法正常返回时,事务会提交;如果抛出异常,事务会回滚,事务的边界非常明确。

如果 Service 返回的是 Result,很难界定事务是否应该回滚。比如:

public Result<Order> createOrder(OrderDTO orderDTO) {
Order order = new Order();
// 设置订单属性
orderMapper.insert(order);

// 扣减库存
Result<Void> inventoryResult = inventoryService.deduct(orderDTO.getProductId(), orderDTO.getQuantity());
if (!inventoryResult.isSuccess()) {
return Result.fail("库存不足");
}

return Result.success(order);
}

在这种情况下,如果库存不足,虽然 Result 返回失败信息,但事务并不会回滚,可能会导致数据不一致,反而还得额外去抛出异常。

而通过抛出异常的方式,事务的回滚语义非常清晰:异常抛出则回滚,方法正常返回则提交,这种设计确保了事务的边界更加明确,避免了潜在的数据一致性问题。

文章目录
  1. 1. 前言
  2. 2. 正文
    1. 2.1. 职责分离原则
    2. 2.2. 可复用性问题
    3. 2.3. 异常处理机制
    4. 2.4. 测试便利性
    5. 2.5. 领域驱动设计角度
    6. 2.6. 接口适配的灵活性
    7. 2.7. 事务边界清晰