Spring REST API客户端演进:从RestTemplate到RestClient

本文深入探讨Spring框架中REST API客户端的演进历程,详细对比传统RestTemplate与现代RestClient的实现方式,涵盖配置、认证、异常处理等核心技术要点,并提供完整的代码示例和迁移指南。

Spring REST API客户端演进:从RestTemplate到RestClient

正如人类总是偏好共存和交流想法,从同伴那里寻求和提供建议一样,如今的应用程序也处于相同的情境中,它们需要交换数据以协作并实现其目的。

在很高的层面上,应用程序的交互要么是通过对话方式(REST API的情况)进行,即通过询问和响应同步交换信息;要么是通过异步通知(事件驱动API的情况)进行,即数据由生产者发送,并在消费者准备就绪时被消费。

本文分析了客户端和服务器通过REST进行的同步通信,重点放在客户端部分。其主要目的是展示如何首先使用RestTemplate,然后使用较新的RestClient来实现Spring REST API客户端,并无缝完成相同的交互。

简要历史

RestTemplate是在Spring Framework 3.0版本中引入的,根据API参考,它是一个“执行HTTP请求的同步客户端,在底层HTTP客户端库之上暴露了一个简单的模板方法API”。它灵活且高度可配置,长期以来是在Spring应用程序中实现功能齐全的同步但阻塞的HTTP客户端时的最佳选择。随着时间的推移,它缺乏非阻塞能力、使用过时的模板模式以及相当繁琐的API,极大地促成了新的、更现代的HTTP客户端库的出现,该库也可以处理非阻塞和异步调用。

Spring Framework 5.0版本引入了WebClient,这是一个“在底层HTTP客户端库之上的流畅、响应式API”。它专为WebFlux栈设计,通过遵循现代的函数式API风格,对开发人员来说更加清晰易用。然而,对于阻塞场景,WebClient的易用性优势伴随着额外的成本——需要在项目中添加额外的库依赖。

从Spring Framework 6.1版本和Spring Boot 3.2版本开始,一个新的组件可用——RestClient——它“为同步HTTP访问提供了更现代的API”。

演进相当显著,如今的开发人员可以根据应用程序的需求和特点,在这三种选项(RestTemplate、WebClient和RestClient)中进行选择。

实现

如上所述,本文中的概念验证同时试验了RestTemplate和RestClient,而将WebClient排除在外,因为这里的通信是对话式的,即同步的。

涉及两个简单的参与者,两个应用程序:

  • figure-service – 暴露REST API并允许管理Figure的服务器
  • figure-client – 消费REST API并实际管理Figure的客户端

两者都是自定义的,使用Java 21、Spring Boot 3.5.3版本和Maven 3.9.9版本。

Figure是一个通用实体,可以表示虚构角色、超级英雄或乐高迷你人仔等。

服务器

figure-service是一个小型服务,允许对表示figure的简单实体执行常见的CRUD操作。

由于本文的重点在客户端,因此仅突出服务器特性。实现是按照常见最佳实践以标准、直接的方式完成的。

该服务暴露了一个用于管理figure的REST API:

  • 读取所有 – GET /api/v1/figures
  • 读取一个 – GET /api/v1/figures/{id}
  • 读取随机一个 – GET /api/v1/figures/random
  • 创建一个 – POST /api/v1/figures
  • 更新一个 – PUT /api/v1/figures/{id}
  • 删除一个 – DELETE /api/v1/figures/{id}

操作至少通过API密钥进行最低限度的安全保护,该密钥应作为请求头提供:

1
"x-api-key": the api key

Figure实体存储在一个内存中的H2数据库中,由一个唯一的标识符、一个名称和一个代码描述,并建模如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Entity
@Table(name = "figures")
public class Figure {

    @Id
    @GeneratedValue
    private Long id;

    @Column(name = "name", unique = true, nullable = false)
    private String name;

    @Column(name = "code", nullable = false)
    private String code;
    
    ...
}

虽然id和name对外部世界可见,但code被视为业务领域信息并保持私有。因此,使用的DTO如下所示:

1
2
3
public record FigureRequest(String name) {}

public record FigureResponse(long id, String name) {}

所有服务器异常都在单个ResponseEntityExceptionHandler中通用处理,并以以下形式发送回客户端,并带有相应的HTTP状态:

