InfoQ: Tapestry for Nonbelievers

引言


Apache Tapestry是一个使用Java语言创建web应用的面向组件的开发框架。Tapestry应用建立在根据组件构建的页面的基础上。这个框架能够提供输入验证(input validation)、本地化/国际化(localization/internationalization)、状态/持续性(state/persitency)管理、URL构建/参数映射(parameter mapping)等功能。



为什么Tapestry值得推荐呢?一部分原因是:



  • 它便于终端用户使用。Tapestry在设计最初就考虑到了应用的安全和伸缩性,有内嵌的Ajax、输入验证、国际化以及异常报告功能。
  • 它便于开发人员使用。Tapestry独一无二的类重加载(class-reloading)特性大大地推动了开发人员的开发效率。借助于 Tapestry,对源代码的修改立马就可以看到结果,不需要重新部署和启动应用 !它的异常报告也极为具体,甚至提供可能的修正建议。
  • 它便于web设计者使用。Tapesry页面是有效的HTML(或XHTML)文件!你可以用自己喜欢的浏览器打开这些页面。
  • 它封装了最佳实践:REST风格的URL、可降解的JavaScript、没有XML的配置等等。
  • 它支持与HibernateSpringSeamAcegi等框架的集成。

本文中,我们会向大家介绍Tapestry框架版本5。我们将利用Tapestry 5开发一个简单的具有创建/读/更新/删除功能的应用,在创建这个应用的过程中,你将体会到Tapestry带来的开发效率的提升。我们会从多方面来讲解 Tapestry应用,比如应用的页面导航(page navigation)、依赖性(dependency)和资源注入(resource injection)、用户输入验证(input validation)和应用状态管理(state management)等等。你还将了解如何应用Tapestry中内嵌的Ajax功能来创建自己的支持Ajax的组件。


本文的目标在于向大家展示如何借助Tapestry在尽可能减小开发花销的情况下创建更漂亮、更好用、更安全、更灵活的应用。


先决条件


开发本文所举例子,需要安装下列软件:



  • Java SE Development Kit (JDK) 5.0版本或更新版本。可以从http://java.sun.com/javase/downloads/下载。
  • Servlet容器,如Apache Tomcat 5.5或更新版本。
  • 可以选择下载安装Apache Maven 2.0.8。在这种情况下,拟不需要再安装一个单独的servlet容器,可以使用Maven来构建并运行Tapestry 5应用(请参考Appendix以获得更多相关信息)。
  • 我们也建议使用当前流行的集成开发环境(IDE),如EclipseNetBeans,你可以使用这些集成开发环境来编辑应用中的Java和HTML文件。

第一个Tapestry 5 应用


开始着手使用Tapestry框架来开发应用的方式有很多,其中一种是下载这里提供的Web archive (WAR) file文件,将它们载入你所选择的IDE中。如果你选择的是结合Web工具的Eclipse的话,那么你需要完成下列步骤:



  • 启动Eclipse并使用Java视图
  • 选择“文件”>“导入”……或者在项目浏览窗口右击鼠标,选择“导入”……
  • 在“导入”对话框中,选择“WAR文件”选项,然后点击“下一步”。
  • 点击“浏览…”,然后从文件系统中选择WAR文件。如果你还没有服务器运行环境的话, 那就需要选择一个已安装的运行环境,比如Apache Tomcat。
  • 点击“结束”,IDE环境会根据导入的WAR文件生成一个web项目。

你也可以使用Apache Maven,在Appendix中有更多关于如何使用quickstart原型来开发Tapestry项目的信息。


在刚创建的这个项目上点击鼠标右键,选择 Run As > Run on Server来启动应用。服务器启动之后,在浏览器地址栏输入URL:http://localhost:8080/app,你会看到如下页面:



第一个Tapestry应用就这样轻松搞定,并且启动运行了。我们来看一下这个项目的目录结构:





在source文件夹下,你可以找到这个示范应用的root包--t5demo。该应用的web.xml部署描述器中,你可以发现一个叫做tapestry.app-package的上下文参数,该参数值就是这个应用包的名字。和几乎所有的Java web开发框架不同的是,Tapestry 5不需要任何XML配置文件。刚刚提到的上下文参数是唯一一个你需要提供的配置。它告诉Tapestry在运行时从哪里可以找到应用的页面、组件以及其它一些必需的类。比如,页面类应该被存储在tapestry.app-package下名为pages的子包中(也就是t5demo.pages),对应地,组件的类则应该存储在t5demo.components中。


第一个Tapestry页面


让我们从第一个Tapestry页面开始这个示范应用的开发旅程。每个Tapestry页面都由一个Java类和Tapestry组件模板合成。组件模板是指那些以“.tml”为扩展名的文件,这里“tml”是Tapestry标记语言(Tapestry Markup Language)的缩写。页面模板和页面通常存储于同一个类包中,但也可以直接存储在web应用的根目录(WebContent)中。Tapestry模板是组织良好的XML文档,你可以以修改XHTML文档同样的方式来对待这些模板。Tapestry的元素和属性都在其对应的XML命名空间中定义,通常都以“t:”作为前缀。在Tapestry中,Start页面等同于“index”页面,如果URL中没有明确指明页面的名字的话,那么Start页面会作为默认页面被调用。


针对本文中的示范应用,我们一起来简单地修改一下Start.tml文件。让我们把这个文件中所有的初始内容删除掉,从头开始,使用下面这段代码来更新这个模板:



Tapestry 5 Demo Application


${helloWorld}


body标签中有这样一个表达式:${helloWorld},这个表达式被称作扩展(expansion)。在这段示范代码中,这个扩展被用来访问对应的页面类t5demo.pages.StarthelloWorld属性。


接下来,我们在t5demo.pages包中创建一个Start类。和几乎所有其他的Java web框架不同的是,Tapestry不会强迫你在基础类的基础上进行扩展,也不强迫你去实现一个特定的接口。Tapestry页面是 POJO(Plain Old Java Objects)。和所有POJO一样,页面类必须声明为public,并且必须拥有一个无参构造函数。你需要考虑的仅仅是应用的root包和它的页面、组件等等的子包。


