一起學 Spring 之 RestTemplate

聞人的技術部落格發表於2019-05-11

一起學 Spring 之 RestTemplate

前言

在 Java 服務端開發領域裡,Spring 是繞不開的話題,尤其是現在微服務概念盛行,Spring Boot 的出現更是給 Spring 注入了新的活力,除此之外還有 Spring Cloud,這些框架讓 Spring 技術體系更加豐富。Spring 從 2014 年的 1.0.0 版本迭代到 現在的 5.2.0 M1 版本,緊隨著 Java 語言發展,不斷引入新的特性和功能。本文關注的是 Spring 框架中 RestTemplate 內容,可以減少我們平時開發常使用的 HttpClient API 依賴。文章所涉及的示例 Demo 詳見Github 地址:Resttemplate demo 。感謝小七同學的認真校對,如果有任何技術問題或者文章紕漏,歡迎留言聯絡,一起交流探討 ?。

認識 RestTemplate

首先在我們學習使用 RestTemplate 之前,先認識下這個類,來看 Spring 官方怎麼描述的。 從官方 API 文件 RestTemplate javadoc 可以找該類的描述如下:

Synchronous client to perform HTTP requests, exposing a simple, template method API over underlying HTTP client libraries such as the JDK HttpURLConnection, Apache HttpComponents, and others. The RestTemplate offers templates for common scenarios by HTTP method, in addition to the generalized exchange and execute methods that support of less frequent cases.

從這裡可以清楚地瞭解到 RestTemplate 採用同步方式執行 HTTP 請求的類,底層使用 JDK 原生 HttpURLConnection API ,或者 HttpComponents等其他 HTTP 客戶端請求類庫。還有一處強調的就是 RestTemplate 提供模板化的方法讓開發者能更簡單地傳送 HTTP 請求。

值得注意的是,RestTemplate 類是在 Spring Framework 3.0 開始引入的,這裡我們使用的 Spring 版本為當前最新的 GA 版本 5.1.6。而在 5.0 以上,官方標註了更推薦使用非阻塞的響應式 HTTP 請求處理類 org.springframework.web.reactive.client.WebClient 來替代 RestTemplate,尤其是對應非同步請求處理的場景上 。

這裡我們先簡單總結下什麼是 RestTemplateRestTemplate 就是 Spring 封裝的處理同步 HTTP 請求的類。具體如何使用這個類進行 HTTP 請求操作,可見文章的實戰部分。

接下來我們看下 RestTemplate 類提供的 API 有哪些,RestTemplate 提供了將近 30 個請求方法,其中多數是單個方法過載實現,這裡我主要參考官方文件 rest-client-access 進行如下分類:

方法名 描述
getForObject 通過 GET 請求獲得響應結果
getForEntity 通過 GET 請求獲取 ResponseEntity 物件,包容有狀態碼,響應頭和響應資料
headForHeaders 以 HEAD 請求資源返回所有響應頭資訊
postForLocation 用 POST 請求建立資源,並返回響應資料中響應頭的欄位 Location 的資料
postForObject 通過 PATCH 請求建立資源,獲得響應結果
put 通過 PUT 方式請求來建立或者更新資源
patchForObject 通過 PATH 方式請求來更新資源,並獲得響應結果。(JDK HttpURLConnection 不支援 PATH 方式請求,其他 HTTP 客戶端庫支援)
delete 通過 DELETE 方式刪除資源
optionsForAllow 通過 ALLOW 方式請求來獲得資源所允許訪問的所有 HTTP 方法,可用看某個請求支援哪些請求方式
exchange 更通用版本的請求處理方法,接受一個 RequestEntity 物件,可以設定路徑,請求頭,請求資訊等,最後返回一個 ResponseEntity 實體
execute 最通用的執行 HTTP 請求的方法,上面所有方法都是基於 execute 的封裝,全面控制請求資訊,並通過回撥介面獲得響應資料

看到那麼多方法也記不全,為了更好理解,可以簡單看下 RestTemplate 的類層級體系,通過官方原始碼就能看到:

/**
 * Interface specifying a basic set of RESTful operations.
 * Implemented by {@link RestTemplate}. Not often used directly, but a useful
 * option to enhance testability, as it can easily be mocked or stubbed.
 *
 * @author Arjen Poutsma
 * @author Juergen Hoeller
 * @since 3.0
 * @see RestTemplate
 */