1
2
3
4
5
6
{
  "title": "Bad Request",
  "status": 400,
  "detail": "Figure not found.",
  "instance": "/api/v1/figures/100"
}
1
public record ErrorResponse(String title, int status, String detail, String instance) {}

基本上,在此服务实现中,客户端要么收到预期的响应(如果有),要么在出现服务错误时收到一个突出显示错误的响应(在第4点详细说明)。

客户端

假设figure-client是一个在其业务操作中使用Figure实体的应用程序。由于这些实体由figure-service管理和暴露,客户端需要通过REST与服务器通信,并且还要遵守服务提供者的契约和要求。

在这个方向上,在实际实现之前需要进行一些考虑。

契约

由于同步通信首先使用RestTemplate实现,然后修改为使用RestClient,因此客户端操作在下面的接口中概述。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public interface FigureClient {

    List<Figure> allFigures();

    Optional<Figure> oneFigure(long id);

    Figure createFigure(FigureRequest figure);

    Figure updateFigure(long id, FigureRequest figureRequest);

    void deleteFigure(long id);

    Figure randomFigure();
}

通过这种方式,实现更改被隔离,不会影响应用程序的其他部分。

认证

由于服务器访问是安全的,因此需要有效的API密钥。一旦可用,它将被存储为环境变量,并通过application.properties在ClientHttpRequestInterceptor中使用。根据API参考,这样的组件定义了拦截客户端HTTP请求的契约,并允许实现者修改传出请求和/或传入响应。

对于这个用例,所有请求都被拦截,配置的API密钥被设置为x-api-key头,然后继续执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@Component
public class AuthInterceptor implements ClientHttpRequestInterceptor {

    private final String apiKey;

    public AuthInterceptor(@Value("${figure.service.api.key}") String apiKey) {
        this.apiKey = apiKey;
    }

    @Override
    public ClientHttpResponse intercept(HttpRequest request,
                                        byte[] body,
                                        ClientHttpRequestExecution execution) throws IOException {
        request.getHeaders()
                .add("x-api-key", apiKey);
        return execution.execute(request, body);
    }
}

AuthInterceptor在RestTemplate配置中使用。

数据传输对象(DTO)

特别在这个POC中,由于Figure实体在描述它们的属性方面很简单,因此RestTemplate在感兴趣的操作中使用的DTO也很简单。

1
2
3
public record FigureRequest(String name) {}

public record Figure(long id, String name) {}

由于一旦读取,Figure对象可能会被进一步使用,因此它们的名称被简化了,尽管它们表示响应DTO。

异常处理

RestTemplate(然后是RestClient)允许在其配置期间设置ResponseErrorHandler实现,这是一个策略接口,用于确定特定响应是否有错误,并允许自定义处理。

在这个POC中,由于figure-service以相同的形式发送所有错误,因此采用通用处理方式非常方便和容易。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Component
public class CustomResponseErrorHandler implements ResponseErrorHandler {

    private static final Logger log = LoggerFactory.getLogger(CustomResponseErrorHandler.class);

    private final ObjectMapper objectMapper;

    public CustomResponseErrorHandler() {
        objectMapper = new ObjectMapper();
    }

    @Override
    public boolean hasError(ClientHttpResponse response) throws IOException {
        return response.getStatusCode().isError();
    }

    @Override
    public void handleError(URI url, HttpMethod method,
                            ClientHttpResponse response) throws IOException {
        HttpStatusCode statusCode = response.getStatusCode();
        String body = new String(response.getBody().readAllBytes());

        if (statusCode.is4xxClientError()) {
            throw new CustomException("Client error.", statusCode, body);
        }

        String message = null;
        try {
            message = objectMapper.readValue(body, ErrorResponse.class).detail();
        } catch (JsonProcessingException e) {
            log.error("Failed to parse response body: {}", e.getMessage(), e);
        }

        throw new CustomException(message, statusCode, body);
    }

    @JsonIgnoreProperties(ignoreUnknown = true)
    private record ErrorResponse(String detail) {}
}

