大家好,我是飘渺。在今天的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
强烈依赖于RemoteService
和ExchangeRateRemote
对象。如果外部服务的方法或ExchangeRateRemote
字段发生变化,都会影响到ApplicationService
的代码。当有多个服务依赖此外部接口时,迁移和改造的成本将会巨大。同时,外部依赖的兜底、限流和熔断策略也会受到影响。
在复杂系统中,我们应该尽量避免自己的代码因为外部系统的变化而修改。那么如何实现对外部系统的隔离呢?答案就是引入防腐层(Anti-Corruption Layer,简称ACL)。
1.1 什么是防腐层
在许多情况下,我们的系统需要依赖其他系统,但被依赖的系统可能具有不合理的数据结构、API、协议或技术实现。如果我们强烈依赖外部系统,就会导致我们的系统受到**“腐蚀”**。在这种情况下,通过引入防腐层,可以有效地隔离外部依赖和内部逻辑,无论外部如何变化,内部代码尽可能保持不变。
防腐层不仅仅是一层简单的调用封装,在实际开发中,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的情况下,系统需要直接依赖外部对象和外部调用接口,调用逻辑如下:
而有了防腐层ACL后,系统只需要依赖内部的值类和接口,调用逻辑如下:
2. 微服务中的远程调用
在构建微服务时,我们经常需要跨服务调用,例如在DailyMart系统中,购物车服务需要调用商品服务以获取商品详细信息。理论上,我们可以遵循上述ACL的实现逻辑,在购物车模块创建Facade接口和内部转换类。然而,在实际开发中,由于是内部系统,差异性不太明显,通常可以直接使用OpenFeign进行远程调用,忽略Facade定义和内部类转换的过程。
以下是在微服务中使用OpenFeign实现跨服务调用的过程:
@FeignClient
注解进行标注。@FeignClient("product-service")
public interface ProductRemoteFacade {
@GetMapping("/api/product/spu/{spuId}")
Result 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
对象来接收。如果对此逻辑不太清晰,建议参考第七章的内容。
@EnableFeignClient注
解@SpringBootApplication
@EnableFeignClients("com.jianzh5.dailymart.module.cart.infrastructure.acl")
public class CartApplication {
public static void main(String[] args) {
SpringApplication.run(CartApplication.class, args);
}
}
@Override
public void getShoppingCartDetail(Long cartId) {
ShoppingCart shoppingCart = shoppingCartRepository.find(new CartId(cartId));
Result 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
的消息体,如下所示:
显然,这个包装后的异常我们不需要,应该直接将捕获到的生产者的业务异常抛给前端。那么,如何解决这个问题呢?
可以通过重写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
。
由于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()
.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()
.setCode(remoteException.getCode())
.setMessage(remoteException.getMessage())
.setTimestamp(System.currentTimeMillis());
}
// 全部转换成RemoteException
return new Result()
.setCode(ErrorCode.REMOTE_ERROR.getCode())
.setMessage(e.getMessage())
.setTimestamp(System.currentTimeMillis());
}
}
如此一来,框架会自动将业务异常传递给调用服务,业务中也无需关心全局包装的拆解问题,这就是OpenFeign远程调用的最佳实践。当然,在DailyMart中可能有许多服务都需要远程调用,我们可以将上述内容构建成一个通用的Starter模块,以便其他业务模块共享。具体实现可参考源代码。
小结
本文深入研究了领域驱动设计(DDD)和微服务架构中的两个关键概念:防腐层(ACL)和远程调用的最佳实践。在DDD中,我们学习了如何使用ACL来隔离外部依赖,降低系统耦合度。在微服务架构中,我们探讨了如何通过OpenFeign来实现跨服务调用,并解决了全局包装和异常处理的问题,希望本文的内容对您在软件开发项目中有所帮助。
DDD&微服务系列源码已经上传至GitHub,如果需要获取源码地址,请关注公号 java日知录 并回复关键字 DDD 即可!