这个模板中提到了一个叫做helloWorld的属性,那么我们现在就来创建这个属性。Tapestry遵循Sun的JavaBeans代码编写规范,所以我们需要创建一个命名为getHelloWorld()的accessor方法。但为了更好地理解Tapestry的工作原理,我们先不创建这个getHelloWorld()方法,而是创建一个叫做getHello()的方法,看看Tapestry将会有怎样的反应:

package t5demo.pages;

public class Start {

public String getHello(){
return "Hello World!";
}
}

如果这时候你访问这个应用的话,你会看到Tapestry的标准异常页面:



这个异常页面,不仅仅拥有报告错误(拼写错误或者错误的属性名)的作用,它还列出一系列有用的属性以方便开发人员纠正其错误。同时,它还指明了错误在模板中所处的位置,它不单告诉你这个错误发生在哪个文件哪行,甚至能够提供给你错误所处位置前后一小段代码。Tapestry的这个功能比较贴近于开发者的实际需要,也是它本身许多与众不同的优点之一。


现在,我们可以将Start.tml文件中的错误表达式更正为${hello},然后再刷新页面的话,我们就可以看到这样一个正确的页面:



上面这段简单操作中还蕴藏着一个坚定不移的事实,那就是无论你怎样修改代码,Tapestry都能像在检索模板中的修改一样轻松检索到Java类中的修改。使用Tapestry,不需要重新部署应用就能查看到代码修改导致的页面和组件类的改变。在这种方式下,你会发现自己的工作效率空前得高,而且没有什么挫折感。这是使用Tapestry的另一个好处。在Tapestry网站上,你还能查到关于Tapestry 5所具有的重加载(reloading)特性的更多信息。


同一个应用的页面通常使用通用的前后一致的导航元素、整体布局、CSS(Cascading Style Sheets)、版权信息等等。传统的方法通常是采用某种服务器端引入(include)的方式,但Tapestry的方式是创建并使用一个组件。


第一个Tapestry组件


很多Java Web框架都使用SiteMeshTiles来修饰页面。在Tapestry中,一个简单的组件就可以达到这个目的。我们下面将要创建的第一个组件--Layout--它将会被作为所有页面的统一的页面布局。在这个示范应用中,我们选择Blue Freedom设计风格并将它的markup复制到t5demo.components代码包下名为Layout.tml的新文件中,另外,还有它的stylesheet和相关图片也需要复制到这个web应用的根目录(WebContent)下。



正如下面的代码段所示,所有页面所需的一致的内容(title,header,footer等)都需要在这个文件中定义。<?XML:NAMESPACE PREFIX = T />

元素用来指明布置页面特定内容的位置。在显示页面的时候,这个会由页面特定内容取代。


Tapestry 5 Demo Application



Tapestry 5 Demo


Springfield



...




这样一来,那些负责页面布局的HTML元素完全可以从Start页面模板中清除掉,而使用Layout组件来引入公用的HTML代码。实现这个操作很简单,只要采用在Tapestry命名空间中名字和组件类型相吻合的元素就可以了。以这种方式来访问Layout组件的话,Start模板可以简化成如下代码:

<layout xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"><br>     ${hello}<br></layout>

注意<layout>组件包含${hello}的方式:${hello}代表的是页面特定内容,不同的页面含有不同的表单、表格或web应用程序其它类型的页面元素。你可能会产生疑问:“究竟应该是layout还是Layout?”回答都是肯定的,因为Tapestry实际上不区分大小写。</layout>


Tapestry组件--比如页面--都包含一个组件模板和一个Java类,在修改完组建模板之后,现在我们应该来创建类了。你可能已经注意到模板并不指定stylesheet,但大部分的页面显示风格都是衍生自同一个stylesheet(和一些相关图片)。那是因为在Java类中指定 stylesheet其实比在模板中要来得容易!Tapestry专门提供一个注解来达到这个目的:

@IncludeStylesheet("context:style.css")
public class Layout {

}

代码中的“context:”前缀实际上指明了Tapestry寻找指定文件的路径。另外一个选项是“classpath:”,用来引用存储在JAR中的文件。Tapestry负责为客户创建一个可以访问所引用文件的URL。


讲到这里,我们可以跳过那些类似显示“Hello World”的初始应用,直接投入到比较贴近实际的应用中来。我们就从定义模型、定义可显示可编辑的数据对象开始。


自定义模型


我们现在就来为我们的示范应用创建一个简单的域模型。我们即将创建一个CRUD(Create、Retreive、Update和Delete)形式的应用来管理用户,每个用户信息如下所示:



要想实现这样的结果,我们需要为应用创建这样一个模块类:

package t5demo.model;

public class User {
private long id;
private String name;
private String email;
private Date birthday;
private Role role = Role.GUEST;
...
}

User类中的属性id专门用来标识用户,在外部存储(比如数据库)中担当主键。其中用户角色(role)是用enum类型来描述的:

package t5demo.model;

public enum Role {
ADMIN, USER, GUEST
}

开发人员通常选择持续存储介质--比如数据库--来存储模型类,并通过数据库访问对象(DAO——database access objects)从数据库中读取数据。在下一面的章节中,我们将一起创建一个DAO并将它集成到Tapestry页面中去。


Tapestry IoC


Tapestry IoC(控制反转-Inversion of Control)是Tapestry的一个子框架,它充当对象或者说服务容器的角色。Tapestry本身包含超过120个互相关联的服务,而 Tapestry IoC这个子框架是为了专门容纳开发者自行开发的专用服务而设计的。你也可以把IoC容器想象成主要关注于管理服务对象的生命周期以及将这些服务对象互相关联起来的EJB的一个流水线版本。该子框架中用来关联各服务的部分叫作依赖注射(Dependency Injection-DI)。


Tapestry IoC框架设计的灵感来自于一些众所周知的容器(如SpringGuiceApache HiveMind)的特性,它将简单、灵活、易于部署、快速启动和超强功能等优点集于一身。