这里的逻辑如下:

  • 客户端和服务器错误都被考虑和处理——参见hasError()方法。
  • 所有错误都会导致一个自定义的RuntimeException,装饰有HTTP状态码和详细信息,默认分别是通用的Internal Server Error和原始响应体。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class CustomException extends RuntimeException {

    private final HttpStatusCode statusCode;
    private final String detail;

    public CustomException(String message) {
        super(message);
        this.statusCode = HttpStatusCode.valueOf(HttpStatus.INTERNAL_SERVER_ERROR.value());
        this.detail = null;
    }

    public CustomException(String message, HttpStatusCode statusCode, String detail) {
        super(message);
        this.statusCode = statusCode;
        this.detail = detail;
    }

    public HttpStatusCode getStatusCode() {
        return statusCode;
    }

    public String getDetail() {
        return detail;
    }
}

对于可恢复的错误,FigureClient中声明的所有方法都抛出CustomExceptions,从而提供了一个简单的异常处理机制。

  • 首先尝试提取figure-service在响应体中提供的详细信息,如果可能,将其包含在CustomException中,否则将按原样设置响应体。

有用的功能

虽然不是必需的,尤其是在开发期间,但不仅仅如此,能够在客户端应用程序的日志中查看交换的请求和响应被证明非常有用。为了实现这一点,在RestTemplate配置中添加了一个LoggingInterceptor。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Component
public class LoggingInterceptor implements ClientHttpRequestInterceptor {

    private static final Logger log = LoggerFactory.getLogger(LoggingInterceptor.class);

    @Override
    public ClientHttpResponse intercept(HttpRequest request,
                                        byte[] body,
                                        ClientHttpRequestExecution execution) throws IOException {
        logRequest(body);

        ClientHttpResponse response = execution.execute(request, body);

        logResponse(response);
        return response;
    }

    private void logRequest(byte[] body) {
        var bodyContent = new String(body);
        log.debug("Request body : {}", bodyContent);
    }

    private void logResponse(ClientHttpResponse response) throws IOException {
        var bodyContent = StreamUtils.copyToString(response.getBody(), Charset.defaultCharset());
        log.debug("Response body: {}", bodyContent);
    }
}

这里只记录了请求和响应体,尽管其他项(如头信息、响应状态等)也可能令人感兴趣。虽然有用,但有一个值得解释的陷阱需要考虑。

可以看出,当在上面的拦截器中记录响应时,它基本上被读取并且流被“消耗”,这导致客户端最终得到一个空体。为了防止这种情况,应该使用BufferingClientHttpRequestFactory组件,该组件允许将流内容缓冲到内存中,从而能够读取响应两次。现在响应可用性问题得到了解决,但是当响应体大小很大时,将整个响应体缓冲到内存中可能不是一个好主意。在盲目地开箱即用使用它之前,开发人员应该分析可能的性能影响并进行调整,特别是对于每个应用程序。

配置

在澄清了figure-service的契约和要求之后,更重要的是,在已经实现了某些“部分”之后,现在可以配置RestTemplate。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Bean
public RestOperations restTemplate(LoggingInterceptor loggingInterceptor,
                                   AuthInterceptor authInterceptor,
                                   CustomResponseErrorHandler customResponseErrorHandler) {
                                    
    RestTemplateCustomizer customizer = restTemplate -> restTemplate.getInterceptors()
            .addAll(List.of(loggingInterceptor, authInterceptor));

    return new RestTemplateBuilder(customizer)
            .requestFactory(() -> new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory()))
            .errorHandler(customResponseErrorHandler)
            .build();
}

使用RestTemplateBuilder,通过RestTemplateCustomizer添加LoggingInterceptor、AuthInterceptor,而错误处理程序设置为CustomResponseErrorHandler实例。

RestTemplate实现

一旦构建了RestTemplate实例,就可以将其注入到实际的FigureClient实现中,并用于与figure-service通信。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
@Service
public class FigureRestTemplateClient implements FigureClient {

    private final String url;
    private final RestOperations restOperations;

    public FigureRestTemplateClient(@Value("${figure.service.url}") String url,
                                    RestOperations restOperations) {
        this.url = url;
        this.restOperations = restOperations;
    }

    @Override
    public List<Figure> allFigures() {
        ResponseEntity<Figure[]> response = restOperations.exchange(url,
                HttpMethod.GET, null, Figure[].class);

        Figure[] figures = response.getBody();
        if (figures == null) {
            throw new CustomException("Could not get the figures.");
        }
        return List.of(figures);
    }

