➜ Old React website
Chung Cheuk Hang MichaelJava Web Developer
Support 指引Spring Security 漏洞 CVE-2023-34034

Java Spring Boot/Cloud HTTP clients

Table of contents

1 Java Spring 提供既 HTTP client APIs

微服務(又或者 monolith applications)可以透過 HTTP clients 以 HTTP REST calls 既形式連接其他服務,包括獲取、上載、更新、刪除資料。
以下係 Java Spring Boot 或者 Spring Cloud 項目可以用到既 HTTP client APIs:
  1. Java HttpClient
  2. Spring RestTemplate
  3. Spring Webflux WebClient
  4. Spring 6.1 RestClient(新)
  5. Spring Cloud OpenFeign

2 動手寫

2.1 Java HttpClient

以下係 Java 本身提供既 HttpClient API。
構建 client object:
final HttpClient client = HttpClient.newBuilder() .version(Version.HTTP_1_1) .connectTimeout(Duration.ofSeconds(10)) .build();
發送 request:
1final String requestBody = "{ \"value\": \"123\" }"; 2 3final HttpRequest request = HttpRequest.newBuilder() 4 .version(Version.HTTP_1_1) 5 .uri(new URI("http://localhost:8081/api/order/123?param1=value&param2=value")) 6 .POST(BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8)) 7 .header("Authorization", "Basic " + Base64.getEncoder().encodeToString(("username" + ":" + "password").getBytes())) 8 .header("Content-Type", "application/json") 9 .header("header1", "header1-value") 10 .header("header2", "header2-value") 11 .build(); 12 13final HttpResponse<String> response = client.send(request, BodyHandlers.ofString(StandardCharsets.UTF_8));
2xx3xx4xx5xx responses:
1final String responseBody = response.body(); 2final int responseStatusCode = response.statusCode(); 3final boolean isResponse2xx = responseStatusCode >= 200 && responseStatusCode < 300; 4final boolean isResponse3xx = responseStatusCode >= 300 && responseStatusCode < 400; 5final boolean isResponse4xx = responseStatusCode >= 400 && responseStatusCode < 500; 6final boolean isResponse5xx = responseStatusCode >= 500 && responseStatusCode < 600; 7final Map<String, List<String>> responseHeaders = response.headers().map();

2.2 Spring RestTemplate

以下係 Spring 一直以黎都有既 RestTemplate API。自從 Spring 5,呢個 class 已經係喺 maintenance mode,Spring 建議我地轉用 WebClient
構建 client object:
1final RestTemplate client = new RestTemplateBuilder() 2 .setConnectTimeout(Duration.ofSeconds(10)) 3 .basicAuthentication("username", "password") 4 .defaultHeader("header1", "header1-value") 5 .defaultHeader("header2", "header2-value") 6 .build(); 7 8// 如果加入左 jackson-dataformat-xml 9// 令 RestTemplate 唔好喺 Accept request header 度加入 application/xml 或相關既 values 10client.getMessageConverters().removeIf(e -> e.getClass()==MappingJackson2XmlHttpMessageConverter.class);
發送 request:
1final String requestBody = "{ \"value\": \"123\" }"; 2final HttpHeaders requestHeaders = new HttpHeaders(); 3requestHeaders.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); 4requestHeaders.set("header1", "header1-value"); 5requestHeaders.set("header2", "header2-value"); 6 7try { 8 final ResponseEntity<String> response = client.exchange( 9 "http://localhost:8081/api/order/{orderId}?param1={param1}&param2={param2}", 10 HttpMethod.POST, 11 new HttpEntity<>(requestBody, requestHeaders), 12 String.class, 13 "123", 14 "param1-value", 15 "param2-value"); 16} catch (RestClientResponseException e) { 17 // 4xx 或 5xx responses 18}
2xx3xx responses:
final String responseBody = response.getBody(); final int responseStatusCode = response.getStatusCode().value(); final boolean isResponse2xx = response.getStatusCode().is2xxSuccessful(); final boolean isResponse3xx = response.getStatusCode().is3xxRedirection(); final Map<String, List<String>> responseHeaders = response.getHeaders();
4xx5xx responses:
1} catch (RestClientResponseException e) { 2 final String responseBody = e.getResponseBodyAsString(); 3 final int responseStatusCode = e.getStatusCode().value(); 4 final boolean isResponse4xx = e.getStatusCode().is4xxClientError(); 5 final boolean isResponse5xx = e.getStatusCode().is5xxServerError(); 6 final Map<String, List<String>> responseHeaders = e.getResponseHeaders(); 7}
注意:
  • 某啲 classpath libraries(Maven dependencies)會影響到 Spring RestTemplate 幫我地 send HTTP calls 既時候加入既 Accept request header。
    • 創建 Spring RestTemplate 既時候,佢會因為喺 classpath 存在既 classes 而去推斷 Accept request header。
    • 例如 classpath 度有 jackson-dataformat-xml 既話,Spring RestTemplate 就會用左 Accept: text/plain, application/xml, text/xml, application/json, application/*+xml, application/*+json, */* 既 request header 去 send HTTP calls。
    • 如果呢啲 libraries 係必需,咁我地可以透過刪除 Spring RestTemplate object 裡面既 message converters 去改變佢既 behavior。
