瞧瞧别人家的OpenFeign,那叫一个优雅!

微服务数据库NoSQL数据库

大家好,我是苏三。

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

picture.image

防腐层不仅仅是一层简单的调用封装,在实际开发中,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的情况下,系统需要直接依赖外部对象和外部调用接口,调用逻辑如下:

picture.image

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

picture.image

  1. 微服务中的远程调用

在构建微服务时,我们经常需要跨服务调用,例如在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的消息体,如下所示:

picture.image

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

可以通过重写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

picture.image

由于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模块,以便其他业务模块共享。 (在本公众号回复DDD获取源码)

picture.image

小结

本文深入研究了领域驱动设计(DDD)和微服务架构中的两个关键概念:防腐层(ACL)和远程调用的最佳实践。在DDD中,我们学习了如何使用ACL来隔离外部依赖,降低系统耦合度。

在微服务架构中,我们探讨了如何通过OpenFeign来实现跨服务调用,并解决了全局包装和异常处理的问题,希望本文的内容对您在软件开发项目中有所帮助。

最后欢迎 加入苏三的星球 ,你将获得: 商城微服务实战、秒杀系统实战 、 商城系统实战、秒杀系统实战、代码生成工具、系统设计、性能优化、技术选型、底层原理、Spring源码解读、工作经验分享、痛点问题、面试八股文等多个优质专栏。

还有1V1答疑、修改简历、职业规划、送书活动、技术交流。

picture.image

目前星球已经更新了4900+篇优质内容,还在持续爆肝中.....

星球已经被官方推荐了3次,收到了小伙伴们的一致好评。戳我加入学习,已有1600+小伙伴加入学习。

0
0
0
0
关于作者
关于作者

文章

0

获赞

0

收藏

0

相关资源
字节跳动云原生降本增效实践
本次分享主要介绍字节跳动如何利用云原生技术不断提升资源利用效率,降低基础设施成本;并重点分享字节跳动云原生团队在构建超大规模云原生系统过程中遇到的问题和相关解决方案,以及过程中回馈社区和客户的一系列开源项目和产品。
相关产品
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论