看到上面列出的这些容器,你可能会因此产生疑问:为什么不直接使用Spring或Guice呢?为什么要重新开发一个框架?实际上,Tapestry曾经收到关于生命周期和扩展性方面的一些需求,就这两个方面来说,目前现存的其它容器的表现不太令人满意,而这些特别的需求正是Tapestry IoC框架存在的理由。另外,Tapestry IoC可以单独使用,不需要总是捆绑着Tapestry。关于Tapestry IoC更详细的信息,请参阅Tapestry IoC文档


DAO(Data Access Object--数据访问对象)是从外部资源--例如数据库--访问数据的标准模式。在示范应用中,我们将采用UserDAO服务来读取User实例。这里需要指出的是,Tapestry IoC和Guice称之为“服务”,但在Spring中使用bean一词。

public interface UserDAO {
List<user> findAllUsers();<br> User find(long id);<br> void save(User user);<br> void delete(User user);<br> User findUserByName(String name);<br>} </user>

正如上面这段代码所示,该接口拥有一个简单的DAO所需的所有方法。由于DAO的编码实现并不特别,跟本文关系不大,所以这里就不再多费口舌。但在实际编写应用的时候,你还需要一个对象-关系(Object-Relational)映射工具(例如Hibernate),当然可能性更大的是选择Tapestry 5 Hibernate集成。本例中,我们将简单地使用java.util.ArrayList来模拟数据库,避免其它任何偏离本文主旨的配置。


现在,我们需要告诉Tapestry在UserDAO服务被调用的时候应该实例化哪个实现。出于这个目的,我们需要修改t5demo.services应用服务包中的AppModule类。如果你还记得的话,在上文讨论目录结构的时候,我们曾提到t5demo是该示范应用的根类包,services这个子包应该专门用于应用类,而AppModule类则是定义该应用特定服务的地方,也是配置服务并将服务内建到Tapestry的地方。在AppModule类中,我们还需要将UserDAO服务的接口和它的实现捆绑起来:

public class AppModule {
public static void bind(ServiceBinder binder) {
binder.bind(UserDAO.class, UserDAOImpl.class);
}
}

一举搞定,上面这段代码中的ServiceBinder会将UserDAO接口捆绑到UserDAOImpl实现上。UserDAOImpl类有一个默认构造函数,UserDAOImpl类的实例化能够通过Java Reflection API实现。Tapestry 5会创建一个UserDAOImpl的新实例并在页面或服务调用UserDAO的时候注射该实例。这一系列的动作都不需要任何XML配置。为调用UserDAO的类编写单元测试也相当简单。由于所有服务默认的实现模式是singletons,因此只可能创建一个UserDAOImpl实例,多线程间或应用的多个用户之间共享这个唯一的实例。


此外,我们还需要在“假数据库”中预备一些数据以供使用。因此,我们需要在UserDAOImpl中添加一个初始化函数,并在构造函数中调用此初始化函数。

public class UserDAOImpl implements UserDAO {

public UserDAOImpl() { createDemoData(); }

public void createDemoData() {
save(new User("Homer Simpson", "homer@springfield.org", new Date()));
save(new User("Marge Simpson", "marge@springfield.org", new Date()));
save(new User("Bart Simpson", "bart@springfield.org", new Date()));
...
}

....
}

这下就该显示用户信息了。


网格组件


在我们准备创建的第一个页面中,我们将使用表格的形式来显示 Users列表的内容:



创建这个新版本的Start页面需要结合下列四个元素:



  • 统一风格的Layout组件
  • Tapestry中用来处理表格元素的Grid组件
  • 访问Users列表的UserDAO 服务
  • 集成其它所有元素的Start页面本身

上一章节中,我们描述了Tapestry 5 IoC的一些基本概念,而在本章节中,我们将会应用上文AppModule所创建的服务。我们只需要声明一个 t5demo.services.UserDAO类型的私有成员,并为其添加 @Inject注解,就可以实现UserDAO服务的注射。 Tapestry会自行根据服务类型查找到这个UserDAO,然后通过field将这个UserDAO连接到相关页面上。

public class Start{

@Inject
private UserDAO userDAO;

public List<user>getUsers() {<br> return userDAO.findAllUsers();<br> }<br>}</user>

这个页面的模板非常简单,只包含一个Grid 组件实例。在模板中,必须声明Grid的source参数才能得到想要在页面上显示的数据。本例的source是由getUsers()返回的List。

<layout xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"><br><grid source="users"></grid><br></layout>

我们可以看到,表格的每一行代表一个 User。每行数据都来自于一个User实例的属性,这些数据根据其本身类型在显示时自动格式化,比如“birthday”一栏中的数据就自行格式化成本地日期。Tapestry幕后其实还做了一些额外的工作,比如将Role enum中的字段转变成更易于用户阅读的格式(例如用“Guest”替代“GUEST”)。除此以外,页面上显示的这个表格还拥有排序功能。假如显示数据源所需的行数超出一定的界限,Grid会自动添加新页面以显示所有的数据。每页能够显示的行数可以通过设置 Grid'srowsPerPage参数来限定。


按照缺省,Grid列的顺序和User类中各个getter的顺序一致。Grid的reorder参数值的设置可以定制列的顺序,参数值的各个属性名之间必须使用逗号分隔。

<layout xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"><br><grid source="users">reorder="role,email,name,id,birthday"/&gt;<br></grid></layout>

定制列顺序之后的Start页面显示如下:



id这样的属性其实对于终端用户来说没有什么意义,所以没有必要显示在页面。这时候就需要使用Grid组件中的exclude参数,这个参数专门用来指明哪些属性是可以跳过不用显示的。

<layout xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"><br><grid source="users">exclude="id,email"/&gt;<br></grid></layout><layout xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"><grid source="users"></grid></layout>

在声明了exclude参数之后,我们的Start页面修改为如下显示:



本文的下续章节中还会提到Tapestry的一个强大组件,可以直接利用JavaBeans来生成用户输入表单。为了保持域模型的一致,我们还需要另一种方法来隐藏用户的某些属性,使其不在页面上显示,那就是对那些不应该在用户界面显示的属性加注@NonVisual注解。在Tapestry中,不仅仅 Grid能够识别这个元信息,还有其它一些组件也能够识别(请参考本文中BeanEditForm的相关章节)。这里,我们仅简单地演示如何隐藏User 的id属性。

