高级指南
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配置的时候,需要格外的当心。