DDD中如何进行远程调用?

大家好,我是飘渺。在今天的DDD与微服务系列文章中,让我们探讨如何在DDD的分层架构中调用第三方服务以及在微服务中使用OpenFeign的最佳实践。

1. DDD中的防腐层

在应用服务中,经常需要调用外部服务接口来实现某些业务功能,这就在代码层面引入了对外部系统的依赖。例如,下面这段转账的代码逻辑需要调用外部接口服务RemoteService来获取汇率。

public class TransferServiceImpl implements TransferService{
	private RemoteService remoteService;
	@Override
  public void transfer(Long sourceUserId, String targetUserId, BigDecimal targetAmount){
		//...
		ExchangeRateRemote exchangeRate = remoteService.getExchangeRate(
sourceAccount.getCurrency(), targetCurrency);
		BigDecimal rate = exchangeRate.getRate();
  }
	  //...
}

这里可以看到,TransferService强烈依赖于RemoteServiceExchangeRateRemote对象。如果外部服务的方法或ExchangeRateRemote字段发生变化,都会影响到ApplicationService的代码。当有多个服务依赖此外部接口时,迁移和改造的成本将会巨大。同时,外部依赖的兜底、限流和熔断策略也会受到影响。

在复杂系统中,我们应该尽量避免自己的代码因为外部系统的变化而修改。那么如何实现对外部系统的隔离呢?答案就是引入防腐层(Anti-Corruption Layer,简称ACL)。

1.1 什么是防腐层

在许多情况下,我们的系统需要依赖其他系统,但被依赖的系统可能具有不合理的数据结构、API、协议或技术实现。如果我们强烈依赖外部系统,就会导致我们的系统受到“腐蚀”。在这种情况下,通过引入防腐层,可以有效地隔离外部依赖和内部逻辑,无论外部如何变化,内部代码尽可能保持不变。

image-20230918221104538

防腐层不仅仅是一层简单的调用封装,在实际开发中,ACL可以提供更多强大的功能:

  • 适配器: 很多时候外部依赖的数据、接口和协议并不符合内部规范,通过适配器模式,可以将数据转化逻辑封装到ACL内部,降低对业务代码的侵入。
  • 缓存: 对于频繁调用且数据变更不频繁的外部依赖,通过在ACL里嵌入缓存逻辑,能够有效的降低对于外部依赖的请求压力。同时,很多时候缓存逻辑是写在业务代码里的,通过将缓存逻辑嵌入ACL,能够降低业务代码的复杂度。
  • 兜底: 如果外部依赖的稳定性较差,提高系统稳定性的策略之一是通过ACL充当兜底,例如在外部依赖出问题时,返回最近一次成功的缓存或业务兜底数据。这种兜底逻辑通常复杂,如果散布在核心业务代码中,会难以维护。通过集中在ACL中,更容易进行测试和修改。
  • 易于测试: ACL的接口类能够很容易的实现Mock或Stub,以便于单元测试。
  • 功能开关: 有时候,我们希望在某些场景下启用或禁用某个接口的功能,或者让某个接口返回特定值。我们可以在ACL中配置功能开关,而不会影响真实的业务代码。

1.2 如何实现防腐层

实现ACL防腐层的步骤如下:

  • 对于依赖的外部对象,我们提取所需的字段,并创建一个内部所需的DTO类。
  • 构建一个新的Facade,在Facade中封装调用链路,将外部类转化为内部类。Facade可以参考Repository的实现模式,将接口定义在领域层,而将实现放在基础设施层。
  • 在ApplicationService中依赖内部的Facade对象。

具体实现如下:

// 自定义的内部值类
@Data
public class ExchangeRateDTO {
  ...
}

// 税率Facade接口
public interface ExchangeRateFacade {
    ExchangeRateDTO getExchangeRate(String sourceCurrency, String targetCurrency);
}

// 税率facade实现
@Service
public class ExchangeRateFacadeImpl implements ExchangeRateFacade {

    @Resource
    private RemoteService remoteService;

    @Override
    public ExchangeRateDTO getExchangeRate(String sourceCurrency, String targetCurrency) {
        ExchangeRateRemote exchangeRemote = remoteService.getExchangeRate(sourceCurrency, targetCurrency);
        if (exchangeRemote != null) {
            ExchangeRateDTO dto = new ExchangeRateDTO();
            dto.setXXX(exchangeRemote.getXXX());
            return dto;
        }
        return null;
    }
}

通过ACL改造后,我们的ApplicationService代码如下:

public class TransferServiceImpl implements TransferService{
	private ExchangeRateFacade exchangeRateFacade;
	@Override
  public void transfer(Long sourceUserId, String targetUserId, BigDecimal targetAmount){
		...
		ExchangeRateDTO exchangeRate = exchangeRateFacade.getExchangeRate(
sourceAccount.getCurrency(), targetCurrency);
		BigDecimal rate = exchangeRate.getRate();
   }
		...
}

这样,经过ACL改造后,ApplicationService的代码已不再直接依赖外部的类和方法,而是依赖我们自己内部定义的值类和接口。如果未来外部服务发生任何变化,只需修改Facade类和数据转换逻辑,而不需要修改ApplicationService的逻辑。

1.3 小结

在没有防腐层ACL的情况下,系统需要直接依赖外部对象和外部调用接口,调用逻辑如下:

image-20230919105321155

而有了防腐层ACL后,系统只需要依赖内部的值类和接口,调用逻辑如下:

image-20230919105336405

2. 微服务中的远程调用

在构建微服务时,我们经常需要跨服务调用,例如在DailyMart系统中,购物车服务需要调用商品服务以获取商品详细信息。理论上,我们可以遵循上述ACL的实现逻辑,在购物车模块创建Facade接口和内部转换类。然而,在实际开发中,由于是内部系统,差异性不太明显,通常可以直接使用OpenFeign进行远程调用,忽略Facade定义和内部类转换的过程。

以下是在微服务中使用OpenFeign实现跨服务调用的过程:

  1. 首先,在购物车模块的基础设施层创建一个接口,并使用@FeignClient注解进行标注。
@FeignClient("product-service")
public interface ProductRemoteFacade {

    @GetMapping("/api/product/spu/{spuId}")
    Result<ProductRespDTO> getProductBySpuId(@PathVariable("spuId") Long spuId);

}

需要注意的是,我们在商品服务中对外提供的商品详情接口定义返回的是ProductRespDTO对象,但通过OpenFeign调用时返回的是Result对象。

@Operation(summary = "查询商品详情")
@Parameter(name = "spuId", description = "商品spuId")
@GetMapping("/api/product/spu/{spuId}")
public ProductRespDTO getProductBySpuId(@PathVariable("spuId") Long spuId) {
	return productRemoteFacade.getProductBySpuId(spuId);
}

这是因为在前文中,我们定义了一个全局的包装类GlobalResponseBodyAdvice,会自动给所有接口封装返回对象Result。因此,在定义Feign接口时,也需要使用Result对象来接收。如果对此逻辑不太清晰,建议参考第七章的内容。

  1. 在启动类上添加@EnableFeignClient注
@SpringBootApplication
@EnableFeignClients("com.jianzh5.dailymart.module.cart.infrastructure.acl")
public class CartApplication {
    
    public static void main(String[] args) {
        SpringApplication.run(CartApplication.class, args);
    }
    
}
  1. 在应用服务中注入Feign接口并使用
    @Override
    public void getShoppingCartDetail(Long cartId) {
    	ShoppingCart shoppingCart = shoppingCartRepository.find(new CartId(cartId));
      
    	Result<ProductRespDTO> productRespResult = productRemoteFacade.getProductBySpuId(1L);
      
      // 从Result对象中获取真实的业务对象
    	if(productRespResult.getCode().equals("OK")){
    		ProductRespDTO data = productRespResult.getData();
    	}
    
    }

如上所示,我们可以看到,每次调用Feign接口都需要解析Result对象以获取真正的业务对象。这种代码看起来有些冗余,是否有办法去除呢?

2.1 自定义Feign的解码器

这时,我们可以通过重写Feign的解码器来实现,在解码器中完成封装对象的拆解。

@RequiredArgsConstructor
public class DailyMartResponseDecoder implements Decoder {

    private final ObjectMapper objectMapper;
    @Override
    public Object decode(Response response, Type type) throws IOException, FeignException {
        Result<?> result = objectMapper.readValue(response.body().asInputStream(), objectMapper.constructType(Result.class));
        if(result.getCode().equals("OK")){
            Object data = result.getData();
            JavaType javaType = TypeFactory.defaultInstance().constructType(type);
            return objectMapper.convertValue(data, javaType);
        }else{
            throw new RemoteException(result.getCode(), result.getMessage());
        }
    }
}

同时,创建一个配置类,替换原生的解码器。

@Bean
public Decoder feignDecoder(){
	return new DailyMartResponseDecoder(objectMapper);
}

这样,在定义或调用OpenFeign接口时,直接使用原生对象ProductRespDTO即可。

@FeignClient("product-service")
public interface ProductRemoteFacade {

    @GetMapping("/api/product/spu/{spuId}")
    ProductRespDTO getProductBySpuId(@PathVariable("spuId") Long spuId);

}

...