public class User {
...
@NonVisual
public Long getId() { return id; }
}

除了添加注解之外,Grid组件的声明也需要改用简单方式:

<layout xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"><br><grid source="users"></grid><br></layout>


这些修改带来的最后显示结果是:



下一步要定制的是添加编辑用户的功能,为此需要创建一个新页面。这会在下文再做讨论,这会儿我们先把它暂时搁置一边。我们首先要做的是添加一个edit链接,这牵涉到覆写Grid组件显示表格的列的方式。在User的name属性上耍一点小花招并不是解决问题的根本方法,我们选择的方案是在页面上添加一个指向负责修改用户信息的页面的链接。


添加链接的工作可以由一个特殊的模板元素--<parameter>来达成,这个元素可以快速地将组件模板的一部分作为参数传递给组件。 </parameter>


<parameter>元素的name决定了需要覆写处理方式的属性,而name的命名规则是在属性名的后面加上后缀Cell(此外,表格中每列的属性名也可以通过添加Header前缀进行覆写)。 </parameter>


本例中的元素名为nameCell,因此,Grid组件将通过PageLink组件来处理表格中包含用户名的列。PageLink组件显示的HTML链接将用户导向在page参数中指定的页面,调用这个指定的页面所需的额外信息则在 context参数中定义。本例中,编辑用户资料的是Edit页面,调用该页面时还需要传递一个用户id参数。此外,还需要一个属性来存储即将被编辑的用户对象才能在调用编辑页面的时候传递该用户对应的name和id值。为了实现这个目的,我们需要在Start类中添加一个叫做user的属性,再把这个新建属性捆绑到Grid组件的row参数上。<parameter name="nameCell"><pagelink context="user.id" page="edit"></pagelink></parameter><layout xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"><grid source="users"><span style="FONT-FAMILY: monospace"><br></span></grid></layout>

<layout xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"><br><grid source="users">row="user"&gt;<br><strong><parameter name="nameCell"><br><pagelink page="edit" context="user.id">${user.name}/t:pagelink&gt;<br></pagelink></parameter></strong><br></grid><br></layout>

<layout xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"><grid source="users"><span style="FONT-FAMILY: monospace"></span></grid></layout>


单个属性含有的展开式和其它属性表达式的数量没有任何限制,你也可以使用点标识( dotted notaion)来表示一个或多个属性,使用这种表达式的目的在于读取(在下文还会接触到“编辑”)内嵌属性。


我们能够使用这种方法定制任意数量的Grid的列。


但千万不要忘记在Start类中添加user属性:

public class Start {
...
private User user;
public User getUser() { return user; }
public void setUser(User user) { this.user = user; }

}

如果Edit页面连起码的一个对应类都没有的话,Tapestry自然会因为无法知道如何去调用这个Edit页面而抛出异常。

public class Edit {
}

我们可以稍后再补充完整Edit页面的内容。


到目前位置,我们得到的完整的Start页面显示如下:



假如我们想在页面的表格中添加一个不属于User类属性的列的话,那要怎么做呢?比如,我们想添加一栏专门用来删除不想要的user记录。这时候就要用到virtual属性,但可能会稍微多费一点功夫。


Grid组件通过先生成一个BeanModel来决定如何显示一个bean,例如本例中的User对象。我们可以通过创建和定制这个BeanModel来完完全全地控制Grid的操作方式。还有一些新对象必须注射到Start页面中:



  • org.apache.tapestry.services.BeanModelSource-- 负责为特定的bean类创建BeanModel的服务
  • org.apache.tapestry.ComponentResources -- 提供BeanModelSource需要的一些框架方面的功能

在创建的BeanModel中定义我们“人工添加的”列“delete”。

public class Start {
...
@Inject
private BeanModelSource beanModelSource;

@Inject
private ComponentResources resources;

public BeanModel getModel() {
BeanModel model = beanModelSource.create(User.class, false, resources);
model.add("delete", null);
return model;
}

}

在Start页面的模板中,我们还需要修改另外两处:首先要显示地告知Grid组件它应该应用哪个模型。其次,为显示这个新添加的delete列提供一个<parameter>定义。</parameter>


ActionLink组件可以用来监控什么时候用户作出了删除某个用户记录的指令。在收到这个指令的时候,ActionLink会触发Start页面上的一个事件。通过为该事件函数提供特殊命名来观察这个事件。通常来说,id为“delete”的组件在被触发了某个action的时候会调用名为 onActionFromDelete()的函数。


但问题是究竟要删除的是哪个用户记录?这时候我再一次需要将用户的id作为上下文参数传递,id的值会作为参数传递给所触发的事件处理函数。

<layout xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"><br><grid source="users" row="user">model="model"&gt;<br><parameter name="nameCell">...</parameter><br><strong><parameter name="deleteCell"><br><actionlink t:id="delete" context="user.id">Delete/t:actionlink&gt;<br></actionlink></parameter></strong><br></grid><br></layout><layout xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"><grid source="users" row="user"></grid></layout>

Start类中,我们提供这样一个事件处理函数:

public class Start {
...
void onActionFromDelete(long id) {
user = userDAO.find(id);
if (user != null)
userDAO.delete(user);
}

}

事件处理函数没有必要一定是公有的,其实最好的选择是作为整个类包的私有函数(也就是函数不需要显式声明修饰符)。Tapestry可以调用这样的函数,同时同一个类包中的其它类也能够调用该函数(比如单元测试),但这个函数并不属于该页面类的公共API。


在所有这些修改之后,Start页面修改为如下显示,用户从该页面还可以链接到Edit页面:



导航模式


上文提到,我们需要创建一个能够修改User的页面,我们称之为Edit页面。在这一小节中,我们将描述怎样从Start页面链接到Edit页面。出于这个目的,我们首先要做的是在web应用的根目录下创建一个叫作Edit.tml的模板(之前,我们已经创建了一个 t5demo.pages.Edit空类)。然后,我们要详细描述的是Tapestry 5中的导航逻辑,关于edit逻辑会在下面一个小节中讲到。

<layout xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"><br><h1>Edit/Create Citizen</h1>
<br></layout>

