Play 框架手册(3) – 控制器


在Play框架中,商业逻辑在domain model层里进行管理,Web客户端不能直接调用这些代码,domain对象的功能作为URI资源暴露出来。

客户端使用HTTP协议提供的统一API来暗中操作这些底层的商业逻辑实现资源的维护。然而,这些domain对象到资源的映射并不是双向注入的:它可以表示成不同级别的粒度,一些资源可能是虚拟的,某些资源的别名或许已经定义了…

这正是控制器层发挥的作用:它在模型对象和传输层事件之间提供了粘合代码。和模型层一样,控制器也是纯java书写的代码,这样控制器层就很容易访问和修改model对象。和http接口类似,控制器是一个面向Request/Response的程序。

控制器层减少了http和模型层之间的阻抗。

注意!

不同的策略具有不同的架构模型。一些协议可以让你直接访问模型对象。典型代表就是EJB和Corba协议,这种架构风格使用的是RPC(远程过程调用),这样的通信风格和web架构很难兼容。

SOAP协议则试着通过web访问model对象。这是另外一个rpc类型的协议,这种情况,soap使用http作为传输协议,这不是一种应用程序协议。

由于web规则并不是完全面向对象的,所以在这些协议下,针对不同的语言,需要不同的http适配器。

3.1.控制器概览

一个controller就是一个java类,位于controller包下,是play.mvc.Controller的一个子类。

示例:

package controllers;

import models.Client;
import play.mvc.Controller;

public class Clients extends Controller {

    public static void show(Long id) {
        Client client = Client.findById(id);
        render(client);
    }

    public static void delete(Long id) {
        Client client = Client.findById(id);
        client.delete();
    }

}

控制器中的每个public、static方法叫做Action(动作)。action动作方法签名总是如下:

public static void action_name(params...);

在action方法签名里可以定义参数,这些参数值会被框架自动从相应的http参数里找到。

通常情况下,一个action方法不包括return字段,方法退出是通过调用result方法完成的,在上面的示例里,render()就是一个result方法,用于执行和显示一个模板。

3.2.获取http参数

一个HTTP请求包含有数据。这些数据可以从如下渠道提取:

  • URI路径:比如/clients/1541请求, 1541就是URI范示的动态部分
  • 请求字符串:/clients?id=1541
  • 请求体:如果是通过html窗体发送的请求,请求体就包含有以x-www-urlform-encoded方式编码的窗体数据
    所有这些情况下,play都会自动进行数据提取,并存入同一个Map<String, String[]> ,这里面包含有所有的HTTP参数。key就是参数名name,具体从以下方式确定:
  • URI范示的动态部分名称(和在routes文件里指定的一样)
  • 查询字符串里的name-value pair中的name
  • x-www-urlform-encoded体的内容

使用params map

params对象是一个可用于任何控制器类的变量(在play.mvc.Controller超类中定义的),这个对象包含了所有从当前http请求找到的参数。

比如:

public static void show() {
    String id = params.get("id");
    String[] names = params.getAll("names");
}

你也可以让Play帮助完成类型转换:

public static void show() {
    Long id = params.get("id", Long.class);
}

请等一等,你有更好的方式完成类型转换,如下:

还可以从action方法签名实现转换

你可以直接从action方法签名里取回http参数。但Java参数的名称必须和http参数的名称相同:

比如下面的请求:

/clients?id=1451

一个action方法可以通过在其方法签名里声明一个id参数来取回id参数值:

public static void show(String id) {
    System.out.println(id); 
}

还可以使用其他java类型,比如String。在这种情况下,框架将试着预测参数值的正确类型:

public static void show(Long id) {
    System.out.println(id);  
}

如果某个参数具有多个值,则可以声明一个数组参数:

public static void show(Long[] id) {
    for(String anId : id) {
        System.out.println(anid); 
    }
}

或是一个集合类型:

public static void show(List<Long> id) {
    for(String anId : id) {
        System.out.println(anid); 
    }
}

例外情况!

如果在action方法参数里找不到http参数对应的参数,则相应的方法参数将默认设置为默认值(对象类型为null,数字类型为0)。如果能够在action方法参数里找到对应参数,但框架不能预测需要的java类型, play将在validation error集合里增加一个错误,并使用默认值。

