14.1 保存对象状态
你已经奏出完美的乐章,现在会想把它储存起来。你可以抓个文房四宝把它记下来,但也可以按下储存按钮(或按下File菜单上的Save)。然后你帮文件命名,并希望这个文件不会让屏幕变成蓝色的画面。
储存状态的选择有很多种,这可能要看你会如何使用储存下来的状态而决定。我们会在这一章讨论下面两种选项:
如果只有自己写的Java程序会用到这些数据:
① 用序列化(serialization)。
将被序列化的对象写到文件中。然后就可以让你的程序去文件中读取序列化的对象并把它们展开回到活生生的状态。
如果数据需要被其他程序引用:
② 写一个纯文本文件。用其他程序可以解析的特殊字符写到文件中。例如写成用tab字符来分隔的档案以便让电子表格或数据库应用程序能够应用。
当然还有其他的选择。你可以将数据存进任何格式中。举例来说,你可以把数据用字节而不是字符来写入,或者你也可以将数据写成Java的primitive主数据类型,有一些方法可以提供int、long、boolean等的写入功能。但不管用什么方法,基本所需的输入/输出技巧都一样:把数据写到某处,这可能是个磁盘上的文件,或者是来自网络上的串流。读取数据的方向则刚好相反。当然此处所讨论的部分不涉及使用数据库的情况。
存储状态
假设你有个程序,是个幻想冒险游戏,要过很多关才能完成。在游戏进行的过程中,游戏的人物会累积经验值、宝物、体力等。你不会想让游戏每次重新启动时都得要从头来过—这样根本没人玩。因此你需要一种方法来保存人物的状态,并且在重新开启时能够将状态回复到上次存储时的原状。因为你是程序员,所以你的工作是要让存储与恢复尽可能的简单容易。
① 选项一
把3种序列化的人物对象写入文件中。
创建一个文件,让序列化的3种对象写到此文件中。这文件在你以文本文件形式阅读时是无意义的:
“isr GameCharacter
“oge8iTpowerLjava/lang/
String;[weaponst[Ljava/lang/
String;xp&tlfur[Ljava.lang.String;-“vA应(Gxptbowtswordtdustsq~》tTrolluq~tbare handstbig axsq-xtMagicianuq-tspe llstinvisibility
② 选项二
写入纯文本文件
创建文件,写入3行文字,每个人物一行,以逗点来分开属性:
80,EIf,bow, sword,dust
800,Troll,bare hands,big ax
180,Magician,spells,invisibility
14.2 写入文件的序列化对象
将序列化对象写入文件
下面是将对象序列化(存储)的方法步骤:
1.创建出FileOutputStream
FileOutputStream fileStream = new FileOutputStream("MyGame.eser");
2.创建ObjectOutputStream
ObjectOutputStream os = new ObjectOutputStream(fileStream);
3.写入对象
os.writeObject(characterOne);
os.writeObject(characterTwo);
os.writeObject(characterThree);
4.关闭ObjectOutputStream
os.close();
14.3 输入/输出串流
Java的输入/输出API带有连接类型的串流,它代表来源与目的地之间的连接,连接串流将串流与其他串流连接起来。
一般来说,串流要两两连接才能作出有意义的事情——其中一个表示连接,另一个则是要被调用方法的。为何要两个?因为连接的串流通常都是很低层的。以FileOutputStream为例,它有可以写入字节的方法。但我们通常不会直接写字节,而是以对象层次的观点来写入,所以需要高层的连接串流。
那又为何不以单一的串流来执行呢?这就要考虑到良好的面向对象设计了。每个类只要做好一件事。FileOutputStream把字节写入文件。ObjectOutputStream把对象转换成可以写入串流的数据。当我们调用ObjectOutputStream的writeObject时,对象会被打成串流送到FileOutputStream来写入文件。
这样就可以通过不同的组合来达到最大的适应性!如果只有一种串流类的话,你只好祈祷API的设计人已经想好所有可能的排列组合。但通过链接的方式,你可以自由地安排串流的组合与去向。
将串流(stream)连接起来代表来源与目的地(文件或网络端口)的连接。串流必须要连接到某处才能算是个串流。
14.4 对象序列化
对象被序列化的时候发生了什么事?
1.在堆上的对象
在堆上的对象有状态——实例变量的值。这些值让同一类的不同实例有不同的意义
2.被序列化的对象
序列化的对象保存了实例变量的值,因此之后可以在堆上带回一模一样的实例
对象的状态是什么?有什么需要保存?
存储primitive主数据类型值37和70是很简单的。但如果对象有引用到其他对象的实例变量时要怎么办?如果这些对象还带有其他对象又该如何?对象基本上有哪些部分是独特的?有哪些东西需要被带回来才能让对象回到和存储时完全相同的状态?当然它会有不同的内存位置,但这无关紧要。我们在乎的是堆上是否有与存储时一模一样的对象状态。
当对象被序列化时,被该对象引用的实例变量也会被序列化。且所有被引用的对象也会被序列化··最棒的是,这些操作都是自动进行的!
序列化程序会将对象版图上的所有东西存储起来。被对象的实例变量所引用的所有对象都会被序列化。
Kennel对象带有对Dog数组对象的引用。Dog[]中有两个Dog对象的引用。每个Dog对象带有String和Collar对象的引用。String对象维护字符的集合,而Collar对象持有一个int。
当保存kennel对象时,所有的对象都会保存!
14.5 实现Serializable接口
如果要让类能够被序列化,就实现Serializable
Serializable接口又被称为marker或tag类的标记用接口,因为此接口并没有任何方法需要实现的。它的唯一目的就是声明有实现它的类是可以被序列化的。也就是说,此类型的对象可以通过序列化的机制来存储。如果某类是可序列化的,则它的子类也自动地可以序列化(接口的本意就是如此)。
import java.io.*;public class Box implements Serializable {private int width;private int height;public void setWidth(int w) {width = w;}public void setHeight(int h) {height = h;}public static void main(String[] args) {Box myBox = new Box();myBox.setWidth(50);myBox.setHeight(20);try {FileOutputStream fs = new FileOutputStream("foo.ser");ObjectOutputStream os = new ObjectOutputStream(fs);os.writeObject(myBox);os.close();} catch (Exception ex) {ex.printStackTrace();}}
}
序列化时全有或全无的
整个对象版图都必须正确地序列化,不外就得全部失败如果Duck对象不能序列化,Pond对象就不能被序列化。
14.6 使用瞬时变量
如果某实例变量不能或不应该被序列化,就把它标记为transient(瞬时)的
如果你需要序列化程序能够跳过某个实例变量,就把它标记成transient的变量
import java.io.*;public class Chat implements Serializable {transient String currentID;String userName;//还有更多程序代码……
}
如果你有无法序列化的变量不能被存储,可以用transient这个关键词把它标记出来,序列化程序会把它跳过。
为什么有些变量不能被序列化?可能是设计者忘记实现Serializable。或者动态数据只可以在执行时求出而不能或不必存储。虽然Java函数库中大部分的类可以被序列化,你还是无法将网络联机之类的东西保存下来。它得要在执行期当场创建才有意义。一旦程序关闭之后,联机本身就不再有用,下次执行时需要重新创建出来。
14.7 对象解序列化
解序列化(Deserialization):还原对象
将对象序列化整件事情的重点在于你可以在事后,在不同的Java虚拟机执行期(甚至不是同一个Java虚拟机),把对象恢复到存储时的状态。解序列化有点像是序列化的反向操作。
1.创建FileInputStream
FileInputStream fileStream = new FileInputStream("Mygame.ser");
2.创建ObjectInputStream
ObjectInputStream os = new ObjectInputStream(fileStream);
3.读取对象
Object one = os.readObject();
Object two = os.readObject();
Object three = os.readObject();
4.转换对象类型
GameCharacter elf = (GameCharacter) one;
GameCharacter troll = (GameCharacter) two;
GameCharacter magician = (GameCharacter) three;
5.关闭ObjectInputStream
os.close();
解序列化的时候发生了什么事?
当对象被解序列化时,Java虚拟机会通过尝试在堆上创建新的对象,让它维持与被序列化时有相同的状态来恢复对象的原状。但这当然不包括transient的变量,它们不是null(对对象引用而言)不然就是使用primitive主数据类型的默认值。
1.对象从stream中读出来。
2.Java虚拟机通过存储的信息判断出对象的class类型。
3.Java虚拟机尝试寻找和加载对象的类。如果Java虚拟机找不到或无法加载该类,则Java虚拟机会抛出例外。
4.新的对象会被配置在堆上,但构造函数不会执行!很明显的,这样会把对象的状态抹去又变成全新的,而这不是我们想要的结果。我们需要的是对象回到存储时的状态。
5.如果对象在继承树上有个不可序列化的祖先类,则该不可序列化类以及在它之上的类的构造函数(就算是可序列化也一样)就会执行。一旦构造函数连锁启动之后将无法停止。也就是说,从第一个不可序列化的父类开始,全部都会重新初始状态。
6.对象的实例变量会被还原成序列化时点的状态值。transient变量会被赋值null的对象引用或primitive主数据类型的默认为0、false等值。
存储与恢复游戏人物
import java.io.*;public class GameSaverTest {public static void main(String[] args) {GameCharacter one = new GameCharacter(50, "Elf", new String[] {"bow", "sword", "dust"});GameCharacter two = new GameCharacter(200, "Troll", new String[] {"bare hands", "big ax"});GameCharacter three = new GameCharacter(120, "Magician", new String[] {"spell", "invisibility"});// 假设此处有改变人物状态的程序代码try {ObjectOutputStream os = new ObjectOutputStream(new FileOutputStream("Game.ser"));os.writeObject(one);os.writeObject(two);os.writeObject(three);os.close();} catch (IOException ex) {ex.printStackTrace();}one = null;two = null;three = null;try {ObjectInputStream is = new ObjectInputStream(new FileInputStream("Game.ser"));GameCharacter oneRestore = (GameCharacter) is.readObject();GameCharacter twoRestore = (GameCharacter) is.readObject();GameCharacter threeRestore = (GameCharacter) is.readObject();System.out.println("One's type: " + oneRestore.getType());System.out.println("Two's type: " + twoRestore.getType());System.out.println("Three's type: " + threeRestore.getType());} catch (Exception ex) {ex.printStackTrace();}}
}
import java.io.*;public class GameCharacter implements Serializable {int power;String type;String[] weapons;public GameCharacter(int p, String t, String[] w) {power = p;type = t;weapons = w;}public int getPower() {return power;}public String getType() {return type;}public String getWeapons() {String weaponList = "";for (int i = 0; i < weapons.length; i++) {weaponList += weapons[i] + " ";}return weaponList;}
}
14.8 写入文本文件
将字符串写入文本文件
通过序列化来存储对象是Java程序在来回执行间存储和恢复数据最简单的方式。但有时你还得把数据存储到单纯的文本文件中。假设你的Java程序必须把数据写到文本文件中以让其他可能是非Java的程序读取。例如你的servlet(在Web服务器上执行的Java程序)会读取用户在网页上输入的数据,并将它写入文本文件以让网站管理人能够用电子表格来分析数据。
写入文本数据(字符串)与写入对象是很类似的,你可以使用FileWrite来代替FileOutputStream(当然不会把它链接到ObjectOutputStream上)。
写序列化的对象:
ObjectOutputStream.writeObject(someObject);
写字符串:
fileWriter.write("My first String to save");
import java.io.*;public class WriteAFile {public static void main(String[] args) {try {FileWriter writer = new FileWriter("Foo.txt");writer.write("hello foo!");writer.close();} catch (IOException ex) {ex.printStackTrace();}}
}
14.9 java.io.File
File这个类代表磁盘上的文件,但并不是文件中的内容。啥?你可以把File对象想象成文件的路径,而不是文件本身。例如File并没有读写文件的方法。关于File有个很有用的功能就是它提供一种比使用字符串文件名来表示文件更安全的方式。举例来说,在构造函数中取用字符串文件名的类也可以用File对象来代替该参数,以便检查路径是否合法等,然后再把对象传给FileWriter或FileInputStream。
File对象代表磁盘上的文件或目录的路径名称,如:
/Users/Kathy/Data/GameFile.txt
但它并不能读取或代表文件中的数据。
你可以对File对象做的事情:
1.创建出代表现存盘文件的File对象。
File f = new File("MyCode.txt");
2.建立新的目录。
File dir = new File("Chapter7");
dir.mkdir();
3.列出目录下的内容。
if (dir.isDirectory()) {String[] dirContents = dir.list();for (int i = 0; i < dirContents.length; i++) {System.out.println(dirContents);}
}
4.取得文件或目录的绝对路径。
System.out.println(dir.getAbsolutePath());
5.删除文件或目录(成功会返回true)。
boolean isDeleted = f.delete();
缓冲区的奥妙之处
没有缓冲区,就好像逛超市没有推车一样。你只能一次拿一项东西结账。
缓冲区的奥妙之处在于使用缓冲区比没有使用缓冲区的效率更好。你也可以直接使用FileWriter,调用它的write()来写文件,但它每次都会直接写下去。
你应该不会喜欢这种方式额外的成本,因为每趟磁盘操作都比内存操作要花费更多时间。通过BufferedWriter和FileWriter的链接,BufferedWriter可以暂存一堆数据,然后到满的时候再实际写入磁盘,这样就可以减少对磁盘操作的次数。
如果你想要强制缓冲区立即写入,只要调用writer.flush()这个方法就可以要求缓冲区马上把内容写下去
14.10 读取文本文件
读取文本文件
从文本文件读数据是很简单的,但是这次我们会使用File对象来表示文件,以FileReader来执行实际的读取,并用BufferedReader来让读取更有效率。
读取是以while循环来逐行进行,一直到readLine()的结果为null为止。这是最常见的读取数据方式(几乎非序列化对象都是这样的):以while循环(实际上应该称为while循环测试)来读取,读到没有东西可以读的时候停止(通过读取结果为null来判断)。
import java.io.*;public class ReadAFile {public static void main(String[] args) {try {File myFile = new File("MyTest.txt");FileReader fileReader = new FileReader(myFile);BufferedReader reader = new BufferedReader(fileReader);String line = null;while ((line = reader.readLine()) != null) {System.out.println(line);}reader.close();} catch (Exception ex) {ex.printStackTrace();}}
}
14.11 拆分字符串
用String的split()解析
要如何分开问题和答案?
当你读取文件时,问题和答案是合并在同一行,以“/”字符来分开的。
String的split()可以把字符串拆开。
split()可以将字符串拆开成String的数组。
Version ID:序列化的识别
版本控制很重要!
如果你将对象序列化,则必须要有该类才能还原和使用该对象。OK,这是废话。但若你同时又修改了类会发生什么事?假设你尝试要把Dog对象带回来,而某个非transient的变量却已经从double被改成String。这样会很严重地违反Java的类型安全性。其实不只是修改会伤害兼容性,想想下列的情况:
会损害解序列化的修改:
删除实例变量。
改变实例变量的类型。
将非瞬时的实例变量改为瞬时的。
改变类的继承层次。
将类从可序列化改成不可序列化。
将实例变量改成静态的。
通常不会有事的修改:
加入新的实例变量(还原时会使用默认值)A
在继承层次中加入新的类。
从继承层次中删除类。
不会影响解序列化程序设定变量值的存取层次修改。
将实例变量从瞬时改成非瞬时(会使用默认值)。
使用serialVersionUID
每当对象被序列化的同时,该对象(以及所有在其版图上的对象)都会被“盖”上一个类的版本识别ID。这个ID被称为serialVersionUID,它是根据类的结构信息计算出来的。在对象被解序列化时,如果在对象被序列化之后类有了不同的serialVersionUID,则还原操作会失败!但你还可以有控制权。
如果你认为类有可能会演化,就把版本识别ID放在类中。
当Java尝试要还原对象时,它会比对对象与Java虚拟机上的类的serialVersionUID。例如,如果Dog实例是以23这个ID来序列化的(实际的ID长得多),当Java虚拟机要还原Dog对象时,它会先比对Dog对象和Dog类的serialVersionUID。如果版本不相符,Java虚拟机就会在还原过程中抛出异常。
因此,解决方案就是把serialVersionUID放在class中,让类在演化的过程中还维持相同的ID。
这只会在你有很小心地维护类的变动时才办得到!也就是说你得要对带回旧对象的任何问题负起全责。
若想知道某个类的serialVersionUID,则可以使用JavaDevelopment Kit里面所带的serialver工具来查询。