Lab 6:Project 2 入门¶
简介¶
本实验介绍 Project 2 Gitlet 所依赖的核心工程知识:
- 在命令行中编译和运行 Java 程序;
- 理解程序的当前工作目录(CWD);
- 使用相对路径和绝对路径;
- 通过
File和课程提供的Utils.java读写文件; - 使用 Java 序列化,让对象在程序退出后仍能保存;
- 使用
make和集成测试; - 使用远程 JVM 调试命令行程序。
实验真正需要编写代码的部分从 Canine Capers 开始,但前面的背景必须认真阅读,否则后面的持久化逻辑会很难理解。
先获取骨架:
持久化(概念)¶
此前课程中的程序只在运行期间保存状态。程序一结束,实例变量和静态变量都会消失。例如 Project 0 的 2048 无法在退出后重新加载之前的进度。
现实程序通常要跨多次运行保存状态。例如:
即使 git add 已经退出,随后运行:
Git 仍然知道哪些文件被加入暂存区。即使关机一年,这些信息仍会存在。
实现持久化的关键是文件系统:程序把数据写入磁盘,下一次运行时再读回来。
Java 的静态变量不会跨进程保存。程序结束后,实例变量和静态变量全部丢失。要跨运行保留数据,必须将其写到文件系统中。
Java 与编译¶
过去你可能一直点击 IntelliJ 的绿色运行按钮。Java 也可以直接从终端编译和运行。
Java 源文件是 .java;javac 将其编译为包含 Java 字节码的 .class 文件;java 再执行编译后的类。
确认 Java 版本至少为 15:
Windows 用户必须使用 Git Bash,而不是以 C: 开头的普通命令提示符。
进入实验目录:
编译所有 Java 文件:
*.java 是通配符,表示当前目录下所有 .java 文件。编译后会出现若干 .class 文件。
查看编译后的字节码:
它对人类来说大多是不可读字符,但 JVM 能执行。
直接运行下面命令会失败:
错误类似:
Error: Could not find or load main class Main
Caused by: java.lang.NoClassDefFoundError: capers/Main (wrong name: Main)
原因是 Main.java 位于 capers 包中。运行包内类时,必须在包目录的父目录运行,并使用完整规范名称:
向 main 传递参数¶
运行类时,实际调用的是:
参数直接写在类名后:
此时:
双引号可将包含空格的内容作为一个参数。
用 IntelliJ 打开:
在 Main.java 中找到:
其后临时加入:
如果 Arrays 变红,按 Alt+Enter(macOS 为 Option+Enter)导入 java.util.Arrays。
从终端运行:
预期:
实验正式实现时,要删除这条调试输出。
Make¶
Capers 和 Gitlet 都具有持久状态。一次测试往往需要:
- 启动程序执行一个命令;
- 等程序退出;
- 再启动程序执行另一个命令;
- 验证第二次运行是否记住第一次运行的数据。
因此,本实验不主要依赖 JUnit,而使用课程提供的 Python 集成测试框架,并通过 Unix 工具 make 执行。
安装好 make 和 Python 后,在 lab6 及其子目录中运行:
它会编译项目中的 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
运行测试:
清理 .class 等生成文件:
check 和 clean 称为 Make target。
如果出现 python3: command not found 或权限错误,请查看文末 FAQ。
Java 中的文件与目录¶
当前工作目录¶
Java 程序的当前工作目录,是你从哪个目录启动程序。程序中可通过:
查询。
例如:
class Example {
public static void main(String[] args) {
System.out.println(System.getProperty("user.dir"));
}
}
若你先进入文件所在目录再运行:
输出会是:
Windows 下则类似:
在 IntelliJ 中,CWD 位于:
在终端中可用:
绝对路径与相对路径¶
- 绝对路径:相对于文件系统根目录的位置。
- 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 类可表示文件或目录。
这只创建了一个指向路径的 Java 对象,并没有真的创建磁盘文件。
创建文件:
检查是否存在:
课程提供的 Utils.java 简化了文件读写。例如:
目录¶
还可以查看 mkdirs(),它能创建多级目录。
请重点熟悉 Utils.java 中的读写、连接路径、序列化辅助方法。
总结¶
Java 中还有许多操作文件的方法。你可以阅读 File 的 Javadocs 并在需要时搜索;本 Lab 和 Gitlet 应优先使用课程提供的 Utils.java。
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) {
...
}
读取:
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;
}
使用 Utils 后,写对象只需:
读对象:
练习: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]¶
持久化增加指定狗的年龄,并打印狗的信息和生日祝福。
示例:
输出:
第二次运行:
输出必须包含之前保存的内容:
创建狗:
输出:
生日:
输出类似:
有用的 Utils 函数¶
以下函数是一个起点;你可能还需要其他函数,也可能不需要全部函数:
writeContents:把字符串或字节数组写入文件。readContentsAsString:把文件读为字符串。readContents:把文件读为字节数组。writeObject:把可序列化对象写入文件。readObject:从文件读取可序列化对象。join:安全地把多个路径部分组合为File,不要用字符串拼接创建路径。
持久化目录¶
所有持久数据都应放在当前工作目录下的 .capers 目录。点号前缀使其默认隐藏,用户不需要知道内部实现细节。
推荐结构:
这些文件和目录应由程序创建,不应手动创建。
删除全部持久状态:
注意不要误删源代码目录 capers。
建议的完成顺序¶
setUpPersistence:如果需要的目录和文件不存在,就创建它们;writeStory:读取旧故事、追加新文本,再覆盖写回完整内容;Dog构造器和toString;saveDog:将Dog序列化到文件;Dog.fromFile:根据姓名读取狗对象;haveBirthday:修改年龄后必须重新保存;Main.main:解析命令行参数并分派命令。
用法¶
从 lab6 目录运行 make,再使用 java capers.Main [args] 执行命令。story 的文本包含空格时应使用引号。
测试¶
除了 make check,还应手工测试:
测试文件位于:
一个 .in 文件代表一个集成测试。例如:
规则:
#开头为注释;>后是传给capers.Main.main()的参数;- 直到
<<<的内容是预期输出; - 输出必须完全匹配。
这类测试称为集成测试:一次测试会跨多个类和多次程序运行,不像单元测试只隔离一个小组件。
提交¶
通过全部 make check 后,你应修改了:
capers/Main.javacapers/Dog.javacapers/CapersRepository.java
提交:
然后在 Gradescope 提交。本实验没有风格检查。
强制结尾:调试¶
完成并提交后,仍应做这一节。它会教你如何调试 Capers 和 Gitlet 的多次进程执行。
1. 配置远程 JVM 调试¶
先用 Git 将 Lab 6 临时恢复到初始骨架,以便观察失败测试;实验结束后务必切回自己的解答。
在 IntelliJ 中:
- 打开 Lab 6;
Run → Edit Configurations;- 点击
+; - 选择
Remote JVM Debug; - 给配置起一个易识别的名称;
- 保持课程说明中的默认端口与设置。
2. 从测试运行器启动调试¶
进入 lab6/testing,运行某个测试:
若要保留测试结束后的 .capers 文件夹:
交互命令:
n:不调试当前命令,直接运行并转到下一条,类似 Step Over;s:调试当前命令,类似 Step Into;输入后在 IntelliJ 点击 Debug;q:退出调试。
先不断输入 n,找出究竟是哪一次程序执行首次产生错误。若启用了 --keep,测试目录会被保存在类似:
找到出错命令后,重新运行测试,在该命令处输入 s,然后用 IntelliJ 连接远程 JVM,并使用正常的断点、单步和变量观察。
3. 持久化错误的特殊性¶
测试第二条命令输出错误,不一定说明第二次执行有 bug。可能第一次执行没有正确写入持久化数据,第二次执行只是读到了错误状态。
调试时第一目标应是找到最早产生错误状态的那次执行。否则你可能花很久调试一个本身正确的后续命令。
Gitlet 使用完全相同的调试方式。
提示、FAQ 与常见误区¶
提示¶
setUpPersistence:确保所需目录/文件不存在时会被创建;writeStory:使用readContentsAsString和writeContents。仅使用writeContents会覆盖旧故事,不能直接“追加”;saveDog:使用writeObject,目标File必须表示文件而不是目录;fromFile:使用readObject;- 修改对象后若要求“持久化”,必须再次把修改后的对象写回文件。
FAQ 与常见误区¶
Python 命令差异¶
若你的系统运行 Python 的命令不是 python3:
例如:
也可修改 Makefile 中对应配置,避免每次重复输入。
File 常见误解¶
new File(...)不会创建实际文件或目录;- 创建文件要调用
createNewFile(); - 创建目录要调用
mkdir()或mkdirs(); File对象可以表示文件,也可以表示目录;Utils.join(d, s)只是创建表示d下s路径的File对象,不会创建实际文件;writeObject(file, object)的第一个参数必须是文件路径;- “持久地修改”意味着不仅修改内存中的对象,还必须把修改写回文件系统。
致谢¶
Capers 最初由 Sean Dooher 于 2019 年秋季编写。规格与 Lab 改编由 Michelle Hwang 于 2020 年春季完成,之后由 Omar Khan 于 2021 年春季继续改编。