Java SOAP 处理复杂数据类型


106 第五章 处理复杂数据类型 在前一章中,我们用 Java 创建了几个 RPC 形式的服务,这些服务仅仅处理了简 单数据类型。简单数据类型可能在有些场合很合适,但你总会遇到需要使用更复 杂的数据类型的时候,就像平时在 Java编程中经常遇到的一样。在这一章中,我 们来看看如何使用以数组和 Java bean 作为参数和返回值的方法来建立服务。由 于所有的自定义数据类型都可能用到本章中讨论的复杂数据类型,因此一直到下 一章结束,我们都将涉及到用户自定义的数据类型。 传递数组参数 我们来看看数组,它们也许是在程序设计中使用得最普遍的复杂数据类型了。它 们无处不在,当然在 SOAP 中也是如此。我们在第三章中曾使用过 SOAP 数组, 因此你可能对数组如何编码的细节已有所了解。那么现在就让我们用数组来编写 Java 的服务吧。 本章中我们继续使用前面使用过的股票市场的例子。一个能够返回股票信息的服 务会是很有用的,它需要返回当天股票的交易量、这些股票的平均交易价格、当 天价格上涨的股票数等等。可能还有其他各种情况。我们以一个返回当日股票总 交易量的服务作为开始。我们将这个服务叫做urn:BasicTradingService,它有 一个名为 getTotalVolume 的方法。下面是实现这个服务的 Java 类: 处理复杂数据类型 107 package javasoap.book.ch5; public class BasicTradingService { public BasicTradingService() {} public int getTotalVolume(String[] stocks) { // 从某个数据来源得到各股票的交易量 // 并返回其总数 int total = 345000; return total; } } BasicTradingService类有一个getTotalVolume()方法,该方法返回交易的总 数。由于我们不打算真正从数据提供者处取得输入数据,因此我们只返回一个虚 构的值。这个方法接受一个名为stocks的字符串数组并返回一个整数。这个字符 串数组中的值代表股票代码,在实际应用中,我们需要从数据提供者处分别取得 字符串数组中各个股票的交易数量,然后返回一个总数。 Apache SOAP已经为数组提供了内置的支持,因此你不需要在服务器端做什么特 别的事情。这也意味着在部署描述文件中不用添加新的内容,和前几章中介绍的 一样即可。下面是服务 urn:BasicTradingService 的部署描述文件: org.apache.soap.server.DOMFaultListener 你可以使用上面这个部署描述文件来部署你的服务,或者在浏览器中使用Apache SOAP 的 Admin 工具来部署它。 第五章108 现在我们要写一个访问该服务的客户应用程序。这与前几章里的很相似,不同的 仅仅是传入的参数类型,这里我们用字符串数组代替了前面的简单类型。这是它 的代码: package javasoap.book.ch5; import java.net.*; import java.util.*; import org.apache.soap.*; import org.apache.soap.rpc.*; public class VolumeClient { public static void main(String[] args) throws Exception { URL url = new URL("http://georgetown:8080/soap/servlet/rpcrouter"); Call call = new Call(); call.setTargetObjectURI("urn:BasicTradingService"); call.setEncodingStyleURI(Constants.NS_URI_SOAP_ENC); String[] stocks = { "MINDSTRM", "MSFT", "SUN" }; Vector params = new Vector(); params.addElement(new Parameter("stocks", String[].class, stocks, null)); call.setParams(params); try { call.setMethodName("getTotalVolume"); Response resp = call.invoke(url, ""); Parameter ret = resp.getReturnValue(); Object value = ret.getValue(); System.out.println("Total Volume is " + value); } catch (SOAPException e) { System.err.println("Caught SOAPException (" + e.getFaultCode() + "): " + e.getMessage()); } } } 我们向Parameter构造函数传入的第二个参数是String[].class,这说明stocks 参数的类型是字符串数组。好了,到现在为止我们已经完成了所有的工作。如果 你运行这个例子,其输出应该是这样的: Total Volume is 345000 处理复杂数据类型 109 我们现在来看看为了调用服务的 getTotalVolume 方法而向服务器传送的 SOAP 封套: MINDSTRM MSFT SUN stocks元素表示我们传给服务方法的字符串数组参数。通过把 xsi:type属性设 置为 ns2:Array 来指定数组类型。 ns2 名称空间标识符在前面的行中定义。接下来,为 ns2:arrayType属性分配一 个值xsd:string[3]。这表示该数组的大小为3,数组的每个元素都是xsd:string 类型。stocks 元素有三个子元素,每一个的名称都是 item。记住,数组的子元 素使用什么名称并不重要,因为不同的 SOAP实现使用不同的模式来为这些元素 命名。本例通过将 xsi:type设为xsd:string显式地指定了数组中每个元素的类 型。 这个例子使用的是同构数组( homogeneous array),也就是说,数组中所有的元 素都是同种类型的。你或许还会遇到要使用异构数组(heterogeneous array)的 情况,让我们来看看这种可能性。在 Java 语言中,方法常常使用数组作为参数, 而在其他语言里可能使用一个变长的参数列表。举个例子, C语言中的printf() 函数的参数个数就是不固定的。尽管 Java不支持这种方式,但你可以通过在数组 第五章110 中传递参数值来模拟这种功能。一个数组可以是任何长度的,而且其中的元素并 不一定要是同一类型的。 现在我们向 urn:BasicTradingService 服务中添加一个用异构数组作为参数的 方法executeTrade。它的参数是一个数组,数组的元素包含股票代码、需要交易 的数量和一个表示是买还是卖的标志( true 表示买)。返回值是一个描述交易的 字符串(注 1)。这里是改动后的 BasicTradingService 类: package javasoap.book.ch5; public class BasicTradingService { public BasicTradingService() { } public int getTotalVolume(String[] stocks) { // 从某个数据来源得到各股票的交易量 // 并返回其总数 int total = 345000; return total; } public String executeTrade(Object[] params) { String result; try { String stock = (String)params[0]; Integer numShares = (Integer)params[1]; Boolean buy = (Boolean)params[2]; String orderType = "Buy"; if (false == buy.booleanValue()) { orderType = "Sell"; } result = (orderType + " " + numShares + " of " + stock); } catch (ClassCastException e) { result = "Bad Parameter Type Encountered"; } return result; } } 注 1: 当然还有其他方式来设计这样的方法接口,而且这也不是我所选择的设计。这种情况 可能会要求使用自定义类或 Java bean。但本例中所使用的方法演示了同构数组的使 用。 处理复杂数据类型 111 executeTrade()方法只有一个参数:一个叫 params 的 Object[]数组。这个数 组中的对象必须转换成相应的类型:String、Integer 和Boolean。我喜欢将类 型转换放在一个 try/catch块中以防调用者出错。在这种方式下,我可以在方法 调用错误时做一些适当的处理,尽管有时候只是简单地返回错误描述。在第七章 里,我们将介绍在这种情况下如何生成 SOAP 错误。executeTrade()方法将数 组传递的信息生成一个描述输入参数的字符串,该字符串保存在result变量中并 返回给调用者。 现在我们来修改客户应用程序,使它能够向服务的 executeTrade 方法传递一个 适当的 Object[]类型的参数。multiParams变量被声明为 Object[]类型,它由 一个字符串 MINDSTRM、一个值为 100 的整数和一个值为 true 的布尔变量组成。 既然我们使用的是 Java 的 Object 数组,我们就不再使用 Java 的简单类型作为 数组的元素。取而代之,我们将这些简单值包装到等价的Java对象里。Parameter 构造函数的第二个参数是 Object[].class,它是一个对象数组的类。 package javasoap.book.ch5; import java.net.*; import java.util.*; import org.apache.soap.*; import org.apache.soap.rpc.*; public class TradingClient { public static void main(String[] args) throws Exception { URL url = new URL( "http://georgetown:8080/soap/servlet/rpcrouter"); Call call = new Call(); call.setTargetObjectURI("urn:BasicTradingService"); call.setEncodingStyleURI(Constants.NS_URI_SOAP_ENC); Object[] multiParams = { "MINDSTRM", new Integer(100), new Boolean(true) }; Vector params = new Vector(); params.addElement(new Parameter("params", Object[].class, multiParams, null)); call.setParams(params); 第五章112 try { call.setMethodName("executeTrade"); Response resp = call.invoke(url, ""); Parameter ret = resp.getReturnValue(); Object value = ret.getValue(); System.out.println("Trade Description: " + value); } catch (SOAPException e) { System.err.println("Caught SOAPException (" + e.getFaultCode() + "): " + e.getMessage()); } } } 如果一切正常,运行 executeTrade()服务方法的结果将是: Trade Description: Buy 100 of MINDSTRM 我们可以试试改变multiParams数组中参数的顺序。 在这种情况下,我们将得到 一个类转换异常,而且该方法会返回一个错误字符串。 这里是正确调用 executeTrade()服务方法的 SOAP 封套: MINDSTRM 100 true 处理复杂数据类型 113 通过将xsi:type属性的值设置为ns2:Array来设定params元素的类型。它与同 构的情况相比惟一不同的是,数组中各个元素的类型由不同的ns2:arrayType属 性值来设定。值 xsd:anyType[3]指定数组包含 3 个元素,每一个都可能是任意 的有效数据类型(注 2)。 现在让我们看看在 GLUE 中传递数组参数的方法。BasicTradingService 类在 GLUE中可以不做修改就部署。我们将使用一个简单的 Java应用程序来启动这个 服务: package javasoap.book.ch5; import electric.util.Context; import electric.registry.Registry; import electric.server.http.HTTP; public class BasicTradingApp { public static void main( String[] args ) throws Exception { HTTP.startup("http://georgetown:8004/glue"); Context context = new Context(); context.addProperty("activation", "application"); context.addProperty("namespace", "urn:BasicTradingService"); Registry.publish("urn:BasicTradingService", javasoap.book.ch5.BasicTradingService.class, context ); } } 编译并运行这个应用程序,该服务将被部署。现在让我们使用 GLUE API写一个 简单的例子来访问这个服务。首先我们来看服务的接口IBasicTradingService: package javasoap.book.ch5; public interface IBasicTradingService { int getTotalVolume(String[] symbols); String executeTrade(Object[] params); } 注 2: 在第三章里我们提到了用 ur-type 来表示任何可能的数据类型。在这里我们使用了 anyType 来达到同样的目的。2001 年 5 月发布的 XML Schema 建议性标准的第 0 部 分在第2.5.4节中做出了下面的解释:“anyType表示一个叫做ur-type的抽象,ur- type是派生所有的简单类型和复杂类型的基础类型。anyType类型不以任何形式约束 它的内容。” 第五章114 现在我们可以编写一个绑定到该服务并调用它的两个方法的应用程序: package javasoap.book.ch5; import electric.registry.RegistryException; import electric.registry.Registry; public class BasicTradingClient { public static void main(String[] args) throws Exception { try { IBasicTradingService srv = (IBasicTradingService)Registry.bind( "http://georgetown:8004/glue/urn:BasicTradingService.wsdl", IBasicTradingService.class); String[] stocks = { "MINDSTRM", "MSFT", "SUN" }; int total = srv.getTotalVolume(stocks); System.out.println("Total Volume is " + total); Object[] multiParams = { "MINDSTRM", new Integer(100), new Boolean(true) }; String desc = srv.executeTrade(multiParams); System.out.println("Trade Description: " + desc); } catch (RegistryException e) { System.out.println(e); } } } 正如我们以前看到的,GLUE允许我们使用熟悉的Java编程语法,而不必考虑底 层的 SOAP结构。这对传递数组参数同样适用。在将接口绑定到服务之后,所有 的事情都已经被我们处理得非常好了。 在本例所产生的 SOAP 请求封套中,你会看到一些有趣的事情。这就是 getTotalVolume()方法调用的 SOAP 封套: 处理复杂数据类型 115 MINDSTRM MSFT SUN Body 元素的前面部分与我们以前看到的差不多;而封套由名称空间标识符 soap 限定,它表示 SOAP 封套的名称空间。SOAP 体的第一个子元素是 getTotal- Volume,这当然是被调用的服务方法的名称。getTotalVolume 是由服务名称作 为名称空间限定的。getTotalVolume的惟一子元素是 arg0,表示被传递给方法 的参数。但是这不是我们传递的数组;它是对该数组的引用。这是GLUE与Apache SOAP API为产生这个调用所采用的方式之间的一个明显不同。Apache SOAP把 数组作为 getTotalVolume 的一个子元素放在封套中,而 GLUE 使用引用并在 getTotalVolume元素结束之后才串行化该数组。于是该参数就被串行化为arg0, 包含一个值为#id0的href属性。它不包含任何数据,因为这个数组存在于别处。 我们传递的数组参数紧跟在getTotalVolume元素之后。它叫做 id0,由 GLUE产 生,不过元素的名称本身并不重要。其 id 属性的值被设定为 id0,它与 get- TotalVolume元素中href属性的值一致。GLUE生成了soapenc:root属性并赋 值为 0,这意味着该元素不被作为某个对象图( object graph)的根( GLUE 好像 会自动包括这个属性)。接着我们会看到一个叫做ns2的名称空间标识符,它看起 来好像代表了 GLUE的内部包 java.lang;然而,ns2 名称空间标识符根本就没 有被用到。xsi:type和soapenc:arrayType属性建立的方式与在Apache SOAP 例子中的一样。最后,数组中的元素被串行化。在串行化方面,这个例子与由 Apache SOAP生成的那个例子之间的惟一不同在于数组元素本身的名称。Apache SOAP 将它们命名为 item,而 GLUE 将它们命名为 i。名称无关紧要;结果会是 一样的。 这个例子给了我们一个机会看到两种串行化数组的方式,而这两种方式都是正确 的。如果要实现互操作,那么让 SOAP实现了解这些不同的形式就非常重要。这 第五章116 就是我们坚持要列出例子所产生的封套内容的原因。如果你在让基于不同实现的 应用程序互相正确通信的问题上遇到了困难(我是说如果),那么熟悉这些不同的 形式将帮助你找到解决的办法。 让我们看看调用 executeTrade 服务方法所使用的 SOAP封套。这个方法接受一 个异构数组作为参数。它也使用了一个对独立串行化数组的引用,这次它被编码 为 xsd:anyType: MINDSTRM 100 true 返回数组 到目前为止我们已经将数组作为传入参数使用过了。现在我们来使用一个数组作 为服务方法的返回值。我们将在服务中加入一个叫做 getMostActive()的方法, 它返回一个 String[]来包含当日成交最为活跃的那些股票的代码。这里是 BasicTradingService 类的新版本: package javasoap.book.ch5; public class BasicTradingService { public BasicTradingService() { } 处理复杂数据类型 117 public String[] getMostActive() { // 取得交易最活跃的股票 String[] actives = { "ABC", "DEF", "GHI", "JKL" }; return actives; } public int getTotalVolume(String[] stocks) { // 从某个数据源得到股票的交易量 // 并返回其总数 int total = 345000; return total; } public String executeTrade(Object[] params) { String result; try { String stock = (String)params[0]; Integer numShares = (Integer)params[1]; Boolean buy = (Boolean)params[2]; String orderType = "Buy"; if (false == buy.booleanValue()) { orderType = "Sell"; } result = (orderType + " " + numShares + " of " + stock); } catch (ClassCastException e) { result = "Bad Parameter Type Encountered"; } return result; } } 既然我们并不是真正从数据来源处获取数据,我们只需要将一些假的股票代码填 入数组并返回它就可以了。接下来要重新部署这个服务。 从一个Apache SOAP客 户调用这个服务很简单。服务方法没有输入参数,因此我们仅需要建立调用然后 启动它: package javasoap.book.ch5; import java.net.*; import org.apache.soap.*; import org.apache.soap.rpc.*; public class MostActiveClient { public static void main(String[] args) throws Exception { 第五章118 URL url = new URL("http://georgetown:8080/soap/servlet/rpcrouter"); Call call = new Call(); call.setTargetObjectURI("urn:BasicTradingService"); call.setMethodName("getMostActive"); Response resp; try { resp = call.invoke(url, ""); Parameter ret = resp.getReturnValue(); String[] value = (String[])ret.getValue(); int cnt = value.length; for (int i = 0; i < cnt; i++) { System.out.println(value[i]); } } catch (SOAPException e) { System.err.println("Caught SOAPException (" + e.getFaultCode() + "): " + e.getMessage()); } } } 我们将ret.getValue返回值的类型转换为String[],因为这才是我们所需要的 类型。在过去的例子中,我们能够让一个值保持为 Object 的实例不变是因为我 们依赖了Object的toString()方法来显示这个值。现在我们要返回该数组,因 此需要将它的值转换成适当的数组类型。接下来,我们需要得到数组的长度然后 顺序打印我们所得到的数组的每一个值。如果运行了这个例子,你将会看到如下 的输出: ABC DEF GHI JKL 这个方法调用所返回的 SOAP 封套是相当简单的: ABC DEF GHI JKL 为了在 GLUE 中部署 BasicTradingService 类的这一个版本,可以使用我们原 来的 BasicTradingApp类。我们可以修改 Java接口IBasicTradingService,使 它包括新的方法: package javasoap.book.ch5; public interface IBasicTradingService { int getTotalVolume(String[] symbols); String executeTrade(Object[] params); String[] getMostActive(); } 现在我们要修改应用程序BasicTradingClient来加入一个对getMostActive() 方法的调用,然后在数组中迭代各个值并打印它们。在使用 GLUE时我们不需要 将返回值的类型转换为 String[],这是因为,与 Apache SOAP 的例子不同,这 个接口的getMostActive()方法已经被定义为返回正确的类型了。这里是修改后 的代码: package javasoap.book.ch5; import electric.registry.RegistryException; import electric.registry.Registry; public class BasicTradingClient { public static void main(String[] args) throws Exception { try { IBasicTradingService srv = (IBasicTradingService)Registry.bind( "http://georgetown:8004/glue/urn:BasicTradingService.wsdl", IBasicTradingService.class); 第五章120 String[] stocks = { "MINDSTRM", "MSFT", "SUN" }; int total = srv.getTotalVolume(stocks); System.out.println("Total Volume is " + total); Object[] multiParams = { "MINDSTRM", new Integer(100), new Boolean(true) }; String desc = srv.executeTrade(multiParams); System.out.println("Trade Description: " + desc); String[] actives = srv.getMostActive(); int cnt = actives.length; for (int i = 0; i < cnt; i++) { System.out.println(actives[i]); } } catch (RegistryException e) { System.out.println(e); } } } 运行这个例子会得到如下输出: Total Volume is 345000 Trade Description: Buy 100 of MINDSTRM ABC DEF GHI JKL GLUE对作为返回值的数组使用了与前面作为参数的数组相同的串行化技术;它 为实际的返回值建立了一个独立串行化的数组,然后又引用了这个实际的数组值。 调用 getMostActive()方法所返回的 SOAP 封套是: ABC DEF GHI JKL 传递自定义类型参数 作为 Java程序员,我们当然不会把自己限制于仅仅使用标准 Java软件包中的类。 我们所编写的所有代码的实质部分是自定义类型,也就是我们自己建立的 Java 类,它们具体实现了我们所要创建的系统的功能和特性。 考虑一下要在一个Java类中描述一次股票交易所需要的所有数据。这个新类可能 包含交易股票的代码、交易的数量以及一个对交易类型的描述(买还是卖) 。当设 计这样一个类的时候,在更大的系统上下文中去分析它是很重要的。这种分析能 提供像给予类什么样的属性和行为这样的线索。我们一直在做这样的工作;这称 为软件设计。其结果是产生了包含访问属性和行为的方法的 Java类。由于 SOAP 是一个数据传送协议,所以我们对类的属性更感兴趣。这是我们想要在线路上传 输的东西。 表示一个 Java类的属性经常采用的方法是使用JavaBeans设计模式。这些模式为 那些用于访问类的方法指定命名惯例。你可能不熟悉 JavaBeans,但是我敢打赌, 你肯定多次见过这个模式。这里是 O'Reilly公司出版的《 Developing Java Beans》 一书中对属性存取器模式的描述: 用于保存或获取属性值的方法应遵循属性的标准设计模式。这些方法允许抛 出检验过的异常,但是这是可选的。方法的声明方式如下: public void set( value); public get(); 遵照这个模式声明的一对方法表示了一个名为 类型的可读写属性。如果只有 get 方法,就表示该属性为只 第五章122 读;而如果只有 set方法,则表示该属性为只写。在 为布尔 类型时,其 get 方法可以使用如下的表示方式代替: public boolean is(); 如果你遵照这个模式来命名属性存取器,Java的映射机制就能够在运行时确定这 个存取方法(注3)。对于为了在SOAP消息中串行化数据而访问某个Java类实例 的数据值的SOAP实现来说,这是一个很方便的方法。它使Apache SOAP和GLUE 都能从这种映射中获益。这意味着你需要遵循一个良好的命名惯例,这样,当在 SOAP 中使用自定义数据时,你会受益匪浅。当然,要使用自定义数据,这还远 远不够,现在让我们开始深入讨论这个问题。 我们首先来定义一次股票交易,看它到底要包含些什么内容。它需要有一个用来 表示要交易的股票的代码;这个代码应该是字符串类型的元素。它还需要一个标 志来说明是要买还是要卖。最后,它还需要一个包含交易数量的整数(在一个实 际的应用程序中,它也可能包含各种不同的安全证书、买方和经纪人的名字以及 其他许多东西。作为选择,这些额外的数据可以在其他对象中表示) 。这种股票交 易的 XML 模式片段看起来应该是这样的: 我们来建立一个名为 StockTrade_ClientSide 的自定义 Java 类,它描述了一次 股票交易。一般情况下我会将这个类命名为StockTrade,但是现在我想要表明我 将在客户端使用这个类。我们会为服务器端的使用编写一个类似的类,它将使用 一个对应的名字。我这样做是要说明你不必在消息事务的两端使用同一个 Java 类。事实上,也许使用同一个类是没有意义的,甚至常常是不可能的。 StockTrade_ClientSide有三个可读写的属性:Symbol、Buy 和NumShares,它 们分别表示股票代码、买卖标志和交易数量。 注 3: JavaBean 提供了其他办法来完成这个工作,但这超出了本书的研究范围。 处理复杂数据类型 123 package javasoap.book.ch5; public class StockTrade_ClientSide { String _symbol; boolean _buy; int _numShares; public StockTrade_ClientSide() { } public StockTrade_ClientSide(String symbol, boolean buy, int numShares) { _symbol = symbol; _buy = buy; _numShares = numShares; } public String getSymbol() { return _symbol; } public void setSymbol(String symbol) { _symbol = symbol; } public boolean isBuy() { return _buy; } public void setBuy(boolean buy) { _buy = buy; } public int getNumShares() { return _numShares; } public void setNumShares(int numShares) { _numShares = numShares; } } 现在让我们创建一个StockTrade_ServerSide类来表示服务器端的股票交易。只 是为了使这个类与它在客户端的副本之间显得不同,我们删掉了带参数的构造函 数。而且为了对应,我们需要改变类变量的名称并调整它们出现的顺序。 package javasoap.book.ch5; public class StockTrade_ServerSide { int _shares; boolean _buyOrder; String _stock; public StockTrade_ServerSide() { } public String getSymbol() { return _stock; 第五章124 } public void setSymbol(String stock) { _stock = stock; } public boolean isBuy() { return _buyOrder; } public void setBuy(boolean buyOrder) { _buyOrder = buyOrder; } public int getNumShares() { return _shares; } public void setNumShares(int shares) { _shares = shares; } } 我们可以向urn:BasicTradingService服务中加入一个新的方法executeStock- Trade(),它将股票交易作为参数。这个方法返回的是一个描述交易的字符串。下 面是被修改后的 BasicTradingService 类。我们能利用这个类中已有的 executeTrade()方法。在新方法 executeStockTrade()中,我们用 trade 参数 的三个属性创建了一个 Object 数组,并将这个数组传递给 executeTrade()方 法。 package javasoap.book.ch5; public class BasicTradingService { public BasicTradingService() { } public String executeStockTrade(StockTrade_ServerSide trade) { Object[] params = new Object[3]; params[0] = trade.getSymbol(); params[1] = new Integer(trade.getNumShares()); params[2] = new Boolean(trade.isBuy()); return executeTrade(params); } public String[] getMostActive() { // 取得交易最活跃的股票 String[] actives = { "ABC", "DEF", "GHI", "JKL" }; return actives; } public int getTotalVolume(String[] stocks) { 处理复杂数据类型 125 // 从某个数据来源得到各股票的交易量 // 并返回其总数 int total = 345000; return total; } public String executeTrade(Object[] params) { String result; try { String stock = (String)params[0]; Integer numShares = (Integer)params[1]; Boolean buy = (Boolean)params[2]; String orderType = "Buy"; if (false == buy.booleanValue()) { orderType = "Sell"; } result = (orderType + " " + numShares + " of " + stock); } catch (ClassCastException e) { result = "Bad Parameter Type Encountered"; } return result; } } 为部署这个服务,我们需要把自定义类型映射到实现该类型的 Java类。我们还需 要给自定义类型一个名称并用一个适当的名称空间限定它。这有点像我们前面声 明一个服务的步骤。映射发生在部署描述文件的 isd:sections部分中。这就是 我们将用来在 Apache SOAP 中部署该服务的部署描述文件: org.apache.soap.server.DOMFaultListener 让我们在继续介绍之前对映射做一个总结,这很重要。从自定义类型到 Java类之 间的映射与一种编码形式和一个完全限定的类型名称有关。一个Java类实现对自 定义类型的描述,而工具类则用来完成串行化和反串行化工作。现在让我们来看 例子中的细节。 部署描述文件中的大部分内容看起来很熟悉;它与第四章给出的很类似。不同的 是我们现在的 isd:mappings 部分中多了一项。isd:map 项描述了从股票交易类 型到StockTrade_ServerSide类的映射。isd:map元素中没有数据;所有的信息 都以属性形式提供。第一个属性encodingStyle指定了与串行化该类型相关的编 码形式。下一个是名称空间标识符 x,其值被设为 urn:BasicTradingService。 我使用了服务的名称作为自定义类型的名称空间限定符;然而,你不一定要这样 做,而且事实上在某些情况下你也不可能这么做。举例来说,如果你的自定义类 型被多个服务所使用,或者你使用的是一个由第三方定义的自定义类型,那么你 肯定想要为自定义类型使用一个适当的名称空间。接下来的属性 qname指定了该 类型被映射到的完全限定符名。给它设定的值是 x:StockTrade,这与用服务名 限定的名称空间StockTrade是同一个名称空间。javaType属性指明了用于实现 这个类型的服务器-本地 Java 类;在服务器端我们使用了 javasoap.book. ch5.StockTrade_ServerSide。最后两个属性 java2XMLClassName 和 xml2JavaClassName分别告诉Apache SOAP使用哪个本地 Java类来完成串行化 和反串行化工作。Apache SOAP 自带了一个自定义串行化 / 反串行化类,它可以 实现自定义XML类型与遵循JavaBeans属性存取器模式的Java类之间的转换。它 能完成串行化和反串行化两种工作,这是我们为两个属性都使用了它的原因。在 下一章中我们将看到如何创建自定义类型串行化器。 既然已经有了一个部署描述文件,我们就可以马上部署 urn:BasicTrading- Service。一旦这样做了,我们的服务就已准备就绪,可以接受对 execute- 处理复杂数据类型 127 StockTrade方法的调用了。然而,我们还需要在客户应用程序中也做一些安装工 作。让我们看看客户应用程序: package javasoap.book.ch5; import java.net.*; import java.util.*; import org.apache.soap.*; import org.apache.soap.rpc.*; import org.apache.soap.encoding.*; import org.apache.soap.encoding.soapenc.*; import org.apache.soap.util.xml.*; public class StockTradeClient { public static void main(String[] args) throws Exception { URL url = new URL("http://georgetown:8080/soap/servlet/rpcrouter"); Call call = new Call(); SOAPMappingRegistry smr = new SOAPMappingRegistry(); call.setSOAPMappingRegistry(smr); call.setEncodingStyleURI(Constants.NS_URI_SOAP_ENC); call.setTargetObjectURI("urn:BasicTradingService"); call.setMethodName("executeStockTrade"); BeanSerializer beanSer = new BeanSerializer(); // 映射 Stock Trade 类型 smr.mapTypes(Constants.NS_URI_SOAP_ENC, new QName("urn:BasicTradingService", "StockTrade"), StockTrade_ClientSide.class, beanSer, beanSer); // 建立一个股票交易的实例 StockTrade_ClientSide trade = new StockTrade_ClientSide("XYZ", false, 350); Vector params = new Vector(); params.addElement(new Parameter("trade", StockTrade_ClientSide.class, trade, null)); call.setParams(params); Response resp; try { resp = call.invoke(url, ""); Parameter ret = resp.getReturnValue(); Object desc = ret.getValue(); System.out.println("Trade Description: " + desc); } catch (SOAPException e) { System.err.println("Caught SOAPException (" + e.getFaultCode() + "): " + e.getMessage()); } 第五章128 } } 在建立了 Call 对象之后,我们又创建了 org.apache.soap.encoding.SOAP- MappingRegistry的一个实例smr。这个类包含了从自定义类型到Java类的映射, 并将它通过 Call 对象的 setSOAPMappingRegistry()方法传给这个 Call 对象。 我们在前面的例子中不需要这样做,这是因为在没有任何参数传递的情况下Call 对象会为自己建立一个映射注册表。SOAPMappingRegistry()中包含了所有预先 定义的映射,例如我们用于数组的那些映射。我们不久将会把我们的映射添加进 去。 接下来我们调用了call.setEncodingStyleURI()方法。这个方法指定了我们为 自定义类型所使用的总体编码形式。来自类 org.apache.soap.Constants 的常 量 NS_URI_SOAP_ENC 表示我们使用的 http://schemas.xmlsoap.org/soap/ encoding/名称空间。这个编码形式的名称空间应该与我们在部署描述文件中为 StockTrade类型指定的编码形式使用的名称空间相同。setTargetObjectURI() 和setMethodName()与它们在前面例子中的使用方法是一样的。下一步是要创建 org.apache.soap.encoding.soapenc.BeanSerializer类的一个实例;我们可 以使用这个标准的串行化器是因为我们的自定义类型遵循了JavaBeans属性存取 器模式。现在我们可以通过调用 smr.mapTypes()来建立映射了。该方法的第一 个参数是 Constants.NS_URI_SOAP_ENC,指明映射要使用的编码形式是标准的 SOAP编码形式。你可能会觉得很奇怪:我们已经在前面为整个调用指定过编码 形式了,在这里为什么还要这样做?理由很简单:这个参数给你一个机会来覆盖 这种特定映射的编码形式。不过,如果你使用 null作为参数值,那么映射将对编 码形式使用null名称空间,这是不正确的。我们来看这个消息的 SOAP封套,你 会看到尽管编码形式与总体调用的编码形式是一致的,但是对于这个串行化参数, encodingStyle 属性并没有重复出现。如果某个参数的编码形式与 Call 使用的 不同,它就会作为该参数的属性出现。 smr.mapTypes()的下一个参数是org.apache.soap.util.xml.Qname的一个实 例,它表示一个被完全限定的名称(名称前面加上了一个名称空间限定符) 。我们 使用urn:BasicTradingService 作为名称空间,StockTrade作为名称。第三个 处理复杂数据类型 129 参数是 StockTrade_ClientSide.class,它是实现了自定义类型的 Java 类。接 下来的两个参数是用于这个类型的串行化器和反串行化器的实例。因为 org.apache.soap.encoding.soapenc.BeanSerializer类实现了串行化和反串 行化,所以在这里我们使用了前面为它们创建的 beanSer 对象。 剩下的部分相当简单。我们创建了StockTrade_ClientSide的一个实例,并用带 参数的构造函数设定了它的属性值。然后我们为参数建立了一个 Vector,这与我 们在前面的例子中所做的是一样的。如果运行这个例子,你应该会看到如下的输 出: Trade Description: Sell 350 of XYZ 让我们看看被传输的 SOAP封套。封套的相关部分由 trade元素开始,它表示要 传递给 executeStockTrade 服务方法的自定义参数。分配给 xsi:type 的值是 ns1:StockTrade。ns1 是一个在父元素 executeStockTrade 中被声明为 urn: BasicTradingService 的名称空间标识符。而且 StockTrade是自定义类型的名 称。trade元素有三个子元素,每一个子元素对应于StockTrade自定义类型的一 个属性。每个元素的名称也与该属性完全对应。这是很关键的,因为 Apache SOAP 要通过 Java 映射来寻找与 Java 类相关联的 set 方法。因此,封套中的名称 一定要和类使用的名称相匹配。这些属性元素的类型都被显式地声明,而且这些 类型也必须与对应的属性类型一致。 350 false XYZ 第五章130 现在让我们看看在GLUE中如何处理自定义类型,我们仍不做修改地使用Basic- TradingService 类。我们需要在 IBasicTradingService 接口中加入 executeStockTrade()方法。我们可以用前面介绍的那些方法来部署这个服务。 这里是 IBasicTradingService 的新版本: package javasoap.book.ch5; public interface IBasicTradingService { int getTotalVolume(String[] symbols); String executeTrade(Object[] params); String[] getMostActive(); String executeStockTrade(StockTrade_ClientSide trade); } 由于我们不需要直接处理SOAP结构,因此使用 GLUE API来编写访问服务的应 用程序就变得很简单了。在这里我们也应该可以按同样的模式来做,但是有一个 问题。IBasicTradingInterface 接口的 executeStockTrade()方法的参数是 StockTrade_ClientSide 的一个实例。而用于实现服务的 BasicTrading- Service类的executeStockTrade方法使用StockTrade_ServerSide作为参数。 这是设计导致的一个失配。默认情况下, GLUE 会在客户端和服务器端寻找同一 个类。假如我们在客户应用程序中用 StockTrade_ServerSide类来取代 Stock- Trade_ClientSide 类,那么一切将会很完美。但我们不准备那样做,因此你只 要相信它会工作得很好就行了(或者,你自己尝试一下当然更好) 。我们将采取另 外一种方式。 每当我开发分布式系统时,我会很高兴让客户端和服务器端代码共享 Java 接口。 但是,我不喜欢共享 Java类,尤其是当那些类中包含了特定于客户或服务器的代 码的时候。对于这个例子,我们可以重写我们的股票交易类,删去那些客户和服 务器功能代码,只剩下相关数据。我们可以让客户和服务器使用同一个包里的类, 因此作为基本交易Web服务的实现者,我们应该分发这样一个包,让它包含用于 开发者客户应用程序的类。这样做是可行的,但通常不会这样做。回到共享 Java 接口而不是类的观念上来吧,我们可以为交易数据建立一个接口,然后让服务器 和客户类都实现那个接口。这是不在服务器和客户之间共享任何实际的可执行代 码而是共享代码要素的一个漂亮而整洁的方法。再说一次,这个接口会存在于一 处理复杂数据类型 131 个对服务器和客户系统都可用的软件包中。GLUE声称将在它的下一个版本中支 持这种机制(当读到这里时你可能已经在使用这个新的版本了)。 GLUE支持的另外一种机制也可以让你达到这个目的。这种机制不需要你修改服 务器端,在客户端要做的工作也很简单。我们要为我们的客户例子创建一个新的 包,这是因为在接下来的工作中我们会创建与前面使用的文件名称相同的文件。 当在同一台计算机上运行客户和服务器时,新的软件包会防止我们覆盖文件。因 此要注意这个例子是一个叫做javasoap.book.ch5.client的新软件包的一部分, 而且我们将要创建的文件存在于这个软件包对应的目录中。 到目前为止我一直有意地避免讨论WSDL,尽管 GLUE是完全建立在WSDL之上 的。然而,本过程的第一个步骤就使用了 GLUE的 wsdl2java工具程序,它将生 成基于某个 WSDL 文件的 Java 代码。那么这个 WSDL 是从哪里来的呢?它是由 GLUE服务器自动产生的。基于 GLUE API的客户端应用程序自始至终都在使用 它。wsdl2java 会生成绑定到服务的Java 接口、一个辅助类、一个描述我们使用 的自定义类型数据字段的Java数据结构类和一个映射文件。映射文件本质上是一 个自定义类型的模式定义;它告诉 GLUE该如何将Java数据结构类的字段映射到 自定义类型的字段。GLUE使用了很多种机制来处理自定义类型映射;在本例中, 映射文件显式地定义了映射,而不是依靠 Java 反射(reflection)来进行映射。 现在让我们来为客户应用程序生成这些文件。确认你的当前目录是 javasoap. book.ch5.client 软件包所在的目录,然后输入下面的命令: wsdl2java http://georgetown:8004/glue/urn:BasicTradingService.wsdl -p javasoap.book.ch5.client wsdl2java 工具程序的参数是服务 WSDL 的完整 URL,就像我们前面例子里在 bing()方法中使用的一样。-p 选项告诉工具程序生成代码所使用的本地软件包 的名称:javasoap.book.ch5.client。wsdl2java 的输出由四个文件组成。第 一个是 IBasicTradingService.java,包含用来绑定到服务的 Java 接口。在此之前 我们一直是用手工编写这个文件的;现在让我们看看wsdl2java生成的这个文件: // 由 GLUE 生成的 package javasoap.book.ch5.client; 第五章132 public interface IBasicTradingService { String executeStockTrade( StockTrade_ServerSide arg0 ); String[] getMostActive(); int getTotalVolume( String[] arg0 ); String executeTrade( Object[] arg0 ); } 这个接口定义与我们自己编写的不同仅仅在于声明的包不同、方法的参数名称不 同以及executeStockTrade使用了StockTrade_ServerSide而不是StockTrade_ ClientSide作为参数。你应该还记得IBasicTradingService的早期版本不能工 作就是因为 GLUE 默认对客户和服务器都使用同一个类。乍一看,这里 StockTrade_ServerSide类的用法违背了我们原来说的要在客户代码与服务器代 码间完全解除耦合的那个原则。但是记住软件包的声明是 javasoap.book.ch5. client,所以这个Java接口并没有引用服务器所使用的那个相同的StockTrade_ ServerSide 类,因为它们属于不同的包。让我们看看由 wsdl2java 生成的 StockTrade_ServerSide.java 文件中的那个 StockTrade_ServerSide 类: // 由 GLUE 生成的 package javasoap.book.ch5.client; public class StockTrade_ServerSide { public int _shares; public boolean _buyOrder; public String _stock; } 这只是在服务器端所使用的javasoap.book.ch5包中对应类的一个镜像。然而, 它的确正确地反映了数据值。我们将在客户应用程序中创建这个类的一个实例并 传递给服务,因为这正是前面生成的那个 IBasicTradingService 接口的 executeStockTrade()方法所要求的类型。 wsdl2java生成的第三个文件是BasicTradingService.map,它包含了前面提到的 映射模式。你可以自己去看看这个映射文件的内容;它包含了定义客户端和服务 器端Java之间字段映射的XML项。生成的最后一个文件是 BasicTradingService- Helper.java,它包含了一个完成bind()操作的辅助类。我不会用到那个辅助类, 因此在这里我们将不理会它。 处理复杂数据类型 133 讲述一个过程要比实现这个过程麻烦得多。接下来让我们着手编写客户应用程序: package javasoap.book.ch5.client; import electric.registry.RegistryException; import electric.registry.Registry; import electric.xml.io.Mappings; public class StockTradeClient2 { public static void main(String[] args) throws Exception { try { Mappings.readMappings("BasicTradingService.map"); IBasicTradingService srv = (IBasicTradingService)Registry.bind( "http://georgetown:8004/glue/urn:BasicTradingService.wsdl", IBasicTradingService.class); StockTrade_ServerSide trade = new StockTrade_ServerSide(); trade._stock = "MINDSTRM"; trade._buyOrder = true; trade._shares = 500; String desc = srv.executeStockTrade(trade); System.out.println("Trade Description is: " + desc); } catch (RegistryException e) { System.out.println(e); } } } 在完成绑定操作之前的关键一步是要读取映射文件。这个工作通过调用 electric.xml.io.Mappings类的readMappings()方法来完成,这个类是GLUE 的一部分。在调用了 bind()之后,我们简单地创建了 StockTrade_ServerSide 类的一个实例,为它的各个字段设置了值,并调用了 executeStockTrade方法。 运行这个应用程序的输出应该是: Trade Description is: Buy 500 of MINDSTRM 现在让我们看看这个应用程序所产生的 SOAP 封套。和我们预期的一样, executeStockTrade 元素使用了服务名作为名称空间限定。其子元素 arg0 是对 一个名为 id0的独立串行化元素的引用,正如 GLUE为一个数组参数所产生的一 样。这个例子向我们说明了 GLUE为什么要产生一个名称空间限定符 ns2,而它 第五章134 看起来是在引用服务器端实现类所在的包。在这里该名称空间被用来限定 xsi:type属性的值,这个值与实现类的名称StockTrade_ServerSide对应。id0 元素的子元素中包含了自定义类型的数据字段。 <_shares xsi:type='xsd:int'>500 <_buyOrder xsi:type='xsd:boolean'>true <_stock xsi:type='xsd:string'>MINDSTRM 返回自定义类型 从服务方法调用返回自定义数据类型与传递自定义类型一样有用(也同样常见)。 我们可以以此增强我们的股票服务,使它提供一个接受单个股票代码然后返回其 当天最高价和最低价的方法。类 HighLow_ServerSide 和HighLow_ClientSide 分别表示服务器端和客户端的最高 / 最低价。 package javasoap.book.ch5; public class HighLow_ServerSide { public float _high; public float _low; public HighLow_ServerSide() { } public HighLow_ServerSide (float high, float low) { setHigh(high); setLow(low); } public float getHigh() { 处理复杂数据类型 135 return _high; } public void setHigh(float high) { _high = high; } public float getLow() { return _low; } public void setLow(float low) { _low = low; } } package javasoap.book.ch5; public class HighLow_ClientSide { public float _high; public float _low; public String toString() { return "High: " + _high + " Low: " + _low; } public HighLow_ClientSide() { } public float getHigh() { return _high; } public void setHigh(float high) { _high = high; } public float getLow() { return _low; } public void setLow(float low) { _low = low; } } 为得到返回值,服务器端的类使用了带参数的构造函数;客户端的类则包含了一 个toString()方法以使客户应用程序更容易显示从服务器返回的对象的内容。现 在让我们在 BasicTradingService 类中添加一个叫做 getHighLow()的新方法。 这个方法接收一个字符串类型的股票代码,并返回 HighLow_ServerSide类的一 个实例。下面是这个包含了新方法的类,其中省略了保持不变的代码: package javasoap.book.ch5; public class BasicTradingService { 第五章136 public BasicTradingService() { } . . . . . . public HighLow_ServerSide getHighLow(String stock) { // 获取指定股票的最高 / 最低价 return new HighLow_ServerSide((float)110.375, (float)109.5); } } 为了能在 Apache SOAP 中使用这个新的方法,我们需要使用一个修改后的部署 描述文件来重新部署这个服务。我们要把 getHighLow加入到方法的列表,而且 在 mappings 部分为最高 / 最低价对象添加一个项目。第二个映射项定义了 HighLow 自定义类型,它的名称空间限定符使用了服务的名称 urn:Basic- TradingService。这里是修改后的部署描述文件: org.apache.soap.server.DOMFaultListener 现在我们创建一个客户应用程序来调用服务方法getHighLow并取得返回的最高/ 最低价对象。对于 Apache SOAP 客户,我们要使用 HighLow_ClientSide 类来 表示返回值。下面就是这个新的应用程序: package javasoap.book.ch5; import java.net.*; import java.util.*; import org.apache.soap.*; import org.apache.soap.rpc.*; import org.apache.soap.encoding.*; import org.apache.soap.encoding.soapenc.*; import org.apache.soap.util.xml.*; public class HighLowClient { public static void main(String[] args) throws Exception { URL url = new URL("http://georgetown:8080/soap/servlet/rpcrouter"); Call call = new Call(); SOAPMappingRegistry smr = new SOAPMappingRegistry(); call.setTargetObjectURI("urn:BasicTradingService"); call.setMethodName("getHighLow"); call.setEncodingStyleURI(Constants.NS_URI_SOAP_ENC); call.setSOAPMappingRegistry(smr); BeanSerializer beanSer = new BeanSerializer(); // 映射 High/Low 类型 smr.mapTypes(Constants.NS_URI_SOAP_ENC, new QName("urn:BasicTradingService", "HighLow"), HighLow_ClientSide.class, beanSer, beanSer); String stock = "XYZ"; Vector params = new Vector(); params.addElement(new Parameter("stock", String.class, stock, null)); call.setParams(params); Response resp; try { resp = call.invoke(url, ""); Parameter ret = resp.getReturnValue(); HighLow_ClientSide hilo = (HighLow_ClientSide)ret.getValue(); System.out.println(hilo); } 第五章138 catch (SOAPException e) { System.err.println("Caught SOAPException (" + e.getFaultCode() + "): " + e.getMessage()); } } } smr.MapTypes()方法将 HighLow 自定义类型映射到 Java 类 HighLow_ ClientSide。既然我们的类遵循了JavaBeans属性存取器模式,那么和以前一样, 我们可以使用Apache的 BeanSerializer来在 XML与Java之间转换。我们建立 了一个叫做 stock 的字符串参数来向 getHighLow方法传递值(尽管在服务器代 码中我们并没有真正用到它)。在调用了这个方法之后,我们将resp.getReturn- Value()的返回值的类型转换成了 HighLow_ClientSide 的一个实例。然后我们 把返回的参数值 ret 传递给 System.out.println()方法来显示它。因为我们已 经在 HighLow_ClientSide 类中实现了 toString()方法,因此我们现在已完成 了所有工作。当你运行这个例子的时候,会得到下面这样的输出: High: 110.375 Low: 109.5 下面是这个方法调用所返回的 SOAP封套。return元素的类型被设为 HighLow, 它用 urnBasicTradingService 作为名称空间限定。HighLow 的属性被作为 return 元素的子元素,其类型被设为浮点数并包含了对应的值。 109.5 110.375 处理复杂数据类型 139 要使用 GLUE 来返回 HighLow,我们将会采取与传递自定义类型时相同的步骤。 我们需要再一次将客户端的程序单独地放到另一个包 javasoap.book.ch5. client 中去。重新启动 BasicTradingApp 应用程序以部署该服务。现在在 javasoap.book.ch5.client 包对应的目录下运行 wsdl2java 工具程序: wsdl2java http://georgetown:8004/glue/urn: BasicTradingService.wsdl -p javasoap.book.ch5.client 下面的代码是wsdl2java所产生的新的IBasicTradingService接口。getHigh- Low()方法返回 HighLow_ServerSide 的一个实例;记住这个类是由 GLUE 作为 javasoap.book.ch5.client 包的一部分生成的,而不是给我们的服务器类 BasicTradingApp 使用的。 // 由 GLUE 生成的 package javasoap.book.ch5.client; public interface IBasicTradingService { HighLow_ServerSide getHighLow( String arg0 ); String executeStockTrade( StockTradeServer arg0 ); String[] getMostActive(); int getTotalVolume( String[] arg0 ); String executeTrade( Object[] arg0 ); } 下一个类HighLow_ServerSide是一个简单的Java数据结构,它反映了将要被映 射到 HighLow 自定义类型的数据字段。我手工添加了 toString()方法以使得显 示结果更容易。 package javasoap.book.ch5.client; public class HighLow_ServerSide { public float _high; public float _low; public String toString() { return "High: " + _high + " Low: " + _low; } } 现在让我们使用 GLUE创建一个客户应用程序来调用 getHighLow服务方法并显 示所产生的返回值的内容。不需要做很多的工作,真的;我们只需要读取映射文 第五章140 件,完成绑定,然后在被绑定的接口中调用 getHighLow方法。javasoap.book. ch5.client.HighLow_ServerSide 返回的实例被传给 System.out.println() 来显示。正如在 Apache SOAP例子中一样,该类的 toString()方法组装了要显 示的字符串。 package javasoap.book.ch5.client; import electric.registry.RegistryException; import electric.registry.Registry; import electric.xml.io.Mappings; public class HighLowClient2 { public static void main(String[] args) throws Exception { try { Mappings.readMappings("BasicTradingService.map"); IBasicTradingService srv = (IBasicTradingService)Registry.bind( "http://georgetown:8004/glue/urn:BasicTradingService.wsdl", IBasicTradingService.class); HighLow_ServerSide hilo = srv.getHighLow("ANY"); System.out.println(hilo); } catch (RegistryException e) { System.out.println(e); } } } 这里是从服务器返回的 SOAP封套。你现在应该可以看得懂它。与返回数组时的 情况一样,GLUE 使用了对该自定义类型的一个独立串行化实例的引用。 <_high xsi:type='xsd:float'>110.375 处理复杂数据类型 141 <_low xsi:type='xsd:float'>109.5 在本章中,我们深入介绍了数组和自定义数据类型的使用,介绍了在 Apache SOAP 和 GLUE中如何支持这些结构。你可能会在它们或者别的 SOAP实现中找 到其他有用的数据类型。这些类型可能包括 Java Vector和 Hashtable类、Java 集合以及其他很多已经被普遍使用的 Java 类。 我们看到 Apache SOAP 和 GLUE 使用了不同的方式来处理复杂类型,而且它们 都工作得很不错。然而,仍然会有可能出现这样的情况:你需要使用一种自定义 类型,但使用你的 SOAP实现所提供的办法不能够对它进行串行化。出现这种情 形多半仅仅是因为它不支持某个特定类型的数据(比如多维的或稀疏的数组),或 者是与你的应用程序有关的其他原因。我们将在下一章中解决这个问题。
还剩35页未读

继续阅读

下载pdf到电脑,查找使用更方便

pdf的实际排版效果,会与网站的显示效果略有不同!!

需要 6 金币 [ 分享pdf获得金币 ] 2 人已下载

下载pdf

pdf贡献者

lewyoung

贡献于2012-11-23

下载需要 6 金币 [金币充值 ]
亲,您也可以通过 分享原创pdf 来获得金币奖励!
下载pdf