    @Override
    public Optional<Figure> oneFigure(long id) {
        ResponseEntity<Figure> response = restOperations.exchange(url + "/{id}",
                HttpMethod.GET, null, Figure.class, id);

        Figure figure = response.getBody();
        if (figure == null) {
            return Optional.empty();
        }
        return Optional.of(figure);
    }

    @Override
    public Figure createFigure(FigureRequest figureRequest) {
        HttpEntity<FigureRequest> request = new HttpEntity<>(figureRequest);
        ResponseEntity<Figure> response = restOperations.exchange(url,
                HttpMethod.POST, request, Figure.class);

        Figure figure = response.getBody();
        if (figure == null) {
            throw new CustomException("Could not create figure.");
        }
        return figure;
    }

    @Override
    public Figure updateFigure(long id, FigureRequest figureRequest) {
        HttpEntity<FigureRequest> request = new HttpEntity<>(figureRequest);
        ResponseEntity<Figure> response = restOperations.exchange(url + "/{id}",
                HttpMethod.PUT, request, Figure.class, id);

        Figure figure = response.getBody();
        if (figure == null) {
            throw new CustomException("Could not update figure.");
        }
        return figure;
    }

    @Override
    public void deleteFigure(long id) {
        restOperations.exchange(url + "/{id}",
                HttpMethod.DELETE, null, Void.class, id);
    }

    @Override
    public Figure randomFigure() {
        ResponseEntity<Figure> response = restOperations.exchange(url + "/random",
                HttpMethod.GET, null, Figure.class);

        Figure figure = response.getBody();
        if (figure == null) {
            throw new CustomException("Could not get a random figure.");
        }
        return figure;
    }
}

为了端到端地观察此解决方案的工作原理,首先启动figure-service。在那里配置了一个CommandLineRunner,以便将一些Figure实体持久化到数据库中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Bean
public CommandLineRunner initDatabase(FigureService figureService) {
    return args -> {
        log.info("Loading data...");
        figureService.create(new Figure("Lloyd"));
        figureService.create(new Figure("Jay"));
        figureService.create(new Figure("Kay"));
        figureService.create(new Figure("Cole"));
        figureService.create(new Figure("Zane"));

        log.info("Available figures:");
        figureService.findAll()
                .forEach(figure -> log.info("{}", figure));
    };
}

然后,作为figure-client应用程序的一部分,将FigureRestTemplateClient实例注入到以下集成测试中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
@SpringBootTest
class FigureClientTest {

    @Autowired
    private FigureRestTemplateClient figureClient;

    @Test
    void allFigures() {
        List<Figure> figures = figureClient.allFigures();
        Assertions.assertFalse(figures.isEmpty());
    }

    @Test
    void oneFigure() {
        long id = figureClient.allFigures().stream()
                .findFirst()
                .orElseThrow(() -> new RuntimeException("No figures found"))
                .id();

        Optional<Figure> figure = figureClient.oneFigure(id);
        Assertions.assertTrue(figure.isPresent());
    }

    @Test
    void createFigure() {
        var request  = new FigureRequest( "Fig " + UUID.randomUUID());

        Figure figure = figureClient.createFigure(request);
        Assertions.assertNotNull(figure);
        Assertions.assertTrue(figure.id() > 0L);
        Assertions.assertEquals(request.name(), figure.name());

        CustomException ex = Assertions.assertThrows(CustomException.class,
                () -> figureClient.createFigure(request));
        Assertions.assertEquals("A Figure with the same 'name' already exists.", ex.getMessage());
        Assertions.assertEquals(HttpStatus.BAD_REQUEST.value(), ex.getStatusCode().value());
        Assertions.assertEquals("""
                {"title":"Bad Request","status":400,"detail":"A Figure with the same 'name' already exists.","instance":"/api/v1/figures"}""", ex.getDetail());
    }