@Override
public void getShoppingCartDetail(Long cartId) {
	ShoppingCart shoppingCart = shoppingCartRepository.find(new CartId(cartId));

	ProductRespDTO productRespResult = productRemoteClient.getProductBySpuId(1L);

}

2.2 上游异常统一处理

在使用OpenFeign进行远程调用时,如果HTTP状态码为非200,OpenFeign会触发异常解析并进入默认的异常解码器feign.codec.ErrorDecoder,将业务异常包装成FeignException。此时,如果不做任何处理,调用时可以返回的消息会变成FeignException的消息体,如下所示:

image-20230905113356979

显然,这个包装后的异常我们不需要,应该直接将捕获到的生产者的业务异常抛给前端。那么,如何解决这个问题呢?

可以通过重写OpenFeign的默认异常解码器来实现,代码如下:

@RequiredArgsConstructor
@Slf4j
public class DailyMartFeignErrorDecoder implements ErrorDecoder {

    private final ObjectMapper objectMapper;

    /**
     * OpenFeign的异常解析
     * @author Java日知录
     * @param methodKey 方法名
     * @param response 响应体
     */
    @Override
    public Exception decode(String methodKey, Response response) {
        try {
            Reader reader = response.body().asReader(Charset.defaultCharset());
            Result<?> result = objectMapper.readValue(reader, objectMapper.constructType(Result.class));
            return new RemoteException(result.getCode(),result.getMessage());
        } catch (IOException e) {
            log.error("Response转换异常",e);
            throw new RemoteException(ErrorCode.FEIGN_ERROR);
        }

    }
}

此异常解码器直接将异常转化为自定义的RemoteException,表示远程调用异常。

当然,还需要在配置类中注入此异常解码器。

2.3 Feign全局异常处理

在2.2小节中,我们抛出了自定义的业务异常,然而OpenFeign处理响应时会捕获到业务异常并将其转换成DecodeException

image-20230905110310623

由于DailyMart中的全局异常处理器没有单独处理DecodeException,它会被兜底异常处理器拦截,并返回类似“系统异常,请联系管理员”的错误提示。

因此,要完全使用上游系统的业务异常,还需要定义一个单独的异常处理器来处理DecodeException。这个处理器可以与全局异常处理器分开,代码如下:

/**
 * Feign的全局异常处理,与常规的全局异常处理类分开
 * @author Java日知录
 */
@RestControllerAdvice
@Slf4j
@Order(Ordered.HIGHEST_PRECEDENCE) // 优先级
@ResponseStatus(code = HttpStatus.BAD_REQUEST) // 统一 HTTP 状态码
public class DailyMartFeignExceptionHandler {
    
    @ExceptionHandler(FeignException.class)
    public Result<?> handleFeignException(FeignException e) {
        return new Result<Void>()
                .setCode(ErrorCode.REMOTE_ERROR.getCode())
                .setMessage(e.getMessage())
                .setTimestamp(System.currentTimeMillis());
    }
    
    @ExceptionHandler(DecodeException.class)
    public Result<?> handleDecodeException(DecodeException e) {
        Throwable cause = e.getCause();
        if (cause instanceof AbstractException) {
            RemoteException remoteException = (RemoteException) cause;
            // 上游符合全局响应包装约定的再次抛出即可
            return new Result<Void>()
                    .setCode(remoteException.getCode())
                    .setMessage(remoteException.getMessage())
                    .setTimestamp(System.currentTimeMillis());
        }
        // 全部转换成RemoteException
        return new Result<Void>()
                .setCode(ErrorCode.REMOTE_ERROR.getCode())
                .setMessage(e.getMessage())
                .setTimestamp(System.currentTimeMillis());
    }
    
}

如此一来,框架会自动将业务异常传递给调用服务,业务中也无需关心全局包装的拆解问题,这就是OpenFeign远程调用的最佳实践。当然,在DailyMart中可能有许多服务都需要远程调用,我们可以将上述内容构建成一个通用的Starter模块,以便其他业务模块共享。具体实现可参考源代码。

image-20230919151021611

小结

本文深入研究了领域驱动设计(DDD)和微服务架构中的两个关键概念:防腐层(ACL)和远程调用的最佳实践。在DDD中,我们学习了如何使用ACL来隔离外部依赖,降低系统耦合度。在微服务架构中,我们探讨了如何通过OpenFeign来实现跨服务调用,并解决了全局包装和异常处理的问题,希望本文的内容对您在软件开发项目中有所帮助。

DailyMart是一个基于领域驱动设计(DDD)和Spring Cloud Alibaba的微服务商城系统。我们将在该系统中整合博主其他专栏文章的核心内容。如果你对这两大技术栈感兴趣,可以关注公众号Java日知录并回复关键词 DDD 以获取相关源码。