3.3.高级HTTP Java绑定

简单类型

所有本地的和通用的java类型都是自动进行绑定的:

int, long, boolean, char, byte, float, double, Integer, Long, Boolean, Char, String, Byte, Float, Double

注意:如果http请求的参数丢失或自动类型转换失败,对象类型将置null,简单类型将设置为其默认值。

Date类型

如果日期的字符串输出匹配下面的范示,,日期对象将进行自动绑定:

  • yyyy-MM-dd’T’hh:mm:ss’Z’ // ISO8601 + 时区
  • yyyy-MM-dd’T’hh:mm:ss” // ISO8601
  • yyyy-MM-dd
  • yyyyMMdd’T’hhmmss
  • yyyyMMddhhmmss
  • dd’/‘MM’/’yyyy
  • dd-MM-yyyy
  • ddMMyyyy
  • MMddyy
  • MM-dd-yy
  • MM’/‘dd’/’yy

使用@As注释,你可以指定日期格式:

archives?from=21/12/1980
public static void articlesSince(@As("dd/MM/yyyy") Date from) {
    List<Article> articles = Article.findBy("date >= ?", from);
    render(articles);
}

你也可以依照对应的语言调整日期格式:

public static void articlesSince(@As(lang={"fr,de","*"}, 
        value={"dd-MM-yyyy","MM-dd-yyyy"}) Date from) {
    List<Article> articles = Article.findBy("date >= ?", from);
    render(articles);
}

在这个示例里,我们假定法语和德语的日期格式为dd-MM-yyyy,其他语言的日期格式为MM-dd-yyyy。请注意lang和value可以用逗号进行分隔。最为重要的是lang的数字型参数匹配的是value的数字型参数。

如果没有指定@As注释,那么play将依照访问者的时区使用默认的日期格式。date.format configuration可以指定默认的日期格式。

Calendar 日历

日历可以精确与日期进行绑定,除非play依照你的时区来选择Calendar对象,还可以使用@Bind注释。

File

在play里,实现文件上传非常简单。首先使用一个multipart/form-data编码格式的请求来传送文件到服务器,然后使用ava.io.File类型来取回上传的文件对象:

public static void create(String comment, File attachment) {
    String s3Key = S3.post(attachment);
    Document doc = new Document(comment, s3Key);
    doc.save();
    show(doc.id);
}

上传到服务器的文件名称和原始文件名称一致。首先,上传的文件会暂时保存在服务器的tmp临时目录,当请求结束时,该文件将被删除。因此,你必须把这个文件复制到安全的目录。

上传文件的MIME类型,通常情况下在http请求的Content-type header里已经 指定。然而,当从一个Web浏览器上传文件时,一些不常见的文件可能不会为其指定MIME类型。在这种情况,你可以手工使用play.libs.MimeTypes类映射文件的扩展名到某个MIME类型。

String mimeType = MimeTypes.getContentType(attachment.getName()); 

play.libs.MimeTypes 类将查找 $PLAY_HOME/framework/src/play/libs/mime-types.properties 里设定的文件扩展名来得到MIME类型。

使用 Custom MIME types configuration 配置,你也可添加你自己的 MIME 类型。

支持类型的数组或集合

所有支持类型都可以当作对象的集合或数组取回:

public static void show(Long[] id) {
    …
}

或:

public static void show(List<Long> id) {
    …
}

或:

public static void show(Set<Long> id) {
    …
}

Play也可以处理特殊的Map<String, String>绑定,比如:

public static void show(Map<String, String> client) {
    …
}

下面这个查询字符串:

?client.name=John&client.phone=111-1111&client.phone=222-2222

将绑定client变量到一个带有两个元素的map。第一个元素为name:John(key/value),第二个元素为phone: 111-1111, 222-2222(同一个key,两个值)。

POJO对象绑定

使用同样的命名转换规则,Play也可自动对任何model类进行绑定。

public static void create(Client client ) {
    client.save();
    show(client);
}

使用上面这个create方法,创建一个client的查询字符串可以是下面这个样式:

?client.name=Zenexity&client.email=contact@zenexity.fr

