使用 Grails 开发 Google App Engine 应用

红薯 发布于 2010/06/26 21:43
阅读 1K+
收藏 2

简介: Grails 作为 Web 框架的新生力量,已经被越来越多的开发人员所接受。而 Google App Engine(以下简称 GAE)作为云计算的平台,为应用提供了广阔的扩展空间。如何将二者擦出绚丽的火花呢?本文将以一个应用为例介绍如何使用 Grails 开发 GAE 应用,以及其中的注意事项。

前言

Grails 作为 Web 框架的新生力量,已经被越来越多的开发人员所接受。而 Google App Engine(以下简称 GAE)作为云计算的平台,为应用提供了广阔的扩展空间。如何将二者擦出绚丽的火花呢?

  1. 本文将借助 Grails 中支持 GAE 的插件 --Grails App Engine(以下简称 GAE 插件),使用 JPA 接口,以一个 ToDo 应用为例,讲述了如何使用 GAE 插件进行 GAE 程序的开发,以及在开发中的注意事项。
  2. 本文使用的环境:
  • Grails 1.3 M1
  • Google App Engine 1.3.1
  • app-engine-0.8.10
  • gorm-jpa-0.7.1

环境搭建

请先下载 GAE SDK for Java以及 Grails。 将下载的 zip 解压至适当的位置,并设置环境变量 APPENGINE_HOME 和 GRAILS_HOME。

使用 grails create-app AppName,创建 Grails 项目。

在你的项目中安装 GAE 插件:grails install-plugin app-engine,数据持久化选择 JPA。这个命令会进行如下工作:

  • 卸载 Hibernate,Tomcat 插件;安装 gorm-jpa 插件。
  • 安装 gorm-jpa 插件。
  • 在 AppName \grails-app\conf 下创建:
    • datastore-indexes.xml:数据存储索引的相关配置,缺省是自动创建索引
    • persistence.xml:持久化的配置
    • 在 AppName \src\templates 下创建 artifacts、scaffolding、war 三个目录,分别存有 domain class、controller/views、web.xml 的模板文件。
    • 创建 userhome\.grails\1.3.0.M1\projects\AppName\plugins\gorm-jpa-0.7.1\src \groovy\org\grails\jpa\JpaPluginSupport.groovy,这是 Plug 最为关键的类,它将 JPA 的相关操作进行了封装,Controller 中调用的方法都要通过这个类。

环境准备就绪后,执行 grails app-engine run,会创建如下内容:

  • 将 GAE 的需要的 Jar 拷贝至 AppName \ web-app \ WEB-INF\lib 目录下;
  • 在 AppName \ web-app \ WEB-INF 下创建 plugin 目录、grails.xml、applicationContext.xml、web.xml

GAE 插件提供的可用命令如下:

  • 启动应用:grails app-engine run,以 debug 的模式启动本地应用
  • 打包应用:grails app-engine package,打包本地程序
  • 从 GAE 上取日志:grails app-engine logs --file=logs.txt --days=5,将最近 5 天的日志保存在 logs.txt 文件中
  • 更新 GAE 上的索引:grails app-engine update_indexes
  • 回滚 GAE 的上一次更新:grails app-engine rollback

ToDo 应用说明

为了便于讲解 GAE 插件,本文将借助一个工作任务应用(ToDo)。ToDo 是一个工作任务列表。其中 Domain Class 为:User,UserProfile,Task,Category。它们之间的关系如下:


图 1. ToDo Domain class 关系图
图 1. ToDo Domain class 关系图

ToDo 的需求如下:

  • 每个 User 可以创建自己的 Category/Task
  • Task 的状态分为 Open、Cancel、End;
  • 可以对 Task 进行分类(Category);
  • 创建一个新的 Task,缺省状态为 Open,Task 的开始时间为当前时间;
  • 如果 Task 已经完成了,用户将 Task 的状态改为 End;
  • 如果 Task 由于某些原因需要撤销,用户可以将 Task 的状态改为 Cancel;
  • 如果一个 Task 在一个月内没有更新状态,应用会给创建者发送邮件进行提醒。