    @Test
    void updateFigure() {
        List<Figure> figures = figureClient.allFigures();
        long id = figures.stream()
                .findFirst()
                .orElseThrow(() -> new RuntimeException("No figures found"))
                .id();

        var updatedRequest = new FigureRequest("Updated Fig " + UUID.randomUUID());
        Figure updatedFigure = figureClient.updateFigure(id, updatedRequest);
        Assertions.assertNotNull(updatedFigure);
        Assertions.assertEquals(id, updatedFigure.id());
        Assertions.assertEquals(updatedRequest.name(), updatedFigure.name());

        Figure otherExistingFigure = figures.stream()
                .filter(f -> f.id() != id)
                .findFirst()
                .orElseThrow(() -> new RuntimeException("Not enough figures"));

        var updateExistingRequest = new FigureRequest(otherExistingFigure.name());
        CustomException ex = Assertions.assertThrows(CustomException.class,
                () -> figureClient.updateFigure(id, updateExistingRequest));
        Assertions.assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), ex.getStatusCode().value());
    }

    @Test
    void deleteFigure() {
        long id = figureClient.allFigures().stream()
                .findFirst()
                .orElseThrow(() -> new RuntimeException("No figures found"))
                .id();

        figureClient.deleteFigure(id);

        CustomException ex = Assertions.assertThrows(CustomException.class,
                () -> figureClient.deleteFigure(id));
        Assertions.assertEquals(HttpStatus.BAD_REQUEST.value(), ex.getStatusCode().value());
        Assertions.assertEquals("Figure not found.", ex.getMessage());
    }

    @Test
    void randomFigure() {
        CustomException ex = Assertions.assertThrows(CustomException.class,
                () -> figureClient.randomFigure());
        Assertions.assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), ex.getStatusCode().value());
        Assertions.assertEquals("Not implemented yet.", ex.getMessage());
    }
}

当运行例如上面的createFigure()测试时,RestTemplate和LoggingInterceptor有助于清晰地描述正在发生的事情,并将其显示在客户端日志中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[main] DEBUG RestTemplate#HTTP POST http://localhost:8082/api/v1/figures
[main] DEBUG InternalLoggerFactory#Using SLF4J as the default logging framework
[main] DEBUG RestTemplate#Accept=[application/json, application/*+json]
[main] DEBUG RestTemplate#Writing [FigureRequest[name=Fig 6aa854a5-ba7a-4bbf-8160-70adf7d3e59b]] with org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
[main] DEBUG LoggingInterceptor#Request body : {"name":"Fig 6aa854a5-ba7a-4bbf-8160-70adf7d3e59b"}
[main] DEBUG LoggingInterceptor#Response body: {"id":8,"name":"Fig 6aa854a5-ba7a-4bbf-8160-70adf7d3e59b"}
[main] DEBUG RestTemplate#Response 201 CREATED
[main] DEBUG RestTemplate#Reading to [com.hcd.figureclient.service.dto.Figure]
[main] DEBUG RestTemplate#HTTP POST http://localhost:8082/api/v1/figures
[main] DEBUG RestTemplate#Accept=[application/json, application/*+json]
[main] DEBUG RestTemplate#Writing [FigureRequest[name=Fig 6aa854a5-ba7a-4bbf-8160-70adf7d3e59b]] with org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
[main] DEBUG LoggingInterceptor#Request body : {"name":"Fig 6aa854a5-ba7a-4bbf-8160-70adf7d3e59b"}
[main] DEBUG LoggingInterceptor#Response body: {"title":"Bad Request","status":400,"detail":"A Figure with the same 'name' already exists.","instance":"/api/v1/figures"}
[main] DEBUG RestTemplate#Response 400 BAD_REQUEST

这样,使用RestTemplate的客户端实现就完成了。

RestClient实现

这里的目标,正如从一开始所述,是能够完成相同的工作,但不是使用RestTemplate,而是使用RestClient实例。

由于LoggingInterceptor、AuthInterceptor和CustomResponseErrorHandler可以被重用,因此它们没有更改,并且RestClient配置如下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Bean
public RestClient restClient(@Value("${figure.service.url}") String url,
                             LoggingInterceptor loggingInterceptor,
                             AuthInterceptor authInterceptor,
                             CustomResponseErrorHandler customResponseErrorHandler) {
    return RestClient.builder()
            .baseUrl(url)
            .requestFactory(new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory()))
            .requestInterceptor(loggingInterceptor)
            .requestInterceptor(authInterceptor)
            .defaultStatusHandler(customResponseErrorHandler)
            .build();
}

然后,将该实例注入到新的FigureClient实现中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
@Service
public class FigureRestClient implements FigureClient {

    private final RestClient restClient;

    public FigureRestClient(RestClient restClient) {
        this.restClient = restClient;
    }

