使用Grails和Flex开发JEE应用

来源:互联网 发布:空间域名费 编辑:程序博客网 时间:2024/05/17 04:17

作者Maarten Winkels译者沙晓兰发布于2009年2月1日 下午8时6分

社区
Java
主题
RIA,
富客户端/桌面
标签
Flex,
AJAX,
Grails

Java平台已经逐渐发展为一个成熟可靠的企业应用平台,成熟的应用平台的一个标志则是它能够带动大量的衍生技术以及可以与其他技术集成的选项。本文将详细讲述怎样用Grails这项传统JEE应用开发的衍生技术,结合另一项完全不同但却可以在Java中使用的Flex技术来开发JEE。这两个平台都能大幅度提高开发效率。两者相结合则在为J2EE应用创建富客户端的同时不影响整体的开发效率。

Grails的前身是一个在JVM中运行的web应用,它使用Groovy以及其它几个著名的框架,比如Spring和Hibernate。为了实现快速应用开发,它极为依赖“Convention overConfiguration”原则。Groovy拥有很多动态的特性,在定义组件间共同行为方面,功能非常强大。Grails采用plug-in构架,因此很容易把它与其他框架集成,而且也很容易在应用间复用各自的功能。

Flex是个RIA开发套件,由它创建的SWF应用只能在FlashPlayer下应用。这是Adobe(前身为MacroMedia)的一个新型Flash开发套件。除了拥有丰富的widget和把各种widget粘合在一起的强大的语言之外,它还能提供一些高端通信解决方案,分布式应用程序的开发因此变得相当容易。它使用两种语法:MXML和ActionScript。MXML创建在XML语法之上,专门用来定义通用组件的用户接口;而ActionScript则用来定义组件之间的动态交互。

Grails和Flex的集成——难题所在

要把Grails和Flex这两个建立在完全不同基础上的框架结合起来,首先会遇到诸多通信方面的问题:

  1. 一个框架中的组件如何才能在另一个框架中找到正确的通信对象?
    从本质上来说,Grails实际是运行在服务器的JVM上的一个web应用框架。Flex则是拥有客户端和(瘦)服务器组件的RIA平台,服务器组件以web应用的方式部署。因此,这两个框架之间的集成实际上在web应用容器内进行。
    用户在Flex UI发起的通信必须通过Grails组件来调用业务逻辑。那么,Flex UI组件该如何找到正确的Grails组件呢?
  2. 框架间如何解析彼此的数据?
    Flex采用ActionScript来描述数据,而Grails则采用Java和Groovy对象。Flex UI向服务器发送的ActionScript对象应该被转述为应用程序能够理解的数据结构。这该如何实现?
  3. 某个用户的修改该如何与该应用程序的其他用户交互?
    这是多用户应用程序普遍存在的问题,但同时运用两个不同的框架使得问题更加复杂。难点在于Grails应用程序,用户通过Flex UI来启动这个应用,但如何通过Flex UI与其他用户通信,让他们知道该用户的这一动作呢?

在接下来的三个部分中,我们详细讨论上文提到的三个问题,寻找采用Grails和Flex的解决方案。

集成——寻找消息接收对象

一个框架中的组件如何才能在另一个框架中找到正确的通信对象呢?

具体到Grails和Flex的话,这个问题其实就是在问Flex组件怎样才能找到正确的Grails组件,进而发送请求数据,或者以用户的名义执行一些操作。为了更好的理解解决这个难点的方法,我们首先来了解一下Flex的通信子系统。

Flex中的客户——服务器通信

Flex的通信子系统可以分为客户和服务器两个部分。客户部分包含了那些允许应用程序发送或者接受消息的组件,比如RemoteObject和Consumer组件。这些组件与服务器部分特定的“服务”对象相关联,比如RemotingService和MessagingService。客户组件及其相关联的服务器组件的结合能够支持典型的通信模式。比方说结合Consumer、Producers和MessagingService,应用软件就能够使用Publish-Subscribe机制来通信。

客户和服务器件的通信通过信道(Channel)来完成。信道的实现方式并不唯一,所有信道中最重要的是AMFChannel和RTMPChannel。AMFChannel建立在HTTP基础上,也就是说建立在请求-回复的构架上。它可以和MessagingService同时使用,从而支持Publish-Subscribe构架。这种结合下,信道定期从发布中读取新的消息,生成请求。RTMPChannel在这样的配置下效率更高,它能够在TCP/IP的基础上支持客户与服务器间的连接。这样一来,客户与服务器之间能够立即发送或接受消息。遗憾的是,Adobe免费开源的Flex实现——BlazeDS不包含这样的RTMPChannel实现。