由于页面是用来修改用户记录的,所以我们需要访问UserDAO。注射(改为“注入”?)这个服务的方法和之前一样,只要在页面类中创建一个UserDAO类型的私有变量并为该变量加注@Inject注解就可以了:

public class Edit {

@Inject
private UserDAO userDAO;
...
}

Edit页面需要在知道User的id的前提下才能修改相对应的用户记录(Edit页面需要知道要编辑的User的id)。这类导航模式(通常称为 master/detailW主/从)在开发web应用的过程中非常常见。Tapestry 5通过REST形式的页面激活上下文(page activation context)机制显式支持这种导航模式。


REST是具象状态传输( Representational State Transfer )的缩写,是分布式超媒体系统软件构架的一种风格。它的中心原则是URI中标识的资源和通过标准接口来修改用户资源、交换这些资源的表现方式。关于REST的优点,你可以参阅 Wikipedia。在我们的例子中,重要的是看到REST鼓励我们将应用状态添加到URL中,这正是页面激活上下文起作用的地方。


Tapestry中的所有页面都支持页面激活上下文。页面激活上下文包含了那些可以在多个请求中保存的页面状态。根据REST的样例,页面激活上下文是为了保存页面状态才添加到URL中的。这和在HTTP Session中存储页面状态非常相似,但后者需要激活的session,并且页面状态是无法作为书签收藏的。


Tapestry中,页面本身参与提供页面激活上下文信息的工作,并同样参与对这些激活上下文的处理。这些工作主要通过事件处理方法对 “passivate”和“activate”事件的处理来完成。(这些工作主要通过为 “passivate”和“activate”两个事件提供事件处理方法来完成。)对于“passivate”事件,页面应该提供一个(或一组)可以完全代表它内部状态的值。在Edit页面中,这个值就是被编辑的用户记录的id。一旦页面请求(比如表单的递交)得到处理,页面会通过“activate”事件处理方法返回其之前的激活状态,根据这个返回值页面可以重新回复到先前的显式状态。


因此,我们第一步要做的是添加这两个事件处理函数:

public class Edit {
...
private User user = null;
private long userId = 0;

void onActivate(long id) {
user = userDAO.find(id);
userId = id;
}

long onPassivate() {
return userId;
}
}

onActivate()方法需要传递参数值是一个long类型的页面激活上下文,(在方法onActivate()中我们希望用一个long类型的变量作为我们的页面激活上下文,)相对地,onPassivate()方法则返回这个页面激活上下文的值。在激活状态下,Edit页面通过注射(注入)的 UserDAO服务从数据库中读取User记录并将数据存储在私有变量中。


我们接着再来看一下显示了上下文的页面的URL,如果你访问这个演示例子的首页的话,你会发现编辑User记录所访问的URL的形式是这样的:http://localhost:8080/app/edit/3。考虑到Tapestry 中所有页面的名字都区分大小写,你可以发现上面这个URL显示的是Edit页面,该页面所要编辑的User记录的id是3。这个URL很直接很简单,它是 REST风格,所以可以作为书签收藏(我们可以将这个URL保存在浏览器的收藏夹中,过阵子再打开该链接仍然可以得到与当前同样的页面,但如果状态是存储在HttpSession中的话就不可能得到同样的结果了)。


这下我们可以追踪所要修改的User记录了,我们下一步要做的是提供一个用户界面,这在Trapestry中实在再简单不过了。


BeanEditForm组件


上一章节中,我们开始动手创建可以添加或修改用户记录的页面,并为此创建了一个能够维护页面状态并且知晓被编辑的用户记录的页面类。和大部分面向组件的 web框架一样,我们还需要创建一个表单组件和内嵌域。每个模型对象的值都应该与合适的域类型相捆绑(比如短文本使用文本框;boolean值使用 checkbox;enum类型的值使用下拉列表等等)。此外,我们还要考虑到输入验证。刚刚提到的这些都是常用方法,但Tapestry提供一个更简单的方式来完成这些工作。


首先,要为user的私有变量创建一个公有的getter方法,这个私有变量在页面通过页面激活上下文来恢复状态的时候得到初始化:

public class Edit {
...
public User getUser() {
return user;
}

...
}

这些工作到目前为止没有什么难度,不是吗?但还有更简单的方法,只要在模板中添加下列代码就能搞定:

<layout xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"><br><h1>Edit/Create Citizen</h1>
<strong><br><div id="section">
<br><beaneditform t:id="form" t:object="user"></beaneditform><br>
</div></strong><br></layout>


添加这些代码的目的在于应用Tapestry中一个叫做BeanEditForm的组件。这个组件只需要一个参数对象,而这个参数必须与我们想要编辑的对象属性相捆绑。你所要做的就只是实现这个捆绑。如果你现在刷新Edit页面的话,会看到页面上显示的是一个自动生成的表单,表单中包含了要编辑的模型类的所有属性。



Tapestry 5又一次为开发者省去了很多工作。我们现在有了一个实用的用户界面,并且得益于Grid组件,这个接口还有很大的定制空间。页面上显示的这个表单处理地很仔细,域标签都对camel风格的命名进行了适当的处理(比如“userName”会显示为“User Name”);enum类型的数据自动采用包含所有选项的下拉框(选项值也自动修改为易读的形式);对于可编辑的日期属性则自动使用日历弹出窗口。 BeanEditFormBean也识别@NonVisual注解,所以id同样被略过没有显示在页面上。由于Grid和BeanEditForm组件都利用BeanModel,所以两者之间互相兼容。


BeanEditForm组件非常灵活,能够提供很多定制选项:



  • 可以通过简单地重新排列源代码中getter的顺序来重新排列自动生成的表单中各个域的顺序,当然也可以通过编写另外的源代码实现。
  • 可以通过@NonVisual注解或者组件参数中提供的以逗号分隔的属性来隐藏某些特定域。
  • 完全可以用定制的代码对特定域采取不同的处理方式,以此来替代自动生成的表单中的某些特殊输入文本域(比如密码域)。
  • 可以采用修改Grid组件元信息同样的方式来动态修改待编辑bean的元信息。
  • 可以修改表单中各个域的标签名,当然也可以得到全面的国际化(internationalization)支持。
  • 可以向表单中添加按钮或者移除按钮。

