Everlasting Pages

返回

Lab 6:Project 2 入门

简介#

本实验介绍 Project 2 Gitlet 所依赖的核心工程知识:

  • 在命令行中编译和运行 Java 程序;
  • 理解程序的当前工作目录(CWD);
  • 使用相对路径和绝对路径;
  • 通过 File 和课程提供的 Utils.java 读写文件;
  • 使用 Java 序列化,让对象在程序退出后仍能保存;
  • 使用 make 和集成测试;
  • 使用远程 JVM 调试命令行程序。

实验真正需要编写代码的部分从 Canine Capers 开始,但前面的背景必须认真阅读,否则后面的持久化逻辑会很难理解。

先获取骨架:

git pull skeleton master
bash

持久化(概念)#

此前课程中的程序只在运行期间保存状态。程序一结束,实例变量和静态变量都会消失。例如 Project 0 的 2048 无法在退出后重新加载之前的进度。

现实程序通常要跨多次运行保存状态。例如:

git add Hello.java Friends.java
bash

即使 git add 已经退出,随后运行:

git status
bash

Git 仍然知道哪些文件被加入暂存区。即使关机一年,这些信息仍会存在。

实现持久化的关键是文件系统:程序把数据写入磁盘,下一次运行时再读回来。

Java 的静态变量不会跨进程保存。程序结束后,实例变量和静态变量全部丢失。要跨运行保留数据,必须将其写到文件系统中。

Java 与编译#

过去你可能一直点击 IntelliJ 的绿色运行按钮。Java 也可以直接从终端编译和运行。

Java 源文件是 .javajavac 将其编译为包含 Java 字节码的 .class 文件;java 再执行编译后的类。

确认 Java 版本至少为 15:

javac -version
java -version
bash

Windows 用户必须使用 Git Bash,而不是以 C: 开头的普通命令提示符。

进入实验目录:

cd $REPO_DIR
cd lab6/capers
bash

编译所有 Java 文件:

javac *.java
bash

*.java 是通配符,表示当前目录下所有 .java 文件。编译后会出现若干 .class 文件。

查看编译后的字节码:

cat Main.class
bash

它对人类来说大多是不可读字符,但 JVM 能执行。

直接运行下面命令会失败:

java Main
bash

错误类似:

Error: Could not find or load main class Main
Caused by: java.lang.NoClassDefFoundError: capers/Main (wrong name: Main)
text

原因是 Main.java 位于 capers 包中。运行包内类时,必须在包目录的父目录运行,并使用完整规范名称:

cd ..
java capers.Main
bash

main 传递参数#

运行类时,实际调用的是:

main(String[] args)
java

参数直接写在类名后:

java capers.Main story "this is a single argument"
bash

此时:

args = new String[]{"story", "this is a single argument"};
java

双引号可将包含空格的内容作为一个参数。

用 IntelliJ 打开:

sp21-s***/lab6/pom.xml
text

Main.java 中找到:

if (args.length == 0) {
    Utils.exitWithError("Must have at least one argument");
}
java

其后临时加入:

System.out.println("args: " + Arrays.toString(args));
java

如果 Arrays 变红,按 Alt+Enter(macOS 为 Option+Enter)导入 java.util.Arrays

从终端运行:

javac capers/Main.java
java capers.Main story "this is a single argument"
bash

预期:

args: [story, this is a single argument]
text

实验正式实现时,要删除这条调试输出。

Make#

Capers 和 Gitlet 都具有持久状态。一次测试往往需要:

  1. 启动程序执行一个命令;
  2. 等程序退出;
  3. 再启动程序执行另一个命令;
  4. 验证第二次运行是否记住第一次运行的数据。

因此,本实验不主要依赖 JUnit,而使用课程提供的 Python 集成测试框架,并通过 Unix 工具 make 执行。

安装好 make 和 Python 后,在 lab6 及其子目录中运行:

make
bash

它会编译项目中的 Java 文件。输出可能类似:

"/Library/Developer/CommandLineTools/usr/bin/make" -C capers default
javac -g -Xlint:unchecked -Xlint:deprecation -cp "..::;..;" CapersRepository.java Dog.java Main.java Utils.java
touch sentinel
text

运行测试:

make check
bash

清理 .class 等生成文件:

make clean
bash

checkclean 称为 Make target。

如果出现 python3: command not found 或权限错误,请查看文末 FAQ。

Java 中的文件与目录#

当前工作目录#

Java 程序的当前工作目录,是你从哪个目录启动程序。程序中可通过:

System.getProperty("user.dir")
java

查询。

例如:

class Example {
    public static void main(String[] args) {
        System.out.println(System.getProperty("user.dir"));
    }
}
java

若你先进入文件所在目录再运行:

cd /home/Michelle/example
javac Example.java
java Example
bash

输出会是:

/home/Michelle/example
text

Windows 下则类似:

C:\Users\Michelle\example
text

在 IntelliJ 中,CWD 位于:

Run → Edit Configurations → Working Directory
text

在终端中可用:

pwd
bash

绝对路径与相对路径#

  • 绝对路径:相对于文件系统根目录的位置。
    • Windows:C:/Users/Michelle/example/Example.java
    • macOS/Linux:/home/Michelle/example/Example.java
  • 相对路径:相对于当前工作目录的位置。
    • 若 CWD 已是 example,则路径可以只是 Example.java
    • 若 CWD 是其父目录,则为 example/Example.java

~ 表示用户主目录,但主目录与文件系统根目录不是一回事。

本实验中通常应使用相对路径,使所有生成内容位于 lab6 的工作目录中,而不是写到计算机上的随机位置。

Java 中的文件与目录操作#

文件#

Java 的 File 类可表示文件或目录。

File f = new File("dummy.txt");
java

这只创建了一个指向路径的 Java 对象,并没有真的创建磁盘文件。

创建文件:

f.createNewFile();
java

检查是否存在:

f.exists();
java

课程提供的 Utils.java 简化了文件读写。例如:

Utils.writeContents(f, "Hello World");
java

目录#

File d = new File("dummy");
d.mkdir();
java

还可以查看 mkdirs(),它能创建多级目录。

请重点熟悉 Utils.java 中的读写、连接路径、序列化辅助方法。

总结#

Java 中还有许多操作文件的方法。你可以阅读 File 的 Javadocs 并在需要时搜索;本 Lab 和 Gitlet 应优先使用课程提供的 Utils.java

Serializable#

如果要保存一个复杂对象,可以手工把它变成字符串,再自己解析回来,但这种做法很繁琐。

Java 提供序列化:把对象转成字节流写入文件;下一次运行时再反序列化成对象。

要允许某个类被序列化,让它实现:

import java.io.Serializable;

public class Model implements Serializable {
    ...
}
java

Serializable 没有要求实现的方法,它只是一个标记接口。

标准 Java 写法较长:

Model m = ....;
File outFile = new File(saveFileName);
try {
    ObjectOutputStream out =
        new ObjectOutputStream(new FileOutputStream(outFile));
    out.writeObject(m);
    out.close();
} catch (IOException excp) {
    ...
}
java

读取:

Model m;
File inFile = new File(saveFileName);
try {
    ObjectInputStream inp =
        new ObjectInputStream(new FileInputStream(inFile));
    m = (Model) inp.readObject();
    inp.close();
} catch (IOException | ClassNotFoundException excp) {
    ...
    m = null;
}
java

使用 Utils 后,写对象只需:

writeObject(outFile, m);
java

读对象:

m = readObject(inFile, Model.class);
java

练习:Canine Capers#

你要实现一个使用文件操作和序列化的小程序。

骨架包含:

  • Main.java:程序入口,根据命令行参数调用正确方法;
  • CapersRepository.java:协调其他类,绝大多数 FIXME 位于这里;
  • Dog.java:表示一只拥有姓名、品种和年龄的狗;
  • Utils.java:文件与序列化工具。

本实验不测试非法输入和错误情况;Gitlet 中则必须处理。

Main#

story [text]#

text 和换行符 "\n" 追加到 .capers 目录中的故事文件,然后打印当前完整故事。打印内容必须包括刚追加的文字。

dog [name] [breed] [age]#

持久化创建一只指定姓名、品种和年龄的狗,并打印其 toString()。可假设狗名唯一。

birthday [name]#

持久化增加指定狗的年龄,并打印狗的信息和生日祝福。

示例:

java capers.Main story "Once upon a time, there was a beautiful dog."
bash

输出:

Once upon a time, there was a beautiful dog.
text

第二次运行:

java capers.Main story "That dog was named Fjerf."
bash

输出必须包含之前保存的内容:

Once upon a time, there was a beautiful dog.
That dog was named Fjerf.
text

创建狗:

java capers.Main dog Mammoth "German Spitz" 10
bash

输出:

Woof! My name is Mammoth and I am a German Spitz! I am 10 years old! Woof!
text

生日:

java capers.Main birthday Qitmir
bash

输出类似:

Woof! My name is Qitmir and I am a Saluki! I am 4 years old! Woof!
Happy birthday! Woof! Woof!
text

有用的 Utils 函数#

以下函数是一个起点;你可能还需要其他函数,也可能不需要全部函数:

  • writeContents:把字符串或字节数组写入文件。
  • readContentsAsString:把文件读为字符串。
  • readContents:把文件读为字节数组。
  • writeObject:把可序列化对象写入文件。
  • readObject:从文件读取可序列化对象。
  • join:安全地把多个路径部分组合为 File,不要用字符串拼接创建路径。

持久化目录#

所有持久数据都应放在当前工作目录下的 .capers 目录。点号前缀使其默认隐藏,用户不需要知道内部实现细节。

推荐结构:

.capers/
├── dogs/      # 每只狗的持久化数据
└── story      # 当前故事文本
text

