Spearal 是一个新的开源的序列化协议,这个协议旨在初步替换JSON 将HTML和移动应用连接到Java的后端.
Spearal的主要目的是提供一个序列协议,这个协议即使是在端点间传输的复杂的数据结构中也可以简单工作: 我们很快就能看到JSON的一些局限将会害了开发者, 这些局限是不会发生在一个好的通用的序列化格式中的.
抛 开这个主要目的, Spearal还提供了在标准JSON中没有的高级功能, 如局部对象序列化,内建的对JPA的非初始化关联, 不同型号的协调, 对象特性过滤等. 虽然还在初期发展阶段, 但是Spearal再将HTML应用连接到Java后端上已经很有用了. Android已经做好了准备IOS也快要跟上了.
内容列表:
源码:http://www.jinhusns.com/Products/Download/?type=xcj
背景
JSON怎么了?
一个好的序列化协议是怎样的?
Spearal序列化协议时怎么样的?
说够了, 来看看Spearal干了些什么!
运行Spearal的AngularJS / JAX-RS / JPA 简单应用
服务器端使用JSON失败
处理过时的客户端应用
结论和未来的工作
对于那些心急的想要看到Spearal如何工作的人, 你可以跳过本文的前四页,直接跳到and jump 够了, 来看看Spearal干了些什么那一页去!.
背景
随 着Web的不断发展,以及异步数据请求在移动应用上的大规模应用,序列化已经变成了一个重大问题。在古老的纯html时代,像是序列化格式,DTO,数据 循环引用(data graph circularity),引用(references)以及java(或者其它后端技术)和javascript之间的数据交互之类的东西根本不用去考 虑
早 在2000年,XMLHttpRequest就被创造出来,使得独立于html页面的对象序列化成为可能。1999年,微软首先在Internet Explorer5.0中引入了Microsoft.XmlHttp对象。不久之后,Mozilla和苹果实现了一个兼容的XMLHttpRequest 对象,这在2006变成了W3C的推荐实现。
顾 名思义,XMLHttpRequest 最初的设计是使用 XML 作为序列化格式。Douglas Crockford 在 2002 年发布了 JSON,从此之后,JSON 迅速变成了XML的替代品,原因就是它比xml更轻(这里有些关于JSON vs. XML 的很有趣的讨论,这里)。
JSON 现在已经变成了应用之间交换数据的事实标准,而不仅限于基于HMLT/JavaScript的应用。当然也有一些其他的替代品,比如谷歌的 Protocol Buffers,但是这些替代品没有一个能像JSON这样流行。
现在XMLHttpRequest规范提供了一种标准方法来序列化和处理二进制数据——ArrayBuffer(精确来说是 W3C Working Draft of the 16 August 2011),是时候反思JSON的局限性以及如何实现一个更好的协议了。这就是这篇文章的目的以及 Spearal 项目的目标。
纳尼?JSON 怎么了?
我们非常清楚: JSON 拥有非常好的可读性、易上手, 易懂的序列化格式,而且对单一的对象的表达非常清晰。但是他有一个不可逾越的限制(最新JSON标准):当你在操作(序列化)任何自循环的对象的时候他会失败:
2 |
selfish.friend = selfish; |
3 |
JSON.stringify(selfish); |
如果你在浏览器上运行了上面代码,以肯定会出现以下错误:
1 |
Uncaught TypeError: Converting circular structure to JSON |
当然这是一个特殊的例子, 这是不寻常的设计数据模型与循环引用,然而在编程语言(尤其是javascript)、数据库和JPA引擎它可以很好支持自循环机构。
另
一个限制是标准json格式缺乏对对象指针的支持。如果你序列化一个包含两个对象的数组,两个对象都指向一个共同的对象,那么这个共同的对象将会被序列化
两次。这不仅仅是浪费传输带宽的问题(如果用对象指针而不是两次序列化同一个对象,那么生成的结果要小一些),这也会破坏你数据图(data
graph)的一致性(consistency),举例如下:
01 |
var child = { age: 1 }; |
02 |
var parents = [{ child: child }, { child: child }]; |
04 |
parents[0].child.age = 2; |
05 |
console.log( "Should be 2: " + parents[1].child.age); |
07 |
parents = JSON.parse(JSON.stringify(parents)); |
09 |
parents[0].child.age = 3; |
10 |
console.log( "Should be 3: " + parents[1].child.age); |
上面代码的输出如下:
子对象在序列化和反序列化之后已经被复制了,两个父对象不再指向同一实例。JSON中没有标准方法来处理引用(同样不能处理循环引用cyclic graph),这种特性不在规范里面。关于这点可以去看下ECMA规范(”JSON不支持循环引用,至少不直接支持“)以及非标准的插件cycle.js。
另一个JSON的问题是它不保存所序列化对象的类名。让我们来执行下面这一段代码:
01 |
function BourneIdentity(name) { |
05 |
var obj = new BourneIdentity( "Jason" ); |
06 |
console.log( "Should be a BourneIdentity: " + (obj instanceof BourneIdentity)); |
08 |
obj = JSON.parse(JSON.stringify(obj)); |
10 |
console.log( "Should be a BourneIdentity: " + (obj instanceof BourneIdentity)); |
下面是输出结果:
1 |
Should be a BourneIdentity: true |
2 |
Should be a BourneIdentity: false |
JavaScript的Date对象也有这个问题:
2 |
console.log( "Should be a Date: " + (date instanceof Date)); |
3 |
date = JSON.parse(JSON.stringify(date)); |
4 |
console.log( "Should be a Date: " + (date instanceof Date)); |
下面是输出结果:
2 |
Should be a Date: false |
当然,你可以找到很多非标准的方法来解决这类问题(比如这里)。但是使用这种非标准的替换恢复的trick越多,你JSON编码和解码的效率就会越低。
目前为止,我只涉及到从标准JSON格式中继承来的限制。而在真正的服务器和客户端数据交互中,事情只会变得更糟(比如java服务器和HTML app)。
Java 有很多 JSON 库,比如 Jackson 和 Gson。
这些类库极大的方便了从JSON数据中解析出JAVA对象。JAX-RS也有对JSON格式的原生支持(通常是Jackson)。在处理非常简单的数据模
型(model)的时候,这些类库都没有什么问题。但是对于复杂的数据模型,它们会出现很多问题。在后面的 JavaEE/AngularJS 例子中将会进一步介绍这一点。
总
的来说,根据我的经验和很多其它开发者的反馈,在实际开发中,序列化已经变成一个很棘手的问题。尽管几乎每个人都在使用JSON(因为它是事实上的数据传
输标准),序列化依然在用各种恼人无趣的问题毒害我们。我绝不是想说JSON有多烂或者说它在实际开发中毫无用处。但是人们依然在花费大量的时间和精力来
解决各种各样的序列化问题,而对于一个好的序列化格式来说,这些问题根本就不应该出现。去看吧,在DZone上有差不多1000篇关于Jackson的文
章。
一个好的序列化协议应该是什么样子的?
下面是一个理想的序列化协议的关键特性列表(排名不分先后)
1. 压缩率高,速度快并且可读:
通过互联网进行传输数据(尤其是移动互联网),性能表现,包括输出大小和执行速度,一直是一个关键指标。序列化数据,即使被序列化成了二进制数据,也应该可以很容易的通过一些工具被转换成人类可读的文本
2. 处理任意复杂的数据结构(graph of objects):
序列化格式不应该强迫我们降低数据结构的复杂度。任何数据结构都应该是可以序列化的,不管是否包含循环引用(cyclic references)。并且共享的实例在序列化反序列化之后仍然应该是共享的,而不是被复制成多份。
3. 能够只序列化需要的属性而不是整个对象
如果一个应用只需要一个复杂对象的一部分属性,应该有一种方式只请求这一部分属性而不是整个对象。与之相反,如果复杂对象中只有一小部分属性被更新,也应该有一种方式可以只向服务器发送这一小部分属性。
4. 在没有DTOs(数据传输对象)的情况下,正确的处理过期数据模型:
当
服务器端的数据模型追加新的属性的时候,有可能有个别客户端没有被同步更新。这些过期的客户端使用之前版本的数据模型,但也可应该可以通过忽略服务器发过
来数据的未知属性来继续运行。另一方面,服务器从过期客户端拿到的是不完整数据,但是不应该因为新属性的缺失而将既存的属性置空。
5. 保存非匿名类实例的类名:
如果类X的实例被序列化了,哪么反序列化之后应该还是X的实例,除非反序列化的上下文没有定义类X。这个需求意味着需要在序列化数据中加入对象的类名。
6. 如果一个类不应该被序列化,那么应该可以阻止它被序列化:
必须要有一种方式可以限制可序列化对象的范围。比如,阻止所有HTTP session对象的序列化。
7. 能够处理JPA未被初始化的属性:
序
列化协议应该提供一个拓展用来正确处理未初始化的JPA属性,以防抛出LazyInitializationException以及不想要集合
(collection 译者注:这里应该是指ORM类中lazy-loading的外键属性,外键属性一般都是数组,对应着一对多的关系)的初始化和序
列化。更重要的是,序列化协议一定要区分出null属性和未初始化的lazy-loading属性。不然的话,有可能导致lazy-loading属性在
数据库更新时被置空。
8. 至少支持Java,HTML/JavaScript以及所有主要的移动平台:
协议应该对所有平台提供具备相同功能的编码解码库。第一步,出于个人需要,我会先实现对以下应用场景的支持:HTML,安卓和ios应用连接到java后台。
9. 对于所有支持的平台,提供可选的代码生成工具来复制数据模型:
在下面这些平台下,数据模型大多只能手动去复制:Java和安卓(在安卓应用中不应该添加JPA库),ios,javascript(用匿名类并不是什么好主意)... 有这样一个工具来快速完成这件事情至少能节省点时间。
上面提到的9个关键特性中,很明显有一部分是出于我的java技术背景,尤其是关于JPA的第七条。然而,我们不能因为一个序列化协议源于java而让它不能应用于其它语言和平台。因此有第十项要求:
10. 可以被接入到任何其它的语言和平台:
序列化协议必须可以在其它语言中实现,比如PHP、C、C++... 有些特性,像是可以处理JPA未初始化属性,应该在有类似应用场景的平台上作为插件实现(比如PHP ORM)。
什么是Spearal序列化协议?
Spearal项
目就是开发满足上述10个特性的一套类库。目前为止,Spearal已经可以应用在"HTML/JavaScript应用连接到Java后台"这种应用场
景。下面你将会看到,在既存的AngularJS/Java
EE系统中应用Spearal并不复杂。并且当数据模型比较复杂的时候,应用Spearal可以得到极大的性能提升。
Spearal是开源的,基于Apache Licence Version 2.0. 欢迎大家来贡献代码(我是说非常欢迎). 现在主要是我和William Drai在开发。我们在开发JavaEE应用和框架方面都有丰富的经验,这些经验主要来源于开发GraniteDS平台。更准确的说,我们都很熟悉一些序列化格式的实现和应用,比如AMF3和JMF.
在深入了解Spearal序列化格式之前,让我们先看一些关键特性:
支持Java、JavaScript、安卓和(不久之后)iOS:
Java
和Javascript的实现基本上完成了,可以正常使用。我们正在开发安卓版本,有些Java实现上的依赖在安卓平台上并不存在(比如对不完整类的
Javassit代理)。iOS版本使用苹果的新语言——Swift,目前还在初级开发阶段。再说一下,非常欢迎大家来贡献代码。
它考虑到了重复出现的对象和字符串:
当
序列化一个对象的时候,Spearal构筑了一个对象字典(identity
map,译者注:这里应该是指基于对象引用相等,比如javascript中的===以及java中的==)和一个字符串字典(基于字符串相等
(equality))。当一个对象或者字符串出现两次,它将会被编码成一个引用而不是基于内容被再次编码。这种特性使得Spearal格式(就像其它的
序列化格式,比如AMF3或者ProtoBuf)能够正确处理循环引用,数据重复(指同一个字符串被多次使用以及同一个实例被多次引用),并且当数据高度
重复的时候,可以极大的节省带宽。
和JAX-RS以及Spring集成:
Spearal
提供了一个开箱即用的JAX-RS提供者(provider)和一个Spring消息转换器(message
converter)。如果你的程序使用上述两种框架之一,那么应用Spearal不过是几行代码的事情。就算你没有用这两种框架,写一个servlet
来处理Spearal编码解码也不是什么难事。
它容许局部序列化并且可以正确处理JPA未初始化的外键属性和过期的客户端数据模型:
当
Spearal编码一个对象的时候,它会记录对象的类名和属性相关的信息。如果一个客户端使用过期的数据模型(一般来说是类的一些新属性缺失),对于其它
节点发过来的数据,未知的属性会被忽略并且缺失的属性会被标示为未定义(undefined)。例如在java实现中,一个不完整对象会被解码成一个代理
(译者注:指java的dynamic proxy(动态代理)),当试图读写该代理中未被客户端提供的属性的时候,代理会抛出UndefinedPropertyException。
对JPA未初始化属性也做类似处理:在编码输出流中忽视它们,在接受者那里标记成未定义。Spearal JPA2 extension,在服务器端,在整合(merge)接收到的不完整对象和已经被持久化的数据的时候,用以下策略:对于缺失的属性,无论它们是否是未初始化的外键,将永远不会把既存的属性值置空。
即使客户端模型和服务器模型是完全相同的,Spearal提供一种方式仍然可以过滤出哪些属性被编码了。 例如,假设一个实体类名称为X,它有很多属性,你可以要求编码器只序列化其中部分属性。 在Java中,你可以这样配置编码器:
1 |
SpearalFactory factory = new DefaultSpearalFactory(); |
2 |
SpearalEncoder encoder = factory.newEncoder(outputStream); |
3 |
encoder.getPropertyFilter().add(X.class, "property1" , "property2" ); |
4 |
encoder.writeAny( new X()); |
根
据上面的配置,编码器会忽略除 property1和 property2属性外 X实例的所有属性。而且,
Spearal提供了一种方式使服务器仅返回 X实例(我们很快会看到在 action中这个特性与
JavaEE/AngularJS结合使用的示例)的部分属性,这种方式是客户端发送给服务器的信息中带有特殊的 http头部标识。
它是一个二进制序列化格式可参数化的类型:
虽然二进制格式不是直接可读的,它们提供了一种紧凑和打字的丰富性应对于很难实现的文本格式。关于如何Spearal序列化数据没有进入深度,让我们快速例证可参数化的类型与积分值的想法。
Spearal有15个基本类型:
第一个3类型字节值分别为null,布尔型的true和false。 所有其他支持更复杂的类型和可参数化的类型: 半字节类型的标识符可以携带4位的参数。
例如,65535(0xffff)的积分值使用3个字节编码:
第
一个字节是INTEGRAL标识符(0 x10),以及在整数类型上的重要的字节数(0xffff使用2显著字节),减去1。因此我们有0 x10
+(2 - 1)= 0 x11,紧随其后的是 0xff 和 0xff
字节。如果积分值为负,这需要首先转化为其绝对值和一个负号标志(0x08),是用于保存这些信息的。因此,所有积分值编码类型等于0x10(标识符)|
0x08(如果负)| 0 x00……0
x07(重要的字节长度减去1)。这里的快速和担心的思考,都是长时间的。MIN_VALUE编码的方式不会导致溢出。
这个编码(IMHO)更简单,并且可读性比其他要好,例如,用于ProtoBuf的ZigZag编码,虽然它也有相同的优点:能够使用不到8个字节编码64位积分值(或少于4字节编码32位值),可以节省大量带宽的处理,在你序列化很多小整数的时候。
另一件关于Spearal JavaScript的事:
Spearal的JavaScript版本已经用ECMAScript 6实现了:它采用了Google的出色的traceur编译器 将JS6代码进行转换使其能够兼容JS 5.
Spearal的Java实现只允许对实现了java.io.Serializable接口的对象进行编码转码。这显然是一个很基本的安全验证系统,但它阻止了许多超出预期的高风险操作。这个安全策略是完全可配置的,并且你也可以采取其他更为严格的方式。
说了这么多,让我们实践下Spearal吧!
在开始一个不同以往的Java EE/AngularJS示例应用前,我们先以一个短小的离线演示程序开始,用以演示JSON的不足(查阅“JSON怎么了”小节)。你可以在这下载这个示例的github gist。
首先,我们初始化Spearal,然后写一个帮助函数实现通过编码/解码复制一个对象。
1 |
var factory = new SpearalFactory(); |
3 |
function encodeDecode(obj) { |
4 |
var encoder = factory.newEncoder(); |
6 |
return factory.newDecoder(encoder.buffer).readAny(); |
SpearalFactory
类是Spearal API的入口,通过它可以创建编码器、解码器。 它也提供一个共享的上下文环境,允许你自定义API的参数。
此处,我们不进行任何修改,全部都使用默认值。 encodeDecode()
函数实现创建一个编码器,把参数序列化到内存中,使用编码后的数据创建一个解码器,返回从缓冲区中读到的数据。
让我们看一下使用循环结构时会发生什么事情:
2 |
selfish.friend = selfish; |
3 |
console.log( "Should be true: " + (selfish === selfish.friend)); |
4 |
selfish = encodeDecode(selfish); |
5 |
console.log( "Should be true: " + (selfish === selfish.friend)); |
控制台输出应当是这样的:
正如我们之前见过的,JSON 处理自我引用的对象时会立即报错。 现在,我们对共享实例进行一个测试:
1 |
var child = { age: 1 }; |
2 |
var parents = [{ child: child }, { child: child }]; |
3 |
parents[0].child.age = 2; |
4 |
console.log( "Should be 2: " + parents[1].child.age); |
5 |
parents = encodeDecode(parents); |
6 |
parents[0].child.age = 3; |
7 |
console.log( "Should be 3: " + parents[1].child.age); |
正确的输出应当是:
跟JSON不一样,Spearal不会重复子对象并且共享保存的参考。对于一个合格的JavaScript对象,现在让我们来看看会发生什么:
1 |
function BourneIdentity(name) { |
4 |
var obj = new BourneIdentity( "Jason" ); |
5 |
console.log( "Should be a BourneIdentity: " + (obj instanceof BourneIdentity)); |
6 |
obj = encodeDecode(obj); |
7 |
console.log( "Should be a BourneIdentity: " + (obj instanceof BourneIdentity)); |
这就是你可以得到的:
1 |
Should be a BourneIdentity: true |
2 |
Should be a BourneIdentity: true |
这里,JSON不跟踪对象的类和解码的副本将是一个不合格的对象。最后,使用JavaScript数据对象:
2 |
console.log( "Should be a Date: " + (date instanceof Date)); |
3 |
date = encodeDecode(date); |
4 |
console.log( "Should be a Date: " + (date instanceof Date)); |
这里是输出:
JSON序列化数据对象作为字符串,不是一个取得数据后反序列化的标准方法。Spearal使用这些简单的测试用例时工作地很好,我们仅仅在比较浅的层面上谈到了这种可能性。
一个运行Spearal的AngularJS / JAX-RS / JPA 示例应用
我们将会从一个已有的优秀示例程序着
手,它是由Roberto Cortez使用AngularJS和Java
EE开发的。我已经fork了此demo并对它进行了细微的修改以使其能够使用AngularJS
1.3.0-rc.0,运行一个嵌入式的Wildfly实例,并且在客户端和服务端程序中包含并启用了Spearal 库。
克隆,构建并运行此demo
你只需运行以下三行命令(你需要安装Git和Maven):
git clone https://github.com/spearal-examples/javaee7-angular.git
cd javaee7-angular
mvn clean install wildfly:run
Wildfly启动后,在浏览器中打开 http://localhost:8080/javaee7-angular-3.1/. 这个应用操作起来很简单,你可以对你喜欢的动漫人物进行基本的CRUD操作。以下是其页面的一个截图:

到目前为止的序列化是由JSON实现的。如果你的浏览器激活了XMLHttpRequest日志的话(在Chrome浏览器中按照这个步骤),你会看到这种输出:

从 JSON 切换到 Spearal
我们可以很轻松地切换到 Spearal 序列化:只需重新打开一个浏览器标签,输入http://localhost:8080/javaee7-angular-3.1/index-spearal.html。页面加载完成后,你会发现它与JSON版本的没什么区别:一切都像之前那样。为确保你的确是在使用Spearal而不是JSON,请查看XMLHttpRequest日志:

我们简单浏览下它的代码:Spearal版本的index页面与JSON版本的区别如下:
1 |
< script src = "lib/dependencies/traceur-runtime.js" ></ script > |
2 |
< script src = "lib/dependencies/spearal.js" ></ script > |
这两行代码会导入traceur运行时和编译过的ECMAScript 6 Spearal类.接下来一个内联的JavaScript代码块会配置AngularJS $resourceProvider以使它使用Spearal而不是JSON:
01 |
app.config([ '$resourceProvider' , function ($resourceProvider) { |
03 |
function encode(data, headersGetter) { |
04 |
if (data !== undefined) { |
05 |
var encoder = spearalFactory.newEncoder(); |
06 |
encoder.writeAny(data); |
07 |
return new Uint8Array(encoder.buffer); |
11 |
function decode(arraybuffer, headersGetter) { |
12 |
if (arraybuffer && arraybuffer.byteLength > 0) |
13 |
return spearalFactory.newDecoder(arraybuffer).readAny(); |
16 |
var spearalFactory = new SpearalFactory(); |
17 |
var contentTypeHeaders = { |
18 |
"Accept" : Spearal.APPLICATION_SPEARAL, |
19 |
"Content-Type" : Spearal.APPLICATION_SPEARAL |
22 |
var actions = $resourceProvider.defaults.actions; |
23 |
for (method in actions) { |
24 |
var action = actions[method]; |
25 |
action.responseType = 'arraybuffer' ; |
26 |
action.transformRequest = [ encode ]; |
27 |
action.transformResponse = [ decode ]; |
28 |
action.headers = angular.copy(contentTypeHeaders); |
30 |
if (method === "query") { |
31 |
action.headers[Spearal.PROPERTY_FILTER_HEADER] = Spearal.filterHeader( |
32 |
"com.cortez.samples.javaee7angular.data.Person", |
33 |
"id", "name", "description" |
基 本上每个REST动作(get, save, query, remove和delete)都会经过配置,这样它们能够接受二进制数据(arraybuffer),使用encode/decode函数进行(反)序列 化,并设置"Accept"和"Content-Type"请求头的值为"application/spearal"。这个代码块可以作为 spearal-angular库的入门:如果有好的文章,欢迎投稿。
使用Spearal局部序列化
到目前为止还不错,但尽管响应的长度减少了大约20%,使用Spearal似乎也不是必须的。接下来我们试试属性过滤。打开src/main/webapp/index-spearal.html文件中下面这段代码的注释:
1 |
if (method === "query" ) { |
2 |
action.headers[Spearal.PROPERTY_FILTER_HEADER] = Spearal.filterHeader( |
3 |
"com.cortez.samples.javaee7angular.data.Person" , |
4 |
"id" , "name" , "description" |
停止 Wildfly, 使用这个命令重建并重启程序:
1 |
mvn clean install wildfly:run |
刷新index-spearal.html页面:一切跟之前一样,但在控制台中你会看到:

加入这几行代码并没有改变程序的功能,但我们已经将编码后的数据从1.9KB减小到了449B(大约小了4倍),并且请求的执行速度也显著提高了(从14ms减少到了10ms)。数据模型的属性和关联越多,性能上的提升就越显著。
对 于以上代码有如下解释:"query"方法在程序获取一列Person时被调用,它们会显示在程序的数据表格中。因为数据表不会显示图片的URL,在数据 展示时我们不需要查询此字段。因此上面这段代码添加了一个请求头来只请求每个Person的id, name和description属性。但当你点击数据表中某一项时,整个Person bean会通过"get"方法获取,这个方法没有设置属性过滤。这就是表单中的图片URL仍可以正常显示的原因。
注 意: 不能在$resourceProvider级别配置"query"方法的属性过滤。它最好只在personService资源中启用,这样也不会影响其他 资源。但此处并没有多大关系,因为我们只使用了一个资源,而且这个全局配置只是用于演示Spearal局部序列化的优点的快速方法。
服务器端无法使用JSON
循环结构
我 们在Person实体中加入一些循环引用。 我们要添加一个多对一的关系:我们的漫画人物有相同的最大的敌人。 编辑文件 src/main/java/com/cortez/samples/javaee7angular/data/Person.java,取消下面代码的 注释:
02 |
private Person worstEnemy; |
04 |
public Person getWorstEnemy() { |
08 |
public void setWorstEnemy(Person worstEnemy) { |
09 |
this .worstEnemy = worstEnemy; |
鉴于一个人A是他最大的敌人B的最大的敌人很平常,我们也修改脚本文件src/main/resources/sql/load.sql 来引入一个循环引用。 取消下面这部分脚本的注释:
1 |
UPDATE PERSON SET WORSTENEMY_ID = 13 WHERE ID = 1; |
2 |
UPDATE PERSON SET WORSTENEMY_ID = 1 WHERE ID = 13; |
Uzumaki Naruto(漩涡鸣人)的最大敌人现在是Rock Lee(李洛克),反之亦然(此处绝对没有对《火影忍者》有任何不敬的意思)。 我们现在停止、重新编译、重启应用程序。 当你加载使用JSON的index.html页面时,你会在服务器的控制台里看到这个错误:
1 |
Caused by: com.fasterxml.jackson.databind.JsonMappingException: |
2 |
Infinite recursion (StackOverflowError)(无限递归,导致堆栈溢出错误) |
另一方面,Spearal页面仅仅像之前那样工作刷新,就会成功检测到循环引用和编码。如果你想要确认客户端应用实际上收到的是worst enemy关联,可以在定义src/main/webapp/script/person.js中取消worst enemy的列:
2 |
{ field: 'id' , displayName: 'Id' }, |
3 |
{ field: 'name' , displayName: 'Name' }, |
4 |
{ field: 'description' , displayName: 'Description' }, |
5 |
{ field: 'worstEnemy.name' , displayName: 'Worst Enemy' }, |
6 |
{ field: '' , width: 30, cellTemplate: [...] } |
我们可以修改我们的过滤器,让其在src/main/webapp/index-spearal.html中包含worstEnemy属性:
1 |
if (method === "query" ) { |
2 |
action.headers[Spearal.PROPERTY_FILTER_HEADER] = Spearal.filterHeader( |
3 |
"com.cortez.samples.javaee7angular.data.Person" , |
4 |
"id" , "name" , "description" , "worstEnemy" |
重建和重启应用后,你可以看到这样的结果:

未初始化的JPA(Java Persistence API)关联
我们再修改一下 Person 实体,配置人物与最大敌人的关联关系,这样它可以通过lazy方式获取。
1 |
@ManyToOne (fetch=FetchType.LAZY) |
2 |
private Person worstEnemy; |
和以前一样,停止、重新编译、重启应用程序。 在浏览器里刷新JSON 索引页面,在服务器控制台里你会立刻看到这个错误:
1 |
Caused by: com.fasterxml.jackson.databind.JsonMappingException: |
2 |
could not initialize proxy - no Session: |
4 |
Caused by: org.hibernate.LazyInitializationException: |
5 |
could not initialize proxy - no Session |
服务器端,JSON(Jackson这里)不能检测到未初始化的JPA关联或序列化一个独立代理的内容,因此出现LazyInitializationException异常。
现 在,刷新 index-spearal.html页面:页面依然正常展示, Spearal已经检测到并跳过未初始化的关联。 如果你已经在页面中展示了最大的敌人列(见上文),你会发现这一列没有任何值。 Spearal这种谨慎处理未初始化的关联的机制并不局限在单个关联(代理),对于一个集合任然有效。
你能解释一下那个奇怪的问题吗?
在 这个应用中,使用Spearal处理的JPA以默认的方式被激活。 如果不进行一些设置,JPA是不会工作的,现在是时间看一下启用它的方式。 下面的代码你可以在src/main/java/com/cortez/samples/javaee7angular/rest /PersonApplication.java文件中找到:
01 |
@ApplicationPath ( "/resources" ) |
02 |
public class PersonApplication extends Application { |
05 |
private EntityManagerFactory entityManagerFactory; |
08 |
private EntityManager entityManager; |
10 |
@Produces @ApplicationScoped |
11 |
public SpearalFactory getSpearalFactory() { |
12 |
SpearalFactory spearalFactory = new DefaultSpearalFactory(); |
13 |
SpearalConfigurator.init(spearalFactory, entityManagerFactory); |
14 |
return spearalFactory; |
18 |
public EntityManager getEntityManager() { |
19 |
return new EntityManagerWrapper(entityManager); |
首 先,在我们的 REST应用中,注入一个 EntityManagerFactory和一个 EntityManager。 这里没有什么特殊,和标准的 JavaEE注入方式一样。 然后,创建一个 SpearalFactory,配置注入的 entityManagerFactory,最后通过 @Produces CDI( JavaEE上下文和依赖注入)反向注入。 在示例应用中使用的 Spearal JAX-RS 模块需要这种反向注入,应用范围, SpearalFactory( Spearal工厂类)编码 /解码操作(此处使用 Spring 消息转换器进行设置会更简单) 。
作 为 Spearal JPA2 模块的一部分,SpearalConfigurator 的 init(...) 方法做了两件事情: 它在JPA环境中创建了一个包含当前所有持久化的类的集合,这样Spearal 编码器就可以知道哪些类有未初始化的属性;它为这些类添加了id和version属性,并把这两个属性添加UnfilterableProperty(无 需过滤)属性集中,这样我们可以保证不管客户端请求类的哪些属性,这两个属性都会被序列化。
在上一节过滤Person属性的javascript代码中,下面两种写法是严格相同的:
2 |
"com.cortez.samples.javaee7angular.data.Person" , |
3 |
"id" , "name" , "description" |
6 |
"com.cortez.samples.javaee7angular.data.Person" , |
实体的id字段(和version字段,如果有version字段的话)经常被编码,你永远不需要显示地请求它们。
接 下来是 EntityManager的包装和反向注入。 无需深入了解包装器是如何工作的,这里有一些重点说明: 当 Spearal 在服务器端(并非一个类的所有属性都会被序列化)收到一个局部对象时,它创建一个代理追踪当前有什么属性、缺少什么属性。 我们会在下一节看到,在把这些对象返回给底层 JPA引擎之前, EntityManagerWrapper 负责反向代理这些对象。
如果你给一个快速视图在 src/main/java/com/cortez/samples/javaee7angular/rest/PersonResource.java 类,你将会看到 实体管理器反射到 CDI @Inject 注释:
2 |
private EntityManager entityManager; |
实 体管理器被反射在person资源上,通过person应用将其提取。 这意味着我们已经透明地从一开始就使用这种包装实体管理器,JSON和 Spearal一致:如果没有Spearal代理和这样的代理,那么其只是在Spearal反序列化期间创建的,包装类不做任何事情。 使用一个未经包装 过的实体管理器,就不会改变我们关于JSON的相关内容。
注意:平心而论,Jackson有一个扩展处 理未初始化的关联( 我们将在下一节中介绍)。但是,与Spearal模块一样,适用于所有JPA 2引擎。jackson-datatype-hibernate,一如其名,仅适合于Hibernate。更重要地,它可以用来防止 LazyInitializationException(异常)( session是active时的一个不必要的初始化):一个未被初始化的关联会被串行化为空,当你把它从客户端更新,服务器就会没有办法重现未初始化关 联到这个空值。在代码中,没有仔细处理这种情况,你可能就会在你的数据库中缺失这种关联。
处理过时的客户机应用
现 在, 我们想要模拟一个过时的客户端应用程序,它有很多充满歧义的特性。这里我们所使用的是与经典的web应用相似的,过时的客户机应用通常会有很多问 题: HTML应用是每次访问时从服务器加载的并且反映的是最近一次部署的状态。然而, 客户机应用是被安装在用户终端的(例如:一个移动端应用通过PhoneGap被创建), 由于过时的客户机对于数据模型的变更是无意识的,你可能要处理这种问题。
首先,我们想要恢复客户端应用程序到它原来的状态。 把src/main/webapp/script/person.js中的WorstEnemy列注释掉:
2 |
{ field: 'id' , displayName: 'Id' }, |
3 |
{ field: 'name' , displayName: 'Name' }, |
4 |
{ field: 'description' , displayName: 'Description' }, |
5 |
// { field: 'worstEnemy.name', displayName: 'Worst Enemy' }, |
6 |
{ field: '' , width: 30, cellTemplate: [...] } |
因为我们希望JSON序列化与未初始化的协会工作,我们需要我们的编辑pom.xml文件,并取消对Jackson的Hibernate的扩展依赖:
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-hibernate4</artifactId>
<version>2.4.0</version>
</dependency>
接 下来,我们需要一个新的JAX-RS提供初始化此jackson的延伸:jackson-ext/ JacksonContextResolver.java类移动到src/main/java/com/cortez/samples /javaee7angular/rest目录下,PersonApplication.Java文件的旁边。
最后,编辑 src/main/java/com/cortez/samples/javaee7angular/rest/PersonResource.java 文件改变部分如下:
...
// return query.getResultList();
List<Person> persons = query.getResultList();
System.out.println("-------------");
for (Person p : persons) {
System.out.printf(
"%d) %s's worst enemy: %s\n",
p.getId(),
p.getName(),
(p.getWorstEnemy() != null ? p.getWorstEnemy().getClass().getName() : "null")
);
}
return persons;
而不是只返回查询结果,我们现在还打印类的最差的协程,看它是否为空或不是。由于该协程延迟访问,类里的最差实体将是一个Javassist进行休眠的代理,我们将很快看到。
停止,重新编译并重启应用。刷新JSON index页面应该可以运行了,Jackson Hibernate扩展机制阻止了未初始化联系的序列化。Spearal页面运行良好且准确。
查看Wildfly控制台,有如下输出:
1 |
1) Uzumaki Naruto's worst enemy: com.cortez.samples.javaee7angular.data.Person_$_jvst153_0 |
2 |
2) Hatake Kakashi's worst enemy: null |
现 在,我们在Spearal页面修改Uzumaki Naruto记录:点击表格里相应的行,在Name属性里输入另一个名称并保存。一切运行良好,java控制台里打印也准确:Uzumaki Naruto (或你输入的别的名称)仍有一个worst enemy,从过时的Spearal页面更新这条记录没有破坏这个联系。
去JSON页面刷新一下:你会看到刚做的修改。编辑第一个人的名称(如Uzumaki Naruto)并保存。再看一下java控制台的打印:
1 |
1) Uzumaki Naruto's worst enemy: null |
2 |
2) Hatake Kakashi's worst enemy: null |
在过时的JSON应用里编辑Uzumaki Naruto记录会使既有的worst enemy联系丢失。Spearal检测到不完整的worst enemy联系只会更新定义过的属性,而JSON会接收并保存成null值。
让我们看一下src/main/java/com/cortez/samples/javaee7angular/rest/PersonResource.java 文件里的savePerson方法:
02 |
public Person savePerson(Person person) { |
03 |
return entityManager.merge(person); |
06 |
if (person.getId() == null) { |
07 |
Person personToSave = new Person(); |
08 |
personToSave.setName(person.getName()); |
09 |
personToSave.setDescription(person.getDescription()); |
10 |
personToSave.setImageUrl(person.getImageUrl()); |
11 |
entityManager.persist(person); |
13 |
Person personToUpdate = getPerson(person.getId()); |
14 |
personToUpdate.setName(person.getName()); |
15 |
personToUpdate.setDescription(person.getDescription()); |
16 |
personToUpdate.setImageUrl(person.getImageUrl()); |
17 |
person = entityManager.merge(personToUpdate); |
代 码中注释掉的那一段本是用来创建或者更新person的。我们有意识地用一个entityManager.merge(person)操作来替代它。重新 使用这段代码当然能防止我们碰到和JSON一样的问题:worst enemy连接并不会被null化,因为上面的代码仅仅更新了name description和imageUrl属性。
然而,一个加到数据模型的属性肯定是要在某处被使用的,并且最直接的对上面代码的改进就是:
1 |
if (person.getId() == null ) { |
3 |
personToSave.setWorstEnemy(person.getWorstEnemy()); |
4 |
entityManager.persist(person); |
7 |
personToUpdate.setWorstEnemy(person.getWorstEnemy()); |
8 |
person = entityManager.merge(personToUpdate); |
我 们并不需要测试这段代码就能发现:它不会改变过时的JSON应用的任何数据:worst enemy连接同样也会损坏。你能想出这个问题一些(糟糕)的解决方案。最安全的一个(IMHO)就是创建一个新的resource,叫做 PersonResourceV2,来被你的新客户端使用,同时过时的客户端依旧使用着旧的版本,没有setWorstEnemy的调用。
采 用任何其他解决方案都不会比Spearal简单:只用一个简单的merge。丢失的属性会被检测到并被忽略,所以过时的客户端弄乱新的属性字段也没什么风 险。并且,如果不新写一个PersonResourceV2但你又想使用get/set模式,Spearal可以让你更简单地做到:
try {
personToUpdate.setWorstEnemy(person.getWorstEnemy());
}
catch (UndefinedPropertyException e) {
// deal with your outdated client.
}
差别更新
差 别更新处理不完整数据时有另一个有趣的应用:客户端可以发送只实际修改的属性字段去做更新操作。使用我们的angular应用可以快速说明这一特性。 src/main/webapp/index-spearal.html里的表单已经把personForm对象作为其调用的updatePerson方 法的一个参数传递了。
<form name="personForm" ng-submit="updatePerson(personForm)" novalidate>
现在,编辑 src/main/webapp/script/person.js 并取消下面代码中的注释 src/main/webapp/script/person.js:
2 |
if (!personForm.name.$dirty) |
4 |
if (!personForm.description.$dirty) |
5 |
delete person.description; |
6 |
if (!personForm.imageUrl.$dirty) |
7 |
delete person.imageUrl; |
8 |
alert( 'Person to save: ' + angular.toJson(person, true )); |
停下来,像往常一样重建并重新启动应用程序。 之后,在你的浏览器中刷新Spearal的主页并编辑某个人的姓名 (就名字就可以) 。 当你点击 "保存" 按钮,你就可以看到警示框弹出:

弹出表示了服务器已经接收: id 和姓名属性, 没有其他的栏。不管如何, 在之后点击 "OK",你就会看到你包含你修改的和没有修改的属性部分(或者差别)的表格的更新。 他没有修改JSON, 甚至是服务器侧老的版本:缺失的属性将会导致属性为空。
结论和未来工作
通过该篇长文(谢谢你能够读到最后一页)我们演示了Spearal如何解决JSON的局限性,并介绍了有趣的新特性:
正确地处理了图的引用和循环引用。
保留了对象的标识(如类)。
允许数据图过滤。
正确地处理了过时的客户端应用。
允许差分更新。
通过与JAX-RS和jpa2的整合,我们已经能够很容易的转换现有用到JSON的js / Java EE应用 ,并且只要数据模型保持简单就可以展现spearal的全部好处。spearal仍处于早期发展阶段,我们计划在不久的将来包含以下客户端技术:
Javascript / HTML(测试版)
Android(测试版)
iOS(早期发展阶段)
和服务器端(Java):
JAX-RS集成(测试版)
Spring集成(测试版)
JPA 2集成(测试版)
源码:http://www.jinhusns.com/Products/Download/?type=xcj
非常渴望得到大家的反馈,特别是如果你计划使用或正在使用spearal。不要犹豫的来评论这篇文章或在spearal论坛联系我们吧!