高級指南
1.1 架構考慮#
應用中通常有兩種方式來保存二進制數據:將數據保存到服務器的文件系統中,或者直接保存到數據庫中。當然這兩種實現各有利弊,使用文件系統非常容易,而使用數據庫則具有事務處理支持,但兩者都有通病,那就是很難擴展。
這一節需要向讀者着重強調的是,Play中提供的play.db.jpa.Blob類型,與java.sql.Blob類型有很大的區別。在Play中聲明爲play.db.jpa.Blob類型的屬性默認將數據存儲在數據庫之外的文件當中,並沒有在數據庫中使用帶有BLOB的java.sql.Blob類型。在服務器端,Play默認將上傳的文件存儲到應用的data/attachments/目錄下。文件名稱(UUID)和MIME類型拼接的字符串被作爲SQL中VARCHAR類型存儲在數據庫屬性中。
如果需要將數據存儲到數據庫表中,可以將@javax.persistence.Lob註解添加到模型屬性前。帶有@Lob註解的數據便會作爲BLOB或者CLOB類型存儲到數據表中。
1.2 上傳文件並存儲到服務器中#
在Play中實現文件的上傳,存儲和共享都非常簡單,因爲框架自動將HTML表單中提交的文件綁定到JPA模型中,而且Play提供的便捷的方法使得共享文件如同顯示文本一樣簡單。
首先,在應用中定義User模型,該模型用於存儲上傳文件。將photo屬性定義爲play.db.jpa.Blob類型:
package models;
import play.db.jpa.Blob;
import play.db.jpa.Model;
import javax.persistence.Entity;
@Entity
public class User extends Model {
public Blob photo;
}
模型定義完成後,在視圖文件中添加上傳文件的表單。爲了綁定JPA模型,上傳文件的name屬性設置爲user.photo:
#{form @addUser(), enctype:'multipart/form-data'}
<input type="file" name="user.photo">
<input type="submit" name="submit" value="Upload">
#{/form}
在控制器中添加相應的Action方法,使用新建的模型對象來保存上傳的文件:
public static void addUser(User user) {
user.save();
index();
}
因爲文件上傳是由框架自動處理的,所以代碼看起來除了保存JPA實體以外,並沒有做其他事情。實際上,框架內部幫我們完成了如下一系列的操作:首先,在Action方法執行之前,上傳的文件會被保存到應用的tmp/uploads子目錄。當實體保存之後,上傳的文件使用UUID命名並被複制到data/attachments目錄下。最後當Action方法執行完畢時,刪除臨時文件。
如果需要將文件保存到其他目錄,可以在conf/application.conf文件中指定路徑,可以是絕對路徑,也可以是Play應用目錄的相對路徑:
attachments.path=photos
爲了使上傳的圖片更加直觀,我們爲模板文件中增加<img>標籤,用於顯示上傳的圖片:
#{list items:models.User.findAll(), as:'user'}
<img src="@{userPhoto(user.id)}">
#{/list}
最後,在控制器中增加Action方法,用於加載模型並返回上傳的圖片:
public static void userPhoto(long id) {
final User user = User.findById(id);
notFoundIfNull(user);
response.setContentTypeIfNotSet(user.photo.type());
renderBinary(user.photo.get());
}
1.3 更新上傳#
如果讀者希望更新上傳的文件,也很容易,只需在請求參數中提供user.id,就可以使用save()方法來更新實體:
public static void updateUser(User user) {
user.save();
index();
}
上傳的更新文件會以新的UUID命名並保存,這意味着原始的文件會失去關聯。如果服務器沒有足夠的空間,那麼就必須實現清理策略。本節介紹以下幾種可行的方案:
- 比較蠻力的方式是使用異步Job定期查詢所有當前使用文件類型的JPA模型,掃描attachments文件夾,然後刪除沒有關聯的文件。這種方法很難在大規模的應用中使用。
- 針對某些應用,在模型中維護所有文件之前版本的引用是非常有意義的。這樣我們可以在用戶界面顯示這些舊版本的文件信息,就像wiki通常會保存每個頁面之前的版本那樣。清理工作可以是手動,也可以是新文件上傳或者異步Job進行觸發。清理的策略可以是保存一定數量的版本,或者刪除指定日期之前的版本。使用@PreUpdate和@PreRemove JPA攔截器能夠很好地處理以上問題。
- 比較極端的做法是直接使用BinaryField字段來實現具有事務的上傳更新。
1.4 文件刪除#
如果我們刪除帶有play.db.jpa.Blob屬性的對象,attachments文件夾中的文件不會自動刪除,我們需要通過引用java.io.File屬性來手動進行刪除,具體操作如下:
public static void deleteUser(long id) {
final User user = User.findById(id);
user.photo.getFile().delete();
user.delete();
index();
}
我們也可以將文件刪除封裝到模型裏,只要在User.java中覆蓋_delete()方法,就可以在數據庫實體成功刪除後自動執行文件刪除操作。
@Override
public void _delete() {
super._delete();
photo.getFile().delete();
}
1.5 上傳文件並保存文件名#
如果讀者需要保存原始上傳文件的名稱,我們可以在服務器端將文件擴展名映射爲MIME類型,這樣就可以以原始文件名的方式保存文件了。
我們需要將表單控制綁定到具有java.io.File類型的Action方法參數中以便得到文件名,這意味着我們需要在控制器中創建一個新的Action方法,將單獨的表單參數構建爲模型對象,而不是像第一個例子那樣直接綁定模型對象。
首先,在User模型中添加photoFileName屬性:
@Entity
public class User extends Model {
public String photoFileName;
public Blob photo;
}
新建Action方法addUserWithFileName(),實例化模型對象以及初始化Blob屬性:
public static void addUserWithFileName(File photo) throws FileNotFoundException {
final User user = new User();
user.photoFileName = photo.getName();
user.photo = new Blob();
user.photo.set(new FileInputStream(photo),
MimeTypes.getContentType(photo.getName());
user.save();
index();
}
接着修改視圖層的模版,顯示上傳後的圖片文件名稱。由於使用新的控制器方法爲addUserWithFileName(),文件上傳控件的名稱也需要做相應的改變(將user.photo改成photo):
#{list items:models.User.findAll(), as:'user'}
<img title="${user.photoFileName}" src="@{userPhoto(user.id)}">
#{/list}
#{form @addUserWithFileName(), enctype:'multipart/form-data'}
<input type="file" name="photo">
<input type="submit" name="submit" value="Upload">
#{/form}
1.6下載文件#
當我們訪問二進制數據(比如圖片)時,可能會以正常的方式直接在瀏覽器中顯示。比如之前通過URL訪問的圖片資源就是直接在瀏覽器中顯示的。但是,我們可以設置HTTP頭來通知瀏覽器將文件作爲附件的形式下載到用戶的計算機中,而不是直接顯式地在瀏覽器中呈現。
首先,創建實現下載功能的Action方法。假設文件名已經被正確設置,實現下載功能的Action方法與userPhoto()方法唯一不同的地方是需要將文件名作爲renderBinary()方法的參數傳遞,這樣Play就會設置Content-Disposition響應頭來提供文件名。
public static void downloadUserPhoto(long id) {
final User user = User.findById(id);
notFoundIfNull(user);
response.setContentTypeIfNotSet(user.photo.type());
renderBinary(user.photo.get(), user.photoFileName);
}
修改模版文件,增加鏈接指向圖片下載的URL:
#{list items:models.User.findAll(), as:'user'}
<a href="@{downloadUserPhoto(user.id)}">
<img src="@{Application.userPhoto(user.id)}">
</a>
#{/list}
1.7 自定義content type#
我們知道,瀏覽器響應的內容是在控制器的Action方法中設置的。框架的play.libs.MimeTypes類負責查找給定文件擴展名的MIME類型,詳細類型列表參見$PLAY_HOME/framework/src/play/libs/mime-types.properties文件。
從Play的1.2版本開始,我們可以在conf/application.conf配置文件中添加自定義類型。比如,增加以xcf爲擴展名的GIMP圖片的MIME類型:
mimetype.xcf=application/x-gimp-image
設置content type的例子只適用於addUserWithFileName()方法,因爲方法會隱式查找基於原始文件名的MIME類型。最早介紹的addUser()例子使用的MIME類型是HTTP請求文件上傳時發送的。
JPA(Java Persistence API)是Java持久化的規範,但JPA本身並不是持久化的具體實現。這就好比規定了所有的手機都必須能打電話。針對以上這個比喻,JPA充當的角色只是手機能打電話這條約束,而不是電話本身。我們能說手機提供了通話功能,但是不能說通話功能就是一個手機,而且很顯然手機提供了通話功能之外的其它很多附加的能力。同樣的,JPA的實現者也可以根據自身的情況,提供基本功能之外的附加功能。
目前JPA的實現主要有:Hibernate,OpenJPA, Toplink,JDO等。Play默認使用了Hibernate的實現,JPA作爲一種規範,因此也可以使用其他任意的JPA實現來替代Hibernate。
JPA的核心概念是通過註解或XML來描述對象與表的映射關係,並將運行期的實體對象持久化到數據庫中。Play在JPA的基礎上提供了一套非常實用的輔助類來簡化對JPA實體的管理,使開發更加便捷。
開發過程中仍然可以通過原生JPA提供的API來管理實體。
2.1 啓用JPA實體管理#
Play會自動查找標記爲javax.persistence.Entity註解的類,@javax.persistence.Entity註解的作用是通知Play對當前實體類進行管理,默認情況下實體類通過Hibernate的Entity Manager進行管理。當然以上所有的前提是必須正確配置JDBC數據源,否則Play將無法進行持久化操作。
2.2 獲得JPA實體管理器#
JPA實體管理器啓動後,程序代碼中就可以通過JPA輔助類來取得被管理的實體。例如:
public static void index(){
Query query = JPA.em().createQuery("from Article");
List<Article> articles = query.getResultList();
render(articles);
}
2.3 事務管理#
事務(Transaction)是訪問並可能更新數據庫中各種數據項的程序執行單元。Play會自動進行事務管理,在每次發送HTTP請求時自動開啓事務,發送HTTP響應完畢後提交事務。如果程序在request/response過程中拋出了異常,事務會自動回滾。當然也可以顯式調用JPA.setRollbackOnly()方法通知JPA不要提交當前事務,而在代碼中對事務進行強制回滾。
Play爲事務管理控制提供了註解的支持。如果需要將Action聲明爲只讀事務,可以在Action方法上標記@play.db.jpa.Transactional(readOnly=true)註解;如果執行當前方法時無需開啓事務,可以在Action方法上標記@play.db.jpa.NoTransaction;如果當前控制器中所有方法都無需開啓事務,可以直接在控制器上標明@play.db.jpa.NoTransaction。這樣做的好處是,大大提升了應用的性能,因爲添加@play.db.jpa.NoTransaction註解後Play不會從連接池中獲取連接。
2.4 play.db.jpa.Model輔助類#
play.db.jpa.Model是Play中主要的JPA輔助類,它提供了大量的輔助方法,簡化程序對JPA的訪問。需要做的就是使JPA實體類繼承play.db.jpa.Model。如下是繼承Model的Post實體類:
@Entity
public class Post extends Model {
public String title;
public String content;
public Date postDate;
@ManyToOne
public Author author;
@OneToMany
public List<Comment> comments;
}
play.db.jpa.Model類默認包含了一個自增長的長整型id字段,並將其作爲主鍵。這通常是比較好的方案:讓自增長的長整型id作爲JPA實體的主鍵(技術主鍵),並指定另一字段作爲實體的功能主鍵。
Play將實體的成員變量的訪問權限定義爲public,從而無需編寫大量的setter/getter方法。
2.5 使用GenericModel自定義ID#
Play並不強制要求實體類都繼承play.db.jpa.Model,某些情況下應用不需要自增長型的id字段作爲實體類的主鍵,可以選擇將JPA實體類繼承自play.db.jpa.GenericModel。
下例是User實體的映射。其中實體的id屬性是UUID(通用唯一識別碼 Universally Unique Identifier),name和mail屬性是必需的,並且使用Play驗證器來驗證簡單的業務規則。
@Entity
public class User extends GenericModel {
@Id
@GeneratedValue(generator = "system-uuid")
@GenericGenerator(name = "system-uuid", strategy = "uuid")
public String id;
@Required
public String name;
@Required
@MaxSize(value=255, message = "email.maxsize")
@play.data.validation.Email
public String mail;
}
2.6 查找對象#
play.db.jpa.Model 類提供多種方法用於數據查找。
通過ID查找
findById()是最常用的查找對象方法,可以根據實體對象的Id進行查找:
Post aPost = Post.findById(5L);
查找所有對象
findAll()用來檢索所有對象:
List<Post> posts = Post.findAll();
以下方法實現的效果與findAll()等價:
List<Post> posts = Post.all().fetch();
採用以下寫法可以對查詢結果進行分頁:
// 最多匹配100篇 post
List<Post> posts = Post.all().fetch(100);
//從第50篇post開始查找,並且最多匹配到第100篇post
List<Post> posts = Post.all().from(50).fetch(100);
使用精簡語句查詢
Play提供了非常語義化的查詢表達式來創建查詢語句,但是隻支持一些簡單的條件查詢。
Post.find("byTitle", "My first post").fetch();
Post.find("byTitleLike", "%hello%").fetch();
Post.find("byAuthorIsNull").fetch();
Post.find("byTitleLikeAndAuthor", "%hello%", connectedUser).fetch();
簡單的查詢語句必須遵循固定語法規則:“[Property][Comparator]And?”。其中Comparator有以下幾種形式:
- LessThan – 小於給定的值
- LessThanEquals – 小於等於給定的值
- GreaterThan – 大於給定的值
- GreaterThanEquals – 大於等於給定的值
- Like – 與SQL語法中like相似,但屬性總是會被轉換成小寫
- Ilike – 與Like相似,但是大小寫敏感,會將參數全部轉換成小寫
- Elike – 與SQL語法中like等價,不會進行大小寫轉換
- NotEqual – 不等於
- Between – 在兩個數值之間(需要兩個參數)
- IsNotNull – 不爲空(不需要參數)
- IsNull – 爲空(不需要參數)
使用JPQL查詢
在Play中也可以書寫完整的JPQL語句進行查詢:
Post.find(
"select p from Post p, Comment c " +
"where c.post = p and c.subject like ?", "%hop%"
);
也可以在JPQL語句中只寫條件部分的查詢:
Post.find("title", "My first post").fetch();
Post.find("title like ?", "%hello%").fetch();
Post.find("author is null").fetch();
Post.find("title like % and author is null", "%hello%").fetch();
Post.find("title like % and author is null order by postDate",
"%hello%").fetch();
甚至可以只寫order by的部分,對查詢結果進行升降排序:
Post.find("order by postDate desc").fetch();
2.6 查找對象#
play.db.jpa.Model 類提供多種方法用於數據查找。
通過ID查找
findById()是最常用的查找對象方法,可以根據實體對象的Id進行查找:
Post aPost = Post.findById(5L);
查找所有對象
findAll()用來檢索所有對象:
List<Post> posts = Post.findAll();
以下方法實現的效果與findAll()等價:
List<Post> posts = Post.all().fetch();
採用以下寫法可以對查詢結果進行分頁:
// 最多匹配100篇 post
List<Post> posts = Post.all().fetch(100);
//從第50篇post開始查找,並且最多匹配到第100篇post
List<Post> posts = Post.all().from(50).fetch(100);
使用精簡語句查詢
Play提供了非常語義化的查詢表達式來創建查詢語句,但是隻支持一些簡單的條件查詢。
Post.find("byTitle", "My first post").fetch();
Post.find("byTitleLike", "%hello%").fetch();
Post.find("byAuthorIsNull").fetch();
Post.find("byTitleLikeAndAuthor", "%hello%", connectedUser).fetch();
簡單的查詢語句必須遵循固定語法規則:“[Property][Comparator]And?”。其中Comparator有以下幾種形式:
- LessThan – 小於給定的值
- LessThanEquals – 小於等於給定的值
- GreaterThan – 大於給定的值
- GreaterThanEquals – 大於等於給定的值
- Like – 與SQL語法中like相似,但屬性總是會被轉換成小寫
- Ilike – 與Like相似,但是大小寫敏感,會將參數全部轉換成小寫
- Elike – 與SQL語法中like等價,不會進行大小寫轉換
- NotEqual – 不等於
- Between – 在兩個數值之間(需要兩個參數)
- IsNotNull – 不爲空(不需要參數)
- IsNull – 爲空(不需要參數)
使用JPQL查詢
在Play中也可以書寫完整的JPQL語句進行查詢:
Post.find(
"select p from Post p, Comment c " +
"where c.post = p and c.subject like ?", "%hop%"
);
也可以在JPQL語句中只寫條件部分的查詢:
Post.find("title", "My first post").fetch();
Post.find("title like ?", "%hello%").fetch();
Post.find("author is null").fetch();
Post.find("title like % and author is null", "%hello%").fetch();
Post.find("title like % and author is null order by postDate",
"%hello%").fetch();
甚至可以只寫order by的部分,對查詢結果進行升降排序:
Post.find("order by postDate desc").fetch();
2.7 統計對象#
Play提供的count()方法具有統計查詢對象結果的功能:
long postCount = Post.count();
也可以在count()方法中指定條件來統計目標對象數量:
long userPostCount = Post.count("author = ?", connectedUser);
2.7 統計對象#
Play提供的count()方法具有統計查詢對象結果的功能:
long postCount = Post.count();
也可以在count()方法中指定條件來統計目標對象數量:
long userPostCount = Post.count("author = ?", connectedUser);
2.8 顯式地持久化對象#
Hibernate會將數據庫中查詢到的結果以對象緩存的形式維護起來。當實體管理器在進行查詢匹配等操作的過程中,數據一直作爲持久對象存在。這意味着如果事務沒有提交,對象的任何改變都將自動持久化到數據庫中。JPA的規範是這樣定義的:對象的修改默認與事務過程一致,所以無需顯式地調用任何方法就可以持久化修改後的數據。
這種全自動的持久化管理,也有不足的地方,因爲我們並不總是希望對象一旦修改就被持久化。所以與其通知實體管理器更新一個對象,還不如告訴它哪些對象不需要更新。refresh()方法可以回滾單個實體,在事務提交之前調用,對象將不被持久化。
下例是提交表單後,處理持久化對象的常規方式:
public static void save(Long id) {
User user = User.findById(id);
user.edit("user", params.all()); //修改持久化的對象
validation.valid(user);
if(validation.hasErrors()) {
// 需要顯式拋棄user對象
user.refresh();
edit(id);
}
show(id);
}
Play總結了這個問題,並做了處理過程的改進。所有繼承JPASupport/JPAModel的對象需要顯式地調用save()方法才能實現實體的持久化操作。重寫上例中的代碼:
public static void save(Long id) {
User user = User.findById(id);
user.edit("user", params.all());
validation.valid(user);
if(validation.hasErrors()) {
edit(id);
}else{
user.save();
show(id);
}
}
顯式地調用save()方法在對象級聯的操作中顯得非常繁瑣,通過在註解中聲明cascade=CascadeType.ALL屬性可以很方便的解決這個問題。
2.9 泛型問題#
play.db.jpa.Model中定義了大量的泛型方法,這些方法通過泛型參數來指定方法的返回類型。調用這些方法時,會根據執行上下文返回具體的類型。
例如,findAll()方法的定義如下:
<T> List<T> findAll();
findAll()的使用方法如下:
List<Post> posts = Post.findAll();
方法返回結果中指定了List<Post>,通知Java編譯器確切的類型,泛型T才被解析爲Post類型。但是我們無法直接在循環參數中指定泛型方法返回值類型。所以下面的代碼無法通過編譯,會提示“Type mismatch: cannot convert from element type Object to Post”類型轉換錯誤:
for(Post p : Post.findAll()) {
p.delete();
}
針對這個問題,通常的解決方案是爲其指定中間變量:
List<Post> posts = Post.findAll();
for(Post p : posts) {
p.delete();
}
下面介紹一種實用卻較少用到的Java語言特性,代碼簡潔並更具可讀性:
for(Post p : Post.<Post>findAll()) {
p.delete();
}
開發者在使用JPA的時候需要注意,Play並不支持XA(採用兩階段提交方式來管理分佈式事務)。如果在同一個請求中配置了多個不同的JPA,Play的做法是盡最大可能commit事務。如第一個數據庫的commit成功了,但是第二個數據庫的commit失敗了,第一個commit並不會回滾。所以,開發者在同個請求中使用多個JPA配置的時候,需要格外的當心。