使用 Spring HATEOAS 開發 REST 服務

絕大多數開發人員對於 REST 這個詞都並不陌生。自從 2000 年 Roy Fielding 在其博士論文中創造出來這個詞以後,REST 架構風格就很快地流行起來,已經成爲了構建 Web 服務時應該遵循的事實標準。不少 Web 服務和 API 都宣稱知足了 REST 架構風格的要求,即所謂的「RESTful」服務。不過就如同其餘不少流行的概念同樣,很多人對於 REST 的含義仍是存在或多或少的種種誤解。REST 在某些時候被當成了一種營銷的手段。很多所謂的「RESTful」 Web 服務或 API 實際上並不知足 REST 架構風格的要求。這其中的部分緣由在於 REST 的含義比較複雜,包含不少不一樣方面的內容。本文首先對 REST 架構作一個簡單的說明以澄清某些誤解。 java

REST 架構

REST 是 Representational state transfer 的縮寫,翻譯過來的意思是表達性狀態轉換。REST 是一種架構風格,它包含了一個分佈式超文本系統中對於組件、鏈接器和數據的約束。REST 是做爲互聯網自身架構的抽象而出現的,其關鍵在於所定義的架構上的各類約束。只有知足這些約束,才能稱之爲符合 REST 架構風格。REST 的約束包括: spring

  • 客戶端-服務器結構。經過一個統一的接口來分開客戶端和服務器,使得二者能夠獨立開發和演化。客戶端的實現能夠簡化,而服務器能夠更容易的知足可伸縮性的要求。
  • 無狀態。在不一樣的客戶端請求之間,服務器並不保存客戶端相關的上下文狀態信息。任何客戶端發出的每一個請求都包含了服務器處理該請求所需的所有信息。
  • 可緩存。客戶端能夠緩存服務器返回的響應結果。服務器能夠定義響應結果的緩存設置。
  • 分層的系統。在分層的系統中,可能有中間服務器來處理安全策略和緩存等相關問題,以提升系統的可伸縮性。客戶端並不須要瞭解中間的這些層次的細節。
  • 按需代碼(可選)。服務器能夠經過傳輸可執行代碼的方式來擴展或自定義客戶端的行爲。這是一個可選的約束。
  • 統一接口。該約束是 REST 服務的基礎,是客戶端和服務器之間的橋樑。該約束又包含下面 4 個子約束。
    • 資源標識符。每一個資源都有各自的標識符。客戶端在請求時須要指定該標識符。在 REST 服務中,該標識符一般是 URI。客戶端所獲取的是資源的表達(representation),一般使用 XML 或 JSON 格式。
    • 經過資源的表達來操縱資源。客戶端根據所獲得的資源的表達中包含的信息來了解如何操縱資源,好比對資源進行修改或刪除。
    • 自描述的消息。每條消息都包含足夠的信息來描述如何處理該消息。
    • 超媒體做爲應用狀態的引擎(HATEOAS)。客戶端經過服務器提供的超媒體內容中動態提供的動做來進行狀態轉換。這也是本文所要介紹的內容。

在瞭解 REST 的這些約束以後,就能夠對「表達性狀態轉換」的含義有更加清晰的瞭解。「表達性」的含義是指對於資源的操縱都是經過服務器提供的資源的表達來進行的。客戶端在根據資源的標識符獲取到資源的表達以後,從資源的表達中能夠發現其可使用的動做。使用這些動做會發出新的請求,從而觸發狀態轉換。 數組

HATEOAS 約束

HATEOAS(Hypermedia as the engine of application state)是 REST 架構風格中最複雜的約束,也是構建成熟 REST 服務的核心。它的重要性在於打破了客戶端和服務器之間嚴格的契約,使得客戶端能夠更加智能和自適應,而 REST 服務自己的演化和更新也變得更加容易。 緩存