Flex中最重要的通信基础设施是Destinations。Destination是通信信道的服务器端终点。一个服务提供一个Destination,而客户组件则通过这个Destination与这个服务相关联。关联的客户组件可以向Destination发送和读取消息。Destinations可以由Factories创建。

Grails暴露的远程接口:服务

如何把Flex复杂的通信设施和Grails结合起来呢?Grails能够识别几类对象:域对象、控制器、视图和服务。Grails中的每个服务都是通过外部通信信道——比如HTTP——展示某些功能或者服务的一个对象。而在Flex中,每个服务则与一个Destination相对应。

这恰恰就是针对Grails的flex-plugin所提供的解决方案。Grails中所有注明向Flex展示的服务都将在Flex框架中以Destination的形式注册。Grails通过一个特定的Factory把注明的服务添加到Flex中特别配置的RemotingService。这个特定的Factory会在Grails使用的Spring上下文中定位到对应的服务。所有这些配置都可以在services-config.xml中找到,flex-plugin会为Grails将这个文件复制到正确的地方。

class UserService {
static expose = ['flex-remoting']
def List all() {
User.createCriteria().listDistinct {}
}
def Object get(id) {
User.get(id);
}
def List update(User entity) throws BindException {
entity.merge();
if (entity.errors.hasErrors()) {
throw new BindException(entity.errors);
}
all();
}
def List remove(User entity) {
entity.delete();
all();
}
}

这段配置将UserService展示给flex客户。下面这段MXML代码则是对前面这段配置的应用。RemoteObject的destination是userService,这个userService正是Grails中目标对象提供的服务名。服务对象的所有方法这下都可以作为远程操作调用。ActionScript可以将这些操作像一般的方法那样调用,而方法调用的结果或错误也可以当作一般的ActionScript事件来处理。

...
<mx:RemoteObject id="service" destination="userService">
<mx:operation name="all" result="setList(event.message.body)"/>
<mx:operation name="get" result="setSelected(event.message.body)"/>
<mx:operation name="update"/>
<mx:operation name="remove"/>
</mx:RemoteObject>
...

结论

flex-plugin为Grails提供的针对集成的解决方案非常漂亮,易于使用而且几乎是自动化的。在Convention-over-Configuration概念下,Destinations动态添加到Flex配置的时候使用命名规范。

数据转换

框架间如何互相转换对方的数据(本文中就是Java和ActionScript对象转换的问题)?

这个问题的关键之处在于两框架相交接的地方。Flex包含Java(web服务器)和ActionScript(客户端)两个组件。因此,Grails和Flex之间的边界就在web服务器,而这个服务器在两个框架内实际上都是Java应用。

Flex的Java组件只关注于与Flex客户间的通信。基于ActionScript对象的AMF协议就用于这样的数据通信。服务器内部的Java代码将数据转换成ActionScript对象,这些对象在信道上实现系列化。Flex支持Java的基本类型,也支持其标准复杂类型(比如Date或者Collection类型)。由于ActionScript是门动态语言,因此它也支持随机对象结构。Java对象域会转换成ActionScript对象的动态属性。但把这些非类型ActionScript对象转换成Groovy域对象的过程则没那么直接,它会默认生成一个Map,将属性以key-Value对的形式存储到这个Map中。

Flex创建与Groovy域对象拥有同样属性的ActionScript类,通过注解将两者互相关联起来,这样一来,数据转换更加方便。下面的这个例子就是这样一对关联的Groovy-ActionScript。

GroovyActionScript
class User implements Serializable {
String username
String password
String displayName
}
[RemoteClass(alias="User")]
public class User {
public var id:*
public var version:*
public var username:String;
public var password:String = "";
public var displayName:String;


public function toString():String {
return displayName;
}
}

注解“RemoteClass”将ActionScript类链接到由alias属性指明的Java(或Groovy)类。alias这个属性应该包含完整的类名。Grails中的领域类通常都添加到默认的类包。Grails类中的所有属性都会复制到ActionScript类。这些属性的名字都应当完全一样。Grails会为所有需要“id”和“version”的领域对象动态添加这两个属性,领域对象因此可以在与客户端交互的时候保留这两个信息。