    @Override
    public List<Figure> allFigures() {
        var figures = restClient.get()
                .retrieve()
                .body(Figure[].class);

        if (figures == null) {
            throw new CustomException("Could not get the figures.");
        }
        return List.of(figures);
    }

    @Override
    public Optional<Figure> oneFigure(long id) {
        var figure = restClient.get()
                .uri("/{id}", id)
                .retrieve()
                .body(Figure.class);

        return Optional.ofNullable(figure);
    }

    @Override
    public Figure createFigure(FigureRequest figureRequest) {
        var figure = restClient.post()
                .contentType(MediaType.APPLICATION_JSON)
                .body(figureRequest)
                .retrieve()
                .body(Figure.class);

        if (figure == null) {
            throw new CustomException("Could not create figure.");
        }
        return figure;
    }

    @Override
    public Figure updateFigure(long id, FigureRequest figureRequest) {
        var figure = restClient.put()
                .uri("/{id}", id)
                .contentType(MediaType.APPLICATION_JSON)
                .body(figureRequest)
                .retrieve()
                .body(Figure.class);

        if (figure == null) {
            throw new CustomException("Could not update figure.");
        }
        return figure;
    }

    @Override
    public void deleteFigure(long id) {
        restClient.delete()
                .uri("/{id}", id)
                .retrieve()
                .toBodilessEntity();
    }

    @Override
    public Figure randomFigure() {
        var figure = restClient.get()
                .uri("/random")
                .retrieve()
                .body(Figure.class);

        if (figure == null) {
            throw new CustomException("Could not get a random figure.");
        }
        return figure;
    }
}

除了这些,只剩下一个重要步骤:测试客户端-服务器集成。为了实现这一点,只需在之前的FigureClientTest中将FigureRestTemplateClient实例替换为上面的FigureRestClient即可。

1
2
3
4
5
6
7
8
@SpringBootTest
class FigureClientTest {

    @Autowired
    private FigureRestClient figureClient;
    
    ...
}

如果运行例如相同的createFigure()测试,客户端输出是相似的。显然,在日志记录方面,RestClient不如RestTemplate慷慨(或详细),但在自定义LoggingInterceptor中有改进的空间。

1
2
3
4
5
6
7
[main] DEBUG DefaultRestClient#Writing [FigureRequest[name=Fig 1155fd2c-91fe-486d-aaa3-35bf682629d4]] as "application/json" with org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
[main] DEBUG LoggingInterceptor#Request body : {"name":"Fig 1155fd2c-91fe-486d-aaa3-35bf682629d4"}
[main] DEBUG LoggingInterceptor#Response body: {"id":9,"name":"Fig 1155fd2c-91fe-486d-aaa3-35bf682629d4"}
[main] DEBUG DefaultRestClient#Reading to [com.hcd.figureclient.service.dto.Figure]
[main] DEBUG DefaultRestClient#Writing [FigureRequest[name=Fig 1155fd2c-91fe-486d-aaa3-35bf682629d4]] as "application/json" with org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
[main] DEBUG LoggingInterceptor#Request body : {"name":"Fig 1155fd2c-91fe-486d-aaa3-35bf682629d4"}
[main] DEBUG LoggingInterceptor#Response body: {"title":"Bad Request","status":400,"detail":"A Figure with the same 'name' already exists.","instance":"/api/v1/figures"}

就是这样,从RestTemplate到RestClient的迁移现在完成了。

结论

当涉及到新的同步API客户端实现时,我发现RestClient是最佳选择,主要是因为其函数式和流畅的API风格。

对于在Spring Framework 6.1版本(分别是Spring Boot 3.2)引入RestClient之前启动的旧项目,并且很可能仍在使用RestTemplate,我认为值得规划和进行迁移(更多细节见[资源4])。此外,重用现有组件(ClientHttpRequestInterceptors、ResponseErrorHandlers等)的可能性是这种迁移的另一个激励因素。

最终,作为最后的手段,甚至可以使用已配置的RestTemplate创建RestClient实例并从那里开始,尽管我认为这个解决方案相当复杂。

资源

  • RestTemplate Spring Framework API参考
  • WebClient Spring Framework API参考
  • RestClient Spring Framework API参考
  • 从RestTemplate迁移到RestClient
  • figure-service源代码
  • figure-client源代码
  • 图片摄于罗马尼亚布加勒斯特。
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计