public interface RestOperations {
					    ...
}
複製程式碼

其實 RestTemplate 類的請求方法都是來自 RestOperations 介面的,根據這個名字就可以大概知道這個介面主要就是提供了 RESTful 請求操作的介面,如 GET,POST,PUT,DELETE 等,具體資訊可以參見 RestOperation javadoc

RestTemplate 類層次圖

關於 RESTful:

來自Wikipedia 定義:表現層狀態轉換,一種設計提供全球資訊網路服務的軟體構建風格,又簡稱為 REST。

用 URL 定位資源,用 HTTP 動詞描述操作,如 GET,POST,DELETE,PUT,簡單來說通過 URL 就知道訪問什麼資源,通過 HTTP Method 就知道執行什麼操作,通過 HTTP Status Code 就知道執行結果。

實戰 RestTemplate

好了,簡單認識了 RestTemplate 類之後,我們先牛刀小試,看看如何上手使用。

1. 生成 Demo 專案,匯入 IDE

為了能快速搭建一個 Demo,我們這邊用 Spring Boot 框架搭建,首先用官方提供的 Spring Initializr 來生成快速構建專案骨架,選擇 Spring Boot 版本 2.1.4,其底層依賴的 Spring Framework 版本為最新發布版本 5.1.6,對於 POM 依賴只選擇一個 Web 模組即可,這樣便於快速搭建 Web 應用。

spring initializr

點選生成工程按鈕,就可以下載到專案的壓縮包,解壓後用自己常用的 IDE 匯入專案,專案結構整理如下:

專案骨架

專案中 ResttemplateApplication.java 為整個程式的引導類,用於啟動專案。

2. 編寫請求控制器類 ProductController

首先為了能夠使用 RestTemplate 傳送多種方式 HTTP 請求,先本地構建接受 HTTP 請求的產品控制器,新建包 com.one.learn.resttemplate.controller,新建產品 Controller ProductController , 程式碼如下:

@RequestMapping("/product")
@RestController
public class ProductController {

    @GetMapping("/get_product1")
    public Product get_product1() {
        return new Product(1, "ProductA", BigDecimal.valueOf(6666.0));
    }

    @GetMapping("/get_product2")
    public Product get_product2(Integer id) {
        return new Product(id, "ProductC", BigDecimal.valueOf(6666.0));
    }

    @GetMapping("/get_product3")
    public String get_product3(Product product) {
        return product.toString();
    }


    @PostMapping("/post_product1")
    public String post_product1(Product product) {
        return product.toString();
    }

    @PostMapping("/post_product2")
    public String post_product2(@RequestBody Product product) {
        return product.toString();
    }

    @DeleteMapping("/delete/{id}")
    public String delete(@PathVariable Integer id) {
        String result = String.format("編號為%s的產品刪除成功", id);
        System.out.println(result);
        return result;
    }

    @PutMapping("/update")
    public String updateByPut(Product product) {
        String result = product.toString() + " 更新成功";
        System.out.println(result);
        return result;
    }

    @PostMapping("/upload")
    public String upload(MultipartRequest request) {
 	       // Spring MVC 使用 MultipartRequest 接受帶檔案的 HTTP 請求
        MultipartFile file = request.getFile("file"); 
        String originalFilename = file.getOriginalFilename();
        return "upload success filename: " + originalFilename;
    }
}
複製程式碼

Product 控制器中涉及的實體類 Product 建立在 com.one.learn.resttemplate.bean 包下,程式碼如下:

public class Product {
    private Integer id;
    private String name;
    private BigDecimal price;
    
    public Product() {
    }
    
    public Product(Integer id, String name, BigDecimal price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

			    // 省去 setter getter 方法
	
    @Override
    public String toString() {
        return "Product{" +
                "id='" + id + '\'' +
                ", name='" + name + '\'' +
                ", price='" + price + '\'' +
                '}';
    }
}
複製程式碼

有了這些類,就可以利用程式引導類 ResttemplateApplication 啟動 Spring Boot 專案,一個簡單的 Web 應用就誕生了,監聽 8080 埠,結果如下圖所示:

專案啟動

我們可以簡單測試一下,開啟瀏覽器,訪問 http://localhost:8080/product/get_product1,會看到如圖所示的結果:

專案訪問

3. 編寫測試類用 RestTemplate 傳送 HTTP 請求

有了 Web 服務,接下來該使用 RestTemplate 來傳送請求並處理響應了。我們在 test 檔案下新建一個測試類 com.one.learn.resttemplate.RestTemplateTests,程式碼如下:

public class RestTemplateTests {
    RestTemplate restTemplate = null;