Play将创建一个Client实例,同时把从http参数里取得的值赋值给对象中与http参数名同名的属性。不能明确的参数将被安全忽略,类型不匹配的也会被安全忽略。

参数绑定是通过递归来实现的,也就是说你可以采用如下的查询字符串来编辑一个完整的对象图(object graphs):

?client.name=Zenexity
&client.address.street=64+rue+taitbout
&client.address.zip=75009
&client.address.country=France

使用数组标记(即[])来引用对象的id,可以更新一列模型对象。比如,假设Client模型有一列Customer模型声明作为List Customer customers。为了更新这列Customers对旬,你需要提供如下查询字符串:

?client.customers[0].id=123
&client.customers[1].id=456
&client.customers[2].id=789

3.4.JPA 对象绑定

使用http到java的绑定,可以自动绑定一个JPA对象。

比如,在http参数里提供了user.id字段,当play找到id字段里,play将从数据库加载匹配的实例进行编辑,随后会自动把其他通过http请求提供的参数应用到实例里,因此在这里可以直接使用save()方法,如下:

public static void save(User user) {
    user.save(); // ok with 1.0.1
}

和在POJO映射里采取的方式相同,你可以使用JPA绑定来编辑完整的对象图(object graphs),唯一区别是你必须为每个打算编辑的子对象提供id:

user.id = 1
&user.name=morten
&user.address.id=34
&user.address.street=MyStreet 

3.5.定制绑定

绑定系统现在支持更多的定制化。

@play.data.binding.As

首先是@play.data.binding.As注释,这个注释使上下方相关的配置进行绑定成为可能。比如,你可以使用这个注释来明确指定日期必须使用DateBinder进行格式化:

public static void update(@As("dd/MM/yyyy") Date updatedAt) {
    …
}

As注释也提供了国际化支持,也就是说你可以为每个时区提供一个特定的注释:

public static void update(
        @As(
            lang={"fr,de","en","*"},
            value={"dd/MM/yyyy","dd-MM-yyyy","MM-dd-yy"}
        )
        Date updatedAt
    ) {
    …
}

As可以和所有支持它的绑定一起工作,包含你自己的绑定,比如使用ListBinder:

public static void update(@As(",") List<String> items) {
    …
}

这个绑定简单使用逗号分隔List里的String。

@play.data.binding.NoBinding

play.data.binding.NoBinding注释允许你标记一个非绑定字段,以解决潜在的安全问题。比如:

public class User extends Model {
    @NoBinding("profile") public boolean isAdmin;
    @As("dd, MM yyyy") Date birthDate;
    public String name;
}

public static void editProfile(@As("profile") User user) {
    …
}

在这种情况下, isAdmin字段绝不会被editProfile action绑定, 即使某个恶意用户在伪造的窗体post里写入了user.isAdmin=true代码。

play.data.binding.TypeBinder

As注释也允许你定制一个绑定。一个定制绑定必须是TypeBinder接口的子类,比如:

public class MyCustomStringBinder implements TypeBinder<String> {

    public Object bind(String name, Annotation[] anns, String value, 
    Class clazz) {
        return "!!" + value + "!!";
    }
}

这样,你就可以在任何action里使用这个绑定了,比如:

public static void anyAction(@As(binder=MyCustomStringBinder.class) 
String name) {
    …
}

@play.data.binding.Global

作为选择,你可以定义一个全局性的定制绑定,这样的绑定将应用于相应的类型。比如,为java.awt.Point类定制了一个绑定,如下:

@Global
public class PointBinder implements TypeBinder<Point> {

    public Object bind(String name, Annotation[] anns, String value, 
    Class class) {
        String[] values = value.split(",");
        return new Point(
            Integer.parseInt(values[0]),
            Integer.parseInt(values[1])
        );
    }
}

正如你所看到的,一个全局性绑定就是一个使用@play.data.binding.Global进行注释的传统绑定。通过这种方式,一个外部模块可以将其定制的绑定应用到一个项目里,这就为定义一个可重用的绑定扩展提供了方法。

3.6.结果类型

一个action方法必须生成一个http response响应,最简便的方法就是放出一个结果Result对象。当一个Result对象被放出时,常规的执行流程被中断,方法退出。比如:

public static void show(Long id) {
    Client client = Client.findById(id);
    render(client);
    System.out.println("这个消息永远不会显示~! ");
}

render(…)方法放出一个Result对象,并停止方法执行。

返回一些文本类型的内容

renderText(…)方法放出一个简单的Result事件,只在底层的http Response里写入了一些简单的文本,比如:

public static void countUnreadMessages() {
    Integer unreadMessages = MessagesBox.countUnreadMessages();
    renderText(unreadMessages);
}

你也可使用java标准的格式化语法来格式化输出文本消息:

public static void countUnreadMessages() {
    Integer unreadMessages = MessagesBox.countUnreadMessages();
    renderText("There are %s unread messages", unreadMessages);
}

返回一个JSON字符串

Play使用renderJSON(…)方法来返回一个JSON字符串。这个方法的作用是设置响应内容为application/json,同时返回一个JSON字符串。

你可以指定你自己的JSON字符串,或把这个字符串传递到一个通过GsonBuilder进行串行化的对象里,比如:

public static void countUnreadMessages() {
    Integer unreadMessages = MessagesBox.countUnreadMessages();
    renderJSON("{"messages": " + unreadMessages +"}");
}

当然,如果面对一个更加复杂的对象结构,你可能会希望使用GsonBuilder来创建这个JSON字符串。

public static void getUnreadMessages() {
    List<Message> unreadMessages = MessagesBox.unreadMessages();
    renderJSON(unreadMessages);
}

当传递一个对象到rndoerJSON(…)方法时,如果你需要对JSON构建器进行更多控制,那么你可以把JSON串行化并把对象输入到定制的输出里。

返回一个XML字符串

和JSON方法一样,这里有几个可以直接从控制器渲染XML的方法。renderXml(…) 方法返回一个内容类型设置为text/xml的XML字符串。

在这里,你可指定自己的xml字符串,传递一个org.w3c.dom.Document对象,或传递一个将被XStream串行化的POJO对象,比如:

public static void countUnreadMessages() {
    Integer unreadMessages = MessagesBox.countUnreadMessages();
    renderXml("<unreadmessages>"+unreadMessages+"</unreadmessages>");
}

当然,你也可以使用org.w3c.dom.Document对象:

public static void getUnreadMessages() {
    Document unreadMessages = MessagesBox.unreadMessagesXML();
    renderXml(unreadMessages);
}

返回二进制内容

向用户返回一个存储在服务器上的二进制文件需要使用renderBinary()方法。比如你有一个User对象,并且有一个play.db.jpa.Blob的图片属性。这时,就可以使用如下语句在一个控制器方法里加载这个模型对象,并使用对应的MIME类型渲染这个对象里的图片:

public static void userPhoto(long id) { 
   final User user = User.findById(id); 
   response.setContentTypeIfNotSet(user.photo.type());
   java.io.InputStream binaryData = user.photo.get();
   renderBinary(binaryData);
} 

作为附件下载文件

通过设置http header,可以指示web浏览器把一个二进制响应当作“附件”来对待,这样就可以在web浏览器把文件下载到用户的电脑。为了完成这个任务,可以把一个文件的名称传递给renderBinary方法,并作为它的参数,这时play会自动设置Content-Disposition响应header。比如,当上面示例里的User模型作为一个photoFileName属性时:

renderBinary(binaryData, user.photoFileName); 

执行一个模板

如果生成的内容,你应该使用模板来生成response内容。

public class Clients extends Controller {

    public static void index() {
        render();    
    }
}

模板名称且根据play约定自动进行推断。默认模板路径就是控制器和Action的名称。
在上面这个示例里调用的模板路径和名称如下:

app/views/Clients/index.html

向模板作用域添加数据

通常情况下,模板是需要数据的。你可以使用renderArgs对象添加数据到模板作用域:

public class Clients extends Controller {

    public static void show(Long id) {
        Client client = Client.findById(id);
        renderArgs.put("client", client);
        render();    
    }
}

在模板执行期间,client变量将被定义。

比如:

<h1>Client ${client.name}</h1>

更加简洁的形式添加数据到模板作用域

使用render(…)方法的参数,可以把数据直接传递到模板:

public static void show(Long id) {
    Client client = Client.findById(id);
    render(client);    
}