在编辑表单样例中要做的第二步是验证。由于我们一直在使用表单自动生成功能,设定验证限制最好的地方是在模型对象中。在这个对象中添加验证限制功能,只需在这个特殊域的访问函数上添加@Validate注解。

public class User {
...
@Validate("required")
public String getName() {
return name;
}

@Validate("required,regexp")
public String getEmail() {
return email;
}
...
}

在我们的例子中,我们只要求用户输入用户名和email地址。@Validate注解可以包含一个用来描述验证的限制规则string参数。该参数的语法对于Tapestry 4的用户来说应该不会陌生,它最关键的地方在于使用逗号把各个限制条件分隔开来。限制条件的名字可以是requiredmaxminmaxLengthminLength或者regexp。正规表达式验证需要两个参数:正规表达式本身以及验证失败时页面需要向用户显示的信息。这两个验证参数都很长,而且由于其中包含的特殊字符使得正规表达式验证并不适合成为模板的一部分。但幸运是,Tapestry页面和应用都可以拥有一个信息目录来存储长而复杂的字符串。建立这个信息目录需要在t5demo.pages类包中创建一个叫做Edit.properties的文件。在我们这里所举的例子中,表达式的键是mail-regexp,这个键由需要验证的属性名与验证器名字组成。定义验证失败时所显示的信息的键名是在mail-regexp的基础上再添加一个-message后缀。

 email-regexp=^[a-zA-Z0-9._-]+@[a-zA-Z0-9-]+\.[a-zA-Z.]{2,5}$
email-regexp-message=Provided email is not valid

Tapestry中所有表格都默认激活客户端验证。如果你在没有填写所有文本域的情况下递交表单,那么你立马会看到下图黑框中的提示信息:






很明显,客户端的验证有时候和服务器端的验证相重复。假如你禁止javascript的话,在你提交表单之后会看到如下的错误警告消息,这个警告消息就是在服务器端验证域值时生成的:






一旦用户从客户端提交了表单,服务器端会选择执行适当的action。假设我们所提交的表单中没有任何验证错误,那么BeanEditForm组件会触发一个“success”(表单提交成功)事件。这时候,我们需要提供一个相应的事件处理函数onSuccess() (这个函数会被匹配到任何组件触发的“success”事件,但BeanEditForm是这儿唯一触发该事件的对象,所以不需要做额外的声明)。接下来,我们将不仅仅执行一个action(在数据库中更新User的数据),还将从编辑用户页面回到Start页面。

public class Edit {
...
public Object onSuccess() {
userDAO.save(user);

return Start.class;
}
...
}

Tapestry采用一种特别的方式来解析事件处理函数所返回的值:将它作为指定回复页面的命令。无论是返回一个页面的实例、或页面类、甚或是页面的名字,Tapestry都能将用户导向一个恰当的页面。Tapestry还支持其他类型的返回值(想要了解Tapestry所能支持的所有类型的返回值,请参考Tapestry文档)。


内嵌在Tapestry中的验证是有局限性的,尽管它们无论在客户端还是服务器端都能够执行,但在验证的范围上却很有限。这些验证能做的都只是文法上的验证,局限于验证定义中所提供的字符。比如,我们想要验证一个业务逻辑来确认当User名字被修改的时候,修改所输入的新名字在数据库中仍然是唯一的。


在这种情况下,Tapestry会在“success”事件触发前先触发一个叫做“validateForm”的事件。

public class Edit {
...

@Component
private BeanEditForm form;

public void onValidateForm() {
User anotherUser = userDAO.findUserByName(user.getName());
if (anotherUser != null && anotherUser.getId() != user.getId()) {
form.recordError("User with the name '" + user.getName() + "' already exists");
}
}
}

正如上面这段代码,我们可以通过 BeanEditForm.recordError()方法在程序中添加需要在表单上显示的错误警告消息。


在实现了编辑现存用户记录的功能之后,我们需要再考虑一下如何添加新用户。BeanEditForm有一个非常漂亮的处理方式:如果被编辑的属性值是null,那么它会自动创建一个新的相关类的实例,并将这个新的实例返回页面。借用BeanEditForm的这个处理方式,我们只要添加一个user变量的公有setter就可以达到添加用户的目的。在页面激活时,我们需要额外注意的是传递的用户id不能为0。

public void onActivate(long id) {
if (id > 0) {
user = userDAO.find(id);
this.userId = id;
}
}

现在如果我们将用户的id传递给Edit页面的话,页面会允许你修改对应的用户记录。但如果id没有传递成功或者被传递的id是0的话,那么Edit页面会自动创建一个新的User实例。


Tapestry 5中的Ajax


Ajax是Asynchronous JavaScript and XML(异步JavaScript和XML)的缩写,专门用来创建动态的、对终端用户更为友好的网站和web应用。使用Ajax,web页面能通过客户和服务器之间的异步数据交换来实现灵活的反应和高互动性。Ajax很强大,如若想了解更多Ajax的相关信息,你可以参考Wikipedia


Tapestry对Ajax的支持建立在当前流行的Prototypescript.aculo.us的JavaScript类库之上。由于这两个类库都被打包囊括在Tapestry中,所以在应用这两个类库的时候不需要再为它们做独立的部署或配置。


通常来说,在使用Ajax的应用中,页面会结合用户请求来刷新其中的一部分显示。Tapestry中,ActionLink组件可以用来触发Ajax的action。我们将通过举例来说明其工作原理。在这个例子中,我们会再一次通过修改Grid组件来追加一栏包含浏览用户详细资料链接的信息栏。我们这就一起来添加一个名为“view”的栏来更新Grid模型,这个新添加的栏的内容最后会以ActionLink的形式显示在页面上。我们首先要做的是在BeanModel中手工添加这样一个属性:

public class Start{
...
public BeanModel getModel() {
BeanModel model = beanModelSource.create(User.class, false, resources);
model.add("delete", null);
model.add("view", null);
return model;
}
}

其次,应该进一步添加一个<parameter></parameter><parameter>来定义这个view属性的显示方式。</parameter>

