CS61B项目笔记(四)-Lab6-文件序列化
Lab知识点
- 如何从命令行运行 Java 并运行 Capers 的测试(Gitlet 的测试非常非常相似)。
- 如何在 Java 中使用文件和目录。
- 如何将 Java 对象序列化为文件并在以后读回它们(也称为持久性)。
重要说明:静态变量在两次执行之间不会在 Java 中保留。当程序完成执行时,所有实例和静态变量都将完全丢失。我们可以在执行之间保持持久性的唯一方法是将数据存储在文件系统上。
命令行
不用shell和cmd,我们用git bash来打开文件夹
首先,确保您当前的工作目录是 sp21-s***/lab6/capers
。这些命令将带您到达那里:
1 | $ cd $REPO_DIR |
编译JAVA文件
1 | $ javac *.java |
*.java
通配符仅返回当前目录中的所有 .java
文件。再次运行 ls
,您将看到一堆新 .class
文件,包括 Main.class
.这些文件构成编译的代码。让我们看看它是什么样子的。
查看JAVA文件
1 | $ cat Main.class |
该命令将打印出文件的内容。你会看到大部分带有许多特殊字符的垃圾。这被称为字节码,尽管它对我们来说看起来很陌生,但 java
程序可以获取这些编译的代码并实际解释它以运行程序。让我们看看它发生:
运行JAVA文件
1 | $ java Main |
我们得到一个错误
1 | Error: Could not find or load main class Main |
如果我们把这个错误翻译成英语,那就是说“我不知道 Main
你在说什么”。那是因为 Main.java
在包中,所以我们必须使用它的完全规范名称,即 capers.Main
.为此,请执行以下操作:
1 | $ cd .. # takes us up a directory to sp21-s***/lab6 |
现在程序终于运行并打印出来了
1 | Must have at least one argument |
教训:要运行包中的 Java 文件,我们必须输入父目录(在我们的例子中是 lab6
)并使用完全规范的名称。
关于命令行执行的最后一件事:我们如何将参数传递给 main
方法?回想一下,当我们运行一个类(即 java Main
),真正发生的是调用该类 main(String[] args)
的方法。要传递参数,只需在调用中将它们添加到: java
参数传递
1 | $ java capers.Main story "this is a single argument" |
如上所述,您可以通过将该参数用引号括起来,在其中一个 String[] args
元素中有一个空格。
在上面的执行中,变量 String[] args
具有以下内容:
1 | {"story", "this is a single argument"} |
您将在本实验和 Gitlet 中使用该 String[] args
变量。已经提供了一些骨架来向您展示如何在 Main
类 main
的方法中完成它。
make的git套件
由 Paul Hilfinger 和 61B 助教构建的自定义命令行测试套件,该套件使用 python 作为基本引擎来验证本实验和项目 2 的程序正确性。
测试套件使用名为 make
的标准 unix 工具执行。我们不会 make
详细讨论如何使用。只要知道有两件事你可以用 make
:
- 使用命令
make
编译代码。 - 使用
make check
命令运行测试套件。
还有一种线程方式可以使用 make
。具体 make clean
来说, .class
如果项目文件夹中有太多文件会打扰您,将删除所有文件和其他混乱。我们建议您在完成测试并希望返回 IntelliJ 中编辑 .java
文件后运行此文件。
Java 中的文件和目录
当前工作目录
Java 程序的当前工作目录 (CWD/current working directory ) 是执行该 Java 程序的目录。您可以使用 从 Java 程序中访问 System.getProperty("user.dir")
CWD。以下是 Windows 和 Mac/Linux 用户的示例 - 它们非常相似,只是在风格上有所不同。
Windows 例如,对于 Windows 用户,假设我们有这个小 Java 程序位于名为 C:/Users/Michelle/example
Example.java
的文件夹(或 ~/example
)中:
1 | // file C:/Users/Michelle/example/Example.java |
这是一个打印出该 Java 程序的 CWD 的程序。
如果我跑了:
1 | $ cd C:/Users/Michelle/example/ |
输出应为:
1 | C:\Users\Michelle\example |
IntelliJ
在 IntelliJ 中,CWD 由“运行”>“>工作目录编辑配置”下的指定目录给出:
请注意,您可能需要运行 Main.java
一次才能显示此选项。
Terminal
在终端 / Git Bash 中,该命令 pwd
将为您提供 CWD。
绝对路径和相对路径
路径是文件或目录的位置。有两种路径:绝对路径和相对路径。
- 绝对路径是文件或目录相对于文件系统根目录的位置。
- 在上面的示例中,
Example.java
绝对路径是C:/Users/Michelle/example/Example.java
(Windows) 或/home/Michelle/example/Example.java
(Mac/Linux)。请注意,这些路径 Windows 以C:/
和Mac/Linux 以/
的根目录开头。
- 在上面的示例中,
- 相对路径是文件或目录相对于程序的 CWD 的位置。
- 在上面的示例中,如果我在
C:/Users/Michelle/example/
(Windows) 或/home/Michelle/example/(Mac/Linux)
文件夹中 ,那么Example.java
的相对路径将是Example.java
。 - 如果我在
C:/Users/Michelle/
或/home/Michelle/
中,则 的Example.java
相对路径为example/Example.java
。
- 在上面的示例中,如果我在
注意:文件系统的根目录与主目录不同。您的主目录通常位于 C:/Users/<your username>
(Windows) 或 /home/<your username>
(Mac/Linux)。我们用作 ~
指代您的主目录的简写,因此当您在 ~/sp21-s***
时,您实际上是在 C:/Users/<your username>/sp21-s***
(Windows) 或 /home/<your username>/sp21-s***
(Mac/Linux)。*
使用路径时, .
指 CWD。因此,相对路径 ./example/Example.java
与 example/Example.java
相同。
同样, ..
引用父(或封闭)目录。所以命令
1 | cd .. |
是导航到父目录的一种快速而简单的方法。如果要转到父目录,可以将它们串在一起,如下所示:
1 | cd ../.. |
与父级的父级目录类似。在此实验室/项目中,您不需要执行此操作,尽管快速浏览终端很有用。
Java 中的文件和目录操作
Java File 类表示操作系统中的文件或目录,并允许您对这些文件和目录执行操作。在本类中,您通常希望通过引用文件和目录的相对路径来对文件和目录执行操作。您希望您创建的任何新文件或目录都与运行程序的位置(在本实验中为 sp21-s***/lab6
文件夹)位于同一目录中,而不是计算机上的某个随机位置。
Files
在Java中,你可以使用File构造函数并传入文件路径来创建一个File对象:
1 | File f = new File("dummy.txt"); |
上面的路径是一个相对路径,在这里我们引用了Java程序当前工作目录中的dummy.txt文件。你可以将这个File对象视为对实际文件dummy.txt的引用 - 当我们创建新的File对象时,我们实际上并没有创建dummy.txt文件本身,我们只是在说,“将来,当我对f进行操作时,我想在dummy.txt上执行这些操作”。要实际创建这个dummy.txt文件,我们可以调用:
类似指代我需要操作的文件
1 | f.createNewFile(); |
然后dummy.txt文件现在实际上就存在了(你可以在文件浏览器/查找器中看到它)。
你可以使用File类的exists方法检查文件“dummy.txt”是否已经存在:
1 | f.exists() |
实际上,在Java中写文件相当丑陋。为了保持简单,我们为你提供了一个Utils.java。这个类在这个实验室和Gitlet中都非常方便。你应该查看Utils.java中提供的可用方法列表,以了解它可以为你做些什么。查看本实验室底部的FAQ以获取关注点提示。
例如,如果你想将一个字符串写入文件,你可以这样做:
1 | Utils.writeContents(f, "Hello World"); |
现在dummy.txt文件中将包含文本“Hello World”。
Directories
在Java中,目录也用File对象表示。例如,你可以创建一个表示目录的File对象:
1 | File d = new File("dummy"); |
与文件类似,这个目录在你的文件系统中可能实际上并不存在。要在你的文件系统中实际创建这个文件夹,你可以运行:
1 | d.mkdir(); |
现在你的当前工作目录中应该有一个名为dummy的文件夹。你还应该查看mkdirs()方法,其文档可以在此处找到。
Summary
在Java中有许多操作文件的方法,你可以通过查看File的Javadoc和搜索来探索更多。在线资源有很多,如果你搜索一下,使用Java进行更多的复杂文件操作可能会变得有点复杂。我们建议通过完成这个实验室来理解基础知识,在将来,如果你遇到不知道如何处理的用例,再开始搜索或在Ed上提问。对于这个实验室和Gitlet,你应该使用我们的Utils.java类,它具有许多有用的文件操作辅助函数。
序列化(Serializable)
将文本写入文件很棒,但如果我们想要在程序中保存一些更复杂的状态怎么办?例如,如果我们想要能够保存2048游戏中的Model对象,以便以后再次使用它呢?我们可以编写一个toString方法将Model转换为String,然后将该String写入文件。然后,我们还必须编写能够读取该String并将其解析回Model的代码。虽然这样做肯定是可能的,但编写这样的代码是很烦琐的。
幸运的是,我们有一个称为序列化的替代方法,Java已经为我们实现了它。序列化是将对象转换为一系列字节的过程,然后可以将这些字节存储在文件中。然后我们可以反序列化这些字节,并在程序的将来调用中获得原始对象。
要为Java中的给定类启用此功能,只需实现java.io.Serializable接口:
1 | import java.io.Serializable; |
这个接口没有方法;它只是为了一些特殊的Java类来执行对象的I/O而标记其子类型。例如,
1 | Model m = ....; |
将m转换为一系列字节,并将其存储在名为saveFileName的文件中。然后可以通过以下代码序列化对象:
1 | Model m; |
Java运行时会自动执行所有的工作,找出哪些字段需要转换为字节以及如何执行转换。你将会左右序列化对象,为了减少你需要编写的代码量,我们在Utils.java中提供了处理读写对象的辅助函数。
请注意,上面的代码非常烦人,有很多神秘的类和try/catch语句。如果你使用Utils类中提供的辅助函数,那么序列化就会变得非常简单:
1 | Model m; |
类似地,反序列化只需简单地:
1 | Model m; |
注意:在Project 2规范中有一些Serializable的限制,你在这个实验室中不会遇到它们。
有用的Utils类方法
这些实用的工具函数(作为起点,可能需要更多,而且您可能并不需要全部):
static void writeContents(File file, Object... contents)
- 将字符串/字节数组写入文件。...
允许我们使用 可变参数,这意味着我们可以使用任意数量的参数调用writeContents
。例如,我们可以调用writeContents(outFile, "dog", "cat", "mouse")
,函数writeContents
将通过数组访问我们传入的所有三个对象。static String readContentsAsString(File file)
- 将文件读取为字符串。static byte[] readContents(File file)
- 将文件读取为字节数组。static void writeObject(File file, Serializable obj)
- 将可序列化对象写入文件。static <T extends Serializable> T readObject(File file, Class<T> expectedClass)
- 从文件中读取可序列化对象。您可以使用<类名>.class
获取一个Class
对象,例如Dog d = readObject(inFile, Dog.class)
。static File join(String first, String... others)
- 将字符串或文件组合成路径。例如,Utils.join(".capers", "dogs")
将为您提供一个路径为 “.capers/dogs”的File
对象,Utils.join(".capers", "dogs", "shitzus")
将为您提供一个路径为 “.capers/dogs/shitzus”的File
对象。您 不应该 使用字符串连接来创建文件!这可能会根据您正在运行的系统产生奇怪的错误。
远程JVM调试
在本节中,我们将讨论如何使用IntelliJ调试lab 6。乍一看,这似乎是不可能的,因为我们是从命令行运行所有东西的。然而,IntelliJ提供了一个称为“远程JVM调试”的功能,它允许您添加断点,在集成测试期间触发。
首先使用git来检出骨架代码的原始版本。也就是说,在检出后,你应该回到实验室的一开始。如果你不知道怎么做,请阅读Lab 4中的这一部分。
此部分剩余部分的操作步骤可以在这里找到。视频简单地介绍了规范中列出的步骤,所以如果你在方向上感到困惑,可以看看它。
由于没有JUnit测试,你可能想知道如何调试你的代码。我们将向你展示如何在Capers和Gitlet中完成。
首先,让我们讨论如何知道你有一个bug。如果你运行make check
测试,你会发现你未通过测试test02-two-part-story.in
。现在我们需要弄清楚哪次执行你的程序有bug。记住,我们的测试会多次运行你的程序;在这种情况下,.in
文件有两行调用capers.Main,所以这个测试运行了两次(在Gitlet中通常会更多)。
要调试这个集成测试,首先我们需要让IntelliJ知道我们想要远程调试。在你的IntelliJ中导航到你的lab6项目,如果你还没有打开的话。在顶部,转到“Run” -> “Run”:
你会得到一个要求你编辑配置的框,看起来像下面这样:
你的界面可能会有更多或更少的框,其他名称,如果你已经在IntelliJ中尝试运行了一个类。如果是这种情况,只需点击其中一个名称为“Edit Configurations”的框。
在这个框中,你需要点击左上角的“+”按钮,并选择“Remote JVM Debug”。现在它应该看起来像这样:
我们只需要默认设置。你应该在顶部的框中添加一个描述性名称,也许是“Capers Remote Debug”。添加名称后,点击“Apply”,然后退出此屏幕。在我们离开IntelliJ之前,在Main类的main方法中设置一个断点,以便我们实际上可以进行调试。确保这个断点实际上会被触发,所以把它放在main方法的第一行。
现在你将导航到终端中的测试目录。连接到IntelliJ JVM的脚本是runner.py:使用以下命令启动测试脚本:
1 | python3 runner.py --debug our/test02-two-part-story.in |
如果你想运行不同的测试,只需放置不同的.in
文件。如果你想在测试完成后保留.capers
文件夹,以便调查其内容,请使用--keep
标志:
1 | python3 runner.py --keep --debug our/test02-two-part-story.in |
对于我们的示例,你可以做任何事情;我们只是包含它,以防你想四处看看。默认情况下,生成的.capers
将被删除。
如果你看到错误消息,则意味着你要么不在测试目录中,要么你的REPO_DIR环境变量设置不正确。检查这两件事情,如果你还是困惑,那就问TA。
1 $env:REPO_DIR = "D:\hj\CS61B-Tutorial\"
否则,你应该准备好调试了!你会看到类似于这样的东西:
1 | ============================================================================ |
顶部框中包含有用的提示。接下来我们看到的是我们正在调试的.in
文件的名称,然后是一系列以>>>
和>
开头的行。
以
>>>
开头的行是将在你的Main类上运行的capers命令,即你程序的特定执行。这些对应于我们在.in
文件中看到的命令,在>
右侧。以
>
开头的行是让你输入调试命令的。有一个提示框列出了3个命令。
记住,每个输入文件都会列出多个命令,因此我们需要首先弄清楚哪个命令是问题所在的。
键入单个字符“n”(表示“next”)以在不调试的情况下执行此命令。你可以将其视为带您到下一个命令。
其中一个将会出错:要么你的代码会产生运行时错误,要么你的输出与预期不同。例如:
1 | ============================================================================ |
实验室项目
介绍
哎呀!我们终于准备好开始这个实验的实际工作了。在这个实验中,您将编写一个程序,该程序将利用文件操作和序列化。我们为您提供了三个文件:
- Main.java:程序的主方法。使用
java capers.Main [args]
运行它以执行下面指定的操作。这个类本身并没有太多的逻辑:相反,它充当了一个“入口点”,只是知道何时调用 CapersRepository.java 中的正确方法。 - CapersRepository.java:负责所有其他类之间的协调。此程序中大多数的 FIXME 都在这里。
- Dog.java:代表具有名称、品种和年龄的狗。包含一些 FIXME。
- Utils.java:用于文件操作和序列化的实用函数。这些是 Gitlet 提供的一部分。
您不需要担心错误情况或无效输入,在这个实验中我们不会对此进行测试(尽管请注意,您需要在 Gitlet 中处理这些)。您可以仅使用 Utils.java 中提供的方法以及本规范中提到的其他 File 类方法完成这个实验,但如果您感觉冒险,可以随意尝试其他方法。
Main
您的代码将支持以下三个命令:
story [text]
:将 “text” + 换行符(即 “\n”)附加到 .capers 目录中的一个故事文件中。此外,打印出当前故事(当前故事应包含最近添加的 “text”)。dog [name] [breed] [age]
:使用指定的参数持久创建一只狗;还应该打印狗的 toString()。假设狗名是唯一的。birthday [name]
:持久地提升狗的年龄并打印出庆祝消息。
例如,下面是一系列 capers.Main 的执行,后跟每个命令的输出。
1 | $ java capers.Main story "Once upon a time, there was a beautiful dog." |
请注意,dog 和 birthday 命令具有相关的功能。还要注意,story 命令的功能完全独立于 dog 或 birthday。
还要注意,您的代码不应在每一步打印出命令行参数。换句话说,您应该删除在此实验早期添加的调用 System.out.println("args: " + Arrays.toString(args));
。
所有持久数据应存储在当前工作目录中的 .capers 目录中。我们在前面加上 . 是因为以 . 开头的文件和目录默认在文件查看器中是隐藏的。毕竟,我们不希望我们的 Java 程序的用户关心我们如何、在哪里或者是否存储持久数据。他们只关心程序是否正常工作。IntelliJ 和 Git 都利用了这个想法:每当您创建一个 IntelliJ 项目时,它都会在当前工作目录中创建一个隐藏的 .idea 文件夹来存储所有的元数据。类似地,每当您初始化一个 Git 仓库时,它都会将所有的持久数据存储在一个 .git 文件夹中。您可以通过在终端中键入 ls -a 而不是只键入 ls 来查看隐藏文件。
推荐的文件结构(您不必遵循此结构):
1 | .capers/ -- 所有持久数据的顶级文件夹 |
完成顺序
建议完成顺序:
- 在 CapersRepository.java 中填写 CAPERS_FOLDER,然后在 Dog.java 中填写 DOG_FOLDER,然后在 CapersRepository.java 中填写 setUpPersistence。
- 在 Main.java 中填写 main 方法。这主要应该是调用 CapersRepository 中的其他方法。
- 在 CapersRepository.java 中填写 writeStory 方法。现在 story 命令应该可以正常工作了。
- 尝试手动使用 story 命令,并验证它是否正常工作。
- 在 Dog.java 中填写 saveDog,然后填写 fromFile。您还需要处理 Dog.java 顶部的 TODO。请记住,狗名是唯一的!
- 使用 Dog.java 中的方法,在 CapersRepository.java 中填写 makeDog 和 celebrateBirthday。您会发现 Dog 类中的 haveBirthday 方法很有用。现在 dog 和 birthday 命令应该可以正常工作了。
- 尝试手动使用 dog 和 birthday 命令,并验证它们是否正常工作。
- 运行 make check 并验证您的代码是否通过了所有测试。如果您的测试未通过且不知道原因,请参阅本实验后面的调试部分。
- 每个 TODO 应该最多需要大约 8 行,但许多都更少。
代码
1.CapersRepository.java
在 CapersRepository.java 中填写 CAPERS_FOLDER,然后在 Dog.java 中填写 DOG_FOLDER,然后在 CapersRepository.java 中填写 setUpPersistence。
1 | /** Current Working Directory. */ |
1 | static final File DOG_FOLDER = Utils.join(CapersRepository.CAPERS_FOLDER, "dogs"); |
2.Main.java
在 Main.java 中填写 main 方法。这主要应该是调用 CapersRepository 中的其他方法。
1 | public static void main(String[] args) { |
3.CapersRepository.java
在 CapersRepository.java 中填写 writeStory 方法。现在 story 命令应该可以正常工作了。
1 | public static void writeStory(String text) { |
注意: 我们是希望写入的时候是续写故事的,所以我们每次写入不能覆盖掉原有的内容,因此我们需要先读取原本的内容,然后在原本内容后面添加新写入的内容
1 | $ java capers.Main story "Once upon a time, there was a beautiful dog." |
4.Dog.java
- 在 Dog.java 中填写 saveDog,然后填写 fromFile。您还需要处理 Dog.java 顶部的 TODO。请记住,狗名是唯一的!
1 | public class Dog implements Serializable{ // TODO |
5**.CapersRepository.java**
使用 Dog.java 中的方法,在 CapersRepository.java 中填写 makeDog 和 celebrateBirthday。您会发现 Dog 类中的 haveBirthday 方法很有用。现在 dog 和 birthday 命令应该可以正常工作了。
1 | /** |
1 | $ java capers.Main dog Mammoth "German Spitz" 10 |
总结
我们体会下这个Lab的代码
- 首先他是先通过Main函数读取命令符来分别调用
CapersRepository
类中的方法 - 而
CapersRepository
类中方法使用了Dog
类中的方法 - 重要的一点是,Dog类中的方法基本上只被C类调用,因此C类是Dog类的客户端。