在这种情况下,模板可以访问的变量与java局部变量的名称完全相同。当然,还可通过这种方式传递多个变量:

public static void show(Long id) {
    Client client = Client.findById(id);
    render(id, client);    
}

非常重要!

你只能通过这种方式向模板传递局部变量。

指定其他模板

如果不想使用默认的模板,可以使用renderTemplate(…)方法指定自己的模板文件,但模板名称要作为第一个参数:

如:

public static void show(Long id) {
    Client client = Client.findById(id);
    renderTemplate("Clients/showClient.html", id, client);    
}

跳转到其他URL

redirect(…)方法放出一个跳转事件, 用于切换生成一个http跳转响应。

public static void index() {
    redirect("http://www.zenexity.fr");
}

Action链

play和Servlet API的forward不同,一个http请求只能调用一个action。如果你想要调用其他的action,就必须跳转浏览器到能够调用这个action的URL。通过这种方式,浏览器URL就总是和被执行的Action保持一致,因此对浏览器的back/forward/refresh管理就非常容易。

在java代码里,通过调用其他action方法,就可以实现发送一个跳转响应到任何action里,比如:

public class Clients extends Controller {

    public static void show(Long id) {
        Client client = Client.findById(id);
        render(client);
    }

    public static void create(String name) {
        Client client = new Client(name);
        client.save();
        show(client.id);
    }
}

上面的示例在使用下面这两条路由的情况下:

GET    /clients/{id}            Clients.show
POST   /clients                 Clients.create 
  • 浏览器发送一个POST到/clients URL
  • 路由调用Clients控制器的create action
  • action方法直接调用show action方法
  • Java调用被中断,反向路由生成器创建一个带id参数的Clients.show方法调用的URL
  • HTTP响应为: 302 Location:/clients/3132
  • 浏览器随后发布GET /clients/3132

定制web编码

play着重使用UTF-8编码,但某些情况下必须使用其他编码。

为当前response定制编码

要为当前response改变编码,可以控制器里参照下面的方法进行定制:

response.encoding = "ISO-8859-1";

当传递一个与服务器默认编码不同的窗体时,你应该在窗体里使用encoding/charset两次,两次都用在accept-charset属性指定上, 而且是用在一个名称叫charset的特殊隐藏窗体上。accept-charset属性会告诉浏览器在传递窗体里要使用哪种编码,form-field charset 则告诉play需要采用哪个编码进行处理:

<form action="@{application.index}" method="POST" accept-charset="ISO-8859-1">
    <input type="hidden" name="_charset_" value="ISO-8859-1">
</form>

为整个应用程序定制编码

配置application.web_encoding 用于指定play与浏览器进行通信时需要采用哪种编码。

3.7.拦截器

在控制器里,可以定义拦截器方法。拦截器将被控制器类及其后代的所有action调用。可以利用这个特点为所有的action定义一些通用的提前处理代码:比如对用户进行验证、加载请求作用域信息等…

这些方法必须是static的,但不能是public的,并且使用有效的拦截注释:

@Before

用@Before注释的方法将在这个控制器的每个action被调用前执行。因此,可以利用这个注释创建一个安全检查方法:

public class Admin extends Controller {

    @Before
    static void checkAuthentification() {
        if(session.get("user") == null) login();
    }

    public static void index() {
        List<User> users = User.findAll();
        render(users);
    }
    …
}

如果不希望@Before方法中断所有的action调用,可以指定一个需要排除的Action列表:

public class Admin extends Controller {

    @Before(unless="login")
    static void checkAuthentification() {
        if(session.get("user") == null) login();
    }

    public static void index() {
        List<User> users = User.findAll();
        render(users);
    }

    …
}

如果希望@Before方法中断列表中的action调用,可以使用only参数:

public class Admin extends Controller {

    @Before(only={"login","logout"})
    static void doSomething() {  
        …  
    }
    …
}

unless和only 参数也可用于@After, @Before和@Finally注释。

@After

用@After注释的方法将在每个action调用执行完后执行。

public class Admin extends Controller {

    @After
    static void log() {
        Logger.info("Action executed ...");
    }

    public static void index() {
        List<User> users = User.findAll();
        render(users);
    }