结论

Flex提供的与Java(或Groovy)间数据转换的解决方案会导致很多重复的代码。每个领域类都会被定义两次,一次用Groovy(或Java)定义,另一次用ActionScript。但是这样一来,可以添加一些客户端特定代码,比如说那些单单用ActionScript编写的控制对象显示的代码。这也推动编辑器同时提供两种代码的自动完成功能。至于用于配置的注解则非常简便。

多用户

应用程序如何将某个用户所作的修改通知到其他用户?

对于一个能同时支持多用户的应用程序来说,将某个用户对共享数据所做的修改通知到其他用户着实是个挑战。对于其他用户来说,这个过程可以看作是有服务器发起的通信。

单个中央结点(通常指服务器)向很多接收点(通常指客户)发起通信的时候,发布-注册(publish-subscribe)就非常实用。客户在服务器上注册之后,服务器上任何相关消息的发布,他们都会收到通知。

由于Grails可以使用Java,自然就可以用到JMS。JMS是应用程序间通信的Java标准,它支持publish-subscribe技术,而且应用程序也可以通过适配器来集成JMS。

Grails中的JMS配置

在众多标准中,有一个特别针对Grails的jms-plugin,它添加了很多有用的方法可以用来向JMS目的地对象、向所有的控制器和服务类发送消息。在上一章中提到的UserService就可以运用这些方法在数据发生变化时通过JMS向所有的用户发送更新消息。

class UserService {
...
def List update(User entity) throws BindException {
entity.merge(flush:true );
if (entity.errors.hasErrors()) {
throw new BindException(entity.errors)
}
sendUpdate();
all();
}
def List remove(User entity) {
entity.delete(flush:true );
sendUpdate();
all();
}
private def void sendUpdate() {
try {
sendPubSubJMSMessage("tpc",all(),[type:"User"]);
} catch (Exception e) {
log.error("Sending updates failed.", e);
}
}
}

服务可以决定什么时候发送什么样的消息。无论用户什么时候更新或删除数据,都会发送一条包含了完整的数据列表的消息。这条消息会发送到特定话题,也就是这里的“tpc”。任何注册了这个话题的用户都将接收到新数据。列表中的对象类型(本例中也就是“User”)作为元数据添加到消息中,接收对象因此在服务器上注册的时候特别指明他们所关注的数据类型。

为了让Grails应用也能够采用JMS,每个JMS都需要实现provider。Apache有个免费的开源实现,只需简单配置就能在Grails应用程序中使用。你所需要做的是把ApacheMQ类库添加到Grails应用的lib文件夹下,再将下列代码段复制到connectionfactory所使用的conf/spring文件夹下的resources.xml中。

...
<bean id="connectionFactory"
class="org.apache.activemq.pool.PooledConnectionFactory"
destroy-method="stop">
<property name="connectionFactory">
<bean class="org.apache.activemq.ActiveMQConnectionFactory">
<property name="brokerURL" value="vm://localhost"
/>
</bean>
</property>
</bean>
...

在Flex中接收JMS消息

目前的Flex配置仅仅包含一个RemotingService,依靠它来支持request-response类型的用户与UserService间交互。这个服务由flex-plugin向Grails中添加。除此之外,我们还需要一个MessagingService来支持publish- subscribe类型的交互。

...
<service id="message-service" class="flex.messaging.services.MessageService" messageTypes="flex.messaging.messages.AsyncMessage">

<adapters>
<adapter-definition id="jms" class="flex.messaging.services.messaging.adapters.JMSAdapter" default="true"/>
</adapters> <destination id="tpc">
<properties>
<jms>

<message-type>javax.jms.ObjectMessage</message-type>
<connection-factory>ConnectionFactory</connection-factory>
<destination-jndi-name>tpc</destination-jndi-name>
<delivery-mode>NON_PERSISTENT</delivery-mode>
<message-priority>DEFAULT_PRIORITY</message-priority>
<acknowledge-mode>AUTO_ACKNOWLEDGE</acknowledge-mode>
<transacted-sessions>false</transacted-sessions>
<initial-context-environment>
<property>
<name>Context.PROVIDER_URL</name>
<value>vm://localhost</value>
</property>
<property>
<name>Context.INITIAL_CONTEXT_FACTORY</name>
<value>org.apache.activemq.jndi.ActiveMQInitialContextFactory</value>
</property>
<property>
<name>topic.tpc</name>
<value>tpc</value>
</property>
</initial-context-environment>
</jms>
</properties>
</destination>