    @Before
    public void setup() {
        restTemplate = new RestTemplate();
    }
    
}
複製程式碼

這裡我們通過編寫測試方法來用 RestTemplate API 實現對 Product 控制器各個介面的請求。

GET 請求

我們先最簡單的下手,嘗試用 RestTemplate 訪問請求路徑為 product/get_product1, 一個不帶任何引數 的 GET 請求,程式碼如下:

@Test
public void testGet_product1() {
   String url = "http://localhost:8080/product/get_product1";
   //方式一:GET 方式獲取 JSON 串資料
   String result = restTemplate.getForObject(url, String.class);
   System.out.println("get_product1返回結果:" + result);
   Assert.hasText(result, "get_product1返回結果為空");
	
	   //方式二:GET 方式獲取 JSON 資料對映後的 Product 實體物件
   Product product = restTemplate.getForObject(url, Product.class);
   System.out.println("get_product1返回結果:" + product);
   Assert.notNull(product, "get_product1返回結果為空");
	
	   //方式三:GET 方式獲取包含 Product 實體物件 的響應實體 ResponseEntity 物件,用 getBody() 獲取
   ResponseEntity<Product> responseEntity = restTemplate.getForEntity(url, Product.class);
   System.out.println("get_product1返回結果:" + responseEntity);
   Assert.isTrue(responseEntity.getStatusCode().equals(HttpStatus.OK), "get_product1響應不成功");
   
}
複製程式碼

首先看下執行測試方法 testGet_product1 後控制檯的輸出日誌:

...
get_product1返回結果:{"id":1,"name":"ProductA","price":6666.0}
...
get_product1返回結果:Product{id='1', name='ProductA', price='6666.0'}
...
get_product1返回結果:<200,Product{id='1', name='ProductA', price='6666.0'},[Content-Type:"application/json;charset=UTF-8", Transfer-Encoding:"chunked", Date:"Thu, 09 May 2019 15:37:25 GMT"]>
...
複製程式碼

可以看到 testGet_product1 請求都是成功響應並獲取到了資料,從上面程式碼上看是不是很簡單。現在來點略複雜的請求方式,使用 RestTemplate API 中 exchangeexecute 方法傳送 GET 請求,可以更加細粒度控制請求的行為,如 Header 資訊,資料處理方式等,同樣在 testGet_product1 方法裡新增程式碼如下:

@Test
public void testGet_product1() {
    String url = "http://localhost:8080/product/get_product1";
    //....
    
    //方式一: 構建請求實體 HttpEntity 物件,用於配置 Header 資訊和請求引數
    MultiValueMap header = new LinkedMultiValueMap();
    header.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
    HttpEntity<Object> requestEntity = new HttpEntity<>(header);
    //方式二: 執行請求獲取包含 Product 實體物件 的響應實體 ResponseEntity 物件,用 getBody() 獲取
    ResponseEntity<Product> exchangeResult = restTemplate.exchange(url, HttpMethod.GET, requestEntity, Product.class);
    System.out.println("get_product1返回結果:" + exchangeResult);
    Assert.isTrue(exchangeResult.getStatusCode().equals(HttpStatus.OK), "get_product1響應不成功");

    	//方式三: 根據 RequestCallback 介面實現類設定Header資訊,用 ResponseExtractor 介面實現類讀取響應資料
    String executeResult = restTemplate.execute(url, HttpMethod.GET, request -> {
        request.getHeaders().add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
    }, (clientHttpResponse) -> {
        InputStream body = clientHttpResponse.getBody();
        byte[] bytes = new byte[body.available()];
        body.read(bytes);
        return new String(bytes);
    }); // 備註:這裡使用了 Java8 特性:Lambda 表示式語法,若未接觸 Lambda 表示式後可以使用匿名內部類代替實現
    System.out.println("get_product1返回結果:" + executeResult);
    Assert.hasText(executeResult, "get_product1返回結果為空");
}
複製程式碼

同樣再執行測試方法 testGet_product1 後控制檯的輸出日誌:

...
get_product1返回結果:<200,Product{id='1', name='ProductA', price='6666.0'},[Content-Type:"application/json;charset=UTF-8", Transfer-Encoding:"chunked", Date:"Thu, 09 May 2019 16:00:22 GMT"]>
...
get_product1返回結果:{"id":1,"name":"ProductA","price":6666.0}
...
複製程式碼

結果也都是正常返回,說明執行的請求都是正確的。

現在來嘗試執行帶有引數的 GET 請求,同樣的方式編寫一個新的測試方法,實現程式碼如下:

@Test
public void testGet_product2() {
    String url = "http://localhost:8080/product/get_product2/id={id}";
	
	    //方式一:將引數的值存在可變長度引數裡,按照順序進行引數匹配
    ResponseEntity<Product> responseEntity = restTemplate.getForEntity(url, Product.class, 101);
    System.out.println(responseEntity);
    Assert.isTrue(responseEntity.getStatusCode().equals(HttpStatus.OK), "get_product2 請求不成功");
    Assert.notNull(responseEntity.getBody().getId(), "get_product2  傳遞引數不成功");

	    //方式二:將請求引數以鍵值對形式儲存到 Map 集合中,用於請求時URL上的拼接
    Map<String, Object> uriVariables = new HashMap<>();
    uriVariables.put("id", 101);
    Product result = restTemplate.getForObject(url, Product.class, uriVariables);
    System.out.println(result);
    Assert.notNull(result.getId(), "get_product2  傳遞引數不成功");
}
複製程式碼

正常執行結果如下:

...
<200,Product{id='101', name='ProductC', price='6666.0'},[Content-Type:"application/json;charset=UTF-8", Transfer-Encoding:"chunked", Date:"Fri, 10 May 2019 14:53:41 GMT"]>
...
Product{id='101', name='ProductC', price='6666.0'}
...
複製程式碼

POST 請求

瞭解完如何用 RestTemplate API 傳送 GET 請求後,再看下平時也很常見的 POST 請求如何使用。由於 POST 請求資料的內容型別 Content-Type 不同,傳送 POST 請求情況相對就多了,我們這裡以常用的 application/x-www-form-urlencodedapplication/json 這兩種內容型別為例子。

