【问题标题】:FeignClient throws instead of returning ResponseEntity with error http statusFeignClient 抛出而不是返回带有错误 http 状态的 ResponseEntity
【发布时间】:2018-01-25 11:25:30
【问题描述】:

由于我使用ResponseEntity<T> 作为我的 FeignClient 方法的返回值,我希望它返回一个状态为 400 的 ResponseEntity(如果它是服务器返回的内容)。但它会抛出一个FeignException

如何从 FeignClient 获得正确的 ResponseEntity 而不是 Exception?

这是我的 FeignClient:

@FeignClient(value = "uaa", configuration = OauthFeignClient.Conf.class)
public interface OauthFeignClient {

    @RequestMapping(
            value = "/oauth/token",
            method = RequestMethod.POST,
            consumes = MULTIPART_FORM_DATA_VALUE,
            produces = APPLICATION_JSON_VALUE)
    ResponseEntity<OauthTokenResponse> token(Map<String, ?> formParams);

    class Conf {

        @Value("${oauth.client.password}")
        String oauthClientPassword;

        @Bean
        public Encoder feignFormEncoder() {
            return new SpringFormEncoder();
        }

        @Bean
        public Contract feignContract() {
            return new SpringMvcContract();
        }

        @Bean
        public BasicAuthRequestInterceptor basicAuthRequestInterceptor() {
            return new BasicAuthRequestInterceptor("web-client", oauthClientPassword);
        }

    }
}

这里是我如何使用它:

@PostMapping("/login")
public ResponseEntity<LoginTokenPair> getTokens(@RequestBody @Valid LoginRequest userCredentials) {
    Map<String, String> formData = new HashMap<>();

    ResponseEntity<OauthTokenResponse> response = oauthFeignClient.token(formData);

    //code never reached if contacted service returns a 400
    ...
}