</service>

...

在services-config.xml文件中,我们需要添加下列这段包含了一个新的MessagingService和JMSAdapter的代码段。添加的这个适配器将服务中的destination链接到JMS资源。这个服务中还包含一个destination的配置,flex代码中的用户可以通过注册获得这个destination的数据更新。Destination中含有很多JMS特定的配置。大部分都是常用的JMS属性。initial-context-environment中的“topic.tpc”属性是个可定制的ActiveMQ属性,这个属性将在上下文中注册一个JNDI名为“tpc”的话题。

...
<mx:Consumer destination="tpc" selector="type = 'User'"
message="setList(event.message.body)"/>
...

Flex客户端代码非常简单。消息根据选择器(selector)被发送到特定的destination,而Consumer组件因此接受到所对应的destination中的消息。在这个例子中,我们通过筛选器指定Consumer所关注的消息是元数据“type”属性值为“User”的内容。无论消息是何时收到的,消息的内容,也就是User-objects列表会被置为可显示的内部列表。消息内容的处理和RemoteObject上“all”处理的返回值完全一样。

结论

Grails和Flex中将数据变化传递给多用户的解决方案完全可以通过标准组件来实现。涉及到的组件数量很多,配置和实现因此相当复杂。如果配置正确的话,这个解决方案使用起来就非常方便。

合并解决方案

回顾前三章提出的解决方案,你会发现还可以把他们合并起来得到一个更为通用的解决方案来实现Flex/Grails应用程序客户与服务器间的关于领域状态信息的通信。本章节中,我们要讨论的就是这样一个更为通用的解决方案。

泛化服务器端代码

问题1和3的解决方案所需要的服务器端的代码可以合并到同一个Groovy服务中。我们将把它指明为针对User领域类的服务。通过Groovy这样一门动态语言,要把这样一个服务泛化到面向所有领域类的操作非常容易。

import org.codehaus.groovy.grails.commons.ApplicationHolder

class CrudService {
static expose = ['flex-remoting']

def List all(String domainType) {
clazz(domainType).createCriteria().listDistinct {}
}

def Object get(String domainType, id) {
clazz(domainType).get(id)
}

def List update(String domainType, Object entity)
throws BindException {
entity.merge(deepValidate:false, flush:true)
if (entity.errors.hasErrors()) {
throw new BindException(entity.errors)
}
sendUpdate(domainType);
all(domainType);
}

def List remove(String domainType, Object entity) {
entity.delete(flush:true);
sendUpdate(domainType);
all(domainType);

}
private def Class clazz(className) {
return ApplicationHolder.application.getClassForName(className);
}

private def void sendUpdate(String domainType) {
try {
sendPubSubJMSMessage("tpc", all(domainType), [type:domainType]);
} catch (Exception e) {
log.error("Sending updates failed.", e);
}
}
}

要实现这个目的的关键在于让客户来决定返回的领域类型。出于这个目的,我们需要为所有服务引入一个参数,通过这个参数为服务器鉴定各个领域类型。很明显,对于这个参数来说,领域类型的类名是无非是最好的选择。为所有领域对象提供C(reate)R(etrieve)U(pdate)D(elete)操作的服务被称为CrudService。

一旦有任何数据更改,CrudService都会向JMS话题发送更新消息。这个更新消息包含了应用程序所知道的完整的领域对象列表。为了让用户来决定这是否是自己心仪的更新内容,领域类型的类名将以元数据方式添加到消息中。

客户端代码

解决方案1和3中的客户端ActionScript代码也可以综合到同一个类中。这个类的实例可以用来管理客户端某个特定领域类型的所有实例集。

public class DomainInstancesManager
{
private var domainType : String;
public function EntityManager(domainType : String, destination : String) {
this.domainType = domainType;
initializeRemoteObject();
initializeConsumer(destination);
}

private var _list : ArrayCollection = new ArrayCollection();
public function get list () : ArrayCollection {
return _list;
}
private function setList(list : *) : void {
_list.removeAll();
for each (var o : * in list) {
_list.addItem(o);
}
}

internal static function defaultFault(error : FaultEvent) : void {
Alert.show("Error while communicating with server: " + error.fault.faultString);
}
...
}