名词解释

数据存储

GAE 为应用提供了分布式数据存储服务,其中包含查询引擎和事务功能。数据存在一个叫做数据存储区的空间中,这个数据存储区则有别于传统的关系数据库。其中的数 据对象(或“实体”)有一类和一组属性。

实体

GAE 中将存储数据对象称为实体。一个实体有一个或者多个属性(参见 属 性值类型),属性可以是对另一个实体的引用。

实体组

多个实体的组合。GAE 的数据存储区通过 “实体组”实现事务,在一个事务中执行多项数据存储区操作(全部成功或者全部失败,从而确保数据的完整性)。应用程序可以在实体创建时将实体分配到实体 组。

每个实体在 GAE 中都有一个唯一标示自己的键值。这个键值可以是长整型 (java.lang.Long),也可以是 Key 实例 (com.google.appengine.api.datastore.Key)。如果在保存一个新对象时,给对象指定的键值已经被类型相同(且具有 相同父实体组)的另一对象使用,那么保存时就会新的对象就会覆盖旧的对象。

如果将主键设置为长整型 (java.lang.Long),可以由数据存储区自动生成。对于没有父实体组的独立实体,可以采用这种主键。

如果将主键设置为 Key 实例 (com.google.appengine.api.datastore.Key),可以由数据存储区自动生成,也可以通过 KeyFactory 显示的创建。如果实体有父实体,那么 Key 的形式为:父实体键 / 子实体键。关于 KeyFactory 的用法将在后面的内容中介绍。

有主关系

所谓有主关系,就是一个对象无法脱离另一个而存在。

无主关系

所谓无主关系,就是两个对象都可存在,只是不会顾及彼此。

事务

事务,这个概念大家都明白。GAE 的数据存储区是支持事务的,程序可以在单个事务中执行多个操作和计算。GAE 插件将事务封装在了一个名为 withTransaction 的方法中。对于实体的新增、修改、删除都必须放在事务中进行,即在 domainName. withTransaction{} 中进行。

由于数据存储区有别于关系型数据库,所以 GAE 对事务的使用有如下限制:

  • 如果要在一个事务中控制多个实体,需要将这些实体归属于一个实体组;
  • 事务会针对实体组设置数据存储区操作,且所有操作都会以组的形式进行。如果事务失败,则全部操作会滚;
  • 缺省情况下,GAE 会在创建实体时,为实体分配实体组;
  • withTransaction 方法中,不能对多个具有不同父实体的实体进行操作;
  • 查询操作不能在事务中进行。

用户验证

GAE 应用的用户信息可以自定义实体来进行维护。这跟传统的方法没什么区别,这里就不赘述。

这里要讲的是在 GAE 应用中如何使用 Google 的帐户信息。GAE 提供了相应的 API 能够检测到当前用户是否以 Google 帐户登录,并且可以将用户重定向到 Google 帐户登录页面,以便登录或新建一个帐户。在 Google 用户登录到应用程序时,应用程序可以访问 Google 用户的电子邮件地址。

GAE 提供了如下用户 API:

  • com.google.appengine.api.users.UserServiceFactory:创建 UserService;
  • com.google.appengine.api.users.UserService:构造用户登录或退出的网址,以及检索有关当前登录用户的信息;
  • com.google.appengine.api.users.User:这是一个特定用户,通过这个对象,能够获取到 Google 帐户的电子邮件地址和用户的 Nickname。

具体用法如下:

  • 获取 UserService 对象

    UserService userService = UserServiceFactory.getUserService();

  • 登录

    如果用户未登录,使用 UserService 创建一个模拟登录页面,用法如下:UserService. createLoginURL(String returnUrl), 这里 returnUrl 指的是在用户成功登录后自动重定向的页面。

  • 退出

    对于已经登录的用户,同样可以使用 UserService 创建一个模拟退出的页面,用法如下:UserService. createLogoutURL(String returnUrl), 这里 returnUrl 的含义同上。

  • 存储

    可以在 Domain Class 中,将 Google 用户对象存储为特殊值类型 , 比如:User author