<layout xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"><br><h1>Springfield citizens</h1>
<br><grid source="users" model="model" row="user"><br><parameter name="nameCell">...</parameter><br><parameter name="deleteCell">...</parameter><br><strong><parameter name="viewCell"><br><actionlink t:id="view" zone="viewZone" context="user.id">View</actionlink><br></parameter></strong><br></grid><br></layout><layout xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"><grid source="users" row="user" model="model"></grid></layout>

正如之前提到的那样,ActionLink是触发服务器端的动作的一个组件。如果对触发动作不做任何定制的话, 那么动作的触发会导致整个页面的刷新。参数zone可以告诉ActionLink应该调用Ajax来更新页面的某个区域,这个更新区域的用户id必须是该zone参数的值。Zone组件标识了页面中动态更新的部分。我们先来初试牛刀创建一个用户id为viewZone的Zone。

<layout xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"><br><h1>Springfield citizens</h1>
<strong><br><zone t:id="viewZone"></zone></strong><br><grid source="users" model="model" row="user">...</grid><br></layout>


页面上由Zone更新的内容可以用<block>来定义。<block>从某种形式上来说是具有free-floating特性的<parameter>,可以被注射到组件中,block中的内容不按照默认方式显示。下面这段代码建立了一个id为userDetails的block,这个block专门用来显示存储在页面的detailUser属性中的一些用户属性。</parameter></block></block>

<layout xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"><br><h1>Springfield citizens</h1>
<br><zone t:id="viewZone"></zone><strong><br><block id="userDetails"><br><h2>${detailUser.name}</h2>
<br><ul style="padding-left: 40px;">
<br><li>Identifier: ${detailUser.id}</li>
<br><li>Email: ${detailUser.email}</li>
<br><li>Birthday: ${detailUser.birthday}</li>
<br>
</ul>
<br></block></strong><br><grid source="users" model="model" row="user">...</grid><br></layout>

当用户点击ActionLink view的时候,应用会触发一个action事件,并相应地调用与该事件匹配的onActionFromView函数。该函数需要用户id作为参数传递,进而在数据库中找到相应的用户记录。从数据库中读取的用户记录会存储到私有属性detailUser中,这个属性会在<block>中用到。</block>