  • 傳送 Content-Typeapplication/x-www-form-urlencoded 的 POST 請求:

    @Test
    public void testPost_product1() {
        String url = "http://localhost:8080/product/post_product1";
    	Product product = new Product(201, "Macbook", BigDecimal.valueOf(10000));
    	  // 設定請求的 Content-Type 為 application/x-www-form-urlencoded
        MultiValueMap<String, String> header = new LinkedMultiValueMap();
        header.add(HttpHeaders.CONTENT_TYPE, (MediaType.APPLICATION_FORM_URLENCODED_VALUE));
        
        //方式二: 將請求引數值以 K=V 方式用 & 拼接,傳送請求使用
        String productStr = "id=" + product.getId() + "&name=" + product.getName() + "&price=" + product.getPrice();
        HttpEntity<String> request = new HttpEntity<>(productStr, header);
        ResponseEntity<String> exchangeResult = restTemplate.exchange(url, HttpMethod.POST, request, String.class);
        System.out.println("post_product1: " + exchangeResult);
        Assert.isTrue(exchangeResult.getStatusCode().equals(HttpStatus.OK), "post_product1 請求不成功");
    
        //方式一: 將請求引數以鍵值對形式儲存在 MultiValueMap 集合,傳送請求時使用
        MultiValueMap<String, Object> map = new LinkedMultiValueMap();
        map.add("id", (product.getId()));
        map.add("name", (product.getName()));
        map.add("price", (product.getPrice()));
        HttpEntity<MultiValueMap> request2 = new HttpEntity<>(map, header);
        ResponseEntity<String> exchangeResult2 = restTemplate.exchange(url, HttpMethod.POST, request2, String.class);
        System.out.println("post_product1: " + exchangeResult2);
        Assert.isTrue(exchangeResult.getStatusCode().equals(HttpStatus.OK), "post_product1 請求不成功");
    }
    複製程式碼

