优雅远程调用,微服务中ACL与OpenFeign的绝佳配合!

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

1. DDD中的防腐层

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

java 复制代码
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、协议或技术实现。如果我们强烈依赖外部系统,就会导致我们的系统受到**"腐蚀"**。在这种情况下,通过引入防腐层,可以有效地隔离外部依赖和内部逻辑,无论外部如何变化,内部代码尽可能保持不变。

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

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

1.2 如何实现防腐层

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

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

具体实现如下:

java 复制代码
// 自定义的内部值类
@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代码如下:

java 复制代码
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实现跨服务调用的过程:

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

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

}

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

java 复制代码
@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注
java 复制代码
@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接口并使用
java 复制代码
@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的解码器来实现,在解码器中完成封装对象的拆解。

java 复制代码
@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());
        }
    }
}

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

java 复制代码
@Bean
public Decoder feignDecoder(){
	return new DailyMartResponseDecoder(objectMapper);
}

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

java 复制代码
@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的默认异常解码器来实现,代码如下:

java 复制代码
@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。这个处理器可以与全局异常处理器分开,代码如下:

JAVA 复制代码
/**
 * 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模块,以便其他业务模块共享。具体实现可参考源代码。

小结

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

DDD&微服务系列源码已经上传至GitHub,如果需要获取源码地址,请关注公号 java日知录 并回复关键字 DDD 即可!

相关推荐
用户9083246027315 小时前
Spring AI 1.1.2 + Neo4j:用知识图谱增强 RAG 检索(上篇:图谱构建)
java·spring boot
用户8307196840821 天前
Spring Boot 集成 RabbitMQ :8 个最佳实践,杜绝消息丢失与队列阻塞
spring boot·后端·rabbitmq
Java水解1 天前
Spring Boot 视图层与模板引擎
spring boot·后端
Java水解1 天前
一文搞懂 Spring Boot 默认数据库连接池 HikariCP
spring boot·后端
洋洋技术笔记2 天前
Spring Boot Web MVC配置详解
spring boot·后端
初次攀爬者2 天前
Kafka 基础介绍
spring boot·kafka·消息队列
用户8307196840822 天前
spring ai alibaba + nacos +mcp 实现mcp服务负载均衡调用实战
spring boot·spring·mcp
Java水解2 天前
SpringBoot3全栈开发实战:从入门到精通的完整指南
spring boot·后端
初次攀爬者3 天前
RocketMQ在Spring Boot上的基础使用
java·spring boot·rocketmq
花花无缺3 天前
搞懂@Autowired 与@Resuorce
java·spring boot·后端