詳情可以睇返呢篇:Spring JSON 變 XML response 問題

2.3 Spring Webflux WebClient

以下係黎自 Spring Webflux 既 WebClient API。佢支援 synchronous(blocking)以及 asynchronous(non-blocking)既 HTTP calls。
Spring Boot servlet WebMVC 既項目只要加入 Spring Webflux 既 library 就用得到呢個 client。
構建 client object:
1final HttpClient httpClient = HttpClient.newBuilder() 2 .version(Version.HTTP_1_1) 3 .connectTimeout(Duration.ofSeconds(10)) 4 .build(); 5 6final WebClient client = WebClient.builder() 7 .clientConnector(new JdkClientHttpConnector(httpClient)) 8 .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) 9 .defaultHeader("header1", "header1-value") 10 .defaultHeader("header2", "header2-value") 11 .build();
發送 request:
1final String requestBody = "{ \"value\": \"123\" }"; 2 3try { 4 final ResponseEntity<String> response = client.post() 5 .uri("http://localhost:8081/api/order/{orderId}?param1={param1}&param2={param2}", "123", "param1-value", "param2-value") 6 .headers(headers -> headers.setBasicAuth("username", "password")) 7 .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) 8 .header("header1", "header1-value") 9 .header("header2", "header2-value") 10 .bodyValue(requestBody) 11 .retrieve() 12 .toEntity(String.class) 13 .block(); 14} catch (WebClientResponseException e) { 15 // 4xx 或 5xx responses 16}
2xx3xx responses:
final String responseBody = response.getBody(); final int responseStatusCode = response.getStatusCode().value(); final boolean isResponse2xx = response.getStatusCode().is2xxSuccessful(); final boolean isResponse3xx = response.getStatusCode().is3xxRedirection(); final Map<String, List<String>> responseHeaders = response.getHeaders();
4xx5xx responses:
1} catch (WebClientResponseException e) { 2 final String responseBody = e.getResponseBodyAsString(); 3 final int responseStatusCode = e.getStatusCode().value(); 4 final boolean isResponse4xx = e.getStatusCode().is4xxClientError(); 5 final boolean isResponse5xx = e.getStatusCode().is5xxServerError(); 6 final Map<String, List<String>> responseHeaders = e.getHeaders(); 7}

2.4 Spring 6.1 RestClient

以下係 Spring 6.1 新加入既 RestClient API。佢支援 synchronous(blocking)既 HTTP calls。
用呢個 client 既話,就唔需要再喺 Spring Boot servlet WebMVC 既項目加入 Spring Webflux 既 library。
構建 client object:
final RestClient client = RestClient.builder() .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .defaultHeader("header1", "header1-value") .defaultHeader("header2", "header2-value") .build();
發送 request:
1final String requestBody = "{ \"value\": \"123\" }"; 2 3try { 4 final ResponseEntity<String> response = client.post() 5 .uri("http://localhost:8081/api/order/{orderId}?param1={param1}&param2={param2}", "123", "param1-value", "param2-value") 6 .headers(headers -> headers.setBasicAuth("username", "password")) 7 .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) 8 .header("header1", "header1-value") 9 .header("header2", "header2-value") 10 .body(requestBody) 11 .retrieve() 12 .toEntity(String.class); 13} catch (RestClientResponseException e) { 14 // 4xx 或 5xx responses 15}
2xx3xx responses:
final String responseBody = response.getBody(); final int responseStatusCode = response.getStatusCode().value(); final boolean isResponse2xx = response.getStatusCode().is2xxSuccessful(); final boolean isResponse3xx = response.getStatusCode().is3xxRedirection(); final Map<String, List<String>> responseHeaders = response.getHeaders();
4xx5xx responses:
1} catch (RestClientResponseException e) { 2 final String responseBody = e.getResponseBodyAsString(); 3 final int responseStatusCode = e.getStatusCode().value(); 4 final boolean isResponse4xx = e.getStatusCode().is4xxClientError(); 5 final boolean isResponse5xx = e.getStatusCode().is5xxServerError(); 6 final Map<String, List<String>> responseHeaders = e.getResponseHeaders(); 7}