【问题讨论】:

    标签: spring-cloud-feign


    【解决方案1】:

    顺便说一句,我之前给出的解决方案可行,但我最初的意图是个坏主意:错误就是错误,不应该在名义流量上处理。像 Feign 那样抛出异常,并用 @ExceptionHandler 处理它是在 Spring MVC 世界中更好的方式。

    所以两个解决方案:

    • FeignException 添加一个@ExceptionHandler
    • 使用ErrorDecoder 配置FeignClient 以转换业务层知道的异常中的错误(并且已经提供@ExceptionHandler

    我更喜欢第二种解决方案,因为收到的错误消息结构可能会从一个客户端更改为另一个,因此您可以通过每个客户端的错误解码从这些错误中提取更细粒度的数据。

    FeignClient with conf(抱歉 feign-form 引入的噪音)

    @FeignClient(value = "uaa", configuration = OauthFeignClient.Config.class)
    public interface OauthFeignClient {
    
        @RequestMapping(
                value = "/oauth/token",
                method = RequestMethod.POST,
                consumes = MULTIPART_FORM_DATA_VALUE,
                produces = APPLICATION_JSON_VALUE)
        DefaultOAuth2AccessToken token(Map<String, ?> formParams);
    
        @Configuration
        class Config {
    
            @Value("${oauth.client.password}")
            String oauthClientPassword;
    
            @Autowired
            private ObjectFactory<HttpMessageConverters> messageConverters;
    
            @Bean
            public Encoder feignFormEncoder() {
                return new SpringFormEncoder(new SpringEncoder(messageConverters));
            }
    
            @Bean
            public Decoder springDecoder() {
                return new ResponseEntityDecoder(new SpringDecoder(messageConverters));
            }
    
            @Bean
            public Contract feignContract() {
                return new SpringMvcContract();
            }
    
            @Bean
            public BasicAuthRequestInterceptor basicAuthRequestInterceptor() {
                return new BasicAuthRequestInterceptor("web-client", oauthClientPassword);
            }
    
            @Bean
            public ErrorDecoder uaaErrorDecoder(Decoder decoder) {
                return (methodKey, response) -> {
                    try {
                        OAuth2Exception uaaException = (OAuth2Exception) decoder.decode(response, OAuth2Exception.class);
                        return new SroException(
                                uaaException.getHttpErrorCode(),
                                uaaException.getOAuth2ErrorCode(),
                                Arrays.asList(uaaException.getSummary()));
    
                    } catch (Exception e) {
                        return new SroException(
                                response.status(),
                                "Authorization server responded with " + response.status() + " but failed to parse error payload",
                                Arrays.asList(e.getMessage()));
                    }
                };
            }
        }
    }
    

    常见业务异常

    public class SroException extends RuntimeException implements Serializable {
        public final int status;
    
        public final List<String> errors;
    
        public SroException(final int status, final String message, final Collection<String> errors) {
            super(message);
            this.status = status;
            this.errors = Collections.unmodifiableList(new ArrayList<>(errors));
        }
    
        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof SroException)) return false;
            SroException sroException = (SroException) o;
            return status == sroException.status &&
                    Objects.equals(super.getMessage(), sroException.getMessage()) &&
                    Objects.equals(errors, sroException.errors);
        }
    
        @Override
        public int hashCode() {
            return Objects.hash(status, super.getMessage(), errors);
        }
    }
    

    错误处理程序(从ResponseEntityExceptionHandler 扩展中提取)

    @ExceptionHandler({SroException.class})
    public ResponseEntity<Object> handleSroException(SroException ex) {
        return new SroError(ex).toResponse();
    }
    

    错误响应 DTO

    @XmlRootElement
    public class SroError implements Serializable {
        public final int status;
    
        public final String message;
    
        public final List<String> errors;
    
        public SroError(final int status, final String message, final Collection<String> errors) {
            this.status = status;
            this.message = message;
            this.errors = Collections.unmodifiableList(new ArrayList<>(errors));
        }
    
        public SroError(final SroException e) {
            this.status = e.status;
            this.message = e.getMessage();
            this.errors = Collections.unmodifiableList(e.errors);
        }
    
        protected SroError() {
            this.status = -1;
            this.message = null;
            this.errors = null;
        }
    
        public ResponseEntity<Object> toResponse() {
            return new ResponseEntity(this, HttpStatus.valueOf(this.status));
        }
    
        public ResponseEntity<Object> toResponse(HttpHeaders headers) {
            return new ResponseEntity(this, headers, HttpStatus.valueOf(this.status));
        }
    
        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof SroError)) return false;
            SroError sroException = (SroError) o;
            return status == sroException.status &&
                    Objects.equals(message, sroException.message) &&
                    Objects.equals(errors, sroException.errors);
        }
    
        @Override
        public int hashCode() {
    
            return Objects.hash(status, message, errors);
        }
    }
    

    Feign 客户端使用请注意如何透明地处理错误(无尝试/捕获),这要归功于 @ControllerAdvice@ExceptionHandler({SroException.class})

    @RestController
    @RequestMapping("/uaa")
    public class AuthenticationController {
        private static final BearerToken REVOCATION_TOKEN = new BearerToken("", 0L);
    
        private final OauthFeignClient oauthFeignClient;
    
        private final int refreshTokenValidity;
    
        @Autowired
        public AuthenticationController(
                OauthFeignClient oauthFeignClient,
                @Value("${oauth.ttl.refresh-token}") int refreshTokenValidity) {
            this.oauthFeignClient = oauthFeignClient;
            this.refreshTokenValidity = refreshTokenValidity;
        }
    
        @PostMapping("/login")
        public ResponseEntity<LoginTokenPair> getTokens(@RequestBody @Valid LoginRequest userCredentials) {
            Map<String, String> formData = new HashMap<>();
            formData.put("grant_type", "password");
            formData.put("client_id", "web-client");
            formData.put("username", userCredentials.username);
            formData.put("password", userCredentials.password);
            formData.put("scope", "openid");
    
            DefaultOAuth2AccessToken response = oauthFeignClient.token(formData);
            return ResponseEntity.ok(new LoginTokenPair(
                    new BearerToken(response.getValue(), response.getExpiresIn()),
                    new BearerToken(response.getRefreshToken().getValue(), refreshTokenValidity)));
        }
    
        @PostMapping("/logout")
        public ResponseEntity<LoginTokenPair> revokeTokens() {
            return ResponseEntity
                    .ok(new LoginTokenPair(REVOCATION_TOKEN, REVOCATION_TOKEN));
        }
    
        @PostMapping("/refresh")
        public ResponseEntity<BearerToken> refreshToken(@RequestHeader("refresh_token") String refresh_token) {
            Map<String, String> formData = new HashMap<>();
            formData.put("grant_type", "refresh_token");
            formData.put("client_id", "web-client");
            formData.put("refresh_token", refresh_token);
            formData.put("scope", "openid");
    
            DefaultOAuth2AccessToken response = oauthFeignClient.token(formData);
            return ResponseEntity.ok(new BearerToken(response.getValue(), response.getExpiresIn()));
        }
    }
    

    【讨论】:

      【解决方案2】:

      因此,查看源代码,似乎唯一的解决方案实际上是使用 feign.Response 作为 FeignClient 方法的返回类型,并使用 new ObjectMapper().readValue(response.body().asReader(), clazz) 之类的东西对主体进行手动解码(当然,因为出现错误,所以要保护 2xx 状态状态,很可能 body 是错误描述,而不是有效的有效负载;)。

      这使得提取和转发状态、标题、正文等成为可能,即使状态不在 2xx 范围内。

      编辑: 这是一种转发状态、标头和映射 JSON 正文的方法(如果可能):

      public static class JsonFeignResponseHelper {
          private final ObjectMapper json = new ObjectMapper();
      
          public <T> Optional<T> decode(Response response, Class<T> clazz) {
              if(response.status() >= 200 && response.status() < 300) {
                  try {
                      return Optional.of(json.readValue(response.body().asReader(), clazz));
                  } catch(IOException e) {
                      return Optional.empty();
                  }
              } else {
                  return Optional.empty();
              }
          }
      
          public <T, U> ResponseEntity<U> toResponseEntity(Response response, Class<T> clazz, Function<? super T, ? extends U> mapper) {
              Optional<U> payload = decode(response, clazz).map(mapper);
      
              return new ResponseEntity(
                      payload.orElse(null),//didn't find a way to feed body with original content if payload is empty
                      convertHeaders(response.headers()),
                      HttpStatus.valueOf(response.status()));
          }
      
          public MultiValueMap<String, String>  convertHeaders(Map<String, Collection<String>> responseHeaders) {
              MultiValueMap<String, String> responseEntityHeaders = new LinkedMultiValueMap<>();
              responseHeaders.entrySet().stream().forEach(e -> 
                      responseEntityHeaders.put(e.getKey(), new ArrayList<>(e.getValue())));
              return responseEntityHeaders;
          }
      }
      

      可以如下使用:

      @PostMapping("/login")
      public ResponseEntity<LoginTokenPair> getTokens(@RequestBody @Valid LoginRequest userCredentials) throws IOException {
          Response response = oauthFeignClient.token();
      
          return feignHelper.toResponseEntity(
                  response,
                  OauthTokenResponse.class,
                  oauthTokenResponse -> new LoginTokenPair(
                          new BearerToken(oauthTokenResponse.access_token, oauthTokenResponse.expires_in),
                          new BearerToken(oauthTokenResponse.refresh_token, refreshTokenValidity)));
      }
      

      这会保存标题和状态代码,但会丢失错误消息:/

      【讨论】:

        猜你喜欢
        • 2020-04-29
        • 2021-11-28
        • 2018-08-09
        • 2021-01-27
        • 1970-01-01
        • 1970-01-01
        • 2018-05-28
        • 2021-01-08
        • 2019-08-25
        相关资源
        最近更新 更多