在介紹 HATEOAS 以前,先介紹一下 Richardson 提出的 REST 成熟度模型。該模型把 REST 服務按照成熟度劃分紅 4 個層次: 安全

  • 第一個層次(Level 0)的 Web 服務只是使用 HTTP 做爲傳輸方式,實際上只是遠程方法調用(RPC)的一種具體形式。SOAP 和 XML-RPC 都屬於此類。
  • 第二個層次(Level 1)的 Web 服務引入了資源的概念。每一個資源有對應的標識符和表達。
  • 第三個層次(Level 2)的 Web 服務使用不一樣的 HTTP 方法來進行不一樣的操做,而且使用 HTTP 狀態碼來表示不一樣的結果。如 HTTP GET 方法來獲取資源,HTTP DELETE 方法來刪除資源。
  • 第四個層次(Level 3)的 Web 服務使用 HATEOAS。在資源的表達中包含了連接信息。客戶端能夠根據連接來發現能夠執行的動做。

從上述 REST 成熟度模型中能夠看到,使用 HATEOAS 的 REST 服務是成熟度最高的,也是推薦的作法。對於不使用 HATEOAS 的 REST 服務,客戶端和服務器的實現之間是緊密耦合的。客戶端須要根據服務器提供的相關文檔來了解所暴露的資源和對應的操做。當服務器發生了變化時,如修改了資源的 URI,客戶端也須要進行相應的修改。而使用 HATEOAS 的 REST 服務中,客戶端能夠經過服務器提供的資源的表達來智能地發現能夠執行的操做。當服務器發生了變化時,客戶端並不須要作出修改,由於資源的 URI 和其餘信息都是動態發現的。 服務器

回頁首 架構

示例

本文將經過一個完整的示例來講明 HATEOAS。該示例是一個常見的待辦事項的服務,用戶能夠建立新的待辦事項、進行編輯或標記爲已完成。該示例中包含的資源以下: mvc

  • 用戶:應用中的用戶。
  • 列表:待辦事項的列表,屬於某個用戶。
  • 事項:具體的待辦事項,屬於某個列表。

應用提供相關的 REST 服務來完成對於列表和事項兩個資源的 CRUD 操做。 app

回頁首 框架

Spring HATEOAS

若是 Web 應用基於 Spring 框架開發,那麼能夠直接使用 Spring 框架的子項目 HATEOAS 來開發知足 HATEOAS 約束的 Web 服務。本文的示例應用基於 Java 8 和使用 Spring Boot 1.1.9 來建立,Spring HATEOAS 的版本是 0.16.0.RELEASE。

基本配置

知足 HATEOAS 約束的 REST 服務最大的特色在於服務器提供給客戶端的表達中包含了動態的連接信息,客戶端經過這些連接來發現能夠觸發狀態轉換的動做。Spring HATEOAS 的主要功能在於提供了簡單的機制來建立這些連接,並與 Spring MVC 框架有很好的集成。對於已有的 Spring MVC 應用,只須要一些簡單的改動就能夠知足 HATEOAS 約束。對於一個 Maven 項目來講,只須要添加代碼清單 1中的依賴便可。

清單 1. Spring HATEOAS 的 Maven 依賴聲明
<dependency>
  <groupId>org.springframework.hateoas</groupId>
  <artifactId>spring-hateoas</artifactId>
  <version>0.16.0.RELEASE</version>
</dependency>

資源

REST 架構中的核心概念之一是資源。服務器提供的是資源的表達,一般使用 JSON 或 XML 格式。在通常的 Web 應用中,服務器端代碼會對所使用的資源建模,提供相應的模型層 Java 類,這些模型層 Java 類一般包含 JPA 相關的註解來完成持久化。在客戶端請求時,服務器端代碼經過 Jackson 或 JAXB 把模型對象轉換成 JSON 或 XML 格式。代碼清單 2給出了示例應用中表示列表的模型類 List 的聲明。

清單 2. 表示列表的模型類 List 的聲明
@Entity
public class List extends AbstractEntity {
   private String name;

   @ManyToOne
   @JsonIgnore
   private User user;

   @OneToMany(mappedBy = "list", fetch = FetchType.LAZY)
   @JsonIgnore
   private Set<Item> items = new HashSet<>();

   protected List() {
   }

   public List(String name, User user) {
       this.name = name;
       this.user = user;
   }

   public String getName() {
       return name;
   }

   public User getUser() {
       return user;
   }

   public Set<Item> getItems() {
       return items;
   }
}

當客戶端請求某個具體的 List 類的對象時,服務器端返回如代碼清單 3所示的 JSON 格式的表達。

清單 3. List 類的對象的 JSON 格式的表達
{
   "id": 1,
   "name": "Default"
}

