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

2023年 10月 8日 30.2k 0

大家好,我是飘渺。在今天的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实现跨服务调用的过程:

  • 首先,在购物车模块的基础设施层创建一个接口,并使用@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);
        }
        
    }
    
  • 在应用服务中注入Feign接口并使用
  • @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的消息体,如下所示:

    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()
                    .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模块,以便其他业务模块共享。具体实现可参考源代码。

    image-20230919151021611

    小结

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

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

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论