    …
}

@Catch

用@Catch注释的方法将在其他action方法抛出指定异常时执行。这个被抛出的异常将作为参数传递给@Catch注释的方法。

public class Admin extends Controller {

    @Catch(IllegalStateException.class)
    public static void logIllegalState(Throwable throwable) {
        Logger.error("Illegal state %s…", throwable);
    }

    public static void index() {
        List<User> users = User.findAll();
        if (users.size() == 0) {
            throw new IllegalStateException("Invalid database - 0 users");
        }
        render(users);
    }
}

和普通的java异常处理一样,你可以捕获一个超类来捕获更多的异常类型。如果不只一个捕获方法,可以指定他们的priority(优先级)参数,以确定执行顺序(1是最高优先级)。

public class Admin extends Controller {

    @Catch(value = Throwable.class, priority = 1)
    public static void logThrowable(Throwable throwable) {
        // Custom error logging…
        Logger.error("EXCEPTION %s", throwable);
    }

    @Catch(value = IllegalStateException.class, priority = 2)
    public static void logIllegalState(Throwable throwable) {
        Logger.error("Illegal state %s…", throwable);
    }

    public static void index() {
        List<User> users = User.findAll();
        if(users.size() == 0) {
            throw new IllegalStateException("Invalid database - 0 users");
        }
        render(users);
    }
}

@Finally

用@Finally注释的方法总是在这个控制器里的每个action调用执行后被执行。用@Finally注释的方法,不管action调用是否成功也会执行。

public class Admin extends Controller {

    @Finally
    static void log() {
        Logger.info("Response contains : " + response.out);
    }

    public static void index() {
        List<User> users = User.findAll();
        render(users);
    }
    …
}

如果 @Finally注释的方法带有一个Throwable类型的参数,那么有异常发生时,异常将会传送到方法里面来。

public class Admin extends Controller {

    @Finally
    static void log(Throwable e) {
        if( e == null ){
            Logger.info("action call was successful");
        } else{
            Logger.info("action call failed", e);
        }
    }

    public static void index() {
        List<User> users = User.findAll();
        render(users);
    }
    …
}

控制器继承

如果一个控制器类是其他控制器类的子类,那么拦截器也会按照继承顺序应用于相应层级的子类。

使用@With注释添加更多的拦截器

由于java不允许多继承,通过控制器继承特点来应用拦截器就受到极大的限制。但是我们可以在一个完全不同的类里定义一些拦截器,然后在任何控制器里使用@With注释来链接他们。

比如:

public class Secure extends Controller {

    @Before
    static void checkAuthenticated() {
        if(!session.containsKey("user")) {
            unAuthorized();
        }
    }
}    

另一个控制器:

@With(Secure.class)
public class Admin extends Controller {

    …
}

3.8.Session 和 Flash 作用域

为了在多个http请求间保存数据,你可以把这些数据存入Session或Flash作用域。存储在Session里的数据在整个用户session期间是可用的,存储在Flash里的数据只在下一个请求里可用。

明白把session和flash数据通过Cookie机制存储在每个请求客户端电脑里而不是存储在服务器上这点很重要,这就确定了数据大小不能超过最多4kb,而且只能存储字符串类型的值。

当然,play已经为cookie指派了一个安全key,因此客户端不能编辑cookie数据(否则cookie将无效)。因此play框架不打算把session用作缓存,如果你需要缓存一些与session相关的数据,你可以使用Play内建的缓存机制,并用session.getId()key来保存某个特定用户session相关的数据,比如:

public static void index() {
    List messages = Cache.get(session.getId() + "-messages", List.class);
    if(messages == null) {
        // Cache miss
        messages = Message.findByUser(session.get("user"));
        Cache.set(session.getId() + "-messages", messages, "30mn");
    }
    render(messages);
}

当你关闭浏览器里,session就过期了。除非对application.session.maxAge进行了配置。

play里的缓存和传统的Servlet HTTP session对象的概念完全不同。千万不要认为这些缓存了的对象会永远在缓存里。因此,play会强迫你去处理缓存丢失的情况,以防止缓存数据丢失,这样就保证了应用程序是完全无状态的。


前一篇:
后一篇:

发表评论