代碼清單 3中,服務器端返回的只是模型類對象自己的內容,並無提供相關的連接信息。爲了把模型對象類轉換成知足 HATEOAS 要求的資源,須要添加連接信息。Spring HATEOAS 使用 org.springframework.hateoas.Link 類來表示連接。Link 類遵循 Atom 規範中對於連接的定義,包含 rel 和 href 兩個屬性。屬性 rel 表示的是連接所表示的關係(relationship),href 表示的是連接指向的資源標識符,通常是 URI。資源一般都包含一個屬性 rel 值爲 self 的連接,用來指向該資源自己。

在建立資源類時,能夠繼承自 Spring HATEOAS 提供的 org.springframework.hateoas.Resource 類,Resource 類提供了簡單的方式來建立連接。代碼清單 4給出了與模型類 List 對應的資源類 ListResource 的聲明。

清單 4. 模型類 List 對應的資源類 ListResource 的聲明
public class ListResource extends Resource {
   private final List list;

   public ListResource(List list) {
       super(list);
       this.list = list;
       add(new Link("http://localhost:8080/lists/1"));
       add(new Link("http://localhost:8080/lists/1/items", "items"));
   }

   public List getList() {
       return list;
   }
}

如代碼清單 4所示,ListResource 類繼承自 Resource 類並對 List 類的對象進行了封裝,添加了兩個連接。在使用 ListResource 類以後,服務器端返回的表達格式如代碼清單 5所示。

清單 5. 使用 ListResource 類以後的 JSON 格式的表達
{
   "list": {
       "id": 1,
       "name": "Default"
   },
   "links": [
       {
           "rel": "self",
           "href": "http://localhost:8080/lists/1"
       },
       {
           "rel": "items",
           "href": "http://localhost:8080/lists/1/items"
       }
   ]
}

代碼清單 5的 JSON 內容中添加了額外的 links 屬性,幷包含了兩個連接。不過模型類對象的內容被封裝在屬性 list 中。這是由於 ListResource 類直接封裝了整個的 List 類的對象,而不是把 List 類的屬性提取到 ListResource 類中。若是須要改變輸出的 JSON 表達的格式,可使用另一種封裝方式的 ListResource 類,如代碼清單 6所示。

清單 6. 不一樣封裝格式的 ListResource 類
public class ListResource extends Resource {
   private final Long id;
      private final String name;

   public ListResource(List list) {
       super(list);
       this.id = list.getId();
       this.name = list.getName();
       add(new Link("http://localhost:8080/lists/1"));
       add(new Link("http://localhost:8080/lists/1/items", "items"));
   }

   public Long getId() {
       return id;
   }
      public String getName() {
       return name;
   }
}

對應的資源的表達如代碼清單 7所示。

清單 7. 使用不一樣封裝方式的 JSON 格式的表達
{
   "id": 1,
   "name": "Default",
   "links": [
       {
           "rel": "self",
           "href": "http://localhost:8080/lists/1"
       },
       {
           "rel": "items",
           "href": "http://localhost:8080/lists/1/items"
       }
   ]
}

這兩種不一樣的封裝方式各有優缺點。第一種方式的優勢是實現起來很簡單,只須要把模型層的對象直接包裝便可;第二種方式雖然實現起來相對比較複雜,可是能夠對資源的表達格式進行定製,使得資源的表達格式更直接。

在代碼實現中常常會須要把模型類對象轉換成對應的資源對象,如把 List 類的對象轉換成 ListResource 類的對象。通常的作法是經過「new ListResource(list)」這樣的方式來進行轉換。可使用 Spring HATEOAS 提供的資源組裝器把轉換的邏輯封裝起來。資源組裝器還能夠自動建立 rel 屬性爲 self 的連接。代碼清單 8中給出了組裝資源類 ListResource 的 ListResourceAssembler 類的實現。

清單 8. 組裝資源類 ListResource 的 ListResourceAssembler 類的實現
public class ListResourceAssembler extends ResourceAssemblerSupport<List, ListResource> {

   public ListResourceAssembler() {
       super(ListRestController.class, ListResource.class);
   }

   @Override
   public ListResource toResource(List list) {
       ListResource resource = createResourceWithId(list.getId(), list);
       return resource;
   }

   @Override
   protected ListResource instantiateResource(List entity) {
       return new ListResource(entity);
   }
}

