java

位置:IT落伍者 >> java >> 浏览文章

Java深度历险:Java对象序列化与RMI


发布日期:2023年12月04日
 
Java深度历险:Java对象序列化与RMI

对于一个存在于Java虚拟机中的对象来说其内部的状态只保持在内存中JVM停止之后这些状态就丢失了在很多情况下对象的内部状态是需要被持久化下来的提到持久化最直接的做法是保存到文件系统或是数据库之中这种做法一般涉及到自定义存储格式以及繁琐的数据转换对象关系映射(Objectrelational mapping)是一种典型的用关系数据库来持久化对象的方式也存在很多直接存储对象的对象数据库对象序列化机制(object serialization)是Java语言内建的一种对象持久化方式可以很容易的在JVM中的活动对象和字节数组(流)之间进行转换除了可以很简单的实现持久化之外序列化机制的另外一个重要用途是在远程方法调用中用来对开发人员屏蔽底层实现细节

基本的对象序列化

由于Java提供了良好的默认支持实现基本的对象序列化是件比较简单的事待序列化的Java类只需要实现Serializable接口即可Serializable仅是一个标记接口并不包含任何需要实现的具体方法实现该接口只是为了声明该Java类的对象是可以被序列化的实际的序列化和反序列化工作是通过ObjectOuputStream和ObjectInputStream来完成的ObjectOutputStream的writeObject方法可以把一个Java对象写入到流中ObjectInputStream的readObject方法可以从流中读取一个Java对象在写入和读取的时候虽然用的参数或返回值是单个对象但实际上操纵的是一个对象图包括该对象所引用的其它对象以及这些对象所引用的另外的对象Java会自动帮你遍历对象图并逐个序列化除了对象之外Java中的基本类型和数组也是可以通过 ObjectOutputStream和ObjectInputStream来序列化的

上面的代码给出了典型的把Java对象序列化之后保存到磁盘上以及从磁盘上读取的基本方式 User类只是声明了实现Serializable接口

在默认的序列化实现中Java对象中的非静态和非瞬时域都会被包括进来而与域的可见性声明没有关系这可能会导致某些不应该出现的域被包含在序列化之后的字节数组中比如密码等隐私信息由于Java对象序列化之后的格式是固定的其它人可以很容易的从中分析出其中的各种信息对于这种情况一种解决办法是把域声明为瞬时的即使用transient关键词另外一种做法是添加一个serialPersistentFields? 域来声明序列化时要包含的域从这里可以看到在Java序列化机制中的这种仅在书面层次上定义的契约声明序列化的域必须使用固定的名称和类型在后面还可以看到其它类似这样的契约虽然Serializable只是一个标记接口但它其实是包含有不少隐含的要求下面的代码给出了 serialPersistentFields的声明示例即只有firstName这个域是要被序列化的

自定义对象序列化

基本的对象序列化机制让开发人员可以在包含哪些域上进行定制如果想对序列化的过程进行更加细粒度的控制就需要在类中添加writeObject和对应的 readObject方法这两个方法属于前面提到的序列化机制的隐含契约的一部分在通过ObjectOutputStream的 writeObject方法写入对象的时候如果这个对象的类中定义了writeObject方法就会调用该方法并把当前 ObjectOutputStream对象作为参数传递进去writeObject方法中一般会包含自定义的序列化逻辑比如在写入之前修改域的值或是写入额外的数据等对于writeObject中添加的逻辑在对应的readObject中都需要反转过来与之对应

在添加自己的逻辑之前推荐的做法是先调用Java的默认实现在writeObject方法中通过ObjectOutputStream的defaultWriteObject来完成在readObject方法则通过ObjectInputStream的defaultReadObject来实现下面的代码在对象的序列化流中写入了一个额外的字符串

序列化时的对象替换

在有些情况下可能会希望在序列化的时候使用另外一个对象来代替当前对象其中的动机可能是当前对象中包含了一些不希望被序列化的域比如这些域都是从另外一个域派生而来的;也可能是希望隐藏实际的类层次结构;还有可能是添加自定义的对象管理逻辑如保证某个类在JVM中只有一个实例相对于把无关的域都设成transient来说使用对象替换是一个更好的选择提供了更多的灵活性替换对象的作用类似于Java EE中会使用到的传输对象(Transfer Object)

考虑下面的例子一个订单系统中需要把订单的相关信息序列化之后通过网络来传输订单类Order引用了客户类Customer在默认序列化的情况下Order类对象被序列化的时候其引用的Customer类对象也会被序列化这可能会造成用户信息的洩露对于这种情况可以创建一个另外的对象来在序列化的时候替换当前的Order类的对象并把用户信息隐藏起来

这个替换对象类OrderReplace只保存了Order的ID在Order类的writeReplace方法中返回了一个OrderReplace对象这个对象会被作为替代写入到流中同样的需要在OrderReplace类中定义一个readResolve方法用来在读取的时候再转换回 Order类对象这样对调用者来说替换对象的存在就是透明的

序列化与对象创建

在通过ObjectInputStream的readObject方法读取到一个对象之后这个对象是一个新的实例但是其构造方法是没有被调用的其中的域的初始化代码也没有被执行对于那些没有被序列化的域在新创建出来的对象中的值都是默认的也就是说这个对象从某种角度上来说是不完备的这有可能会造成一些隐含的错误调用者并不知道对象是通过一般的new操作符来创建的还是通过反序列化所得到的解决的办法就是在类的readObject方法里面再执行所需的对象初始化逻辑对于一般的Java类来说构造方法中包含了初始化的逻辑可以把这些逻辑提取到一个方法中在readObject方法中调用此方法