在开发阶段,UserService 创建的是一个模拟的登录页面,当应用程序发布到 GAE 平台上后,登录页面就会定位到 Google 的登录页面。

创建 DomainClass

在工程目录下执行 grails create-domain-class domainName,此时会根据 AppName\src\templates\artifacts\ DomainClass.groovy 创建 domainName。

如下是 Task 的 Domain Class 的示例代码:


清单 1. Task 的 Domain class

				
package mulan
import javax.persistence.*;
@Entity // 表示这个 domain 是一个实体
class Task implements Serializable {
@Id // 表示这是一个主键
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long id
static constraints = {
id visible:false
}
}

 

缺省情况下主键类型是 Long,还可以使用 com.google.appengine.api.datastore.Key 。

由于 JPA 的 DataNucleus 实现在构建过程中使用后编译“增强(Enhance)”步骤使数据类与 JPA 实现相关联。在其他的 GAE 开发文档或者文章中,提到最多的两种方法是使用 Apache Ant,或者使用 Java 方法。而使用 GAE 插件,就可以不用操心 Enhance 的事情,因为执行 grails app-engine 命令时,插件会自动对数据类进行 Enhance。

ToDo 中的关系

User 和 UserProfile 是一对一的关系。但是是有主的还是无主的呢?这个可以根据具体情况而定。

User 和 UserProfile 有主的一对一关系

如果将 User 和 UserProfile 的关系设置为有主的一对一的关系,那么 User 就是 UserProfile 的父实体。父实体(User)的主键为 Long,子实体(UserProfile)的主键为 Key,父实体中可以通过定义一个子实体的字段,在二者之间创建单向的一对一有主关系。User/UserProfile 的 DomainClass 代码参见清单 2、3:


清单 2. User 的 Domain class

				
package mulan
import javax.persistence.*;
@Entity
class User implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long id
@OneToOne(cascade=CascadeType.ALL)
UserProfile uprofile
……
}



清单 3. UserProfile 的 Domain class

				
package mulan
import com.google.appengine.api.datastore.Key;
import javax.persistence.*;
@Entity
class UserProfile implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Key id
……
}

 

处于这种关系下,在新增的时候可以仅对主实体执行 save 操作,参见清单 4:


清单 4. 有主的一对一关系的新增

				
User.withTransaction {
def userInstance = new User(params)
def userpro = new UserProfile(params)
userInstance.uprofile=userpro
userInstance.save(flush:true)
……
}

 

由于在 User 的 Domain Class 中将 OneToOne 的级联(cascade)设置成了 CascadeType.ALL,这样就会在新建的 User 和 UserProfile 时,将二者放入到同一个实体组中,即将 UserProfile 的主键设置成类似于 User(1)/UserProfile(2) 这样的形式,这表明 User 是 UserProfile 的父实体。当删除 User 实体时,会同时将 UserProfile 实体删除。如果 User 和 UserProfile 都变更了,只需保存 User 就能同时将 UserProfile 进行保存。当然,也可以单独对 UserProfile 进行变更存储。但是需要注意的是,对于已经单独持久化的 UserProfile,是没法将其跟一个新增的 User 创建父子关系的。

至于 CascadeType 的设置,可以根据实际项目需求设置成 All、PERSIST、REMOVE、REFRESH、MERGE。

User 和 UserProfile 无主的一对一关系

如果 User 和 UserProfile 是无主的一对一的关系,二者可以单独创建。它们二者之间通过一个 Key 值来进行关联。可以将这个 Key 值看作在两个对象之间建模任意“外键”,但是 GAE 不能保证这些 Key 的引用完整性,所以在使用 Key 的时候,要牢记:

  • 应用程序要确保 Key 值的类型正确
  • 关系双方必须属于同一实体组,才能在同一个事务中对关系双方执行更新

无主关系下的 User/UserProfile 的 DomainClass 参见清单 5、6:


清单 5. User 的 Domain class

				
package mulan
import javax.persistence.*;
@Entity
class User implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long id
Key uprofile
……
}



清单 6. UserProfile 的 Domain class

				
package mulan
import javax.persistence.*;
@Entity
class UserProfile implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long id
……
}

 

按照代码清单 5、6 中定义的 User 和 UserProfile 只能够单独维护,没有办法将二者放在同一个事务中进行更新操作。如果要同一个事务中操作关系双方,就必须将被调用方(UserProfile)的主键类型 从 Long 变更为 Key,并在其新增的时候将其主键使用 KeyFactory.Builder 生成,从而将 UserProfile 和 User 分配到同一个实体组中。参见清单 7:


清单 7. 将两个实体分配到一个实体组

				
UserProfile.withTransaction {
Builder keyBuilder = new Builder(User.class.getSimpleName(), currentUser.id)
keyBuilder.addChild(UserProfile.class.getSimpleName(), currentUser.id)
def userprofile=new UserProfile(params)
userprofile.id=keyBuilder.getKey()
currentUser.uprofile=keyBuilder.getKey()
currentUser.save()
userprofile.save()
}

 

注意上述代码中的 keyBuilder.addChild,这个方法将 UserProfile 的主键跟 User 的主键关联起来。从而使 UserProfile 和 User 处于同一个实体组,但是由于是无主的关系,所以需要显式的对 UserProfile 进行保存。这样创建出来的 UserProfile 在修改 / 删除时,可以跟它相关联的 User 实体在同一个事务中操作,参见如下代码。


清单 8. 非主一对一关系下的删除操作

				
User.withTransaction {
def userInstance = User.get( params.id )
if(userInstance) {
try {
Builder keyBuilder = new Builder(User.class.getSimpleName(),
userInstance.id);
keyBuilder.addChild(UserProfile.class.getSimpleName(),
userInstance.id);
def up=UserProfile.get(keyBuilder.getKey())
up.delete(flush:true)
userInstance.delete(flush:true)
}
}
}

 

这里需要注意,如果 User 类中没有 uprofile 字段,只要 UserProfile 的主键 Key 的内容正确(形如 User(1)/UserProfile(1)),二者同样是在一个实体组中。

User 和 Task 的有主的一对多关系

ToDo 中,User 和 Task 的关系是有主的一对多的关系,如果某个 User 不存在,那么这个 User 的 Task 也不存在。User 跟 Task 是通过 Task 类的集合进行关联。参见如下代码:


清单 9. User 的 Domain class

				
package mulan
import javax.persistence.*;
@Entity
class User implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long id
……
@OneToMany(cascade=CascadeType.ALL)
List<Task> tasks =[]
……
}



清单 10. Task 的 Domain class

				
package mulan
import com.google.appengine.api.datastore.Key;
import javax.persistence.*;
@Entity
class Task implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Key id
……
}

 

在有主关系下,可以只调用主实体的更新操作就可同时将子实体进行更新。比如新增 Task,可以调用当前用户的 save 操作。参见清单 11:


清单 11. 有主一对多新增操作

				
def taskInstance = new Task(params)
Task.withTransaction {
def currentuser=User.get(params.currentuser)
currentuser.tasks<<taskInstance
currentuser.save(flush:true)
……
}

 

从上述代码中可以看出,currentuser.save操作会同时对 taskInstance 进行新增,而 taskInstance 的主键类似于 User(1)/Task(2)。

对于无主的一对多的关系,可以参考无主的一对一关系。这里不再赘述。

Task 和 Category 的无主的多对多关系

GAE 不支持有主的多对多的关系,那么对于无主的多对多的关系,可以通过保留关系双方的键集合来实现。比如 Task 和 Category 的关系可以是无主的多对多的关系:


清单 12. Task 的 Domain Class

				
package mulan
import com.google.appengine.api.datastore.Key;
import javax.persistence.*;
@Entity
class Task implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Key id
List<Key> cats =[]
……
}