在建立 ListResourceAssembler 類的對象時須要指定使用資源的 Spring MVC 控制器 Java 類和資源 Java 類。對於 ListResourceAssembler 類來講分別是 ListRestController 和 ListResource。ListRestController 類在下一節中會具體介紹,其做用是用來建立 rel 屬性爲 self 的連接。ListResourceAssembler 類的 instantiateResource 方法用來根據一個模型類 List 的對象建立出 ListResource 對象。ResourceAssemblerSupport 類的默認實現是經過反射來建立資源對象的。toResource 方法用來完成實際的轉換。此處使用了 ResourceAssemblerSupport 類的 createResourceWithId 方法來建立一個包含 self 連接的資源對象。

在代碼中須要建立 ListResource 的地方,均可以換成使用 ListResourceAssembler,如代碼清單 9所示。

清單 9. 使用 ListResourceAssembler 的示例
//組裝單個資源對象
new ListResourceAssembler().toResource(list);

//組裝資源對象的集合
new ListResourceAssembler().toResources(lists);

代碼清單 9中的 toResources 方法是 ResourceAssemblerSupport 類提供的。當須要轉換一個集合的資源對象時,這個方法很是實用。

連接

HATEOAS 的核心是連接。連接的存在使得客戶端能夠動態發現其所能執行的動做。在上一節中介紹過連接由 rel 和 href 兩個屬性組成。其中屬性 rel 代表了該連接所表明的關係含義。應用能夠根據須要爲連接選擇最適合的 rel 屬性值。因爲每一個應用的狀況並不相同,對於應用相關的 rel 屬性值並無統一的規範。不過對於不少常見的連接關係,IANA 定義了規範的 rel 屬性值。在開發中可能使用的常見 rel 屬性值如表1所示。

表 1. 經常使用的 rel 屬性
rel 屬性值 描述
self 指向當前資源自己的連接的 rel 屬性。每一個資源的表達中都應該包含此關係的連接。
edit 指向一個能夠編輯當前資源的連接。
item 若是當前資源表示的是一個集合,則用來指向該集合中的單個資源。
collection 若是當前資源包含在某個集合中,則用來指向包含該資源的集合。
related 指向一個與當前資源相關的資源。
search 指向一個能夠搜索當前資源及其相關資源的連接。
first、last、previous、next 這幾個 rel 屬性值都有集合中的遍歷相關,分別用來指向集合中的第一個、最後一個、上一個和下一個資源。

若是在應用中使用自定義 rel 屬性值,通常的作法是屬性值所有爲小寫,中間使用「-」分隔。

連接中另一個重要屬性 href 表示的是資源的標識符。對於 Web 應用來講,一般是一個 URL。URL 必須指向的是一個絕對的地址。在應用中建立連接時,在 URL 中使用硬編碼的主機名和端口號顯然不是好的選擇。Spring MVC 提供了相關的工具類能夠獲取 Web 應用啓動時的主機名和端口號,不過建立動態的連接 URL 還須要能夠獲取資源的訪問路徑。對於一個典型的 Spring MVC 控制器來講,其聲明如代碼清單 10所示。

清單 10. Spring MVC 控制器 ListRestController 類的實現
@RestController
@RequestMapping("/lists")
public class ListRestController {

 @Autowired
 private ListService listService;