正如上文所提到的,action方法返回值类型决定了服务器回复用户请求的类型。在传统非Ajax请求中,action方法返回值类型通常决定了哪个页面是对用户请求的回复。为了实现对页面的部分回复,我们可以返回一个组件或者一个block,返回的这个组件或block的markup会在不刷新整个页面的前提下更新Zone内容。出于这个目的,我们将通过@Inject注解注射刚刚创建的block。需要注意的是,属性名属性类要与组件客户id及组件类保持一致(不然的话,你需要http://tapestry.apache.org/tapestry5/apidocs/org/apache/tapestry/ioc/annotations/Inject.html)。注射以后的block是onActionFromView函数的返回值,如果这个函数被触发,那么block的markup会被插入到对应的Zone中而刷新部分页面。

public class Start{
...
@Inject
private Block userDetails;

private User detailUser;

public User getDetailUser() { return detailUser; }

Block onActionFromView(long id){
detailUser = userDAO.find(id);
return userDetails;
}

}


我们以实例阐述了Ajax最基本的功能--刷新页面的一部分。此类功能在Tapestry 5中非常容易实现,你基本上不需要自己再编写任何JavaScript代码。Tapestry 5中内嵌的JavaScript类库所提供的功能在各浏览器中都兼容。


创建自己的ajax组件


很显然,单一采用页面部分刷新无法完全实现Web应用的交互性,我们还需要为应用创建定制的Ajax组件。本章节中,我们将结合script.aculo.usJavaScript类库中的Ajax.InPlaceEditor来创建一个Tapestry 5组件。我们可以利用InPlaceEditor来编辑页面上的一些文本内容。比如,在鼠标点击特定文本内容的时候,页面自动创建一个以该文本内容为值的输入文本域;用户可以修改文本域中的值,通过提交表单将新值取代文本域中的初始值。


首先,我们采用与CSS导入到Layout中同样的方式来注射含有InPlaceEditor的JavaScript文件--添加@IncludeJavaScriptLibrary注解。需要指出的是,即使一个页面涉及很多组件,并且这些组件中可能有很多都通过@IncludeJavaScriptLibrary注解导入同一个JavaScript文件,你仍然无需担心重复导入的发生。Tapestry对任何资源都只导入一次。


其次,我们还要注射一些组件必需的服务:



  • org.apache.tapestry.ComponentResources --在上一章节中已提过。
  • org.apache.tapestry.PageRenderSupport --用来修饰页面和组件的显示。
  • org.apache.tapestry.services.Request -- HttpServletRequest的一个封装包

我们需要建立一个包含如下内容的 t5demo.components.InPlaceEditor类:

@IncludeJavaScriptLibrary("${tapestry.scriptaculous}/controls.js")
public class InPlaceEditor{

private static final String PARAM_NAME = "t:InPlaceEditor";

@Environmental
private PageRenderSupport pageRenderSupport;

@Inject
private ComponentResources resources;

@Inject
private Request request;

}

接下来要创建的是一个必需的名为value的组件参数,该参数与InPlaceEditor容器属性相捆绑。

public class InPlaceEditor{
...
@Parameter(required = true)
private String value;
...
}

我们再来编写一个用来显示组件的方法。Tapestry组件的显示渲染过程能够被划分为几个阶段。在此之前,我们尚且没有谈论过这些不同的阶段。但出于尽量简化的原则,本文不准备展开对所有阶段的讨论,如果你是在想对这个话题有个全面的了解,那么我们推荐你参考阅读Tapestry文档。


为了对组件显示进行渲染,我们会插入一个AfterRender阶段。这个阶段常常与另一个阶段--BeginRender--结合起来渲染html标识,或者修饰组件模板。我们的组件没有标识也没有任何模板,所以只需要一个AfterRender阶段就够了。插入这个AfterRender阶段还需要实现 afterRender(MarkupWriter)方法。首先可是试着查找markup中元素的名字,如果markup中没有定义任何元素名的话,就取。然后为组件分配一个唯一的 id。接下去,打开这个html元素并对那些非正式参数进行修饰。所谓的非正式参数指的是由模板内部用户提供而非定制组件所定义的那些参数。本例中唯一的正式参数(定制组件所定义的参数)是value。一个典型的非正式参数例子是:style="someCssStyle"。通常,我们并不关心用户提供的Css风格,但我们可以把它存储起来在需要修饰组件的时候应用。在编写了value之后,关闭刚才打开的html元素。最后要做的是--编写创建Ajax.InPlaceEditor的JavaScript代码。


JavaScript“类”--Ajax.InPlaceEditor的构造函数需要三个参数:



  • 第一个是所要编辑的元素的客户id。
  • 第二个是提交修改值的URL。
  • 第三个是包含一些选项的JSON对象。

上节中,我们了解了ActionLink组件能够用来触发服务器端的action。在这个例子中,我们使用ComponentResources服务来创建ActionLink。在这个链接被点击的时候,edit事件会被触发。这个事件的名字会被用来匹配事件触发时应该调用的对应的方法。被传递到InPlaceEditor构造函数的JSONObject只包含一个键/值对,这个键/值用来命名包含有Ajax.InPlaceEditor提交的值的用户请求参数。需要指出的是,PageRenderSupport用来在页面中添加JavaScript代码。该服务会在页面中添加一个JavaScript回滚函数,页面DOM加载时会调用这个回滚函数。在得到最后的HTML之前,我们目前能看到的尽是函数代码:

void afterRender(MarkupWriter writer){
String elementName=resources.getElementName();
if(elementName==null)elementName="span";

String clientId = pageRenderSupport.allocateClientId(resources.getId());
writer.element(elementName, "id", clientId);
resources.renderInformalParameters(writer);
if (value != null)
writer.write(value);
writer.end();

JSONObject config = new JSONObject();
config.put("paramName", PARAM_NAME);

Link link = resources.createActionLink("edit", false);
pageRenderSupport.addScript("new Ajax.InPlaceEditor('%s', '%s', %s);",
clientId, link.toAbsoluteURI(), config);
}

最后要做的是为action方法提供一个匹配的触发事件。点击ActionLink会触发edit事件,那么相应地,所需要添加的action方法应该是onEdit。在这个方法中,我们从特定的请求参数中得到提交的值,并为对应捆绑的属性赋予这个新值。要更新客户端的这个新值,我们向客户端返回一个含有所需数据的TextStreamResponse类型的回复。当然还可以选择其它类型作为返回值来更新客户端数据。



  • 组件或block。上一章节中,我们用到了block来更新Zone。
  • JSON对象
Object onEdit(){
value = request.getParameter(PARAM_NAME);
return new TextStreamResponse("text", value);
}

接下来的例子中,我们将一起使用InPlaceEditor来创建一个页面。为将要编辑的页面添加一个edit属性,再通过@Persist注解使得Tapestry在用户请求中保留文本域的值,然后为edit属性添加公有的getter/setter方法。

public class InPlaceEditorExample {
@Persist
private String edit="Please click here";
public String getEdit() { return edit; }
public void setEdit(String edit) { this.edit = edit; }
}

在模板中把value参数与edit属性捆绑:

<layout xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"><br><h1>Using InPlaceEditor</h1>
<br><div t:type="InPlaceEditor" value="edit"></div>
<br></layout>

最后得到的html代码如下:

...
Please click here

...

Tapestry.onDOMLoaded(function() {
new Ajax.InPlaceEditor('inplaceeditor',
'/app/inplaceeditorexample.inplaceeditor:edit',
{"paramName":"t:InPlaceEditor"});
});

调用页面http://localhost:8080/app/inplaceeditorexample的话,你可以看到edit属性的默认值是“Please click here”。



如果点击这行“Please click here”的话,页面会变成如下所示:



在文本框中输入一个新值,然后点击ok按钮,edit属性的值则会由提交的这个新值所替代。当然我们应该为这个组件继续进一步添加一些实用功能,比如可以添加对edit文本框的验证等等。但本文的目的在于给大家提供一个关于Tapestry功能的全局概览,而不是要开发一个能够用于产品的完备组件包。


总结


我们为大家展示了如何快速并高效地创建一个Tapestry应用。我们曾使用Maven的Java NCSS插件计算应用中非注释的代码行有多少,结果如下:



  • 示范应用中包含205NCSS(non commenting source lines非注释源码行) Java代码。
  • 页面模板有56行,其中存储页面的Layout.tml中占了34行。
  • 两个最大的Java类是UserDAOImpl和User,两者加起来总共68行。
  • 最大的页面类是t5demo.pages.Start,包含27Java代码和12行模板代码。借助于Tapestry 5,我们能够在页面上为用户提供一个可排序可分页并具有删除和查看功能的表格,只用39行代码就可以使用Ajax来刷新页面的特定部分以及渲染页面显示。

很明显,代码行不是唯一的度量标准。但肯定的是代码越多,越难维护越难以理解和测试。


Tapestry团队在提高框架开发友好性上下了大量的功夫:



  • Tapestry 5应用几乎不需要任何配置。大部分你平时需要设定配置的地方,Tapestry 5都已经设定了合理的默认值。你可以发现,我们的例子中没有任何编写任何XML代码。
  • 独特的类重加载特性推动俄开发效率的提升。无须为了页面或者组件代码中的小bug就重启应用容器,页面和组件类能够自动重新加载。
  • 面向开发员友好的错误报告不只提示错误行号并显示前后代码段,还提出可能的修正建议。

Tapestry 5结合了很多应用领域的实用特性,比如受Ruby on Rails启发的配置与scaffolding间的转换、受Google Guice启发的非XML依赖性注射和配置、针对伸缩性问题的REST类型的URL等等。Tapestry的输出是有效的(X)HTML代码。诸如 Grid和BeanEditForm那样的复杂组件都可是可访问的、非table类型并能够用CSS定制。输入的默认编码是UTF-8,框架本身支持本地化。


本文中,我们只覆盖了Tapestry 5新特性的“冰山一角”,还有很多特性比如更深层次的Tapestry IoC、Tapestry应用的本地化、mixins和assets都没有能够一一道来。另外,Tapestry 5针对

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值