实现客户端的ActionScript基本上包含两个组件:简化request-response对话的RemoteObject和用于producer-subscriber对话的Consumer组件。上一章节中,这些对象通过MXML代码实现初始化,但他们也可以通过ActionScript来创建。上面这段代码段显示的是这两个组件共同使用的结构:包含了实例和错误处理的列表。包含实例的列表根据任何一个通信组件发送的消息而更新。

  ...
private var consumer : Consumer;
private function initializeConsumer(destination : String) : void {
this.consumer = new Consumer();
this.consumer.destination = destination;
this.consumer.selector = "type ='" + domainType + "'";
this.consumer.addEventListener(MessageEvent.MESSAGE, setListFromMessage);
this.consumer.subscribe();
}

private function setListFromMessage(e : MessageEvent) : void {
setList(e.message.body);
}
...

这里这段代码显示的是Consumer如何通过ActionScript来构建,这段代码用来接收服务器端发送的消息。Consumer的selector属性仅仅用来接收那些包括了元数据中所指明的领域类型的消息。无论什么时候接收到这样的消息,eventhandler都会被调用,并且列表也会得到更新。

接下来这段代码段将RemoteObject设置为request-response型通信的一个结点。所有必要的操作都作为操作属性而添加到RemoteObject上,客户因而很容易调用这些操作。

...
private var service : RemoteObject;
private var getOperation : Operation = new Operation();
public function initializeRemoteObject() {
this.service = new RemoteObject("crudService");

var operations:Object = new Object();
operations["all"] = new Operation();
operations["all"].addEventListener(ResultEvent.RESULT, setListFromInvocation);
operations["get"] = getOperation
operations["remove"] = new Operation()
operations["remove"].addEventListener(ResultEvent.RESULT, setListFromInvocation);
operations["update"] = new Operation()
operations["update"].addEventListener(ResultEvent.RESULT, setListFromInvocation);
this .service.operations = operations;
this .service.addEventListener(FaultEvent.FAULT, defaultFault);

// Get the instances from the server.
this.service.all(domainType);
}

public function get(id : *, callback : Function) : void {
var future: AsyncToken = getOperation.send(domainType, id);
future.addResponder(new CallbackResponder(callback));
}

public function update(entity : Object) : void {
service.update(domainType, entity);
}

public function remove(entity : Object) : void {
service.remove(domainType, entity);
}

private function setListFromInvocation(e : ResultEvent) : void {
setList(e.message.body);
}
...

大部分方法都将任务委派到服务的其中一个操作。所有这些操作都不会阻塞其它操作,同时它们都是异步操作。服务的返回值无论什么时候都会由注册的事件处理器(eventhandler,本例中为setListFromInvocation)来处理,这个处理器同时还会更新列表。由于返回值在很多地方都会用到,“getOperation”就显得有点特别。CallbackResponder只有注册了调用才能得到该调用的返回值。答复方也将调用一个Function来处理刚接收到的消息的内容。

import  mx.rpc.IResponder;
import mx.rpc.events.ResultEvent;

public class CallbackResponder implements IResponder {
private var callback : Function;
function CallbackResponder(callback : Function) {
this .callback = callback;
}

public function result(data : Object) : void {
callback(ResultEvent(data).message.body);
}

public function fault(info : Object) : void {
DomainInstancesManager.defaultFault(info);
}
}

使用通用的类包

怎样使用这个通用的类包呢?我们来看一个例子,这个例子中我们要实现的是在第二个解决方案中提到的管理User对象的实例。下面这段MXML代码定义了一个PopUpDialog,这个PopUpDialog可以用来编辑系统中Users的详细信息。这个对话框的外观就如左图所示。实例变量“manager”为User领域类型初始为一个DomainInstanceManager实例。界面中包含了所有捆绑到这个manager的list属性的用户的列表。它显示了用户的displayName值。