清单 13. Category 的 Domain Class

				
package mulan
import com.google.appengine.api.datastore.Key;
import javax.persistence.*;
@Entity
class Category implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Key id
List<Key> tasks =[]
……
}

 

使用多对多关系时,关系双方的主键类型可以是 Long 也可以是 Key。如果希望在同一个事务中对关系双方进行操作,就必须将双方的实体放在同一个实体组中,那么双方的主键就必须是 Key,同时在创建实体时,还要给双方的 Key 值指定同一个根,显示的创建 Key 值。参见如下代码:


清单 14. Category 新增操作

				
def categoryInstance = new Category(params)
Builder keyBuilder = new Builder("Root", 1);
keyBuilder.addChild(Category.class.getSimpleName(),Singleid);
categoryInstance.id=keyBuilder.getKey(
Category.withTransaction {
categoryInstance.save(flush:true))
}

 

这里要保证 Singleid 是在 Category 中的唯一的值。Category 的 Key 值类似:Root(1)/Category(1)


清单 15. Task 新增操作

				
def taskInstance = new Task(params)
Builder keyBuilder = new Builder("Root", 1);
keyBuilder.addChild(Task.class.getSimpleName(),singleid);
taskInstance.id=keyBuilder.getKey()
Task.withTransaction {
params.categories.each {catid->
Builder catkeyBuilder = new Builder("Root", 1);
catkeyBuilder.addChild(Category.class.getSimpleName(),
getSingleid(catid));
taskInstance.cats<<catkeyBuilder.getKey()
def cat=Category.get(catkeyBuilder.getKey())
cat.tasks<<keyBuilder.getKey()
cat.save()
}
taskInstance.save(flush:true))
}

同样,这里要保证 Singleid 是在 Task 中的唯一的值。Task 的 Key 值类似:Root(1)/Task(1)。从上述代码中可以看到所有的 Task 和 Category 都有一个共同的根:Root(1),这样就能在同一个事务中对 Task 和 Category 进行操作了。

创建 Controller/Views

Domain Class 都创建好了,下面就是创建 Controller/View。在工程项目下执行 grails generate-all domainName,为实体创建 Controller,以及 Views,这个命令会根据 AppName \src\templates\scaffolding 下的模板进行创建。对于单一的实体,这个命令生成的 Controller 已经能够完成 CRUD。对于 ToDo 中的 Domain Class 的新增、修改、删除,需要根据上一节中的示例代码进行调整,保证数据的完整性,即在 domainName. withTransaction{} 中进行。

这里重点介绍一下 GAE 插件提供的查询方法,方法中的 QueryString 均为 JPQL:

  • findAll,返回满足条件的记录,List 类型。用法示例:Task.findAll("select task from mulan.Task task where task.status=:status and task.id=:tid",[status:'Init',tid:new Long(1)])]
  • find,返回满足条件的第一记录,Object 类型。用法示例:Task.find("select task.id from mulan.Task task")
  • list,对实体对象的记录进行分页显示,List 类型。用法示例:Task.list([max:10,offset:0])
  • count,返回实体对象的记录个数,int 类型。用法示例:Task.count()

在开发期间,GAE 会将程序的生成的实体数据存放在两个临时文件中,AppName\web-app\WEB-INF\appengine-generated 下的:

  • datastore-indexes-auto.xml,自动生成的记录索引的文件;
  • local_db.bin,存储数据的文件。

如果要清空存储数据,可以将这两个文件手动删除。但是如果应用已经发布到了 appspot 上,就需要进入到应用程序管理台 DateDatastore Viewer 上清空数据了。

邮件通知

ToDo 中有一个需求就是每天回去查找是否有 1 个月没有修改状态的 Task,如果有,就会给用户发送邮件通知。这是就用到了 GAE 的 Cron 计划任务和邮件服务。

GAE 中配置 Cron 计划任务很简单,只需要在 AppName \web-app\WEB-INF 下创建一个 cron.xml 的文件,文件配置参见 GAE Cron 计划任务说明。如下是 ToDo 中 cron.xml 的代码:


清单 16. cron.xml

				
<?xml version="1.0" encoding="UTF-8"?>
<cronentries>
<cron>
<url>/inform</url>
<description>Repopulate the cache every 1 minutes</description>
<schedule>every 1 minutes</schedule>
</cron>
</cronentries>

 

上述代码中 inform 是一个 Controller,在它的 index 方法实现邮件发送。

至于发送邮件,GAE 有自己的一套做法,更是简单。创建一个 JavaMail 会话,无需提供任何 SMTP 服务器配置,如下是邮件发送示例代码:


清单 17. 邮件发送示例代码

				
def props = new Properties()
def mailsession = Session.getDefaultInstance(props, null)
def msgBody = "您的任务很久没有更新状态了,怎么回事?"
def mail = new MimeMessage(mailsession)
mail.setFrom(new InternetAddress("land.groovy@gmail.com"))
mail.addRecipient(Message.RecipientType.TO,
new InternetAddress("****@****.com", "Mr. User"))
mail.setSubject("Your Example.com account has been activated")
mail.setText(msgBody)
Transport.send(mail)

是不是很简单呢?关于邮件服务的限制参见 GAE 邮件服务说明

开发注意事项

GAE 为应用程序提供了一个开放平台,这不同于传统的单机开发环境。在开发过程中有如下注意事项:

  1. 无论是在 CMD 还是在集成开发环境中终止 GAE 应用,GAE 应用使用的 java 进程是不会终止的,这需要开发者到任务管理器(Windows 平台)中手动终止。
  2. 在列表显示数据时,通常会先过滤数据,再分页显示数据。对于这种需求,你会发现无论是 findAll,还是 list(),没有一个方法可以做到。这时候就需要分析 userhome\.grails\1.3.0.M1\projects\AppName\plugins\gorm-jpa-0.7.1\src\groovy\org\grails\jpa\JpaPluginSupport.groovy 中的代码了。你会发现 findAll 可以过滤数据,list() 可以分页,那么要实现上述需求,就要将这两个方法选择一个进行修改了。
  3. 对于查询操作,不需要放在事务中进行,否则会出错!
  4. 创建 Domain Class 的时候,一定要带有 package,如果使用缺省的 package,GAE 会找不到这个 Domain Class。如果应用中某个类文件的大小超过了 10K,那么在进行 Enhance 的时候就会出现 java 进程的错误,导致应用无法运行。如果这个类是 Domain Class,那么就要将这个类进行拆分,分成多个小于 10K 的文件。如果这个类 Domain Class,那么可以修改 GAE 的配置文件,不对非 Domain Class 进行 Enhance。这个配置文件就是 App-engine home\config\user 下的 ant-macros.xml。在 ant-macros.xml 找到 enhance_war 任务,在这个任务中,能够看到这样一行代码:<fileset dir="@{war}/WEB-INF/classes" includes="**/*.class"/>。意思很清楚,就是将 /WEB-INF/classes 下的所有类进行 Enhance。修改这行代码,告诉 GAE 只需要 Enhance Domain Class,比如:<fileset dir="@{war}/WEB-INF/classes" includes="**/Task.class"/>
  5. GAE 对每个类文件的大小以及整个项目的文件个数都有限制,在开发时需要遵循这些限制,详细的限制信息阅读 GAE 开发文档;
  6. 在开发环境下是没法运行 Cron 和 Mail 服务的,只有当应用发布到 GAE 上时,这两个服务才会生效。
  7. 使用 GAE 的邮件服务时,发送邮件方必须是应用程序的管理员。
  8. 应用程序开发完成之后,就可以将你的程序发布到 GAE上了, 步骤如下:
    • 设置应用的版本:grails set-version 1,这里的版本号必须为整数;
    • 打包应用: grails app-engine package
    • 发布应用,在你的工程目录下运行:%APPENGINE_HOME%/bin/appcfg.cmd update ./target/war
加载中
返回顶部
顶部