Lab 6:Project 2 入门
简介#
本实验介绍 Project 2 Gitlet 所依赖的核心工程知识:
- 在命令行中编译和运行 Java 程序;
- 理解程序的当前工作目录(CWD);
- 使用相对路径和绝对路径;
- 通过
File和课程提供的Utils.java读写文件; - 使用 Java 序列化,让对象在程序退出后仍能保存;
- 使用
make和集成测试; - 使用远程 JVM 调试命令行程序。
实验真正需要编写代码的部分从 Canine Capers 开始,但前面的背景必须认真阅读,否则后面的持久化逻辑会很难理解。
先获取骨架:
git pull skeleton masterbash持久化(概念)#
此前课程中的程序只在运行期间保存状态。程序一结束,实例变量和静态变量都会消失。例如 Project 0 的 2048 无法在退出后重新加载之前的进度。
现实程序通常要跨多次运行保存状态。例如:
git add Hello.java Friends.javabash即使 git add 已经退出,随后运行:
git statusbashGit 仍然知道哪些文件被加入暂存区。即使关机一年,这些信息仍会存在。
实现持久化的关键是文件系统:程序把数据写入磁盘,下一次运行时再读回来。
Java 的静态变量不会跨进程保存。程序结束后,实例变量和静态变量全部丢失。要跨运行保留数据,必须将其写到文件系统中。
Java 与编译#
过去你可能一直点击 IntelliJ 的绿色运行按钮。Java 也可以直接从终端编译和运行。
Java 源文件是 .java;javac 将其编译为包含 Java 字节码的 .class 文件;java 再执行编译后的类。
确认 Java 版本至少为 15:
javac -version
java -versionbashWindows 用户必须使用 Git Bash,而不是以 C: 开头的普通命令提示符。
进入实验目录:
cd $REPO_DIR
cd lab6/capersbash编译所有 Java 文件:
javac *.javabash*.java 是通配符,表示当前目录下所有 .java 文件。编译后会出现若干 .class 文件。
查看编译后的字节码:
cat Main.classbash它对人类来说大多是不可读字符,但 JVM 能执行。
直接运行下面命令会失败:
java Mainbash错误类似:
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.Mainbash向 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.xmltext在 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 都具有持久状态。一次测试往往需要:
- 启动程序执行一个命令;
- 等程序退出;
- 再启动程序执行另一个命令;
- 验证第二次运行是否记住第一次运行的数据。
因此,本实验不主要依赖 JUnit,而使用课程提供的 Python 集成测试框架,并通过 Unix 工具 make 执行。
安装好 make 和 Python 后,在 lab6 及其子目录中运行:
makebash它会编译项目中的 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 sentineltext运行测试:
make checkbash清理 .class 等生成文件:
make cleanbashcheck 和 clean 称为 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 Examplebash输出会是:
/home/Michelle/exampletextWindows 下则类似:
C:\Users\Michelle\exampletext在 IntelliJ 中,CWD 位于:
Run → Edit Configurations → Working Directorytext在终端中可用:
pwdbash绝对路径与相对路径#
- 绝对路径:相对于文件系统根目录的位置。
- Windows:
C:/Users/Michelle/example/Example.java - macOS/Linux:
/home/Michelle/example/Example.java
- Windows:
- 相对路径:相对于当前工作目录的位置。
- 若 CWD 已是
example,则路径可以只是Example.java。 - 若 CWD 是其父目录,则为
example/Example.java。
- 若 CWD 已是
~ 表示用户主目录,但主目录与文件系统根目录不是一回事。
本实验中通常应使用相对路径,使所有生成内容位于 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 {
...
}javaSerializable 没有要求实现的方法,它只是一个标记接口。
标准 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" 10bash输出:
Woof! My name is Mammoth and I am a German Spitz! I am 10 years old! Woof!text生日:
java capers.Main birthday Qitmirbash输出类似:
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 .capersbash注意不要误删源代码目录 capers。
建议的完成顺序#
setUpPersistence:如果需要的目录和文件不存在,就创建它们;writeStory:读取旧故事、追加新文本,再覆盖写回完整内容;Dog构造器和toString;saveDog:将Dog序列化到文件;Dog.fromFile:根据姓名读取狗对象;haveBirthday:修改年龄后必须重新保存;Main.main:解析命令行参数并分派命令。
用法#
从 lab6 目录运行 make,再使用 java capers.Main [args] 执行命令。story 的文本包含空格时应使用引号。
测试#
除了 make check,还应手工测试:
make
java capers.Main story Hello
java capers.Main story World
ls -a .capersbash测试文件位于:
lab6/testing/ourtext一个 .in 文件代表一个集成测试。例如:
# Two uses of the `story` command
> story "Hello"
Hello
<<<
> story "World"
Hello
World
<<<text规则:
#开头为注释;>后是传给capers.Main.main()的参数;- 直到
<<<的内容是预期输出; - 输出必须完全匹配。
这类测试称为集成测试:一次测试会跨多个类和多次程序运行,不像单元测试只隔离一个小组件。
提交#
通过全部 make check 后,你应修改了:
capers/Main.javacapers/Dog.javacapers/CapersRepository.java
提交:
git commit -m "<commit message>"
git push origin masterbash然后在 Gradescope 提交。本实验没有风格检查。
强制结尾:调试#
完成并提交后,仍应做这一节。它会教你如何调试 Capers 和 Gitlet 的多次进程执行。
1. 配置远程 JVM 调试#
先用 Git 将 Lab 6 临时恢复到初始骨架,以便观察失败测试;实验结束后务必切回自己的解答。
在 IntelliJ 中:
- 打开 Lab 6;
Run → Edit Configurations;- 点击
+; - 选择
Remote JVM Debug; - 给配置起一个易识别的名称;
- 保持课程说明中的默认端口与设置。
2. 从测试运行器启动调试#
进入 lab6/testing,运行某个测试:
python3 runner.py --debug our/test02-two-part-story.inbash若要保留测试结束后的 .capers 文件夹:
python3 runner.py --keep --debug our/test02-two-part-story.inbash交互命令:
n:不调试当前命令,直接运行并转到下一条,类似 Step Over;s:调试当前命令,类似 Step Into;输入后在 IntelliJ 点击 Debug;q:退出调试。
先不断输入 n,找出究竟是哪一次程序执行首次产生错误。若启用了 --keep,测试目录会被保存在类似:
test02-two-part-story_0text找到出错命令后,重新运行测试,在该命令处输入 s,然后用 IntelliJ 连接远程 JVM,并使用正常的断点、单步和变量观察。
3. 持久化错误的特殊性#
测试第二条命令输出错误,不一定说明第二次执行有 bug。可能第一次执行没有正确写入持久化数据,第二次执行只是读到了错误状态。
调试时第一目标应是找到最早产生错误状态的那次执行。否则你可能花很久调试一个本身正确的后续命令。
Gitlet 使用完全相同的调试方式。
提示、FAQ 与常见误区#
提示#
setUpPersistence:确保所需目录/文件不存在时会被创建;writeStory:使用readContentsAsString和writeContents。仅使用writeContents会覆盖旧故事,不能直接“追加”;saveDog:使用writeObject,目标File必须表示文件而不是目录;fromFile:使用readObject;- 修改对象后若要求“持久化”,必须再次把修改后的对象写回文件。
FAQ 与常见误区#
Python 命令差异#
若你的系统运行 Python 的命令不是 python3:
make check PYTHON=<你的 Python 命令>bash例如:
make check PYTHON=pybash也可修改 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 年春季继续改编。