<mx:TitleWindow xmlns:mx="http://www.adobe.com/2006/mxml" xmlns:users="users.*" title="User Manager">
<mx:Script>
<![CDATA[
import crud.DomainInstancesManager;
import mx.managers.PopUpManager;
[Bindable]
private var manager : DomainInstancesManager = new DomainInstancesManager("User", "tpc");

private function resetForm() : void {
selectedUser = new User();
secondPasswordInput.text = "";
}

private function setSelected(o : Object) : void
{
selectedUser = User(o);
secondPasswordInput.text = selectedUser.password;
}
]]>
</mx:Script>
<users:User id="selectedUser"
displayName="{displayNameInput.text}"
username="{usernameInput.text}"
password="{passwordInput.text}"/>
<mx:List height="100%" width="200" dataProvider="{manager.list}" labelField="displayName"
itemClick="manager.get(User(event.currentTarget.selectedItem).id, setSelected)"/>
<mx:VBox height="100%" horizontalAlign="right">
<mx:Form>
<mx:FormItem label="Display Name">
<mx:TextInput id="displayNameInput" text="{selectedUser.displayName}"/>
</mx:FormItem>
<mx:FormItem
label="User Name">
<mx:TextInput id="usernameInput" text="{selectedUser.username}"/>
</mx:FormItem>
<mx:FormItem
label="Password">
<mx:TextInput id="passwordInput" text="{selectedUser.password}" displayAsPassword="true"/>
</mx:FormItem>
<mx:FormItem
label="Password">
<mx:TextInput id="secondPasswordInput" text="" displayAsPassword="true"/>
</mx:FormItem>
</mx:Form>
<mx:HBox
width="100%">
<mx:Button label="New User" click="{resetForm()}"/>
<mx:Button label="Update User" click="{manager.update(selectedUser);resetForm()}"/>
<mx:Button label="Remove User" click="{manager.remove(selectedUser);resetForm()}"/>
</mx:HBox>
<mx:Button
label="Close" click="PopUpManager.removePopUp(this)"/>
</mx:VBox>
</mx:TitleWindow>

一旦点击列表中的数据项,你就可以从服务端读取对应的user对象的数据,这些数据存储在界面的“selectedUser”中。这个属性在MXML中定义,因此很容易用来与表单中的域绑定。“selectedUser”属性的属性和表单中的input域是双向绑定,所以“selectedUser”属性值的改变(由服务器端的事件引发的修改)会影响到input域,而input域的值的改变(由用户输入值所引发的修改)也会影响到“selectedUser”属性值。界面上的按钮是链接到manager的方法,这个方法的参数就是“selectedUser”属性值。方法调用的结果会影响到manager维护的表单,也会影响到界面上显示的列表内容,因为这两者也是互相绑定的。

注意事项

需要注意的是,在使用这个通用类库的时候,你需要在客户端维护一个包含了系统所识别的某个特定类型的所有对象的列表。有些你所期望使用的引用数据和数据本身可能会在实例的数量上有一定的限制,这没什么问题。另外还有一些数据你可能不是必须的,甚至不可能维护一个完整的列表,这时候你可以在这个完整的表单的子集上采用同样的原理。

有趣的是,无论客户何时修改数据(无论是保存、更新或是删除领域对象),他都会得到一个包含了新的列表的回复。他还会接收到一个消息表明其他用户也都收到了更新的列表。因此,用户会因为自己的每个修改而收到两条更新消息。第一条(针对他请求的回复)可以被丢弃,但这条消息会添加到系统中,因为直接回复常常比通过JMS发送消息更费时间。

另外一个值得提及的是,由于消息中包含的更新(本例中就是完整的列表)来自于不同的信道,这个模型中可能存在并发问题。消息有可能被延迟,用户可能在收到一个更新消息之后再收到收到上一个更新的消息。这也意味着用户看到的是失去实效的数据。解决这个问题的一个方法是在消息中添加一个序列号,然后在每次接收消息的时候通过检验这个序列号来查看这条信息是否是最新的。

结论

通用的类包以易于使用的形式包含了前面几章中讨论的解决方案。

本文中提到的解决方案能够为开发使用Flex和Grails的JEE应用程序提供坚固的基础。采用这个工具箱的JEE开发人员的开发将可以更快、更敏捷,也许更重要的是开发将变得更有趣!

关于作者

MaartenWinkels是具有五年Java和JEE开发经验的软件开发工程师和咨询师。他最近从荷兰搬迁到印度,宣传Xebia所提供的分布式敏捷开发过程。Xebia是一家专于Java技术、海外Agile项目、Agile咨询和培训、IT构架和审核的公司。请参考:http://www.xebia.com/。

阅读英文原文:Writing JEE applications with Grails and Flex。


给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家加入到InfoQ中文站用户讨论组中与我们的编辑和其他读者朋友交流。

原创粉丝点击