2.5 Spring Cloud OpenFeign

Spring Cloud OpenFeign 將開源既 Feign library(本來係 Netflix,但之後開源成為 OpenFeign)整合到 Spring Cloud framework 裡面。
用 Spring Cloud OpenFeign 既好處係佢係 declarative,基本上將需要用到 upstream apps 既 declarative request mappings 搬入黎我地既 downstream app project 就得。
構建 client object:
1@FeignClient( 2 name = "fooClient", 3 configuration = FooFeignConfig.class 4) 5public interface FooFeignClient { 6 7 // 注意: 8 // consumes ➜ Content-Type request header 9 // produces ➜ Accept request header 10 11 @PostMapping(path = "/api/order/{orderId}", consumes = MediaType.APPLICATION_JSON_VALUE) 12 ResponseEntity<String> api(@PathVariable("orderId") String orderId, 13 @RequestParam(name = "param1", required = false) String param1, 14 @RequestParam(name = "param2", required = false) String param2, 15 @RequestBody String body, 16 @RequestHeader("header1") String header1, 17 @RequestHeader("header2") String header2); 18}
Basic auth Spring configuration:
1// 如果加 @Configuration,呢個 config 就會係所有 Feign clients 既默認 config 2public class FooFeignConfig { 3 4 @Bean 5 public BasicAuthRequestInterceptor fooBasicAuthInterceptor() { 6 return new BasicAuthRequestInterceptor("username", "password"); 7 } 8}
Application 配置:
1spring.cloud.openfeign.client.config: 2 default: 3 connectTimeout: 10000 4 loggerLevel: FULL 5 fooClient: 6 connectTimeout: 10000 7 loggerLevel: FULL 8 url: http://localhost:8081 9 defaultRequestHeaders: 10 header1: header1-value 11 header2: header2-value 12 13logging: 14 level: 15 my.package.FooFeignClient: DEBUG
發送 request:
1@Autowired 2FooFeignClient client; 3 4public void sampleCall() { 5 final String requestBody = "{ \"value\": \"123\" }"; 6 7 try { 8 final ResponseEntity<String> response = 9 client.api("123", "param1-value", "param2-value", requestBody, "header1-value", "header2-value"); 10 } catch (FeignException e) { 11 // 3xx、4xx 或 5xx responses 12 } 13}
2xx responses:
final String responseBody = response.getBody(); final int responseStatusCode = response.getStatusCode().value(); final boolean isResponse2xx = response.getStatusCode().is2xxSuccessful(); final Map<String, List<String>> responseHeaders = response.getHeaders();
3xx4xx5xx responses:
1} catch (FeignException e) { 2 final String responseBody = e.contentUTF8(); 3 final int responseStatusCode = e.status(); 4 final boolean isResponse3xx = e.status() >= 300 && e.status() < 400; 5 final boolean isResponse4xx = e.status() >= 400 && e.status() < 500; 6 final boolean isResponse5xx = e.status() >= 500 && e.status() < 600; 7 final Map<String, Collection<String>> responseHeaders = e.responseHeaders(); 8}
注意:
  • 需要喺某個 Spring config class 度加上 @EnableFeignClients,否則唔會生成 Feign client objects。
  • Feign clients 裡面既 request mapping methods 既 API contract 應該係要 match 返 target upstream app 既 request mapping methods。
    • consumesContent-Type
    • producesAccept
    • ❌ 唔好以為 produces 指既係呢個 app 所產生既 request body 既 Content-Type,咁樣係錯既。
  • 如果唔畀 consumes attribute,佢會根據 method signature 自動推斷 Content-Type request header 既 value。
  • 就算 Feign client method 既 return type 係 ResponseEntity,當 response status code 唔係 2xx 既時候,佢會照 throw exception。
    • ❌ 唔好諗住 return ResponseEntity 就可以拎到 response status code 然後 apply 自己既 logic,呢個係做唔到既。
  • ⚠️ 某啲 Spring Cloud OpenFeign 4.x 版本有個 bug,當我地喺 @FeignClient 定義左 path attribute(以免喺 request mapping annotations 度重複 common path prefix),而 target upstream app 既 URL 係喺 application 配置檔度定義,佢就會出 exception target values must be absolute.,呢個 bug 應該會喺後期版本修復。
參考資料:

3 參考資料