21 世紀初,Spring 框架的誕生和崛起讓沉重而腐朽的 J2EE 遭到了當頭棒喝,隨后大批開發人員轉投 Spring 陣營,呼吸間就讓 J2EE 陣營大傷元氣。然而這種命懸一線的危機并沒有造成毀滅性的打擊,尤其是對于 Java 這種提倡開放的平臺而言,取長補短,互相促進才是正道。于是,JCP 委員會痛定思痛,在 2006 年推出 Java EE 5 規范,主要是對 EJB 的開發進行了極大幅度的簡化。2008 年發布的 Java EE 6 引入了 CDI、BV、JAX-RS 等一系列新功能,并且以配置文件(profile)的方式讓 Java EE 向輕量級邁進了一步。特別有趣的是,Spring 框架也開始提供對某些 Java EE 注解的支持,是否標志著兩大陣營開始合流?Java EE 7 預定于今年下半年發布,目標是支持云計算。最近幾年來,云計算一直被炒作,卻從來沒有一個準確的定義和規范,希望 Java EE 7 能夠在 Java 界扭轉這種尷尬的局面。
下面開始詳細列舉 Java EE 7 的新功能前瞻,數據來源于《Java Magazine 2012-01/02》中的《Cloud/Java EE: Looking Ahead to Java EE 7》一文。Java EE 7 是以“日期驅動”的方式開發的,也就是說,在計劃日期到達前沒有完成的功能都將被推遲到 Java EE 8。
@Inject
更緊密集成。ELContext
分離為解析和求值上下文。cc:interface
可選,Facelet 標記庫的速記 URL,與 CDI 集成,JSF 組件的 OSGi 支持。fileUpload
和 BackButton
等新組件。前面介紹了各種請求參數的注入,這些參數在 HTTP 請求中都是以純文本的方式存在的。在處理參數的時候,往往需要把這些文本參數轉換為 Java 對象。JAX-RS 提供了一些內置的規則里自動完成這種轉換。
JAX-RS 提供了四條自動類型轉換規則,下面我們逐條考察。
這個早就見識過了,無需多說。舉例回顧一下:
@GET @Path("{id}") public Movie getXxx(@PathParam("id") int id) {/*...*/}
String
參數的構造器的類型這個也不難理解,JAX-RS 會自動調用該構造器創建一個對象:
public class Style { public Style(String name) {/* ... */} // ... } @GET @Path("{name}") public Movie getXxx(@PathParam("name") Style style) { // JAX-RS 已自動調用 xxx = new Style(name) // ... }
valueOf(String)
的類型也好理解。特別需要注意的是,所有的枚舉類型都在此列,因為編譯器會自動給枚舉類型加上一個這樣的工廠方法。例如:
public enum Style {/*...*/} @GET @Path("{name}") public Movie getXxx(@PathParam("name") Style style) { // JAX-RS 已自動調用 style = Style.valueOf(name) // ... }
List<T>
、Set<T>
和 SortedSet<T>
這條規則適用于多值參數,例如查詢參數:
@GET @Path("xxx") public Movie getXxx(@QueryParam("style") Set<Style> styles) { // JAX-RS 已自動轉換每個 Style 對象并組裝到 Set 中 // ... }
如果轉換失敗,JAX-RS 會根據情況自動拋出一個包裝了初始異常,但是帶不同 HTTP 錯誤碼的 WebApplicationException
:對矩陣參數(@MatrixParam
)、查詢參數 (@QueryParam
)或路徑參數(@PathParam
)來說為 HTTP 404 找不到
,而對頭部參數(@HeaderParam
)或 Cookie 參數(@CookieParam
)為 HTTP 400 錯誤請求
。
在《JAX-RS 從傻逼到牛叉 3:路徑匹配》中,我們已經見過如何使用 @PathParam
、@QueryParam
和 @MatrixParam
分別注入 URI 中的路徑參數、矩陣參數和查詢參數,以及如何編程訪問這些參數。本文介紹表單參數、HTTP 頭部參數和 Cookie 參數的注入。
HTTP 請求也可以使用提交表單的方式。這時請求方法一般是 POST,當然春哥也無法阻止你用 GET。在前面我們雖然介紹過處理 POST 請求的例子,但那只是利用了 JAX-RS 對 JAXB 的支持,并沒有涉及到對具體請求參數的注入。JAX-RS 提供了 @FormParam
注解來注入 POST 請求的參數,例如:
@POST public Response createMovie(@FormParam("title") String title) { // 此處省略若干行 }
這兒省略了 @Consumes
注解,JAX-RS 會自動默認為 @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
,也就是 application/x-www-form-urlencoded
格式的請求。如果請求格式為 multipart/form-data
,就必須顯示指明:
@POST @Consumes(MediaType.MULTIPART_FORM_DATA) public Response createMovie(@FormParam("title") String title) { // 此處省略若干行 }
JAX-RS 還支持文件的上傳和下載,以后再介紹。
注入 HTTP 頭部參數簡單得不能再簡單了:
@GET @Path("xxx") @Produces(MediaType.TEXT_PLAIN) public String xxx(@HeaderParam("User-Agent") String userAgent) { // 此處省略若干行 }
如果有很多頭部參數,為了避免臃腫的參數列表,可以注入一個頭部對象,然后編程訪問頭部參數:
@GET @Path("xxx") @Produces(MediaType.TEXT_PLAIN) public String xxx(@Context HttpHeaders headers) { // 此處省略若干行 }
注入 Cookie 參數同樣的簡單:
@GET @Path("xxx") @Produces(MediaType.TEXT_PLAIN) public String xxx(@CookieParam("userName") String userName) { // 此處省略若干行 }
如果希望編程訪問,則可以像編程訪問那樣注入一個 HttpHeaders
對象,然后通過它的 getCookies()
方法來獲取所有的 Cookie。
目前我們的電影服務只提供了對電影信息的訪問服務,現在我們要再增加兩項級服務,分別用來訪問導演和演員信息。加上原先的電信信息服務,我們把 URI 統一放到 /ms/rest/service/
的子路徑下。最先想到的方法就是為這三個 URI 分別寫 JAX-RS 服務:
@Singleton @Path("service/movie") public class MovieService { // 此處省略若干行 } @Singleton @Path("service/director") public class DirectorService { // 此處省略若干行 } @Singleton @Path("service/director") public class ActorService { // 此處省略若干行 }
這種寫法的缺點就是讓三個本來有點關系(父級 URI 相同)的服務被放到了毫不相干的三個類里面,不一個個類地查看注解難以看出這點關系。為此,JAX-RS 提供了動態資源綁定的功能,讓我們能夠對這種情況做一些整理。
首先,我們引入一個服務定位器來處理集中管理這三個子級服務:
@Singleton @Path("service") public class ServiceLocator { @Inject private MovieService movieService; @Inject private DirectorService directorService; @Inject private ActorService actorService; private Map<String, Object> serviceMap; @PostConstruct private initServiceMap() { serviceMap = new HashMap<>(); serviceMap.put("movie", movieService); serviceMap.put("director", directorService); serviceMap.put("actor", actorService); } @Path("{name}") public Object locateService(@PathParam("name") String name) { Object service = serviceMap.get(name); if (service == null) { throw new WebApplicationException(Status.SERVICE_UNAVAILABLE); } return service; } }
該類中的 locateService
方法根據服務的名稱返回相應的服務實例,注意該方法只有一個 @Path
注解,因為它并不清楚請求的具體內容;返回對象的類型為 Object
,表明動態資源定位不要求服務類實現相同的接口,只需要它們的方法帶有相應的 JAX-RS 注解,就能夠被 JAX-RS 自動發現和處理(專業術語稱為 introspect,內省),以 MovieService
為例:
@Singleton public class MovieService { @GET @Path("{id}") @Produces(MediaType.APPLICATION_JSON) public Movie find(@PathParam("id") int id) { Movie movie = movieDao.get(id); if (movie != null) { return movie; } else { throw new WebApplicationException(Status.NOT_FOUND); } } // 此處省略若干行 }
這樣,每個請求實際上都由兩個類先后處理。例如,處理請求 GET /ms/rest/service/movie/1
的時候,先由 ServiceLocator
返回相配的服務實例 movieService
,然后再由該實例的 find
方法返回結果。比起最開始那三個簡單的類,雖然多了一層調用,但換來了更加清晰的結構。
動態資源定位是一個非常靈活強大的功能,用好的話,完全可以把 URI 層次整理成一個類似于文件目錄結構的抽象文件系統。
籠子大了什么鳥都有。同樣的道理,不論多么細心地設計 URI 結構,在系統復雜到一定程度后,仍然難以避免路徑沖突。為此,JAX-RS 使用一些規則來定義路徑匹配的優先級。
如果某個請求路徑可以對上多個 URI 匹配模式,那么 JAX-RS 就把可能匹配上的 URI 模式先拼接完整,按照下列規則依次進行比較,直到找出最適合的匹配模式:
/
和模板參數。例如 /ms/rest/movie/{id : \\d+}
包含 11 個字面字符。/ms/rest/movie/{id : \\d+}
帶一個模板參數。/ms/rest/movie/{id : \\d+}
帶一個含正則表達式的模板參數。現在看一個例子。回顧一下,/ms/rest/movie/{id : \\d+}
已經用來根據 ID 獲取電影信息。為了制造麻煩,現在引入 /ms/rest/movie/{title}
來根據電影標題獲取電影信息。先請你猜一猜 /ms/rest/movie/300
代表啥?ID 為 300 的神秘電影,還是我們可愛的勇士?只能跟著規則一條一條地看:
/ms/rest/movie/{id : \\d+}
帶了一個含正則表達式的模板參數,勝利!所以返回 ID 為 300 的片片。傳說這三條規則能夠覆蓋 90% 以上的情景。不過我們馬上就能造出一個打破規則的東西:/ms/rest/movie/{title : [ \\w]+}
。經過測試,/ms/rest/movie/300
會匹配上 /ms/rest/movie/{id : \\d+}
。如何解釋?JAX-RS 規范文檔 3.7.2 定義了完整的匹配規則,對于這兩個簡單的 URI 匹配模式,似乎一直進行到底都無法比較出優先級。莫非有另外的潛規則?或者是 JAX-RS 的實現(參考實現為 Jersey)自行規定?但無論如何,搞出這種怪物本身就是一個設計錯誤,所以也不必去深究原因。
JAX-RS 的核心功能是處理向 URI 發送的請求,所以它提供了一些匹配模式以便簡化對 URI 的解析。樓主在本系列的上一篇文章中已經使用了最簡單的路徑參數,本文將介紹一些稍微高級點的咚咚。
前面已經見過用 @Path("{id}")
和 @PathParam("id")
來匹配路徑參數 id
。這種匹配方式可以被嵌入到 @Path
注解中的任何地方,從而匹配多個參數,例如下面的代碼用來查找 ID 在某一范圍內的電影:
@GET @Path("{min}~{max}") @Produces(MediaType.APPLICATION_JSON) public List<Movie> findMovies(@PathParam("min") int min, @PathParam("max") int max) {
于是,GET /ms/rest/movie/5~16
就將返回 ID 為 5 到 16 的電影。此處的 min
和 max
已被自動轉換為 int
類型。JAX-RS 支持多種類型的自動轉換,詳見 @PathParam
的文檔。
根據 HTTP 規范,參數可能會編碼。默認情況下,JAX-RS 會自動解碼。如果希望得到未解碼的參數,只需在參數上再加個 @Encoded
注解。該注解適用于大多數 JAX-RS 注入類型,但并不常用。
模板參數雖然靈活,也可能會帶來歧義。例如想用 {firstName}-{lastName}
匹配一個人的姓名,但恰好某人的名(lastName
)含有“-”字符,像 O-live K 這種,匹配后就會變成姓 live-K,名 O。這種場景很難避免,一種簡單的解決方法就是對參數值進行兩次編碼,然后在服務端代碼解碼一次,因為 JAX-RS 默認會進行一次解碼,或者加上 @Encoded
注解,自己進行兩次解碼。
另外,在一個復雜系統中,多個 @Path
可能會造成路徑混淆,例如 {a}-{b}
和 {a}-z
都能匹配路徑 a-z
。雖然 JAX-RS 定義了一些規則來指定匹配的優先級,但這些規則本身就比較復雜,并且也不能完全消除混淆。樓主認為,設計一個 REST 系統的核心就是對 URI 的設計,應當小心處理 URI 的結構,合理分類,盡量保證匹配的唯一性,而不要過度使用晦澀的優先級規則。樓主將在下一篇文章介紹優先級規則。
模板參數可以用一個正則表達式進行驗證,寫法是在模板參數的標識符后面加一個冒號,然后跟上正則表達式字符串。例如在根據 ID 查詢電影信息的代碼中,模板參數 {id}
只能是整數,于是代碼可以改進為:
@GET @Path("{id : \\d+}") @Produces(MediaType.APPLICATION_JSON) public List<Movie> findMovies(@PathParam("min") int min, @PathParam("max") int max) {
冒號左右的空格將被忽略。用正則表達式驗證數據很有局限性,可惜 JAX-RS 目前并不能直接集成 Bean 驗證框架,因此復雜的驗證只能靠自己寫代碼。
查詢參數很常見,就是在 URI 的末尾跟上一個問號和一系列由“&”分隔的鍵值對,例如查詢 ID 為 5 到 16 的電影也可以設計為 /ms/rest/movie?min=5&max=16
。JAX-RS 提供了 QueryParam
來注入查詢參數:
@GET @Produces(MediaType.APPLICATION_JSON) public List<Movie> findMovies(@DefaultValue("0") @QueryParam("min") int min, @DefaultValue("0") @QueryParam("max") int max) {
查詢參數是可選的。如果 URI 沒有設定某個查詢參數,JAX-RS 就會根據情況為其生成 0、空字符串之類的默認值。如果要手動設定默認值,需要像上面的代碼一樣用 @DefaultValue
注解來指定。另外還可以加上 Encoded
注解來得到編碼的原始參數。
有的查詢參數是一對多的鍵值對,例如 /xyz?a=def&a=pqr
,這種情況只需將注入的參數類型改為 List
即可。
矩陣參數應該屬于 URI 規范中的非主流類型,但它實際上比查詢參數更靈活,因為它可以嵌入到 URI 路徑中的任何一段末尾(用分號隔開),用來標識該段的某些屬性。例如 GET /ms/rest/movie;year=2011/title;initial=A
表示在 2011 年出品的電影中查找首字母為 A 的標題。year
是電影的屬性,而 initial
是標題的屬性,這比把它們都作為查詢參數放在末尾更直觀可讀。匹配 URI 的時候,矩陣參數將被忽略,因此前面的 URI 匹配為 /ms/rest/movie/title
。矩陣參數可以用 @MatrixParam
來注入:
@GET @Path("title") @Produces(MediaType.APPLICATION_JSON) public List<String> findTitles(@MatrixParam("year") int year, @MatrixParam("initial") String initial) {
如果 URI 的多個段中含有相同名稱的矩陣參數,例如 /abc;name=XXX/xyz;name=OOO
,這種直接注入就失效了,只能用下面要講的編程式訪問來取得。
如果簡單的注入不能達到目的,就需要通過注入 PathSegment
或 UriInfo
對象來直接編程訪問 URI 的信息。
一個 PathSegment
對象代表 URI 中的一個路徑段,可以從它得到矩陣參數。它可以通過 @PathParam
來注入,這要求該路徑段必須整個被定義為一個模板參數。例如下面的代碼也可以用來處理 GET /ms/rest/movie/{id}
:
@GET @Path("{id}") @Produces(MediaType.APPLICATION_JSON) public Movie findMovie(@PathParam("id") PathSegment ps) {
@PathParam
也可以注入多個段,如果想把 /a/b/c/d
匹配到 /a/{segments}/d
,直接注入一個字符串顯然不行,因為 b/c
是兩個路徑段。唯一的選擇是把注入的類型改為 List<PathSegment>
。樓主嚴重不推薦用一個模板參數匹配多個路徑段,因為這很容易干擾其他匹配的設計,最后搞成一團亂麻。URI 路徑段應當盡量設計得簡單明晰,再輔以矩陣參數或查詢參數就能應付大多數場景。不論對服務端還是客戶端開發人員來說,簡潔的 URI 既便于管理,又便于使用。網上有不少關于 URI 設計指南的文章,此處不再贅述。
如果想完全手動解析路徑,則可以用 @Context
注入一個 UriInfo
對象,通過此對象可以得到 URI 的全部信息,詳見 API 文檔。例如:
@GET @Path("{id}/{segments}") @Produces(MediaType.PLAIN_TEXT) public String getInfo(@PathParam("id") int id, @Context UriInfo uriInfo) {
UriInfo
主要用在某些特殊場合下起輔助作用,設計良好的 URI 用普通的注入就能完成大部分匹配。
工欲善其事必先利其器,為此 JAX-RS 提供了這些利器來解析 URI。至于如何用這些器來做出一個好系統,則還是依賴于 URI 本身的設計。
實體間的多對多的關聯需要一張關聯表。如果直接使用 ManyToMany
來映射,JPA 就會隱式地幫我們自動管理關聯表,代碼寫出來和其他類型的關聯差別不大。例如,某州炒房團需要一個炒房跟蹤系統,那么該系統中的炒房客和房子就是多對多的關系:
public class Speculator implements Serializable { @Id private Integer id; @ManyToMany @JoinTable(joinColumns = @JoinColumn(name = "speculator_id"), inverseJoinColumns = @JoinColumn(name = "house_id")) private List<House> houses; // 此處省略若干行 } public class House implements Serializable { @Id private Integer id; @ManyToMany(mappedBy = "houses") private List<Speculator> speculators; // 此處省略若干行 }
如果炒房客 s
要賣掉房子 h
(嚴格點說是賣掉房子的產權部分),那么系統執行的代碼差不多就是 s.getHouses().remove(h)
。看似簡單,然而底層的操作卻性能低下:JPA 會先從數據庫中取出炒房客的所有房產(s.getHouses()
),然后再刪除指定的那套房子;從數據庫層面上看,這將先從關聯表(speculator_house
)中找到該炒房客的所有房子的外鍵,然后從 house
表載入這些 House
對象,最后才從 speculator_house
刪除關聯。在 ORM 出現前,這種操作只需要改關聯表,根本不用關心其他房子。這種簡單的多對多映射寫法將關聯表隱藏起來,雖然簡化了代碼,卻也可能帶來性能隱患。
很自然地可以想到,如果把關聯表也映射成實體類,就能解決這個問題。speculator_house
包含兩個外鍵,可用作聯合主鍵。如果把它映射為 SpeculatorHouse
類,則該類與 Speculator
和 House
都是多對一的關系。關聯表實體類的代碼如下(EmbeddedId
的映射技巧見《JPA 應用技巧 2:主鍵外鍵合體映射》):
@Embeddable public class SpeculatorHouseId implements Serializable { private Integer speculatorId; private Integer houseId; // 此處省略若干行 } @Entity @Table(name = "speculator_house") public class SpeculatorHouse implements Serializable { @EmbeddedId private SpeculatorHouseId id; @MapsId("speculatorId") @ManyToOne private Speculator speculator; @MapsId("houseId") @ManyToOne private House house; // 此處省略若干行 }
Speculator
和 House
也要增加相應的關聯信息:
public class Speculator implements Serializable { @Id private Integer id; @ManyToMany @JoinTable(joinColumns = @JoinColumn(name = "speculator_id"), inverseJoinColumns = @JoinColumn(name = "house_id")) private List<House> houses; @OneToMany(mappedBy = "speculator") private List<SpeculatorHouse> speculatorHouses; // 此處省略若干行 } public class House implements Serializable { @Id private Integer id; @ManyToMany(mappedBy = "houses") private List<Speculator> speculators; @OneToMany(mappedBy = "house") private List<SpeculatorHouse> speculatorHouses; // 此處省略若干行 }
這樣既保留了多對多關系,又映射了關聯表,然后就可以根據實際情況選擇隱式或顯示的關聯表管理。例如,要得到一個炒房客的全部房子,就使用隱式管理:s.getHouses()
;而要刪除炒房客和某套房子的關聯,則用顯示管理:delete from SpeculatorHouse sh where sh.speculator = :s and sh.house = :h
。
JAX-RS 使用注解進行配置,所以用它開發 REST 風格的服務非常簡單。樓主在本文用一個小例子來說明 JAX-RS 的基本用法。
假設樓主要開發一個小電影服務,客戶端可以通過請求 URI 對電影進行 CRUD 操作。為簡明起見,這兒不使用數據庫,只在內存中模擬。先用一個非常簡單的 Movie
類,在后續的文章中根據情況逐步擴充:
public class Movie { private int id; private String title; // 此處省略若干行 }
嗯,就是一個很普通的 JavaBean,實際項目中可以根據需要加上 @Entity
等注解。接下來看看如何編寫 JAX-RS 服務。
一個 JAX-RS 服務就是一個使用了 JAX-RS 注解來將 HTTP 請求綁定到方法的 Java 類,一共支持兩種類型:單請求對象或單例對象。單請求對象意味著每來一個請求,就創建一個服務對象,在請求結束時銷毀。單例對象則意味著只有一個服務對象處理所有的請求,從而可以在多個請求間維持服務狀態。JAX-RS 服務可通過繼承 javax.ws.rs.core.Application
來定義,其中的 getClasses
方法返回單請求對象的類型,getSingletons
方法返回單例對象的類型。這兩個方法是可選的。在 Java EE 6 環境中,如果這兩個方法都返回 null
或者空集合,那么應用程序中的所有 JAX-RS 都將被部署。這時可以用 CDI 的 @javax.inject.Singleton
或者 EJB 的 @javax.ejb.Singleton
注解來指定單例對象。
如果電影服務的上下文根路徑為 http://localhost/ms,而樓主希望將服務部署到 http://localhost/ms/rest 下面,只需要寫一個類:
@ApplicationPath("rest") public class RestApplication extends Application { }
@ApplicationPath
注解指定所有服務的相對基址,如果為空字符串,則直接使用上下文根路徑。另一種配置方式是在 web.xml 文件中進行聲明,那是為了使 JAX-RS 能在 Servlet 容器(例如 Tomcat)中運行,此處略過。這項配置必不可少,否則無法部署服務。
很好很強大,現在開始編寫電影服務類 MovieService
,先看看聲明和初始化:
@Singleton @Path("movie") public class MovieService { private AtomicInteger ai; private ConcurrentMap<Integer, Movie> movieMap; @PostConstruct private void init() { ai = new AtomicInteger(); movieMap = new ConcurrentHashMap<>(); int id = ai.getAndIncrement(); movieMap.put(id, new Movie().setId(id).setTitle("Avatar")); }
因為樓主只需要一個“內存數據庫”,所以用單例對象即可,此處使用 CDI 的 @javax.inject.Singleton
來聲明單例。@Path
聲明了一個服務,它指示 MovieService
負責處理發送到 http://localhost/ms/rest/movie 的請求。路徑的拼接方式非常直觀。init
方法帶有 @PostConstruct
注解,因此將在 MovieService
構造完成后立即調用,它向 movieMap
中存入了一個 ID 為 0 的 Movie
對象。為簡化代碼,Movie
的設置方法都返回 this
,有點偽造構建者模式的味道。
接下來看看如何處理 HTTP 請求。
GET 請求用于獲取一個或多個資源。在本例中用來獲取一部電影的信息:
@GET @Path("{id}") @Produces(MediaType.APPLICATION_JSON) public Movie find(@PathParam("id") int id) { Movie movie = movieMap.get(id); if (movie != null) { return movie; } else { throw new WebApplicationException(Response.Status.NOT_FOUND); } }
該方法標注了 @GET
,表示用來處理向 http://localhost/ms/rest/movie/{id} 發送的 GET 請求。@Path
再次用來綁定路徑,注意其參數 {id}
,它帶有花括號,對應 URI 的最后一段,也正好和方法參數 id
的 @PathParam
的值相對應。這種參數還有很多高級用法,以后再介紹。@Produces
注解指定輸出格式為 JSON。JAX-RS 內置了很多格式,詳見 MediaType
的文檔。如果找到了相應 ID 的對象,則將其返回,JAX-RS 會自動加上響應碼 200 OK;否則拋出異常,錯誤碼為 404 Not Found。
例如,通過瀏覽器訪問 http://localhost/ms/rest/movie/0,得到的結果為 {"@id":"0","@title":"Avatar"}。
POST 請求用于創建一個資源。在本例中用來創建一部電影:
@POST @Consumes(MediaType.APPLICATION_JSON) public Response create(Movie movie) { int id = ai.getAndIncrement(); movieMap.put(id, movie.setId(id)); return Response.created(URI.create(String.valueOf(id))).build(); }
由于沒有 @Path
注解,所以 POST 請求的目標就直接是 http://localhost/ms/rest/movie。Consumes
和 @Produces
相反,表示接受的數據類型,此處 JAX-RS 會自動把 JSON 數據轉換為 Movie
對象。返回的響應碼為 201 Created,并且帶有所創建資源的 URI。
例如,向 http://localhost/ms/rest/movie 發送 POST 請求,正文為 {"@title": "007"},則可以從 FireBug 的網絡監控中看到返回的響應碼,以及頭部中 Location 的值為 http://localhost:8080/rest/service/movie/1。多次發送該 POST 請求,將會創建多個資源,以保證 POST 不是冪等的。
PUT 請求用于創建或更新一個資源。與 POST 不同,PUT 請求要指定某個特定資源的地址。在本例中用來更新一部電影的信息:
@PUT @Path("{id}") @Consumes(MediaType.APPLICATION_JSON) public Response update(@PathParam("id") int id, Movie movie) { movie.setId(id); if (movieMap.replace(id, movie) != null) { return Response.ok().build(); } else { throw new WebApplicationException(Response.Status.NOT_FOUND); } }
更新成功就返回 200 OK,否則返回 404 Not Found。這兒先把 movie
對象的 ID 強制改為 URI 所指定的,以免出現不一致。也可以根據需求,將不一致作為異常處理,給客戶端返回一個錯誤碼。
順便啰嗦一句,反正代碼在自己手中,樓主也可以把 PUT 搞成非冪等的,例如將 PUT 當成 POST 來處理,就像以前把 GET 和 POST 一視同仁那樣。不過咱既然在搞 JAX-RS,就還是要沾染一點 REST 風格,嚴格遵守 HTTP 才是。
DELETE 請求用于刪除一個資源。在本例中用來刪除一部電影:
@DELETE @Path("{id}") public Response delete(@PathParam("id") int id) { if (movieMap.remove(id) != null) { return Response.ok().build(); } else { throw new WebApplicationException(Response.Status.NOT_FOUND); } }
沒什么特別的,該說的前面都說了。
HEAD 和 OPTIONS 請求就忽略吧,用得不太多,也同樣挺簡單的。
JAX-RS 服務的部署和部署常規 Web 程序一樣,打包成 war 文件就可以了。最后贊一下 NetBeans 可以為 REST 風格的服務自動生成測試頁面,很好用,雖然在 Firefox 下頁面顯示不正常(對此我已經提了一個 bug),但 IE 是可以的。
JAX-RS(JSR 311 - Java™ API for RESTful Web Services,用于 REST 風格的 Web 服務的 Java™ API)是 Java EE 6 規范的一部分,其目標在于簡化和標準化用 Java 開發 REST 風格的 Web 服務。雖然 Java EE 6 剛出爐的時候,樓主也從頭到尾看過這份規范,但苦于沒有實際的項目練手,看過又忘了,現在最多算達到大成傻逼的境界。這次邊看邊寫,期望完成后至少能破入小成牛逼。先從 REST 本身開始。
REST(REpresentational State Transfer,代表性狀態傳輸)自稱是一種風格而非標準,這在樓主看來有炒作的嫌疑。如果僅僅是一種風格,那么不同的框架如何兼容?所以才有 JAX-RS 的誕生。REST 最大的貢獻是帶來了 HTTP 協議的復興。為什么叫復興呢?本來 HTTP 的功能挺豐富的,可惜長期以來只被用作傳輸數據,大好青年被埋沒了。樓主記得剛開始學 Servlet 的時候,一向是把 doGet
和 doPost
兩個方法一視同仁的,因為書上這么教,很多 Web 框架也這么搞,以至于弄了很久才搞清楚 GET
和 POST
是兩種不同的請求。現在 REST 拍磚說道,HTTP 早就定義好了一堆操作,以前一直被混淆使用,現在應該重新賦予它們本來的意義了,而且如果充分發揮 HTTP 的功能,完全能夠勝任分布式應用的開發(傳說中的 SOA)。
SOA 的理念在于將系統設計為一系列可重用的、解耦的、分布式的服務。這也不是新鮮玩意兒了,早期有 CORBA,稍晚有 SOAP 等等。REST 作為后起之秀,能夠快速崛起,也必有其非同尋常的特色。下面一一列舉。
系統中的每個資源都可以通過唯一標識符來訪問。小插一句,“標識”的正確讀音是 biāozhì。REST 使用 URI(統一資源標識符)管理資源的地址。URI 的概念不解釋。一個 URI 可以指向一個或者多個資源。
實際上強調了 HTTP 操作的原意。REST 主要使用了 GET、PUT、DELETE、POST、HEAD 和 OPTIONS 這 6 種操作。此處有兩個曾經被忽略的 HTTP 概念:冪等(idempotent)和安全(safe)。冪等應該是 HTTP 從數學借來的一個術語(原始的數學意義樓主也不懂),意味著若干次請求的副作用與單次請求相同,或者根本沒有副作用。GET、PUT、DELETE、HEAD 和 OPTIONS 都是冪等的:GET、HEAD 和 OPTIONS 都是讀操作,容易理解;PUT 用于創建或更新已知 URI 的資源,多次創建或更新同一個資源顯然和一次的效果相同;DELETE 刪除資源,亦然。安全表示操作不會影響服務器的狀態。GET、HEAD 和 OPTIONS 是安全的。POST 既不冪等又不安全,因為和 PUT 不同,POST 創建資源的時候并不知道資源的 URI,所以多個 POST 請求將會創建多個資源。
表象這個詞有點拗口,傳聞在一個 REST 風格的系統中,服務端和客戶端之間傳輸的咚咚就是表象……表象可以是純文本、XML、JSON……或者自編的山寨格式。唉,不就是數據么?只不過可以用任意的格式來傳輸,因為 HTTP 正文里面啥都能放。Content-Type
頭用來聲明格式,一般是 MIME(多用途因特網郵件擴展),像 text/plain
、text/html
、application/pdf
這些。MIME 可以帶屬性,例如 text/html; charset=UTF-8
。
REST 服務器只管理資源,而不會像 Web 服務器一樣記錄客戶的會話狀態,這些應該由客戶端來管理,如此就能增強 REST 服務器的伸縮性。此處的客戶端可以是客戶端程序、瀏覽器,甚至一個 Web 應用。總之,REST 只負責庫管啦!
猛詞砸來了!HATEOAS = Hypermedia As The Engine Of Application State,超媒體作為應用狀態的引擎,怎么看起來比 SaaS(Software as a Service,軟件作為服務)還要嚇人呢?超文本不過是一只紙老虎,超媒體也瞞不過樓主的天眼:超媒體就是是由文字、圖像、圖形、視頻、音頻……鏈成一體的大雜燴!很簡單的一個例子,有些坑爹的電影網站經常發布一些內嵌了廣告的電影,播放的時候會彈出廣告窗口,里面很多鏈接,你去點兩下就中招了:這個電影文件就算是超媒體。
其實這個詞最關鍵的地方是“狀態引擎”。例如樓主在去網購,先選了幾個東西,接下來可以干啥呢?可以繼續選、可以把購物車清空、可以結賬……樓主可以從現狀“轉換”到其他某些狀態,而控制狀態轉換的那個咚咚就被冠名為狀態引擎。多么聰明的詞匯啊!樓主發現凡是高手都是造詞磚家呀!用超媒體來控制狀態轉換,就是 HATEOAS:你是要繼續看電影還是看廣告?看哪個廣告?自己慢慢考慮……
REST 相比 CORBA、SOAP、WS-* 之流確實獨樹一幟,但也難逃玩弄概念的嫌疑。記得大學里講數據庫的老師說過:“你們現在學了這么多理論,其實以后在工作中未必管用。在大街上隨便找一個軟件培訓學校出來的小伙子,他哪兒懂什么第二第三范式啊?但卻能把數據庫玩兒得飛轉!”
考慮兩個具有一對一關聯的實體類,受控方除了有一個自己的主鍵,還有一個引用主控方的外鍵。如果主控方和受控方是同生同滅的關系,換句話說,雙方的一對一關聯一旦確立就不可更改,就可以考慮讓雙方共享相同的主鍵,簡化受控方的表結構。下面就讓樓主通過實例來說明如何用 JPA 2.0 來實現這種映射。
盯著眼前的電腦,樓主想到了一個也許不太貼切的例子:員工和公司配的電腦的關系。員工的主鍵就是工號。雖然電腦可能被換掉,但電腦實體完全可以用工號做主鍵,只是把電腦配置的詳細信息改掉而已。此處的難點就在與如何將電腦的主鍵字段同時映射一個員工,請看樓主是怎么一步步推導出來的。
一開始是最想當然的寫法:
public class Computer implements Serializable { @Id @OneToOne private Employee employee; // 此處省略若干行 }
然而根據規范,只有這些類型可以作為主鍵:Java 原始類型(例如 int
)、原始包裝類型(例如 Integer
)、String
、java.util.Date
、java.sql.Date
、 java.math.BigDecimal
和 java.math.BigInteger
。所以直接拿 Employee
做主鍵是不行的。順便提一下,也許某些 JPA 實現自己做了擴展,使得可以直接拿實體類做主鍵,這已經超出了 JPA 規范的范疇,此處不討論。
直接映射是行不通了,那有什么間接的方式嗎?這時樓主想起了一個特殊的注解:EmbeddedId
。該注解的本意是用于聯合主鍵,不過變通一下,是否可以將 Employee
包裝進一個嵌入式主鍵,然后再將這個嵌入式主鍵作為 Computer
的主鍵以達到目的?帶著這種想法,樓主有了下面的代碼:
@Embeddable public class ComputerId implements Serializable { @OneToOne private Employee employee; // 此處省略若干行 }
public class Computer implements Serializable { @EmbeddedId private ComputerId id; // 此處省略若干行 }
現在又出現了新的問題:JPA 不支持定義在嵌入式主鍵類中的關聯映射。好在天無絕人之路,EmbeddedId
的文檔中直接指出,可以使用 MapsId
注解來間接指定嵌入式主鍵類中的關聯映射,而且還附帶了一個例子!于是最終的成品就出爐了:
@Embeddable public class ComputerId implements Serializable { private String employeeId; // 此處省略若干行 }
public class Computer implements Serializable { @EmbeddedId private Employee employee; @MapsId("employeeId") @OneToOne private Employee employee; // 此處省略若干行 }
唯一的遺憾是,邏輯上的主控方 Employee
現在不得不成為受控方了,好在可以定義級聯操作來達到差不多的效果:
public class Employee implements Serializable { @Id private String id; @OneToOne(mappedBy = "employee", cascade = CascadeType.ALL) private Computer computer; // 此處省略若干行 }
雖然做到了,但確實挺繞的。希望未來版本的 JPA 能直接支持將實體類作為主鍵,樓主個人覺得不是一個技術問題。
JSF 都 2.0 了,尼瑪居然還是無法識別 multipart/form-data
(至少參考實現 Mojarra 如此),綁定的屬性一個都讀不出來,坑爹啊!!!既然官方不支持,咱就自己搞一個補丁,讓它不從也得從。
說到底,JSF 的屬性綁定功能不外乎是利用 FacesServlet
幫我們把參數進行轉換和校驗,然后拼裝成受管 Bean。而 FacesServlet
必定是通過 HttpServletRequest
的相關方法來讀取請求參數,因此只需要在 FacesServlet
之前增加一個過濾器,把文本類型的 Part
參數轉換為普通參數就可以了。至于文件類型的 Part
,則可以使用一些第三方工具來綁定,例如使用 PrimeFaces 將文件綁定到 File
對象。下圖是這種思路的流程:
第二步的過濾器就是給 JSF 打的“補丁”:
/** * 該過濾器幫助 {@link FacesServlet} 識別 {@code multipart/form-data} 格式的 POST 請求。 */ @WebFilter("*.xhtml") public class MultipartFilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String contentType = request.getContentType(); // 判斷請求的格式是否為 multipart/form-data。 if (contentType != null && contentType.startsWith("multipart/form-data")) { MultipartRequest req = new MultipartRequest((HttpServletRequest) request); for (Part part : req.getParts()) { // 如果該 Part 的內容類型不為 null, 那它是一個文件,忽略。 if (part.getContentType() == null) { req.addParameter(part.getName(), decode(part)); } } chain.doFilter(req, response); } else { chain.doFilter(request, response); } } @Override public void destroy() { } /** * 將 {@link Part} 對象解碼為字符串。 */ private String decode(Part part) throws IOException { try (InputStreamReader in = new InputStreamReader( part.getInputStream(), StandardCharsets.UTF_8)) { char[] buffer = new char[64]; int nread = 0; StringBuilder sb = new StringBuilder(); while ((nread = in.read(buffer)) != -1) { sb.append(buffer, 0, nread); } return sb.toString(); } } /** * {@link HttpServletRequest} 中的請求參數映射是只讀的,所以自己封裝一個。 */ private static class MultipartRequest extends HttpServletRequestWrapper { private Map<String, String[]> parameters; public MultipartRequest(HttpServletRequest request) { super(request); parameters = new HashMap<>(); } private void addParameter(String name, String value) { String[] oldValues = parameters.get(name); if (oldValues == null) { parameters.put(name, new String[] {value}); } else { int size = oldValues.length; String[] values = new String[size + 1]; System.arraycopy(oldValues, 0, values, 0, size); values[size] = value; parameters.put(name, values); } } @Override public String getParameter(String name) { String[] values = getParameterValues(name); return values == null ? null : values[0]; } @Override public Map<String, String[]> getParameterMap() { return parameters; } @Override public Enumeration<String> getParameterNames() { final Iterator<String> it = parameters.keySet().iterator(); return new Enumeration<String>() { @Override public boolean hasMoreElements() { return it.hasNext(); } @Override public String nextElement() { return it.next(); } }; } @Override public String[] getParameterValues(String name) { return parameters.get(name); } } }
這兒噴一下,為什么 HttpServletRequest
里面的請求參數映射是只讀的,非得要通過繼承 HttpServletRequestWrapper
這種蛋疼的彎路來黑?
最近閑來無事(樓主確實太懶了),重翻舊賬,搗鼓了下 JPA 2.0,通過不斷地寫代碼和谷歌,又有了一些舊瓶裝新酒的發現和吐槽。樓主將在這一系列文章中慢慢道來。本次開篇帶來的是兩個模板類:用作實體類基礎框架的 AbstractEntity
, 以及實現了對實體的基本 CRUD 操作的 BasicEntityDao
。
一個實體類必須實現 java.io.Serializable
接口,必須有一個 ID 字段作為主鍵,且最好覆蓋 equals
和 hashCode
方法。因為實體類和數據表有對應關系,所以往往根據 ID 來實現 equals
和 hashCode
。這很自然地可以引出一個模板類,所有的實體類都可以從它繼承:
/** * 該類可作為實體類的模板,其 {@link #equals(Object)} 和 {@link hashCode()} 方法基于主鍵實現。 * 子類只需要實現 {@link #getId()} 方法。 */ public abstract class AbstractEntity implements Serializable { /** * 返回主鍵。 */ public abstract Object getId(); @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj == null || getClass() != obj.getClass()) { return false; } return getId() == null ? false : getId().equals(((AbstractEntity) obj).getId()); } @Override public int hashCode() { return Objects.hashCode(getId()); } }
針對主鍵的類型,AbstractEntity
可以進一步擴展。例如,可以擴展出一個 UuidEntity
,它使用隨機生成的 UUID 作為主鍵:
@MappedSuperclass public class UuidEntity extends AbstractEntity { @Id private String id; @Override public String getId() { return id; } @PrePersist private void generateId() { // 僅在持久化前生成 ID,提升一點性能。 id = UUID.randomUUID().toString(); } }
繼續發揮想象,讓它支持樂觀鎖:
@MappedSuperclass public class VersionedUuidEntity extends UuidEntity { @Version private int version; }
這兒順便插嘴吐槽下主鍵的類型。用整數還是 UUID 好呢?這個問題在網上也是爭論紛紛。在樓主看來,兩者各有優劣:整數主鍵性能高,可讀性也好,但會對數據遷移,例如合并兩個數據庫,造成不小的麻煩,因為可能出現一大堆重復的主鍵;UUID 性能差些,看起來晃眼,雖然據說有些數據庫針對性地做了優化,想來也不大可能優于整數,不過好處就是理論上出現重復主鍵的概率比中彩票還小(福彩除外)。說這么一大堆,其實還是蠻糾結啊……樓主一般傾向于用 UUID,只要服務器的配置夠勁,想來不會出現明顯的性能問題。
接下來說說 BasicEntityDao
,它提供了基本的 CRUD 實現,可以用來為會話 Bean 做模板:
/** * 提供了對實體進行基本 CRUD 操作的實現,可作為會話 Bean 的模板。 */ public abstract class BasicEntityDao<T> { private Class<T> entityClass; private String entityClassName; private String findAllQuery; private String countQuery; protected BasicEntityDao(Class<T> entityClass) { this.entityClass = Objects.requireNonNull(entityClass); entityClassName = entityClass.getSimpleName(); findAllQuery = "select e from " + entityClassName + " e"; countQuery = "select count(e) from " + entityClassName + " e"; } /** * 返回用于數據庫操作的 {@link EntityManager} 實例。 */ protected abstract EntityManager getEntityManager(); public void persist(T entity) { getEntityManager().persist(entity); } public T find(Object id) { return getEntityManager().find(entityClass, id); } public List<T> findAll() { return getEntityManager().createQuery(findAllQuery, entityClass).getResultList(); } public List<T> findRange(int first, int max) { return getEntityManager().createQuery(findAllQuery, entityClass) .setFirstResult(first).setMaxResults(max).getResultList(); } public long count() { return (Long) getEntityManager().createQuery(countQuery).getSingleResult(); } public T merge(T entity) { return getEntityManager().merge(entity); } public void remove(T entity) { getEntityManager().remove(merge(entity)); } }
子類只需要提供 getEntityManager()
的實現即可。假設樓主要做一個養雞場管理系統,對雞圈進行操作的會話 Bean 就可以簡單地寫成:
@Stateless public class CoopDao extends BasicEntityDao<Coop> { @Persistence private EntityManager em; public CoopDao() { super(Coop.class); } @Override protected EntityManager getEntityManager() { return em; } // 更多方法…… }
multipart/form-data
類型的 HTTP 請求的直接支持,我們從此就可以擺脫諸如 Apache Commons FileUpload 之類的第三方依賴。然而,該支持太過單純,所以還要多做點事情,以便能更有效地進行工作。我將在本文中介紹兩個輔助方法。 閱讀全文