    對應的輸出日誌如下:

    ...
    post_product1: <200,Product{id='201', name='Macbook', price='10000'},[Content-Type:"text/plain;charset=UTF-8", Content-Length:"48", Date:"Fri, 10 May 2019 16:07:43 GMT"]>
    ...
    post_product1: <200,Product{id='201', name='Macbook', price='10000'},[Content-Type:"text/plain;charset=UTF-8", Content-Length:"48", Date:"Fri, 10 May 2019 16:07:43 GMT"]>
    複製程式碼
  • 傳送 Content-Typeapplication/json 的 POST 請求:

    @Test
    public void testPost_product2() {
        String url = "http://localhost:8080/product/post_product2";
        
       	// 設定請求的 Content-Type 為 application/json
        MultiValueMap<String, String> header = new LinkedMultiValueMap();
        header.put(HttpHeaders.CONTENT_TYPE, Arrays.asList(MediaType.APPLICATION_JSON_VALUE));
        // 設定 Accept 向伺服器表明客戶端可處理的內容型別
        header.put(HttpHeaders.ACCEPT, Arrays.asList(MediaType.APPLICATION_JSON_VALUE));
        // 直接將實體 Product 作為請求引數傳入,底層利用 Jackson 框架序列化成 JSON 串傳送請求
        HttpEntity<Product> request = new HttpEntity<>(new Product(2, "Macbook", BigDecimal.valueOf(10000)), header);
        ResponseEntity<String> exchangeResult = restTemplate.exchange(url, HttpMethod.POST, request, String.class);
        System.out.println("post_product2: " + exchangeResult);
        Assert.isTrue(exchangeResult.getStatusCode().equals(HttpStatus.OK), "post_product2 請求不成功");
    }
    複製程式碼

    驗證的輸出日誌如下:

    ···
    post_product2: <200,Product{id='2', name='Macbook', price='10000'},[Content-Type:"application/json;charset=UTF-8", Content-Length:"46", Date:"Fri, 10 May 2019 16:09:11 GMT"]>
    ···
    複製程式碼

DELETE 請求 和 PUT 請求

DELETE 請求和 PUT 請求屬於 RESTful 請求方式的兩種,但通常不會被使用到,這裡也只是簡單演示下,具體程式碼如下:

// DELETE 方法請求
@Test
public void testDelete() {
   String url = "http://localhost:8080/product/delete/{id}";
   restTemplate.delete(url, 101);
}

// PUT 方法請求
@Test
public void testPut() {
    String url = "http://localhost:8080/product/update";
    Map<String, ?> variables = new HashMap<>();
    MultiValueMap<String, String> header = new LinkedMultiValueMap();
    header.put(HttpHeaders.CONTENT_TYPE, Arrays.asList(MediaType.APPLICATION_FORM_URLENCODED_VALUE));
    Product product = new Product(101, "iWatch", BigDecimal.valueOf(2333));
    String productStr = "id=" + product.getId() + "&name=" + product.getName() + "&price=" + product.getPrice();
    HttpEntity<String> request = new HttpEntity<>(productStr, header);
    restTemplate.put(url, request);
}
複製程式碼

上傳檔案

現在我們再試下如何使用 RestTemplate API 進行檔案上傳,也比較簡單,首先看下實現程式碼:

@Test
public void testUploadFile() {
    String url = "http://localhost:8080/product/upload";
    MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
    FileSystemResource file = new FileSystemResource(new File("/Users/One/Desktop/b.txt"));
    body.add("file", file);

    MultiValueMap<String, String> header = new LinkedMultiValueMap();
    header.put(HttpHeaders.CONTENT_TYPE, Arrays.asList(MediaType.MULTIPART_FORM_DATA_VALUE));
    HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, header);
    ResponseEntity<String> responseEntity = restTemplate.postForEntity(url, requestEntity, String.class);
    System.out.println("upload: " + responseEntity);
    Assert.isTrue(responseEntity.getStatusCode().equals(HttpStatus.OK), "upload 請求不成功");
}
複製程式碼