版本更新

把一个Java对象序列化之后所得到的字节数组一般会保存在磁盘或数据库之中在保存完成之后有可能原来的Java类有了更新比如添加了额外的域这个时候从兼容性的角度出发要求仍然能够读取旧版本的序列化数据在读取的过程中当ObjectInputStream发现一个对象的定义的时候会尝试在当前JVM中查找其Java类定义这个查找过程不能仅根据Java类的全名来判断因为当前JVM中可能存在名称相同但是含义完全不同的Java 类这个对应关系是通过一个全局惟一标识符serialVersionUID来实现的通过在实现了Serializable接口的类中定义该域就声明了该Java类的一个惟一的序列化版本号JVM会比对从字节数组中得出的类的版本号与JVM中查找到的类的版本号是否一致来决定两个类是否是兼容的对于开发人员来说需要记得的就是在实现了Serializable接口的类中定义这样的一个域并在版本更新过程中保持该值不变当然如果不希望维持这种向后兼容性换一个版本号即可该域的值一般是综合Java类的各个特性而计算出来的一个哈希值可以通过Java提供的serialver命令来生成在Eclipse中如果Java类实现了Serializable接口Eclipse会提示并帮你生成这个serialVersionUID

在类版本更新的过程中某些操作会破坏向后兼容性如果希望维持这种向后兼容性就需要格外的注意一般来说在新的版本中添加东西不会产生什么问题而去掉一些域则是不行的

序列化安全性

前面提到Java对象序列化之后的内容格式是公开的所以可以很容易的从中提取出各种信息从实现的角度来说可以从不同的层次来加强序列化的安全性

对序列化之后的流进行加密这可以通过CipherOutputStream来实现

实现自己的writeObject和readObject方法在调用defaultWriteObject之前先对要序列化的域的值进行加密处理

使用一个SignedObject或SealedObject来封装当前对象用SignedObject或SealedObject进行序列化

在从流中进行反序列化的时候可以通过ObjectInputStream的registerValidation方法添加ObjectInputValidation接口的实现用来验证反序列化之后得到的对象是否合法

RMI

RMI(Remote Method Invocation)是Java中的远程过程调用(Remote Procedure CallRPC)实现是一种分布式Java应用的实现方式它的目的在于对开发人员屏蔽横跨不同JVM和网络连接等细节使得分布在不同JVM上的对象像是存在于一个统一的JVM中一样可以很方便的互相通讯之所以在介绍对象序列化之后来介绍RMI主要是因为对象序列化机制使得RMI非常简单调用一个远程服务器上的方法并不是一件困难的事情开发人员可以基于Apache MINA或是Netty这样的框架来写自己的网络服务器亦或是可以采用REST架构风格来编写HTTP服务但这些解决方案中不可回避的一个部分就是数据的编排和解排(marshal/unmarshal)需要在Java对象和传输格式之间进行互相转换而且这一部分逻辑是开发人员无法回避的RMI的优势在于依靠Java序列化机制对开发人员屏蔽了数据编排和解排的细节要做的事情非常少JDK 之后RMI通过动态代理机制去掉了早期版本中需要通过工具进行代码生成的繁琐方式使用起来更加简单

RMI采用的是典型的客户端服务器端架构首先需要定义的是服务器端的远程接口这一步是设计好服务器端需要提供什么样的服务对远程接口的要求很简单只需要继承自RMI中的Remote接口即可Remote和Serializable一样也是标记接口远程接口中的方法需要抛出RemoteException定义好远程接口之后实现该接口即可如下面的Calculator是一个简单的远程接口

实现了远程接口的类的实例称为远程对象创建出远程对象之后需要把它注册到一个注册表之中这是为了客户端能够找到该远程对象并调用

CalculatorServer是远程对象的Java类在它的start方法中通过UnicastRemoteObject的exportObject把当前对象暴露出来使得它可以接收来自客户端的调用请求再通过Registry的rebind方法进行注册使得客户端可以查找到

客户端的实现就是首先从注册表中查找到远程接口的实现对象再调用相应的方法即可实际的调用虽然是在服务器端完成的但是在客户端看来这个接口中的方法就好像是在当前JVM中一样这就是RMI的强大之处

在运行的时候需要首先通过rmiregistry命令来启动RMI中用到的注册表服务器

为了通过Java的序列化机制来进行传输远程接口中的方法的参数和返回值要么是Java的基本类型要么是远程对象要么是实现了 Serializable接口的Java类当客户端通过RMI注册表找到一个远程接口的时候所得到的其实是远程接口的一个动态代理对象当客户端调用其中的方法的时候方法的参数对象会在序列化之后传输到服务器端服务器端接收到之后进行反序列化得到参数对象并使用这些参数对象在服务器端调用实际的方法调用的返回值Java对象经过序列化之后再发送回客户端客户端再经过反序列化之后得到Java对象返回给调用者这中间的序列化过程对于使用者来说是透明的由动态代理对象自动完成除了序列化之外RMI还使用了动态类加载技术当需要进行反序列化的时候如果该对象的类定义在当前JVM中没有找到RMI会尝试从远端下载所需的类文件定义可以在RMI程序启动的时候通过JVM参数debase来指定动态下载Java类文件的URL

上一篇:struts2与freemarker的集成

下一篇:java虚拟机管理大内存