 @RequestMapping(method = RequestMethod.GET)
 public Resources<ListResource> readLists(Principal principal) {
 String username = principal.getName();
 return new Resources<ListResource>(new 
 ListResourceAssembler().toResources(listService.findByUserUsername(username)));
 @RequestMapping(value = "/{listId}", method = RequestMethod.GET)
 public ListResource readList(@PathVariable Long listId) {
 return new ListResourceAssembler().toResource(listService.findOne(listId));
 }
}

從代碼清單 10中能夠看到,Spring MVC 控制器 ListRestController 類經過「@RequestMapping」註解聲明瞭其訪問路徑是「/lists」,而訪問單個資源的路徑是相似「/lists/1」這樣的形式。在建立資源的連接時,指向單個資源的連接的 href 屬性值是相似「http://localhost:8080/lists/1」這樣的格式。而其中的「/lists」不該該是硬編碼的,不然當修改了 ListRestController 類的「@RequestMapping」時,全部相關的生成連接的代碼都須要進行修改。Spring HATEOAS 提供了 org.springframework.hateoas.mvc.ControllerLinkBuilder 來解決這個問題,用來根據 Spring MVC 控制器動態生成連接。代碼清單 11給出了建立單個資源的連接的方式。

清單 11. 使用 ControllerLinkBuilder 類建立連接
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*;

Link link = linkTo(ListRestController.class).slash(listId).withSelfRel();

經過 ControllerLinkBuilder 類的 linkTo 方法,先指定 Spring MVC 控制器的 Java 類,再經過 slash 方法來找到下一級的路徑,最後生成屬性值爲 self 的連接。在使用 ControllerLinkBuilder 生成連接時,除了可使用控制器的 Java 類以外,還可使用控制器 Java 類中包含的方法。如代碼清單 12所示。

清單 12. 經過控制器 Java 類中的方法生成連接
Link link = linkTo(methodOn(ItemRestController.class).readItems(listId)).withRel("items");

代碼清單 12中的連接使用的是 ItemRestController 類中的 readItems 方法。參數 listId 是組成 URI 的一部分,在調用 readItems 方法時須要提供。

上面介紹的是經過 Spring MVC 控制器來建立連接,另一種作法是從模型類中建立。這是由於控制器一般用來暴露某個模型類。如 ListRestController 類直接暴露模型類 List,並提供了訪問 List 資源集合和單個 List 資源的接口。對於這樣的狀況,並不須要經過控制器來建立相關的連接,而可使用 EntityLinks。

首先須要在控制器類中經過「@ExposesResourceFor」註解聲明其所暴露的模型類,如代碼清單 13中的 ListRestController 類的聲明。

清單 13. 「@ExposesResourceFor」註解的使用
@RestController
@ExposesResourceFor(List.class)
@RequestMapping("/lists")
public class ListRestController {

}

另外在 Spring 應用的配置類中須要經過「@EnableEntityLinks」註解來啓用 EntityLinks 功能。此外還須要添加代碼清單 14中給出的 Maven 依賴。

清單 14. EntityLinks 功能所需的 Maven 依賴
<dependency>
 <groupId>org.springframework.plugin</groupId>
 <artifactId>spring-plugin-core</artifactId>
 <version>1.1.0.RELEASE</version>
</dependency>

在須要建立連接的代碼中,只須要經過依賴注入的方式添加對 EntityLinks 的引用,就可使用 linkForSingleResource 方法來建立指向單個資源的連接,如代碼清單 15所示。

清單 15. 使用 EntityLinks 建立連接
@Autowired
private EntityLinks entityLinks;
   
entityLinks.linkForSingleResource(List.class, 1)  

須要注意的是,爲了 linkForSingleResource 方法能夠正常工做,控制器類中須要包含訪問單個資源的方法,並且其「@RequestMapping」是相似「/{id}」這樣的形式。

超媒體控制與 HAL

在添加了連接以後,服務器端提供的表達能夠幫助客戶端更好的發現服務器端所支持的動做。在具體的表達中,應用雖然能夠根據須要選擇最適合的格式,可是在表達的基本結構上應該遵循必定的規範,這樣能夠保證最大程度的適用性。這個基本結構主要是總體的組織方式和連接的格式。HAL(Hypertxt Application Language)是一個被普遍採用的超文本表達的規範。應用能夠考慮遵循該規範,Spring HATEOAS 提供了對 HAL 的支持。

HAL 規範

HAL 規範自己是很簡單的,代碼清單 16給出了示例的 JSON 格式的表達。

清單 16. HAL 規範的示例 JSON 格式的表達
{
 "_links": {
 "self": {
 "href": "http://localhost:8080/lists"
 }
 },
 "_embedded": {
 "lists": [
 {
 "id": 1,
 "name": "Default",
 "_links": {
 "todo:items": {
 "href": "http://localhost:8080/lists/1/items"
 },
 "self": {
 "href": "http://localhost:8080/lists/1"
 },
 "curies": [
 {
 "href": "http://www.midgetontoes.com/todolist/rels/{rel}",
 "name": "todo",
 "templated": true
 }
 ]
 }
 }
 ]
 }
}

HAL 規範圍繞資源和連接這兩個簡單的概念展開。資源的表達中包含連接、嵌套的資源和狀態。資源的狀態是該資源自己所包含的數據。連接則包含其指向的目標(URI)、所表示的關係和其餘可選的相關屬性。對應到 JSON 格式中,資源的連接包含在_links 屬性對應的哈希對象中。該_links 哈希對象中的鍵(key)是連接的關係,而值(value)則是另一個包含了 href 等其餘連接屬性的對象或對象數組。當前資源中所包含的嵌套資源由_embeded 屬性來表示,其值是一個包含了其餘資源的哈希對象。

連接的關係不只是區分不一樣連接的標識符,一樣也是指向相關文檔的 URL。文檔用來告訴客戶端如何對該連接所指向的資源進行操做。當開發人員獲取到了資源的表達以後,能夠經過查看連接指向的文檔來了解如何操做該資源。

使用 URL 做爲連接的關係帶來的問題是 URL 做爲屬性名稱來講顯得過長,並且不一樣關係的 URL 的大部份內容是重複的。爲了解決這個問題,可使用 Curie。簡單來講,Curie 能夠做爲連接關係 URL 的模板。連接的關係聲明時使用 Curie 的名稱做爲前綴,不用提供完整的 URL。應用中聲明的 Curie 出如今_links 屬性中。代碼中定義了 URI 模板爲「http://www.midgetontoes.com/todolist/rels/{rel}」的名爲 todo 的 Curie。在使用了 Curie 以後,名爲 items 的連接關係變成了包含前綴的「todo:items」的形式。這就表示該連接的關係其實是「http://www.midgetontoes.com/todolist/rels/items」。

Spring HATEOAS 的 HAL 支持

目前 Spring HATEOAS 僅支持 HAL 一種超媒體表達格式,只須要在應用的配置類上添加「@EnableHypermediaSupport(type= {HypermediaType.HAL})」註解就能夠啓用該超媒體支持。在啓用了超媒體支持以後,服務器端輸出的表達格式會遵循 HAL 規範。另外,啓用超媒體支持會默認啓用「@EnableEntityLinks」。在啓用超媒體支持以後,應用須要進行相關的定製使得生成的 HAL 表達更加友好。

首先是內嵌資源在_embedded 對應的哈希對象中的屬性值,該屬性值是由 org.springframework.hateoas.RelProvider 接口的實現來提供的。對於應用來講,只須要在內嵌資源對應的模型類中添加 org.springframework.hateoas.core.Relation 註解便可,如代碼清單 17所示。

清單 17. 在模型類中添加 @Relation 註解
@Relation(value = "list", collectionRelation = "lists")
public class List extends AbstractEntity {
}

代碼清單 17中聲明瞭當模型類 List 的對象做爲內嵌資源時,單個資源使用 list 做爲屬性值,多個資源使用 lists 做爲屬性值。

若是須要添加 Curie,則提供 org.springframework.hateoas.hal.CurieProvider 接口的實現,如代碼清單 18所示。利用已有的 org.springframework.hateoas.hal.DefaultCurieProvider 類並提供 Curie 的前綴和 URI 模板便可。

清單 18. 添加 CurieProvider 接口的實現
@Bean
public CurieProvider curieProvider() {
 return new DefaultCurieProvider("todo",
 new UriTemplate("http://www.midgetontoes.com/todolist/rels/{rel}"));
}


結束語

在開發一個新的 Web 服務或 API 時,REST 架構風格已經成爲事實上的標準。在開發時須要明白 REST 架構風格中所包含的約束的含義。HATEOAS 做爲 REST 服務約束中最複雜的一個,目前尚未獲得普遍的使用。可是採用 HATEOAS 所帶來的好處是很大的,能夠幫助客戶端和服務器更好的解耦,能夠減小不少潛在的問題。Spring HATEOAS 在 Spring MVC 框架的基礎上,容許開發人員經過簡單的配置來添加 HATEOAS 約束。若是應用自己已經使用了 Spring MVC,則同時啓用 HATEOAS 是一個很好的選擇。本文對 REST 和 HATEOAS 的相關概念以及 Spring HATEOAS 框架的使用作了詳細的介紹。

————————————————————————————————————————————————————————————————

示例代碼下載:http://www.ibm.com/developerworks/apps/download/index.jsp?contentid=994534&filename=sample_code.zip&method=http&locale=zh_CN

相關文章
相關標籤/搜索