如果需要上傳檔案型別資料,就只能使用 POST 請求,並且內容型別為 multipart/form-data,需要手動給 Header 指定這個 Content-Type。而需要上傳的檔案可以用 FileSystemResource 物件封裝,表示了一個檔案資源,同時服務端需要用 MultipartRequest 物件來獲取檔案資料。結合已執行的 Web 服務,執行上述測試方法即可得到下面日誌輸出:

...
upload: <200,upload success filename: b.txt,[Content-Type:"text/plain;charset=UTF-8", Content-Length:"30", Date:"Fri, 10 May 2019 17:00:45 GMT"]>
...
複製程式碼

進階 RestTemplate

到這裡我們就學習了 RestTemplate API 請求資料的幾種常見方式,現在來進一步地深入使用 RestTemplate

底層 HTTP 請求庫切換

我們首先看下官方文件的描述:

The default constructor uses java.net.HttpURLConnection to perform requests. You can switch to a different HTTP library with an implementation of ClientHttpRequestFactory. There is built-in support for the following:

  • Apache HttpComponents
  • Netty
  • OkHttp

從上面可以看出 RestTemplate 預設使用 JDK 原生的 java.net.HttpURLConnection 執行請求。而除此之外,Spring 還封裝了 Apache HttpComponents, Netty, OkHttp 三種請求庫,第一個就是我們平常用的 HttpClient API 相關的庫,而 Netty 則是一個效能高的NIO 請求處理網路庫,OkHttp 為功能豐富且高效的網路框架,多用於 Android 程式。

而我們上文采用預設的構造器方法建立的 RestTemplate 例項,即採用了 JDK 原生的網路 API。想要切換,只需要在構造方法中傳入特定 ClientHttpRequestFactory 實現類即可,如下程式碼:

RestTemplate template = new RestTemplate(new HttpComponentsClientHttpRequestFactory());
複製程式碼

我們檢視 RestTemplate 原始碼找不到預設採用JDK HttpURLConnection API 的程式碼,那就根據前文給出 RestTemplate 類層次圖向上查詢,可以在父類 HttpAccessor 上能找到如下程式碼:

public abstract class HttpAccessor {
	// ...
	private ClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
	// ...
}
複製程式碼

而 Spring 對工廠類 SimpleClientHttpRequestFactory 描述為: implementation that uses standard JDK facilities, 也正說明了預設構造 RestTemplate 例項的行為都會直接使用 JDK 網路 API。

請求超時設定

通常我們會對 HTTP 請求類進行執行行為的定製,例如呼叫超時時間設定,連線時長的限制等,而採用預設的 HttpURLConnection 預設的配置時, 從 SimpleClientHttpRequestFactory 原始碼類可以看到是沒有超時限制,也就意味著無限等待請求響應:

// RestTemplate 預設超時設定
...
private int connectTimeout = -1;
private int readTimeout = -1;
...
複製程式碼

那麼我們該如何調整超時時間,可以參考如下程式碼:

RestTemplate customRestTemplate = new RestTemplate(getClientHttpRequestFactory());

private SimpleClientHttpRequestFactory getClientHttpRequestFactory() {
    SimpleClientHttpRequestFactory clientHttpRequestFactory
            = new SimpleClientHttpRequestFactory();
    // 連線超時設定 10s
    clientHttpRequestFactory.setConnectTimeout(10_000);

    // 讀取超時設定 10s
    clientHttpRequestFactory.setReadTimeout(10_000);
    return clientHttpRequestFactory;
}
複製程式碼

如果要調整 HttpComponentsClient 的超時設定,可以參考文章resttemplate-timeout-example 。當然除了設定超時時間之外,還有更多引數進行定製,這裡就不一一列舉,可以參考文章 resttemplate-httpclient-java-config 進一步學習。

到這裡我們對 RestTemplate 的學習告一段落,如果有興趣可以進一步研究下相關原始碼,有機會嘗試使起來吧。?

參考資料

www.baeldung.com/rest-templa…

blog.didispace.com/spring-boot…

www.baeldung.com/spring-rest…

www.zhihu.com/question/28…

howtodoinjava.com/spring-boot…

docs.spring.io/spring/docs…

zh.wikipedia.org/wiki/表現層狀態轉…

docs.spring.io/spring-fram…

相關文章