这些文件和目录应由程序创建,不应手动创建。

删除全部持久状态:

rm -rf .capers
bash

注意不要误删源代码目录 capers

建议的完成顺序#

  1. setUpPersistence:如果需要的目录和文件不存在,就创建它们;
  2. writeStory:读取旧故事、追加新文本,再覆盖写回完整内容;
  3. Dog 构造器和 toString
  4. saveDog:将 Dog 序列化到文件;
  5. Dog.fromFile:根据姓名读取狗对象;
  6. haveBirthday:修改年龄后必须重新保存;
  7. Main.main:解析命令行参数并分派命令。

用法#

lab6 目录运行 make,再使用 java capers.Main [args] 执行命令。story 的文本包含空格时应使用引号。

测试#

除了 make check,还应手工测试:

make
java capers.Main story Hello
java capers.Main story World
ls -a .capers
bash

测试文件位于:

lab6/testing/our
text

一个 .in 文件代表一个集成测试。例如:

# Two uses of the `story` command
> story "Hello"
Hello
<<<
> story "World"
Hello
World
<<<
text

规则:

  • # 开头为注释;
  • > 后是传给 capers.Main.main() 的参数;
  • 直到 <<< 的内容是预期输出;
  • 输出必须完全匹配。

这类测试称为集成测试:一次测试会跨多个类和多次程序运行,不像单元测试只隔离一个小组件。

提交#

通过全部 make check 后,你应修改了:

  • capers/Main.java
  • capers/Dog.java
  • capers/CapersRepository.java

提交:

git commit -m "<commit message>"
git push origin master
bash

然后在 Gradescope 提交。本实验没有风格检查。

强制结尾:调试#

完成并提交后,仍应做这一节。它会教你如何调试 Capers 和 Gitlet 的多次进程执行。

1. 配置远程 JVM 调试#

先用 Git 将 Lab 6 临时恢复到初始骨架,以便观察失败测试;实验结束后务必切回自己的解答。

在 IntelliJ 中:

  1. 打开 Lab 6;
  2. Run → Edit Configurations
  3. 点击 +
  4. 选择 Remote JVM Debug
  5. 给配置起一个易识别的名称;
  6. 保持课程说明中的默认端口与设置。

2. 从测试运行器启动调试#

进入 lab6/testing,运行某个测试:

python3 runner.py --debug our/test02-two-part-story.in
bash

若要保留测试结束后的 .capers 文件夹:

python3 runner.py --keep --debug our/test02-two-part-story.in
bash

交互命令:

  • n:不调试当前命令,直接运行并转到下一条,类似 Step Over;
  • s:调试当前命令,类似 Step Into;输入后在 IntelliJ 点击 Debug;
  • q:退出调试。

先不断输入 n,找出究竟是哪一次程序执行首次产生错误。若启用了 --keep,测试目录会被保存在类似:

test02-two-part-story_0
text

找到出错命令后,重新运行测试,在该命令处输入 s,然后用 IntelliJ 连接远程 JVM,并使用正常的断点、单步和变量观察。

3. 持久化错误的特殊性#

测试第二条命令输出错误,不一定说明第二次执行有 bug。可能第一次执行没有正确写入持久化数据,第二次执行只是读到了错误状态。

调试时第一目标应是找到最早产生错误状态的那次执行。否则你可能花很久调试一个本身正确的后续命令。

Gitlet 使用完全相同的调试方式。

提示、FAQ 与常见误区#

提示#

  • setUpPersistence:确保所需目录/文件不存在时会被创建;
  • writeStory:使用 readContentsAsStringwriteContents。仅使用 writeContents 会覆盖旧故事,不能直接“追加”;
  • saveDog:使用 writeObject,目标 File 必须表示文件而不是目录;
  • fromFile:使用 readObject
  • 修改对象后若要求“持久化”,必须再次把修改后的对象写回文件。

FAQ 与常见误区#

Python 命令差异#

若你的系统运行 Python 的命令不是 python3

make check PYTHON=<你的 Python>
bash

例如:

make check PYTHON=py
bash

也可修改 Makefile 中对应配置,避免每次重复输入。

File 常见误解#

  • new File(...) 不会创建实际文件或目录;
  • 创建文件要调用 createNewFile()
  • 创建目录要调用 mkdir()mkdirs()
  • File 对象可以表示文件,也可以表示目录;
  • Utils.join(d, s) 只是创建表示 ds 路径的 File 对象,不会创建实际文件;
  • writeObject(file, object) 的第一个参数必须是文件路径;
  • “持久地修改”意味着不仅修改内存中的对象,还必须把修改写回文件系统。

致谢#

Capers 最初由 Sean Dooher 于 2019 年秋季编写。规格与 Lab 改编由 Michelle Hwang 于 2020 年春季完成,之后由 Omar Khan 于 2021 年春季继续改编。


原始页面:https://sp21.datastructur.es/materials/lab/lab6/lab6