本系列将记述使用Java实现一个简单的二级文件系统的过程。这个项目是本学期专业课操作系统的第三个实验。上一篇文章说明了如何利用已经搭建好的代码构造一个简单的文件系统。本文将记述人机交互界面的构建，并最终让整个系统运行起来。架构构建项目的时候，避免代码变成一坨屎的最重要的技巧在于区分哪些功能是哪些部分应该实现的，从语义和逻辑上考察这个问题，搞清楚了之后代码就不会变成一团乱。之前对于文件、用户和磁盘的操作全都在文件系统中实现了。而我们要写的交互界面，将起到操作系统的作用。换句话说它要处理用户的认证、命令解析、打印必要的提示信息、询问命令执行依赖的参数，并最终根据已有的信息调用文件系统或其他代码产生影响。对于命令，我设计了一种比较便于解析的格式：help打印帮助信息exit退出系统user是与用户相关的命令user create创建用户user login用户登录......file是与文件相关的命令file list列出用户的文件file create创建文件......在此基础上，交互界面的大体框架如下：package info.skyblond.os.exp.experiment3; import info.skyblond.os.exp.experiment3.model.IndexEntry; import info.skyblond.os.exp.experiment3.model.OpenedFile; import info.skyblond.os.exp.experiment3.model.User; import info.skyblond.os.exp.utils.Pair; import java.io.File; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Scanner; import java.util.stream.Collectors; /** * 文件系统模拟器的主入口 * 模仿一个简单的操作系统，利用文件系统进行用户鉴权、文件操作 */ public class Exp3 { private static final Simulator simulator = Simulator.getInstance(); public static String getPromptLine(String username, String hostname) { if (username.isBlank()) { return "nobody@" + hostname + "> "; } else { return username + "@" + hostname + "> "; } } public static void main(String[] args) { var dataFile = new File("./data.bin"); simulator.setDataFile(dataFile); if (!dataFile.exists()) { System.out.println("未检测到数据文件，开始初始化。"); simulator.initDataFile(); System.out.println("已创建默认用户：root，密码：" + Simulator.ROOT_DEFAULT_PASSWORD); } var hostname = "FSS"; var currentUser = ""; // prompt System.out.print(getPromptLine(currentUser, hostname)); Scanner scanner = new Scanner(System.in); while (scanner.hasNext()) { String[] line = scanner.nextLine().split(" "); if (line[0].equalsIgnoreCase("help")) { // 帮助 } else if (line[0].equalsIgnoreCase("exit")) { // 退出 } else if (line.length == 2) { // 功能命令 switch (line[0].toLowerCase()) { case "user": // 用户相关命令 break; case "file": // 文件相关命令 break; default: // 未知命令 break; } else { // 未知命令 } System.out.print(getPromptLine(currentUser, hostname)); } } }一上来有一个工具方法getPromptLine帮我们拼接出一个提示符，如果当前登录的用户为空，说明没有人登录，就把用户名替换成nobody，然后在解析命令之前先把这个提示符打印出来，像下面这样：nobody@FSS> user login root@FSS> file list这样一个优化能够让简陋的命令行一下子上档次不少。主函数一上来要先准备数据文件，如果数据文件不存在就要初始化一个，并且提供用户默认账户的信息。之后开始命令的解析工作帮助这是系统中两个特殊命令之一。通过忽略大小写判断，如果用户在命令行内输入的内容等于help，那么就打印一个帮助信息，告诉用户这个系统都支持什么命令：System.out.println("user list \t\t 列出所有已知用户"); System.out.println("user create \t 创建新用户（仅限root）"); System.out.println("user delete \t 删除已有用户（仅限root）"); System.out.println("user login \t\t 登录（仅未登录时）"); System.out.println("user logout \t 登出（仅登录时）"); System.out.println("user chpasswd \t 修改密码（仅登录时）"); System.out.println("-".repeat(50)); System.out.println("file list \t\t 列出文件（仅登录时）"); System.out.println("file create \t 创建文件（仅登录时）"); System.out.println("file delete \t 删除文件（仅登录时）"); System.out.println("file open \t\t 打开文件（仅登录时）"); System.out.println("file close \t\t 关闭文件（仅登录时）"); System.out.println("file read \t\t 读文件（仅登录时）"); System.out.println("file write \t\t 写文件（仅登录时）"); System.out.println("file cutoff \t 截断文件（仅登录时）"); System.out.println("-".repeat(50)); System.out.println("help \t\t\t 打印帮助信息"); System.out.println("exit \t\t\t 退出系统");就是一堆打印命令。退出系统这个是系统中第二个特殊命令。如果用户输入了exit，那么就退出系统：System.out.println("Bye!"); return;因为在主函数里，所以直接打印一个Bye!之后return，主函数结束，系统自然就退出了。功能命令这部分是实际执行一些操作的命令。由于规定这些命令都是两个单词构成，所以只要判断单词的个数就能确定是这一类命令：if (line[0].equalsIgnoreCase("help")) { // 帮助 } else if (line[0].equalsIgnoreCase("exit")) { // 退出 } else if (line.length == 2) { // 功能命令 switch (line[0].toLowerCase()) { case "user": // 用户相关命令 break; case "file": // 文件相关命令 break; default: // 未知命令 System.out.println("未知命令"); break; } else { // 未知命令 System.out.println("命令格式有误"); }所有用户相关的命令都以user开头，所有文件相关的命令都由file开头，所以直接一个switch语句就可以将二者区分开。随后为了提高鲁棒性，加一个default处理所有不认识的命令。用户相关命令直接看第二个字段就可以判断出命令：case "user": switch (line[1].toLowerCase()) { case "list": { // 列出当前所有用户 break; } case "create": { // 创建 break; } case "delete": { // 删除 break; } case "login": { // 登录 break; } case "logout": { // 登出 break; } case "chpasswd": { // 修改密码 break; } default: { System.out.println("未知命令"); break; } } break;列出用户case "list": { // 列出当前所有用户 var userTable = simulator.readUserTable(); var usernames = userTable.listUsername(); for (String username : usernames) { System.out.println(username); } break; }通过文件系统读出UserTable，然后列出用户名一一打印即可。创建用户case "create": { // 创建 if (currentUser.equals("root")) { var userTable = simulator.readUserTable(); var username = ""; System.out.print("请输入用户名："); username = scanner.nextLine(); if (username.isBlank()) { System.out.println("用户名不可为空"); break; } if (userTable.get(username) != null) { System.out.println("用户已存在"); break; } System.out.print("请输入密码："); var password = scanner.nextLine(); var result = simulator.createUser(username, password); if (result) { System.out.println("用户创建成功"); } else { System.out.println("创建用户失败，请重试"); } } else { System.out.println("请使用root账户登录后重试"); } break; }创建用户要求root账户操作，所以要先检查当前登陆的用户是否为root。不是则直接进入else分支打印提示信息。对于创建流程来说，询问用户意图创建的用户名，然后检查用户名是否为空、用户名是否已经存在。如果用户名不满足条件，直接结束当前语句的执行。一开始是使用do..while来做的，后来认为如果用户中途改变主意不想新增用户了，那么没有办法退出。因此只要不满足条件，直接打印失败信息结束命令。对于密码则没有那么多要求，如果高兴的话密码为空也不是不行。最后调用文件系统创建用户，并根据文件系统的结果打印成功或失败的信息。删除用户case "delete": { // 删除 if (currentUser.equals("root")) { var userTable = simulator.readUserTable(); var username = ""; System.out.print("请输入用户名："); username = scanner.nextLine(); if (userTable.get(username) == null) { System.out.println("用户不存在"); break; } var result = simulator.deleteUser(username); if (result) { // 删除成功回收打开的文件 for (String filename : queryAllOpenedFilename(username)) { closeFile(username, filename); } System.out.println("用户删除成功"); } else { System.out.println("删除用户失败，请重试"); } } else { System.out.println("请使用root账户登录后重试"); } break; }删除用户同样要求root操作。如果用户名不存在则直接打印提示并结束命令。否则删除用户，如果删除成功，再回收被删除用户已经打开的文件，这部分内容将在后面打开或关闭文件的时候提到。用户登录case "login": { // 登录 if (!currentUser.isBlank()) { System.out.println("已登录，请先登出"); } else { var userTable = simulator.readUserTable(); User user; System.out.print("请输入用户名："); var username = scanner.nextLine(); user = userTable.get(username); if (user == null) { System.out.println("找不到用户"); break; } System.out.print("请输入用户密码："); var password = scanner.nextLine(); if (user.getPassword().equals(password)) { currentUser = user.getUsername(); System.out.println("登陆成功"); } else { System.out.println("密码错误，请重试"); } } break; }对于用户登录，在逻辑上已经登陆的用户不应该再次登录，所以只有已登录用户名为空的时候才允许登录。首先询问用户名，如果找不到用户则直接结束。找到了就继续询问密码，如果输入的密码和记录的密码相匹配，则成功登录。顺带一说：这里使用的equals方法来判断相等，实践上并不安全。一般来说使用XOR做对比，如果结果全0就说明相等，它与equals的区别在于后者会在第一个不相等的地方返回，这样攻击者可以根据运算时间的变化猜测正确的密码，而XOR则是要全部过一遍才能得出结果，从运行时间上并不能判断出是哪一位出错。但是由于这个系统偏向演示，所以只用equals就足以了。用户登出case "logout": { // 登出 if (currentUser.isBlank()) { System.out.println("请先登录"); } else { currentUser = ""; } break; }有登录就得有登出，登出的话就得先登录。具体操作就是把当前已登录的用户名置为空。修改密码case "chpasswd": { // 修改密码 if (currentUser.isBlank()) { System.out.println("请先登录"); } else { var userTable = simulator.readUserTable(); User user = userTable.get(currentUser); if (user == null) { System.out.println("未找到当前已登录用户信息，已自动登出，请重新登陆后尝试"); currentUser = ""; break; } else { var password = ""; System.out.print("请输入旧密码："); password = scanner.nextLine(); if (!user.getPassword().equals(password)) { System.out.println("旧密码错误，请重试"); break; } System.out.print("请输入新密码："); password = scanner.nextLine(); var result = simulator.changePassword(user.getUsername(), password); if (result) { System.out.println("修改密码成功"); } else { System.out.println("修改密码失败，请重试"); } } }虽然实验要求没说要有改密码的功能，但代码都写了，不做浪费了。改密码也很简单，首先得要求用户登录，其次要验证旧密码。为了避免意料之外的事情，先要确保当前登录的用户是存在的，如果不存在直接自动登出。旧密码验证失败也直接结束命令的执行。旧密码验证成功之后，询问新密码并调用文件系统做出相应修改。文件相关命令文件相关的命令会有一些共同的操作，例如获取当前用户的目录文件Inode等，因此在开始指令之前，先把这些公用的东西获取出来：case "file": if (currentUser.isBlank()) { System.out.println("请先登录"); } else { var user = simulator.readUserTable().get(currentUser); if (user == null) { System.out.println("找不到当前登录用户的信息，已自动登出，请重新登陆后重试"); currentUser = ""; break; } var homeInode = simulator.readInodeTable().get(user.getHomeInodeIndex()); switch (line[1].toLowerCase()) { case "list": { // 列出当前所文件 break; } case "create": { // 创建一个空文件 break; } case "delete": { // 删除一个文件 break; } case "open": { // 打开一个文件 break; } case "close": { // 关闭一个文件 break; } case "read": { // 读一个文件 break; } case "write": { // 写一个文件 break; } case "cutoff": { // 截断一个文件 break; } default: System.out.println("未知命令"); break; } } break;如果用户当前还没有登陆，使用文件相关的命令时会直接提示。如果用户的Inode找不到，说明用户不存在，直接自动登出并提示用户。之后开始正常的命令解析：列出文件case "list": { // 列出当前所文件 var indexEntry = new IndexEntry(); var entryCount = homeInode.getSize() / indexEntry.byteCount(); var openedFilename = queryAllOpenedFilename(currentUser); var inodeTable = simulator.readInodeTable(); simulator.readSomeIndexEntry(homeInode, 0, entryCount) .forEach((f) -> { System.out.print("文件名：" + f.getFileName()); if (openedFilename.contains(f.getFileName())) { System.out.print("，已打开"); } System.out.println("，大小" + inodeTable.get(f.getInodeIndex()).getSize() + "B"); }); break; }列出文件使用读取目录项的方法。同时为了标记已经打开的文件，还会查询该用户所有已经打开的文件名并加以标注。最后每个文件还会打印它的大小。创建文件case "create": { // 创建一个空文件 var filename = ""; System.out.print("请输入文件名："); filename = scanner.nextLine(); if (filename.isBlank()) { System.out.println("文件名不可为空"); break; } if (simulator.containsFile(homeInode, filename)) { System.out.println("文件已存在"); break; } var result = simulator.createFile(currentUser, filename); if (result) { System.out.println("创建成功"); } else { System.out.println("创建失败"); } break; }创建文件默认创建一个空白文件，不分配任何盘块。同样还是询问文件名，如果文件名已经存在或者为空，直接结束命令执行。通过检查则调用文件系统创建文件，并根据结果打印相关信息。删除文件case "delete": { // 删除一个文件 var filename = ""; System.out.print("请输入文件名："); filename = scanner.nextLine(); if (!simulator.containsFile(homeInode, filename)) { System.out.println("文件不存在"); break; } if (queryOpenedFile(currentUser, filename) != null) { System.out.println("文件已打开，请先关闭再删除"); break; } var result = simulator.deleteFile(currentUser, filename); if (result) { System.out.println("删除成功"); } else { System.out.println("删除失败"); } break; }删除文件也是一样，如果文件不存在，或者已经打开，则拒绝删除。否则调用文件系统删除文件。打开文件case "open": { // 打开一个文件 var filename = ""; System.out.print("请输入文件名："); filename = scanner.nextLine(); if (!simulator.containsFile(homeInode, filename)) { System.out.println("文件不存在"); break; } if (queryOpenedFile(currentUser, filename) != null) { System.out.println("文件已打开"); break; } var result = openFile(currentUser, filename); if (result) { System.out.println("文件打开成功"); } else { System.out.println("文件打开失败"); } break; }打开文件只允许打开已经存在且尚未打开的文件。相关工具方法如下 /** * 打开的文件，（（用户名，文件名），文件描述） */ private static final Map<Pair<String, String>, OpenedFile> openedFiles = new HashMap<>(); /** * 打开文件 */ public static boolean openFile(String username, String filename) { var user = simulator.readUserTable().get(username); if (user == null) { return false; } var inodeTable = simulator.readInodeTable(); var homeInode = inodeTable.get(user.getHomeInodeIndex()); // 不存在的文件直接返回false if (!simulator.containsFile(homeInode, filename)) { return false; } // 如果已经打开也直接返回 if (openedFiles.containsKey(new Pair<>(username, filename))) { return false; } openedFiles.put(new Pair<>(username, filename), new OpenedFile(simulator.findInodeByFilename(homeInode, filename))); return true; } /** * 关闭文件 */ public static boolean closeFile(String username, String filename) { // 如果没打开直接返回 if (!openedFiles.containsKey(new Pair<>(username, filename))) { return false; } openedFiles.remove(new Pair<>(username, filename)); return true; } /** * 通过用户名和文件名查询已打开的文件描述 */ public static OpenedFile queryOpenedFile(String username, String filename) { // 如果没打开直接返回 if (!openedFiles.containsKey(new Pair<>(username, filename))) { return null; } return openedFiles.get(new Pair<>(username, filename)); } /** * 获取已经打开的文件名 */ public static List<String> queryAllOpenedFilename(String username) { return openedFiles.keySet().stream().filter(p -> p.getFirst().equals(username)).map(Pair::getSecond).collect(Collectors.toList()); }打开文件的核心就是将打开的文件信息存储在内存中。打开的文件信息包括文件的Inode引用和一个当前读写指针的位置。后者将提供随机访问的特性。关闭文件case "close": { // 关闭一个文件 var filename = ""; System.out.print("请输入文件名："); filename = scanner.nextLine(); if (queryOpenedFile(currentUser, filename) == null) { System.out.println("文件未打开"); break; } var result = closeFile(currentUser, filename); if (result) { System.out.println("文件关闭成功"); } else { System.out.println("文件关闭失败"); } break; }文件打开了就得关闭，要不然会造成内存泄漏。关闭只允许关闭已经打开的文件。操作上就是将对应的信息从内存中移除。读文件case "read": { // 读一个文件 var filename = ""; System.out.print("请输入文件名："); filename = scanner.nextLine(); var openedFile = queryOpenedFile(currentUser, filename); if (openedFile == null) { System.out.println("文件未打开"); break; } var delta = 0L; System.out.println("当前文件描述符指针在" + openedFile.getPos()); System.out.println("文件大小" + openedFile.getInode().getSize() + "B"); if (openedFile.getPos() >= openedFile.getInode().getSize()) { System.out.println("已到达文件尾。"); } System.out.print("请输入相对偏移量："); delta = scanner.nextLong(); scanner.nextLine(); // 更新偏移量 openedFile.setPos(openedFile.getPos() + delta); System.out.println("移动后指针：" + openedFile.getPos()); var length = 0L; System.out.print("请输入要读取的长度："); length = scanner.nextLong(); scanner.nextLine(); if (length <= 0) { System.out.println("长度必须大于0"); } try { var bytes = simulator.readFile(openedFile.getInode(), openedFile.getPos(), length); System.out.println(new String(bytes, StandardCharsets.UTF_8)); openedFile.setPos(openedFile.getPos() + bytes.length); System.out.println("读取后文件指针：" + openedFile.getPos()); } catch (Exception e) { System.out.println("读取失败：" + e.getMessage()); } break; }读文件要求只能读打开的文件，原因是未打开的文件没有读写指针，不知道从哪里开始读。读取的时候会根据提供的文件名寻找已打开的文件信息，并提示用户操作读写指针进行随机读写。由于这个系统很简陋，所以唯一能读写的东西就是字符。在读取部分简单的包了个异常处理，因为文件系统那里会对传入参数进行检查，如果检查失败直接抛出异常崩溃JVM，这个异常处理要做的就是拦截异常，将其信息打印给用户，然后结束指令。写文件case "write": { // 写一个文件 var filename = ""; System.out.print("请输入文件名："); filename = scanner.nextLine(); var openedFile = queryOpenedFile(currentUser, filename); if (openedFile == null) { System.out.println("文件未打开"); break; } var delta = 0L; System.out.println("当前文件描述符指针在" + openedFile.getPos()); System.out.println("文件大小" + openedFile.getInode().getSize() + "B"); if (openedFile.getPos() >= openedFile.getInode().getSize()) { System.out.println("已到达文件尾"); } System.out.print("请输入相对偏移量："); delta = scanner.nextLong(); scanner.nextLine(); // 更新偏移量 openedFile.setPos(openedFile.getPos() + delta); System.out.println("移动后指针：" + openedFile.getPos()); var length = 0L; System.out.println("请在下方输入要写入的内容，单独一行'$EOF'表示结束："); // 打印一个字符避免退格时回到已经写入的上一行 System.out.print(">"); var inputLine = scanner.nextLine(); try { while (!inputLine.equals("$EOF")) { if (length != 0) { // 不是第一行，向前面行追加回车 inputLine = String.format("\n%s", inputLine); } var bytes = inputLine.getBytes(StandardCharsets.UTF_8); var result = simulator.writeFile(openedFile.getInode(), openedFile.getPos() + length, bytes); if (!result) { System.out.println("写入失败！"); break; } // 更新长度 length += bytes.length; // 读下一行 System.out.print(">"); inputLine = scanner.nextLine(); } } catch (Exception e) { System.out.println("写入失败：" + e.getMessage()); } System.out.println("已写入" + length + "B"); openedFile.setPos(openedFile.getPos() + length); System.out.println("当前文件指针：" + openedFile.getPos()); break; }写文件的交互与读文件的类似。写入支持多行，单行的$EOF表示结束。如果中途写入失败，通常是指针越界，文件系统会直接抛出异常。这个异常同样会被异常处理拦截并转换成错误信息。截断文件case "cutoff": { // 截断一个文件 var filename = ""; System.out.print("请输入文件名："); filename = scanner.nextLine(); var openedFile = queryOpenedFile(currentUser, filename); if (openedFile == null) { System.out.println("文件未打开"); break; } System.out.println("文件大小" + openedFile.getInode().getSize() + "B"); if (openedFile.getPos() >= openedFile.getInode().getSize()) { System.out.println("已到达文件尾"); } var newSize = 0L; System.out.print("请输入新的大小："); newSize = scanner.nextLong(); scanner.nextLine(); if (newSize > openedFile.getInode().getSize()) { System.out.println("新大小不能超过原来的大小"); break; } simulator.cutoffFile(openedFile.getInode(), newSize); System.out.println("截断文件成功"); break; }就是询问文件的新大小，并调用文件系统的相关功能。效果未检测到数据文件，开始初始化。 已创建默认用户：root，密码：password nobody@FSS> user login 请输入用户名：root 请输入用户密码：123 密码错误，请重试 nobody@FSS> user login 请输入用户名：root 请输入用户密码：password 登陆成功 root@FSS> file list root@FSS> file create 请输入文件名：123 创建成功 root@FSS> file open 请输入文件名：123 文件打开成功 root@FSS> file list 文件名：123，已打开，大小0B root@FSS> file write 请输入文件名：123 当前文件描述符指针在0 文件大小0B 已到达文件尾 请输入相对偏移量：1 移动后指针：1 请在下方输入要写入的内容，单独一行'$EOF'表示结束： >123 写入失败：Offset must not bigger than size. 已写入0B 当前文件指针：1 root@FSS> file write 请输入文件名：123 当前文件描述符指针在1 文件大小0B 已到达文件尾 请输入相对偏移量：-1 移动后指针：0 请在下方输入要写入的内容，单独一行'$EOF'表示结束： >1234567890 >abc > >$EOF 已写入15B 当前文件指针：15 root@FSS> file read 请输入文件名：123 当前文件描述符指针在15 文件大小15B 已到达文件尾。 请输入相对偏移量：-15 移动后指针：0 请输入要读取的长度：15 1234567890 abc 读取后文件指针：15 root@FSS> file close 请输入文件名：1234 文件未打开 root@FSS> file close 请输入文件名：123 文件关闭成功 root@FSS> exit Bye! 这个效果还不错，至少我简单的测试了一下，没有意料之外的报错。后来请同学以「让这个系统崩溃」为目标可劲儿的尝试，最终看起来代码没有比较严重的bug，也没有与预期不相符的行为。总结今天程序顺利通过验机，这个系列也就此告一段落。这个实验在11月3号开始写，到12月3号写完，正好一个月。实话说这个程序虽然实用性不强，但是其中用到的许多设计想法都是我未曾在实用程序中实践过的。例如用户输入的条件不满足直接抛异常崩JVM，我大概永远不敢在重要的程序上这样做，通常有一个default会有用很多。另外一个例子就是整个Wrapped的构造，平时写代码的时候别说字节数组了，Serializable都很少用，直接一个Gson或者Jackson，反射就把这事儿做了，我也无须关心底层是怎么实现的（当然了，用了Gson打完包程序上百MB就是另一个故事了）。以及直接使用RandomAccessFile像文件系统那样将数据定位到字节的精度去操作，这种深度还是我头一次用Java做。无论如何，这个项目很顺利的完成了。本文作为这个系列的最后一篇文章，实现了人机交互界面，并最终让这个系统运行了起来。附录：完整代码package info.skyblond.os.exp.experiment3; import info.skyblond.os.exp.experiment3.model.IndexEntry; import info.skyblond.os.exp.experiment3.model.OpenedFile; import info.skyblond.os.exp.experiment3.model.User; import info.skyblond.os.exp.utils.Pair; import java.io.File; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Scanner; import java.util.stream.Collectors; /** * 文件系统模拟器的主入口 * 模仿一个简单的操作系统，利用文件系统进行用户鉴权、文件操作 */ public class Exp3 { private static final Simulator simulator = Simulator.getInstance(); /** * 打开的文件，（（用户名，文件名），文件描述） */ private static final Map<Pair<String, String>, OpenedFile> openedFiles = new HashMap<>(); /** * 打开文件 */ public static boolean openFile(String username, String filename) { var user = simulator.readUserTable().get(username); if (user == null) { return false; } var inodeTable = simulator.readInodeTable(); var homeInode = inodeTable.get(user.getHomeInodeIndex()); // 不存在的文件直接返回false if (!simulator.containsFile(homeInode, filename)) { return false; } // 如果已经打开也直接返回 if (openedFiles.containsKey(new Pair<>(username, filename))) { return false; } openedFiles.put(new Pair<>(username, filename), new OpenedFile(simulator.findInodeByFilename(homeInode, filename))); return true; } /** * 关闭文件 */ public static boolean closeFile(String username, String filename) { // 如果没打开直接返回 if (!openedFiles.containsKey(new Pair<>(username, filename))) { return false; } openedFiles.remove(new Pair<>(username, filename)); return true; } /** * 通过用户名和文件名查询已打开的文件描述 */ public static OpenedFile queryOpenedFile(String username, String filename) { // 如果没打开直接返回 if (!openedFiles.containsKey(new Pair<>(username, filename))) { return null; } return openedFiles.get(new Pair<>(username, filename)); } /** * 获取已经打开的文件名 */ public static List<String> queryAllOpenedFilename(String username) { return openedFiles.keySet().stream().filter(p -> p.getFirst().equals(username)).map(Pair::getSecond).collect(Collectors.toList()); } public static String getPromptLine(String username, String hostname) { if (username.isBlank()) { return "nobody@" + hostname + "> "; } else { return username + "@" + hostname + "> "; } } public static void main(String[] args) { var dataFile = new File("./data.bin"); simulator.setDataFile(dataFile); if (!dataFile.exists()) { System.out.println("未检测到数据文件，开始初始化。"); simulator.initDataFile(); System.out.println("已创建默认用户：root，密码：" + Simulator.ROOT_DEFAULT_PASSWORD); } var hostname = "FSS"; // var currentUser = "root"; // 默认root用户，调试用 var currentUser = ""; // prompt System.out.print(getPromptLine(currentUser, hostname)); Scanner scanner = new Scanner(System.in); while (scanner.hasNext()) { String[] line = scanner.nextLine().split(" "); if (line[0].equalsIgnoreCase("help")) { // 帮助 System.out.println("user list \t\t 列出所有已知用户"); System.out.println("user create \t 创建新用户（仅限root）"); System.out.println("user delete \t 删除已有用户（仅限root）"); System.out.println("user login \t\t 登录（仅未登录时）"); System.out.println("user logout \t 登出（仅登录时）"); System.out.println("user chpasswd \t 修改密码（仅登录时）"); System.out.println("-".repeat(50)); System.out.println("file list \t\t 列出文件（仅登录时）"); System.out.println("file create \t 创建文件（仅登录时）"); System.out.println("file delete \t 删除文件（仅登录时）"); System.out.println("file open \t\t 打开文件（仅登录时）"); System.out.println("file close \t\t 关闭文件（仅登录时）"); System.out.println("file read \t\t 读文件（仅登录时）"); System.out.println("file write \t\t 写文件（仅登录时）"); System.out.println("file cutoff \t 截断文件（仅登录时）"); System.out.println("-".repeat(50)); System.out.println("help \t\t\t 打印帮助信息"); System.out.println("exit \t\t\t 退出系统"); } else if (line[0].equalsIgnoreCase("exit")) { // 退出 System.out.println("Bye!"); return; } else if (line.length == 2) { switch (line[0].toLowerCase()) { case "user": switch (line[1].toLowerCase()) { case "list": { // 列出当前所有用户 var userTable = simulator.readUserTable(); var usernames = userTable.listUsername(); for (String username : usernames) { System.out.println(username); } break; } case "create": { // 创建 if (currentUser.equals("root")) { var userTable = simulator.readUserTable(); var username = ""; System.out.print("请输入用户名："); username = scanner.nextLine(); if (username.isBlank()) { System.out.println("用户名不可为空"); break; } if (userTable.get(username) != null) { System.out.println("用户已存在"); break; } System.out.print("请输入密码："); var password = scanner.nextLine(); var result = simulator.createUser(username, password); if (result) { System.out.println("用户创建成功"); } else { System.out.println("创建用户失败，请重试"); } } else { System.out.println("请使用root账户登录后重试"); } break; } case "delete": { // 删除 if (currentUser.equals("root")) { var userTable = simulator.readUserTable(); var username = ""; System.out.print("请输入用户名："); username = scanner.nextLine(); if (userTable.get(username) == null) { System.out.println("用户不存在"); break; } var result = simulator.deleteUser(username); if (result) { // 删除成功回收打开的文件 for (String filename : queryAllOpenedFilename(username)) { closeFile(username, filename); } System.out.println("用户删除成功"); } else { System.out.println("删除用户失败，请重试"); } } else { System.out.println("请使用root账户登录后重试"); } break; } case "login": { // 登录 if (!currentUser.isBlank()) { System.out.println("已登录，请先登出"); } else { var userTable = simulator.readUserTable(); User user; System.out.print("请输入用户名："); var username = scanner.nextLine(); user = userTable.get(username); if (user == null) { System.out.println("找不到用户"); break; } System.out.print("请输入用户密码："); var password = scanner.nextLine(); if (user.getPassword().equals(password)) { currentUser = user.getUsername(); System.out.println("登陆成功"); } else { System.out.println("密码错误，请重试"); } } break; } case "logout": { // 登出 if (currentUser.isBlank()) { System.out.println("请先登录"); } else { currentUser = ""; } break; } case "chpasswd": { // 修改密码 if (currentUser.isBlank()) { System.out.println("请先登录"); } else { var userTable = simulator.readUserTable(); User user = userTable.get(currentUser); if (user == null) { System.out.println("未找到当前已登录用户信息，已自动登出，请重新登陆后尝试"); currentUser = ""; break; } else { var password = ""; System.out.print("请输入旧密码："); password = scanner.nextLine(); if (!user.getPassword().equals(password)) { System.out.println("旧密码错误，请重试"); break; } System.out.print("请输入新密码："); password = scanner.nextLine(); var result = simulator.changePassword(user.getUsername(), password); if (result) { System.out.println("修改密码成功"); } else { System.out.println("修改密码失败，请重试"); } } } break; } default: { System.out.println("未知命令"); break; } } break; case "file": if (currentUser.isBlank()) { System.out.println("请先登录"); } else { var user = simulator.readUserTable().get(currentUser); if (user == null) { System.out.println("找不到当前登录用户的信息，已自动登出，请重新登陆后重试"); currentUser = ""; break; } var homeInode = simulator.readInodeTable().get(user.getHomeInodeIndex()); switch (line[1].toLowerCase()) { case "list": { // 列出当前所文件 var indexEntry = new IndexEntry(); var entryCount = homeInode.getSize() / indexEntry.byteCount(); var openedFilename = queryAllOpenedFilename(currentUser); var inodeTable = simulator.readInodeTable(); simulator.readSomeIndexEntry(homeInode, 0, entryCount) .forEach((f) -> { System.out.print("文件名：" + f.getFileName()); if (openedFilename.contains(f.getFileName())) { System.out.print("，已打开"); } System.out.println("，大小" + inodeTable.get(f.getInodeIndex()).getSize() + "B"); }); break; } case "create": { // 创建一个空文件 var filename = ""; System.out.print("请输入文件名："); filename = scanner.nextLine(); if (filename.isBlank()) { System.out.println("文件名不可为空"); break; } if (simulator.containsFile(homeInode, filename)) { System.out.println("文件已存在"); break; } var result = simulator.createFile(currentUser, filename); if (result) { System.out.println("创建成功"); } else { System.out.println("创建失败"); } break; } case "delete": { // 删除一个文件 var filename = ""; System.out.print("请输入文件名："); filename = scanner.nextLine(); if (!simulator.containsFile(homeInode, filename)) { System.out.println("文件不存在"); break; } if (queryOpenedFile(currentUser, filename) != null) { System.out.println("文件已打开，请先关闭再删除"); break; } var result = simulator.deleteFile(currentUser, filename); if (result) { System.out.println("删除成功"); } else { System.out.println("删除失败"); } break; } case "open": { // 打开一个文件 var filename = ""; System.out.print("请输入文件名："); filename = scanner.nextLine(); if (!simulator.containsFile(homeInode, filename)) { System.out.println("文件不存在"); break; } if (queryOpenedFile(currentUser, filename) != null) { System.out.println("文件已打开"); break; } var result = openFile(currentUser, filename); if (result) { System.out.println("文件打开成功"); } else { System.out.println("文件打开失败"); } break; } case "close": { // 关闭一个文件 var filename = ""; System.out.print("请输入文件名："); filename = scanner.nextLine(); if (queryOpenedFile(currentUser, filename) == null) { System.out.println("文件未打开"); break; } var result = closeFile(currentUser, filename); if (result) { System.out.println("文件关闭成功"); } else { System.out.println("文件关闭失败"); } break; } case "read": { // 读一个文件 var filename = ""; System.out.print("请输入文件名："); filename = scanner.nextLine(); var openedFile = queryOpenedFile(currentUser, filename); if (openedFile == null) { System.out.println("文件未打开"); break; } var delta = 0L; System.out.println("当前文件描述符指针在" + openedFile.getPos()); System.out.println("文件大小" + openedFile.getInode().getSize() + "B"); if (openedFile.getPos() >= openedFile.getInode().getSize()) { System.out.println("已到达文件尾。"); } System.out.print("请输入相对偏移量："); delta = scanner.nextLong(); scanner.nextLine(); // 更新偏移量 openedFile.setPos(openedFile.getPos() + delta); System.out.println("移动后指针：" + openedFile.getPos()); var length = 0L; System.out.print("请输入要读取的长度："); length = scanner.nextLong(); scanner.nextLine(); if (length <= 0) { System.out.println("长度必须大于0"); } try { var bytes = simulator.readFile(openedFile.getInode(), openedFile.getPos(), length); System.out.println(new String(bytes, StandardCharsets.UTF_8)); openedFile.setPos(openedFile.getPos() + bytes.length); System.out.println("读取后文件指针：" + openedFile.getPos()); } catch (Exception e) { System.out.println("读取失败：" + e.getMessage()); } break; } case "write": { // 写一个文件 var filename = ""; System.out.print("请输入文件名："); filename = scanner.nextLine(); var openedFile = queryOpenedFile(currentUser, filename); if (openedFile == null) { System.out.println("文件未打开"); break; } var delta = 0L; System.out.println("当前文件描述符指针在" + openedFile.getPos()); System.out.println("文件大小" + openedFile.getInode().getSize() + "B"); if (openedFile.getPos() >= openedFile.getInode().getSize()) { System.out.println("已到达文件尾"); } System.out.print("请输入相对偏移量："); delta = scanner.nextLong(); scanner.nextLine(); // 更新偏移量 openedFile.setPos(openedFile.getPos() + delta); System.out.println("移动后指针：" + openedFile.getPos()); var length = 0L; System.out.println("请在下方输入要写入的内容，单独一行'$EOF'表示结束："); // 打印一个字符避免退格时回到已经写入的上一行 System.out.print(">"); var inputLine = scanner.nextLine(); try { while (!inputLine.equals("$EOF")) { if (length != 0) { // 不是第一行，向前面行追加回车 inputLine = String.format("\n%s", inputLine); } var bytes = inputLine.getBytes(StandardCharsets.UTF_8); var result = simulator.writeFile(openedFile.getInode(), openedFile.getPos() + length, bytes); if (!result) { System.out.println("写入失败！"); break; } // 更新长度 length += bytes.length; // 读下一行 System.out.print(">"); inputLine = scanner.nextLine(); } } catch (Exception e) { System.out.println("写入失败：" + e.getMessage()); } System.out.println("已写入" + length + "B"); openedFile.setPos(openedFile.getPos() + length); System.out.println("当前文件指针：" + openedFile.getPos()); break; } case "cutoff": { // 截断一个文件 var filename = ""; System.out.print("请输入文件名："); filename = scanner.nextLine(); var openedFile = queryOpenedFile(currentUser, filename); if (openedFile == null) { System.out.println("文件未打开"); break; } System.out.println("文件大小" + openedFile.getInode().getSize() + "B"); if (openedFile.getPos() >= openedFile.getInode().getSize()) { System.out.println("已到达文件尾"); } var newSize = 0L; System.out.print("请输入新的大小："); newSize = scanner.nextLong(); scanner.nextLine(); if (newSize > openedFile.getInode().getSize()) { System.out.println("新大小不能超过原来的大小"); break; } simulator.cutoffFile(openedFile.getInode(), newSize); System.out.println("截断文件成功"); break; } default: System.out.println("未知命令"); break; } } break; default: System.out.println("未知命令"); break; } } else { System.out.println("命令格式有误"); } System.out.print(getPromptLine(currentUser, hostname)); } } }-全文完-
本系列将记述使用Java实现一个简单的二级文件系统的过程。这个项目是本学期专业课操作系统的第三个实验。上一篇文章说明了元数据的代码描述。本文将记述如何利用已经搭建好的代码构造一个简单的文件系统。架构为了避免单个代码文件过于冗长，我决定将文件系统和交互界面分开。其中文件系统使用单例模式，交互界面则是以主程序的方式，与命令行交互并调用文件系统完成用户意图的操作。文件系统模拟器的大体框架如下：package info.skyblond.os.exp.experiment3; import info.skyblond.os.exp.experiment3.model.*; import info.skyblond.os.exp.utils.CommonUtils; import java.io.*; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * 文件系统模拟器，提供对磁盘基本的读写和维护操作。 */ public class Simulator { private static Simulator instance; private Simulator() { } public static synchronized Simulator getInstance() { if (instance == null) { instance = new Simulator(); } return instance; } // TODO .... }如果说元数据的设计是自底向上（从底层的Inode描述到上层已打开的文件），那么文件系统的设计就是自顶向下，从便于测试的角度看，先设计用户相关的内容会比较方便，更进一步说，先是用户的创建、查询和登录，然后是修改密码和删除用户。实践中往往一个功能并不能单独做完，还需要暂时放下，把别的做完再来继续当前的功能。例如删除用户的操作涉及到删除用户的目录文件，因此也要递归的删除用户目录下的所有文件，这时候就要求文件删除的功能。在此之前还得有文件读写的功能（对文件的操作除了撤销Inode、释放盘块，还要读写目录文件）。抛开这些不谈，我们先说说最基本的问题：如何初始化一个磁盘？格式化为了方便后续代码并减少代码重复，本节的铺垫代码如下： /** * 数据文件 */ private File dataFile = null; public void setDataFile(File dataFile) { this.dataFile = dataFile; } /** * 返回一个随机访问文件，mode参见{@link RandomAccessFile} */ public RandomAccessFile getRandomAccessFile(String mode) { try { return new RandomAccessFile(this.dataFile, mode); } catch (Exception e) { throw new RuntimeException(e); } }首先使用一个File作为磁盘文件，默认为null，单例模式下可以通过setter手动指定文件位置。而之后需要随机读写，因此提供一个简单的成员方法getRandomAccessFile，根据给定模式返回一个数据文件的RandomAccessFile对象。铺垫的代码就这么多，下面是负责初始化的代码： /** * root用户默认密码 */ public static String ROOT_DEFAULT_PASSWORD = "password"; /** * 初始化一个数据文件，默认创建一个 */ public void initDataFile() { try { CommonUtils.require(this.dataFile.createNewFile(), "Cannot create new data file."); var randomAccessFile = this.getRandomAccessFile("rw"); randomAccessFile.seek(0); // 初始化足够大的文件 for (int i = 0; i < BitMap.BLOCK_NUM; i++) { randomAccessFile.write(new byte[Inode.BLOCK_SIZE]); } // SystemInfo初始化 var systemInfo = new SystemInfo( UserTable.getInstance().byteCount(), InodeTable.getInstance().byteCount() ); var totalBytes = 0; totalBytes += systemInfo.byteCount(); totalBytes += UserTable.getInstance().byteCount(); totalBytes += InodeTable.getInstance().byteCount(); totalBytes += BitMap.getInstance().byteCount(); var totalBlock = CommonUtils.divCeil(totalBytes, Inode.BLOCK_SIZE); // 设置被元数据占用的块为已使用 for (int i = 0; i <= totalBlock; i++) { BitMap.getInstance().set(i); } // 写入磁盘 randomAccessFile.seek(0); randomAccessFile.write(systemInfo.toBytes()); randomAccessFile.write(UserTable.getInstance().toBytes()); randomAccessFile.write(InodeTable.getInstance().toBytes()); randomAccessFile.write(BitMap.getInstance().toBytes()); randomAccessFile.close(); // 初始化root用户 CommonUtils.require(this.createUser("root", ROOT_DEFAULT_PASSWORD), "Cannot init user."); } catch (IOException e) { throw new RuntimeException(e); } }既然是初始化，就得先创建一个文件。文件已经存在的情况下直接报错，避免误删造成数据丢失，尽管这个简易磁盘也不会记录什么重要的东西。随后是初始化文件大小，我采用的方法是写空白块，原则上也可以使用RandomAccessFile#setLength来设定文件大小，但是JavaDoc说扩展后的文件内容是不保证的，因此我更偏向于空文件全0的做法。接着就是构造元数据了。UserTable和InodeTable默认应当为空，按照语义来说初始化应该在一切操作之前发生，自然这两个表也就应该是空的。如果不为空问题也不大，原则上也是有效数据。利用两个Table的大小（byteCount()）创建出SystemInfo。同时还要统计这些元数据（SystemInfo、UserTable、InodeTable和BitMap）总大小，然后在BitMap中计算出对应的块，避免后续创建文件的时候将元数据所在的块作为空闲块分配出去。然后就是将这些元数据写入磁盘。最后创建一个默认的root用户，该用户的默认密码硬编码到程序中。其中createUser方法会将它产生的修改写入磁盘。实际上在编写代码时，一开始是没有创建用户的方法的，最初的做法是将创建用户的操作直接展开写在那里。后来随着程序功能的完善，代码的不断重构，变成了现在文章中展现的样子。下面在说创建用户之前，先来写几个基础的读数据的方法。读元数据由于我们不能直接把整个磁盘读进内存操作，因此需要经常读写元数据。本节编写从磁盘读取元数据的代码： /** * 读磁盘的SystemInfo */ public SystemInfo readSystemInfo() { try (var inputStream = new FileInputStream(this.dataFile)) { // 直接读取最开头 var result = new SystemInfo(); var bytes = new byte[result.byteCount()]; CommonUtils.require(result.byteCount() == inputStream.read(bytes), "Bytes not enough"); result.loadFromBytes(bytes); return result; } catch (Exception e) { throw new RuntimeException(e); } } /** * 读取BitMap */ public BitMap readBitMap() { var systemInfo = this.readSystemInfo(); try (var randomAccessFile = this.getRandomAccessFile("r")) { randomAccessFile.seek(systemInfo.getBitmapAddr()); var bitmap = BitMap.getInstance(); var bytes = new byte[bitmap.byteCount()]; randomAccessFile.read(bytes); bitmap.loadFromBytes(bytes); return bitmap; } catch (Exception e) { throw new RuntimeException(e); } } /** * 读取InodeTable */ public InodeTable readInodeTable() { var systemInfo = this.readSystemInfo(); try (var randomAccessFile = this.getRandomAccessFile("r")) { randomAccessFile.seek(systemInfo.getInodeIndexTableAddr()); var table = InodeTable.getInstance(); var bytes = new byte[table.byteCount()]; randomAccessFile.read(bytes); table.loadFromBytes(bytes); return table; } catch (Exception e) { throw new RuntimeException(e); } } /** * 读取用户表 */ public UserTable readUserTable() { try (var randomAccessFile = this.getRandomAccessFile("r")) { var systemInfo = this.readSystemInfo(); randomAccessFile.seek(systemInfo.getUserIndexTableAddr()); var userTable = UserTable.getInstance(); var bytes = new byte[userTable.byteCount()]; randomAccessFile.read(bytes); userTable.loadFromBytes(bytes); return userTable; } catch (Exception e) { throw new RuntimeException(e); } }首先是读取SystemInfo，因为它在文件开头，所以直接使用流读出文件即可。并且这部分数据除了初始化之外，永远都是只读不写，因此不使用单例模式。UserTable、InodeTable和BitMap则是根据SystemInfo的信息从磁盘读出对应的数据，更新单例对象。这里有一个编码时的约束：只有直接被操作系统调用的函数才可以调用这三个函数，其他函数只能使用getInstance取得实例。这种约束的原因是避免修改被丢弃：假设我正在创建文件，首先我读取用户的目录文件，发现它恰好用完一个盘块，因此修改Inode申请了新的盘块并追加了目录项，后续又调用创建文件的工具方法，而该方法上来就从磁盘中读取数据更新InodeTable，而这时对于目录文件Inode的修改还未写入磁盘。这就造成了修改被丢弃，但是由于这三个方法和其他的工具方法都在一个类中，难以控制谁调用了谁没调用，因此只能采用人为约束的方式来限制调用。另外为了使用方便，避免出现下面的情况：readUserTable(); var userTable = UserTable.getInstance();这样的写法，默认读方法会返回已经更新的对象。说完了这些，下面可以说一说如何创建用户了。创建用户创建用户的代码如下： /** * 创建用户，true表示成功 */ public boolean createUser(String username, String password) { // 读取用户表 var userTable = this.readUserTable(); if (userTable.get(username) != null) { return false; } try (var randomAccessFile = this.getRandomAccessFile("rw")) { var inodeTable = this.readInodeTable(); var iIndex = inodeTable.requestNewInode(username); if (iIndex < 0) { return false; // 无可用Inode时返回false表示创建失败 } var systemInfo = this.readSystemInfo(); // 设置用户 var result = userTable.set(username, password, iIndex); // 更新变化 randomAccessFile.seek(systemInfo.getUserIndexTableAddr()); randomAccessFile.write(userTable.toBytes()); randomAccessFile.seek(systemInfo.getInodeIndexTableAddr()); randomAccessFile.write(inodeTable.toBytes()); // 返回用户最终创建成功与否 return result; } catch (IOException e) { throw new RuntimeException(e); } }由于用户的总数量有限制（还记得UserTable中的USER_NUM字段吗），因此创建用户不一定成功。所以使用返回值来告诉调用者是否成功，如果不成功就要报错提示用户，如果是自动化过程（例如初始化），则应该抛出异常。由于创建用户是操作系统会调用的方法，因此可以直接上来就读取硬盘数据更新内存，因此此时上一个操作已经完成，没有未写入的数据。之后再通过UserTable的set方法直接创建一个新用户，最后将修改写入磁盘即可。要注意的一点是set方法在用户存在的时候也会返回true，并且是更新用户。因此在操作系统中应当避免创建已存在用户的问题，否则逻辑上会出问题，在程序上也会出现不可达的Inode（原本的Inode索引被新的索引替换，而原本的Inode没有释放）。这里要额外说一嘴测试的事情。由于一开始代码都是自己编写的，任何未经测试的代码都是不可信的。所以并不能直接在JUnit中用自己的代码写，再用自己的代码读（代码指工具方法）。虽然在编写代码的时候是一边写文件系统，一边写交互界面（充当操作系统），并且一边测试。但对于这样的基础性代码，直接查看代码执行前后数据文件的变化就能直接了当的看出问题所在。我使用的是HxD，这个程序可以直接读二进制文件，所以我可以预期代码执行时应当改动哪些值，而数据文件在执行后应该是什么样子，再将预期与现实对比，即可完成测试。这些基础测试虽然需要手动验证，但一旦通过验证，这些基础的工具代码便不再修改，后续可以直接使用。创建完用户，按照语义应当是用户登录。但实际上文件系统并不关心这个问题，这是操作系统的事儿，因此本文也暂时先不关心这个动作，直接快进到删除用户。删除用户 /** * 删除用户，true表示成功 */ public boolean deleteUser(String username) { if (username.equals("root")) { return false; // 禁止删除root用户 } // 读取用户 var userTable = this.readUserTable(); var user = userTable.get(username); if (user == null) { return false; } try (var randomAccessFile = this.getRandomAccessFile("rw")) { var systemInfo = this.readSystemInfo(); // 读取Inode表、创建根文件 randomAccessFile.seek(systemInfo.getInodeIndexTableAddr()); var inodeTable = this.readInodeTable(); var homeInode = inodeTable.get(user.getHomeInodeIndex()); // 删除用户的文件 var entryCount = homeInode.getSize() / new IndexEntry().byteCount(); var files = this.readSomeIndexEntry(homeInode, 0, entryCount); for (IndexEntry f : files) { // 删除文件 this.deleteFile(username, f.getFileName()); } // 删除用户目录的inode inodeTable.delete(user.getHomeInodeIndex()); // 写入Inode更新 randomAccessFile.seek(systemInfo.getInodeIndexTableAddr()); randomAccessFile.write(inodeTable.toBytes()); // 设置用户 var result = userTable.delete(username); // 更新变化 randomAccessFile.seek(systemInfo.getUserIndexTableAddr()); randomAccessFile.write(userTable.toBytes()); // 返回最终结果 return result; } catch (IOException e) { throw new RuntimeException(e); } }在删除用户上限制就比较多了。首先不能删除root账户，它作为系统的唯一管理员，不应当且不能被删除。其次便是不能删除一个不存在的账户。如果以上两个检查都通过了，那么就读取用户表，找到用户目录Inode，删除已有的文件，最后释放用户目录文件的Inode，删除用户。在编码时我是最后实现的文件打开关闭，在那之前实现的文件读写。因此刚开始写到删除用户的时候这些代码和方法还都不存在。这是开发中一个很常见的现象，我的解决办法就是在对应的位置上放一个TODO，先把基本的功能过了，然后继续其他功能，完善之后再来整理、融合。所以一开始跟着本文走的时候，完全可以先把删除用户文件的部分换成TODO，等后面完成了对应的代码，再回过头来加上，再测试即可。最后对于用户部分，我们在写一个相对简单的功能：修改密码。修改密码这部分代码十分简单： /** * 修改密码 */ public boolean changePassword(String username, String newPassword) { // 读取用户表 var userTable = this.readUserTable(); var user = userTable.get(username); if (user == null) { return false; } // 设置用户 var result = userTable.set(username, newPassword, user.getHomeInodeIndex()); // 更新变化 try (var randomAccessFile = this.getRandomAccessFile("rw")) { var systemInfo = this.readSystemInfo(); randomAccessFile.seek(systemInfo.getUserIndexTableAddr()); randomAccessFile.write(userTable.toBytes()); // 返回用户最终创建成功与否 return result; } catch (IOException e) { throw new RuntimeException(e); } }首先要找出用户，然后修改密码，最后写入磁盘。下面来说一说文件相关的操作。即便是最简单的创建一个空白文件，除了对Inode的操作，还有对目录文件的读（查询避免重复）写（记录新文件）。因此最基础的文件读写应当首先实现。读文件在读写两个操作中，读文件相对简单一些： /** * 从指定位置开始读指定个数字节 */ public byte[] readFile(Inode target, long offset, long length) { CommonUtils.require(length > 0, "Length must bigger than 0."); CommonUtils.require(offset >= 0, "Offset must not smaller than 0."); CommonUtils.require(offset + length <= target.getSize(), "Read too many bytes."); var byteOutputStream = new ByteArrayOutputStream(); try (var randomAccessFile = this.getRandomAccessFile("r")) { var currentOffset = offset; while (byteOutputStream.size() < length) { // 寻找要读的盘块号 var blockNum = target.getBlockNumber(currentOffset); // 移动到盘块内要写入的位置 randomAccessFile.seek(blockNum * Inode.BLOCK_SIZE + currentOffset % Inode.BLOCK_SIZE); // 计算块内剩余可用空间 var availableSize = Inode.BLOCK_SIZE - (currentOffset % Inode.BLOCK_SIZE); var bytes = new byte[(int) Math.min(availableSize, length - byteOutputStream.size())]; var readCount = randomAccessFile.read(bytes); byteOutputStream.write(bytes); // 更新当前offset currentOffset += readCount; } } catch (Exception e) { throw new RuntimeException(e); } return byteOutputStream.toByteArray(); }读文件的作用就是无论文件数据分布在那些盘块上，调用者最终获得的数据一定是连续的。盘块的不连续应当在这个方法中被解决。读之前首先检查一些基础条件，诸如不能读负数个字节（读0个字节也被认为是没有意义的），不能从负数地址开始读，以及总读取的长度不能越界。通过了这些检查之后再开始读。读出来的数据使用ByteArrayOutputStream暂存。每次读的时候需要考虑两种情况：// 寻找要读的盘块号 var blockNum = target.getBlockNumber(currentOffset); // 移动到盘块内要写入的位置 randomAccessFile.seek(blockNum * Inode.BLOCK_SIZE + currentOffset % Inode.BLOCK_SIZE); // 计算块内剩余可用空间 var availableSize = Inode.BLOCK_SIZE - (currentOffset % Inode.BLOCK_SIZE); var bytes = new byte[(int) Math.min(availableSize, length - byteOutputStream.size())]; var readCount = randomAccessFile.read(bytes); byteOutputStream.write(bytes); // 更新当前offset currentOffset += readCount;先要找到当前文件内偏移量所在的盘块号，然后要决定读多少字节。如果读取的结束点不在当前盘块，那么盘块剩下多少字节直接读多少字节就行了（availableSize）；如果读取的结束点在当前盘块，那么应当根据剩余待读取的字节来（length - byteOutputStream.size()）。说完了读文件，下面我们来说一说写文件。追加文件写文件分成两个操作：一种是在文件后面追加，另一种则是覆盖已有数据。而一般意义上的写可以认为是先覆盖有的，再追加新的。追加文件的代码如下： /** * 向给定Inode追加数据，对于inode的修改不会写入磁盘，需要调用者处理 * 返回false失败时已有部分数据写入 */ public boolean appendFile(Inode target, byte[] data) { var byteInputStream = new ByteArrayInputStream(data); try (var randomAccessFile = this.getRandomAccessFile("rws")) { while (byteInputStream.available() > 0) { // 寻找最后一块盘块 var blockNum = target.getBlockNumber(target.getSize()); if (blockNum >= 0) { // 移动到盘块内要写入的位置 randomAccessFile.seek(blockNum * Inode.BLOCK_SIZE + target.getSize() % Inode.BLOCK_SIZE); } // 计算块内剩余可用空间 var availableSize = Inode.BLOCK_SIZE - (target.getSize() % Inode.BLOCK_SIZE); if (blockNum < 0) { // 如果盘块号小于0，说明没分配盘块 availableSize = 0; } if (availableSize > 0) { // 还有可用空间，先往里写 var bytes = new byte[(int) availableSize]; var readCount = byteInputStream.read(bytes); randomAccessFile.write(bytes); // 这里要保存实际写入的内容 target.setSize(target.getSize() + readCount); } else { // 已经写满了，申请一个新盘块 var bitmap = this.readBitMap(); // 寻找一个新的可用盘块 int newBlock = bitmap.nextAvailable(); if (newBlock < 0) { return false; } if (!target.appendBlock((short) newBlock)) { return false; } bitmap.set(newBlock); // 移动到新盘块的位置 randomAccessFile.seek((long) newBlock * Inode.BLOCK_SIZE); // 写数据 var bytes = new byte[Inode.BLOCK_SIZE]; var readCount = byteInputStream.read(bytes); randomAccessFile.write(bytes); // 这里要保存实际写入的数据量 target.setSize(target.getSize() + readCount); // 更新BitMap randomAccessFile.seek(this.readSystemInfo().getBitmapAddr()); randomAccessFile.write(bitmap.toBytes()); } } } catch (Exception e) { throw new RuntimeException(e); } return true; }对于追加文件，首先就要定位到文件的最后一个盘块。例如文件大小是4B，它占用的偏移量应该是0~3，那么我们直接寻到4，应该就是新追加数据的盘块。如果这个盘块号取出来是-1，说明这个盘块没有分配，我们需要向BitMap申请一块新的盘块，然后在这个盘块上写入数据；如果这个盘块号有效，还需要根据当前文件的大小计算出最后一个盘快内的剩余可用空间。最后写入时需要注意写入的上限是min(盘块可用空间，待写入数据大小)。本次写入完成后进入下一次循环，每次循环要不写完数据，要不就写满一块盘块。最后千万不要忘了更新Inode的大小字段，要不然写了白写。写文件由于覆盖文件比较简单，就和写文件合并了： /** * 向给定Offset写数据，重叠的部分直接覆盖，大于原有的部分直接调用appendFile。 * 其中Offset必须小于等于文件大小，这样超出部分才算是追加，不能跳过一段没有数据的部分直接读写 */ public boolean writeFile(Inode target, long offset, byte[] data) { CommonUtils.require(offset <= target.getSize(), "Offset must not bigger than size."); var byteInputStream = new ByteArrayInputStream(data); var result = true; try (var randomAccessFile = this.getRandomAccessFile("rw")) { var currentOffset = offset; while (byteInputStream.available() > 0) { if (currentOffset >= target.getSize()) { // 如果大于指定的大小了，直接追加 result = this.appendFile(target, byteInputStream.readAllBytes()); } else { // 正常写 // 寻找要写入的盘块号 var blockNum = target.getBlockNumber(currentOffset); // 这里不应该出现未分配的情况，如果offset指向未分配的地方 // 说明offset是不小于size的，那么应该在上面的if跳到appendFile // 移动到盘块内要写入的位置 randomAccessFile.seek(blockNum * Inode.BLOCK_SIZE + currentOffset % Inode.BLOCK_SIZE); // 计算块内剩余可用空间 var availableSize = Inode.BLOCK_SIZE - (currentOffset % Inode.BLOCK_SIZE); // 这里需要看是否为最后一块，最后一块应该将可用空间置为size-offset if (target.getSize() - currentOffset < Inode.BLOCK_SIZE) { availableSize = target.getSize() - currentOffset; } var bytes = new byte[(int) availableSize]; var readCount = byteInputStream.read(bytes); // 这里要限制写入的数量严格为读取的数量，避免覆盖了后面的内容 randomAccessFile.write(bytes, 0, readCount); // 覆盖不更新文件大小，只更新当前offset currentOffset += readCount; } } // 更新InodeTable randomAccessFile.seek(this.readSystemInfo().getInodeIndexTableAddr()); randomAccessFile.write(InodeTable.getInstance().toBytes()); } catch (Exception e) { throw new RuntimeException(e); } return result; }对于写，分成两种情况：如果是已有数据的覆盖，那么跟追加差不多，只不过不是最后一个盘块，而是文件中间的盘块。原则是一样的，每次要么把数据写完，要不只覆盖一个盘块。如果写着写着偏移量超过了文件的大小，这意味着接下来的数据要追加到文件尾部，那么直接调用上面的追加方法就是了。这里要注意的一个问题就是覆盖已有数据不修改文件的大小，只有追加才修改文件大小。有了读写文件的工具，下面我们就可以对目录文件进行操作，就可以查询、创建、删除文件了。查询文件在创建文件之前，我们得先知道有什么文件，这样才能避免用户创建重复的文件： /** * 读一些目录项，从第offset个开始读，读count个 */ public List<IndexEntry> readSomeIndexEntry(Inode homeInode, long offset, long count) { CommonUtils.require(offset >= 0, "Offset must not less than 0."); if (count <= 0) { return Collections.emptyList(); } var emptyIndexEntry = new IndexEntry(); var entryCount = homeInode.getSize() / emptyIndexEntry.byteCount(); CommonUtils.require(offset + count <= entryCount, "Accessing invalid index entry."); var result = new ArrayList<IndexEntry>(); // 一次性读出所有目录项 try (var byteInput = new ByteArrayInputStream(this.readFile(homeInode, offset * emptyIndexEntry.byteCount(), count * emptyIndexEntry.byteCount()))) { var bytes = new byte[emptyIndexEntry.byteCount()]; for (int i = 0; i < entryCount; i++) { var indexEntry = new IndexEntry(); byteInput.read(bytes); indexEntry.loadFromBytes(bytes); result.add(indexEntry); } } catch (Exception e) { throw new RuntimeException(e); } return result; }利用刚刚写的读文件方法，配合Wrapped的loadFromBytes，目录文件的数据很容易就被转换为了IndexEntry对象。每一个对象包含Inode索引和文件名，由此我们就可以知道目录下有哪些文件了。同时为了尽可能大的灵活性，我们可以指定从哪里开始读，读几个目录项。避免了遍历时一个一个读需要重复访问磁盘，也避免了只需要一个的时候要全部读入内存查找。创建文件知道了已经有什么文件，我们就可以创建文件了。在创建文件之前还要介绍一个小工具方法： /** * 判断目录内是否已经有某文件 */ public boolean containsFile(Inode homeInode, String filename) { var entryCount = homeInode.getSize() / new IndexEntry().byteCount(); return this.readSomeIndexEntry(homeInode, 0, entryCount).parallelStream().map(IndexEntry::getFileName).anyMatch((s) -> s.equals(filename)); }这个函数的作用就是读取全部的目录项，看看给定的文件名是否已经存在，然后返回一个Boolean。之后我们可以在创建文件之前利用这个方法检查重复文件名： /** * 创建空文件，true表示成功 */ public boolean createFile(String username, String filename) { var user = this.readUserTable().get(username); if (user == null) { return false; } var inodeTable = this.readInodeTable(); var homeInode = inodeTable.get(user.getHomeInodeIndex()); // 检测重复 if (this.containsFile(homeInode, filename)) { return false; } try (var randomAccessFile = this.getRandomAccessFile("rw")) { var iIndex = inodeTable.requestNewInode(homeInode.getOwner()); if (iIndex < 0) { return false; } // 准备新的目录项 var indexEntry = new IndexEntry(); indexEntry.setFileName(filename); indexEntry.setInodeIndex(iIndex); // 将新的目录项追加到用户目录文件的尾部 var result = this.appendFile(homeInode, indexEntry.toBytes()); // 更新Inode表 randomAccessFile.seek(this.readSystemInfo().getInodeIndexTableAddr()); randomAccessFile.write(inodeTable.toBytes()); return result; } catch (IOException e) { throw new RuntimeException(e); } }基本的条件检查就不说了，诸如文件名不能重复、用户名必须存在什么的。通过检查之后首先要申请一个新的Inode给待创建的文件，然后根据新Inode 的索引创建目录项，并把目录项追加到文件尾部。最后将InodeTable更新到磁盘上。这里在插入文件的时候偷了个懒，因此也就顺势规定目录文件是连续分配的，中间不能有空隙，因此创建文件的时候总是追加到文件尾部，删除的时候则是直接用最后一个目录项覆盖被删除的那个，然后减小文件大小就行了。删除文件如上所述，删除文件代码如下： /** * 删除文件，true表示成功 */ public boolean deleteFile(String username, String filename) { var user = this.readUserTable().get(username); if (user == null) { return false; } var inodeTable = this.readInodeTable(); var homeInode = inodeTable.get(user.getHomeInodeIndex()); // 先搜索被删除文件的目录项位置 var indexEntry = new IndexEntry(); var entryCount = homeInode.getSize() / indexEntry.byteCount(); var index = 0; IndexEntry lastEntry; try { for (; index < entryCount; index++) { indexEntry = this.readSomeIndexEntry(homeInode, index, 1).get(0); if (indexEntry.getFileName().equals(filename)) { break; } } if (index == entryCount) { return false; } // 读最后一项 lastEntry = this.readSomeIndexEntry(homeInode, (int) (entryCount - 1), 1).get(0); } catch (Exception e) { throw new RuntimeException(e); } // 释放被删除文件占用的块 var bitmap = this.readBitMap(); var deleted = inodeTable.get(indexEntry.getInodeIndex()); for (Short blockNum : deleted.getBlocks()) { // 在BitMap中依次更新被占用的块 bitmap.clear(blockNum); } // 释放Inode inodeTable.delete(indexEntry.getInodeIndex()); try (var randomAccessFile = this.getRandomAccessFile("rw")) { // 被删除的不是最后一个，用最后一个替换被删除的 var offset = index * indexEntry.byteCount(); if (index != entryCount - 1) { randomAccessFile.seek( homeInode.getBlockNumber(offset) * Inode.BLOCK_SIZE + offset % Inode.BLOCK_SIZE ); randomAccessFile.write(lastEntry.toBytes()); } // 截断文件 homeInode.setSize(homeInode.getSize() - indexEntry.byteCount()); // 释放截断后未占用的块 this.trimFile(bitmap, homeInode); // 更新 randomAccessFile.seek(this.readSystemInfo().getInodeIndexTableAddr()); randomAccessFile.write(inodeTable.toBytes()); randomAccessFile.seek(this.readSystemInfo().getBitmapAddr()); randomAccessFile.write(bitmap.toBytes()); return true; } catch (IOException e) { throw new RuntimeException(e); } }删除文件首先要找两个东西：一个是被删除的目录项的位置，另一个是最后一个目录项的内容。读出被删除的目录项，找出对应的Inode，释放其占用的盘块，最后撤销Inode。文件在磁盘上就被删除了，之后还需要更新目录项，直接用最后一个目录项的内容替换被删除的一项，最后截断文件，即直接把文件长度减去一个目录项的大小，最后还要将文件整理一下： /** * 整理文件，释放没有使用的块。需要调用者更新BitMap */ public void trimFile(BitMap bitmap, Inode target) { var blockNumNeeded = CommonUtils.divCeil(target.getSize(), Inode.BLOCK_SIZE); var actualBlockNum = target.getBlocks().size(); for (int i = 0; i < actualBlockNum - blockNumNeeded; i++) { var blockReleased = target.releaseLastBlock(); bitmap.clear(blockReleased); } }整理的目的是为了释放未占用的盘块。例如当前目录项占用了2个盘块，删除一个目录项后只需要占用一个盘块就能放下，那么我们需要释放多占用的那个盘块，虽然不释放也没什么问题，分配了早晚都能用上。操作上来说，需要根据实际大小计算出需要的盘块数，再和实际占用的盘块数计算差，把差出来的部分回收即可。最后我们的文件系统还需要支持一个操作：截断文件阶段文件的作用在于我想丢弃文件尾部的一部分数据时，可以直接通过修改文件长度的方法丢弃它。 /** * 截断文件，新长度必须小于等于当前长度 */ public void cutoffFile(Inode target, long newSize) { CommonUtils.require(newSize <= target.getSize(), "New size must not bigger than size."); try (var randomAccessFile = this.getRandomAccessFile("rw")) { target.setSize(newSize); this.trimFile(this.readBitMap(), target); // 更新InodeTable randomAccessFile.seek(this.readSystemInfo().getInodeIndexTableAddr()); randomAccessFile.write(InodeTable.getInstance().toBytes()); // 更新BitMap randomAccessFile.seek(this.readSystemInfo().getBitmapAddr()); randomAccessFile.write(BitMap.getInstance().toBytes()); } catch (Exception e) { throw new RuntimeException(e); } }没什么太多需要说明的，直接设定文件大小，然后整理便是了，最后把更新写入磁盘。根据文件名查找Inode这个对于文件的操作并不必要，但是后面我们实现人机交互界面的时候，需要处理文件打开和关闭，其中涉及到打开的文件需要持有一个Inode对象。这个方法就是给文件打开提供的：给定文件名，和用户的目录文件Inode，就可以查找到需要的文件的Inode对象。 /** * 根据filename寻找Inode */ public Inode findInodeByFilename(Inode homeInode, String filename) { var indexEntry = new IndexEntry(); var entryCount = homeInode.getSize() / indexEntry.byteCount(); var resultIndexEntry = this.readSomeIndexEntry(homeInode, 0, entryCount).parallelStream().filter((s) -> s.getFileName().equals(filename)); var inodeIndex = resultIndexEntry.findFirst().orElseThrow().getInodeIndex(); return InodeTable.getInstance().get(inodeIndex); }这里简单粗暴，直接把所有目录项读入内存，然后按文件名查找，最后根据找到的索引查找Inode。如果没有找到符合的目录项，直接抛出NoSuchElementException。总结至此我们已经完成了文件系统的代码构建。本文的内容较前文多，好在没什么复杂的，可能需要一段时间搞清楚原理，搞清原理之后再写代码就很顺畅了。至此文件系统部分就差不多完成了，下一篇文章作为这个系列的最终篇，将构建一个简单的人机交互界面充当操作系统，让这个文件系统能够投入使用。附录：完整代码清单package info.skyblond.os.exp.experiment3; import info.skyblond.os.exp.experiment3.model.*; import info.skyblond.os.exp.utils.CommonUtils; import java.io.*; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * 文件系统模拟器，提供对磁盘基本的读写和维护操作。 */ @SuppressWarnings("ResultOfMethodCallIgnored") public class Simulator { /** * root用户默认密码 */ public static String ROOT_DEFAULT_PASSWORD = "password"; // 使用单例模式 private static Simulator instance; private Simulator() { } public static synchronized Simulator getInstance() { if (instance == null) { instance = new Simulator(); } return instance; } /** * 数据文件 */ private File dataFile = null; public void setDataFile(File dataFile) { this.dataFile = dataFile; } /** * 返回一个随机访问文件，mode参见{@link RandomAccessFile} */ public RandomAccessFile getRandomAccessFile(String mode) { try { return new RandomAccessFile(this.dataFile, mode); } catch (Exception e) { throw new RuntimeException(e); } } /** * 读磁盘的SystemInfo */ public SystemInfo readSystemInfo() { try (var inputStream = new FileInputStream(this.dataFile)) { // 直接读取最开头 var result = new SystemInfo(); var bytes = new byte[result.byteCount()]; CommonUtils.require(result.byteCount() == inputStream.read(bytes), "Bytes not enough"); result.loadFromBytes(bytes); return result; } catch (Exception e) { throw new RuntimeException(e); } } /** * 读取BitMap */ public BitMap readBitMap() { var systemInfo = this.readSystemInfo(); try (var randomAccessFile = this.getRandomAccessFile("r")) { randomAccessFile.seek(systemInfo.getBitmapAddr()); var bitmap = BitMap.getInstance(); var bytes = new byte[bitmap.byteCount()]; randomAccessFile.read(bytes); bitmap.loadFromBytes(bytes); return bitmap; } catch (Exception e) { throw new RuntimeException(e); } } /** * 读取InodeTable */ public InodeTable readInodeTable() { var systemInfo = this.readSystemInfo(); try (var randomAccessFile = this.getRandomAccessFile("r")) { randomAccessFile.seek(systemInfo.getInodeIndexTableAddr()); var table = InodeTable.getInstance(); var bytes = new byte[table.byteCount()]; randomAccessFile.read(bytes); table.loadFromBytes(bytes); return table; } catch (Exception e) { throw new RuntimeException(e); } } /** * 读取用户表 */ public UserTable readUserTable() { try (var randomAccessFile = this.getRandomAccessFile("r")) { var systemInfo = this.readSystemInfo(); randomAccessFile.seek(systemInfo.getUserIndexTableAddr()); var userTable = UserTable.getInstance(); var bytes = new byte[userTable.byteCount()]; randomAccessFile.read(bytes); userTable.loadFromBytes(bytes); return userTable; } catch (Exception e) { throw new RuntimeException(e); } } /** * 初始化一个数据文件，默认创建一个 */ public void initDataFile() { try { CommonUtils.require(this.dataFile.createNewFile(), "Cannot create new data file."); var randomAccessFile = this.getRandomAccessFile("rw"); randomAccessFile.seek(0); // 初始化足够大的文件 for (int i = 0; i < BitMap.BLOCK_NUM; i++) { randomAccessFile.write(new byte[Inode.BLOCK_SIZE]); } // SystemInfo初始化 var systemInfo = new SystemInfo( UserTable.getInstance().byteCount(), InodeTable.getInstance().byteCount() ); var totalBytes = 0; totalBytes += systemInfo.byteCount(); totalBytes += UserTable.getInstance().byteCount(); totalBytes += InodeTable.getInstance().byteCount(); totalBytes += BitMap.getInstance().byteCount(); var totalBlock = CommonUtils.divCeil(totalBytes, Inode.BLOCK_SIZE); // 设置被元数据占用的块为已使用 for (int i = 0; i <= totalBlock; i++) { BitMap.getInstance().set(i); } // 写入磁盘 randomAccessFile.seek(0); randomAccessFile.write(systemInfo.toBytes()); randomAccessFile.write(UserTable.getInstance().toBytes()); randomAccessFile.write(InodeTable.getInstance().toBytes()); randomAccessFile.write(BitMap.getInstance().toBytes()); randomAccessFile.close(); // 初始化root用户 CommonUtils.require(this.createUser("root", ROOT_DEFAULT_PASSWORD), "Cannot init user."); } catch (IOException e) { throw new RuntimeException(e); } } /** * 创建用户，true表示成功 */ public boolean createUser(String username, String password) { // 读取用户表 var userTable = this.readUserTable(); if (userTable.get(username) != null) { return false; } try (var randomAccessFile = this.getRandomAccessFile("rw")) { var inodeTable = this.readInodeTable(); var iIndex = inodeTable.requestNewInode(username); if (iIndex < 0) { return false; // 无可用Inode时返回false表示创建失败 } var systemInfo = this.readSystemInfo(); // 设置用户 var result = userTable.set(username, password, iIndex); // 更新变化 randomAccessFile.seek(systemInfo.getUserIndexTableAddr()); randomAccessFile.write(userTable.toBytes()); randomAccessFile.seek(systemInfo.getInodeIndexTableAddr()); randomAccessFile.write(inodeTable.toBytes()); // 返回用户最终创建成功与否 return result; } catch (IOException e) { throw new RuntimeException(e); } } /** * 删除用户，true表示成功 */ public boolean deleteUser(String username) { if (username.equals("root")) { return false; // 禁止删除root用户 } // 读取用户 var userTable = this.readUserTable(); var user = userTable.get(username); if (user == null) { return false; } try (var randomAccessFile = this.getRandomAccessFile("rw")) { var systemInfo = this.readSystemInfo(); // 读取Inode表、创建根文件 randomAccessFile.seek(systemInfo.getInodeIndexTableAddr()); var inodeTable = this.readInodeTable(); var homeInode = inodeTable.get(user.getHomeInodeIndex()); // 删除用户的文件 var entryCount = homeInode.getSize() / new IndexEntry().byteCount(); var files = this.readSomeIndexEntry(homeInode, 0, entryCount); for (IndexEntry f : files) { // 删除文件 this.deleteFile(username, f.getFileName()); } // 删除用户目录的inode inodeTable.delete(user.getHomeInodeIndex()); // 写入Inode更新 randomAccessFile.seek(systemInfo.getInodeIndexTableAddr()); randomAccessFile.write(inodeTable.toBytes()); // 设置用户 var result = userTable.delete(username); // 更新变化 randomAccessFile.seek(systemInfo.getUserIndexTableAddr()); randomAccessFile.write(userTable.toBytes()); // 返回最终结果 return result; } catch (IOException e) { throw new RuntimeException(e); } } /** * 修改密码 */ public boolean changePassword(String username, String newPassword) { // 读取用户表 var userTable = this.readUserTable(); var user = userTable.get(username); if (user == null) { return false; } // 设置用户 var result = userTable.set(username, newPassword, user.getHomeInodeIndex()); // 更新变化 try (var randomAccessFile = this.getRandomAccessFile("rw")) { var systemInfo = this.readSystemInfo(); randomAccessFile.seek(systemInfo.getUserIndexTableAddr()); randomAccessFile.write(userTable.toBytes()); // 返回用户最终创建成功与否 return result; } catch (IOException e) { throw new RuntimeException(e); } } /** * 向给定Inode追加数据，对于inode的修改不会写入磁盘，需要调用者处理 * 返回false失败时已有部分数据写入 */ public boolean appendFile(Inode target, byte[] data) { var byteInputStream = new ByteArrayInputStream(data); try (var randomAccessFile = this.getRandomAccessFile("rws")) { while (byteInputStream.available() > 0) { // 寻找最后一块盘块 var blockNum = target.getBlockNumber(target.getSize()); if (blockNum >= 0) { // 移动到盘块内要写入的位置 randomAccessFile.seek(blockNum * Inode.BLOCK_SIZE + target.getSize() % Inode.BLOCK_SIZE); } // 计算块内剩余可用空间 var availableSize = Inode.BLOCK_SIZE - (target.getSize() % Inode.BLOCK_SIZE); if (blockNum < 0) { // 如果盘块号小于0，说明没分配盘块 availableSize = 0; } if (availableSize > 0) { // 还有可用空间，先往里写 var bytes = new byte[(int) availableSize]; var readCount = byteInputStream.read(bytes); randomAccessFile.write(bytes); // 这里要保存实际写入的内容 target.setSize(target.getSize() + readCount); } else { // 已经写满了，申请一个新盘块 var bitmap = this.readBitMap(); // 寻找一个新的可用盘块 int newBlock = bitmap.nextAvailable(); if (newBlock < 0) { return false; } if (!target.appendBlock((short) newBlock)) { return false; } bitmap.set(newBlock); // 移动到新盘块的位置 randomAccessFile.seek((long) newBlock * Inode.BLOCK_SIZE); // 写数据 var bytes = new byte[Inode.BLOCK_SIZE]; var readCount = byteInputStream.read(bytes); randomAccessFile.write(bytes); // 这里要保存实际写入的数据量 target.setSize(target.getSize() + readCount); // 更新BitMap randomAccessFile.seek(this.readSystemInfo().getBitmapAddr()); randomAccessFile.write(bitmap.toBytes()); } } } catch (Exception e) { throw new RuntimeException(e); } return true; } /** * 判断目录内是否已经有某文件 */ public boolean containsFile(Inode homeInode, String filename) { var entryCount = homeInode.getSize() / new IndexEntry().byteCount(); return this.readSomeIndexEntry(homeInode, 0, entryCount).parallelStream().map(IndexEntry::getFileName).anyMatch((s) -> s.equals(filename)); } /** * 创建空文件，true表示成功 */ public boolean createFile(String username, String filename) { var user = this.readUserTable().get(username); if (user == null) { return false; } var inodeTable = this.readInodeTable(); var homeInode = inodeTable.get(user.getHomeInodeIndex()); // 检测重复 if (this.containsFile(homeInode, filename)) { return false; } try (var randomAccessFile = this.getRandomAccessFile("rw")) { var iIndex = inodeTable.requestNewInode(homeInode.getOwner()); if (iIndex < 0) { return false; } // 准备新的目录项 var indexEntry = new IndexEntry(); indexEntry.setFileName(filename); indexEntry.setInodeIndex(iIndex); // 将新的目录项追加到用户目录文件的尾部 var result = this.appendFile(homeInode, indexEntry.toBytes()); // 更新Inode表 randomAccessFile.seek(this.readSystemInfo().getInodeIndexTableAddr()); randomAccessFile.write(inodeTable.toBytes()); return result; } catch (IOException e) { throw new RuntimeException(e); } } /** * 向给定Offset写数据，重叠的部分直接覆盖，大于原有的部分直接调用appendFile。 * 其中Offset必须小于等于文件大小，这样超出部分才算是追加，不能跳过一段没有数据的部分直接读写 */ public boolean writeFile(Inode target, long offset, byte[] data) { CommonUtils.require(offset <= target.getSize(), "Offset must not bigger than size."); var byteInputStream = new ByteArrayInputStream(data); var result = true; try (var randomAccessFile = this.getRandomAccessFile("rw")) { var currentOffset = offset; while (byteInputStream.available() > 0) { if (currentOffset >= target.getSize()) { // 如果大于指定的大小了，直接追加 result = this.appendFile(target, byteInputStream.readAllBytes()); } else { // 正常写 // 寻找要写入的盘块号 var blockNum = target.getBlockNumber(currentOffset); // 这里不应该出现未分配的情况，如果offset指向未分配的地方 // 说明offset是不小于size的，那么应该在上面的if跳到appendFile // 移动到盘块内要写入的位置 randomAccessFile.seek(blockNum * Inode.BLOCK_SIZE + currentOffset % Inode.BLOCK_SIZE); // 计算块内剩余可用空间 var availableSize = Inode.BLOCK_SIZE - (currentOffset % Inode.BLOCK_SIZE); // 这里需要看是否为最后一块，最后一块应该将可用空间置为size-offset if (target.getSize() - currentOffset < Inode.BLOCK_SIZE) { availableSize = target.getSize() - currentOffset; } var bytes = new byte[(int) availableSize]; var readCount = byteInputStream.read(bytes); // 这里要限制写入的数量严格为读取的数量，避免覆盖了后面的内容 randomAccessFile.write(bytes, 0, readCount); // 覆盖不更新文件大小，只更新当前offset currentOffset += readCount; } } // 更新InodeTable randomAccessFile.seek(this.readSystemInfo().getInodeIndexTableAddr()); randomAccessFile.write(InodeTable.getInstance().toBytes()); } catch (Exception e) { throw new RuntimeException(e); } return result; } /** * 截断文件，新长度必须小于等于当前长度 */ public void cutoffFile(Inode target, long newSize) { CommonUtils.require(newSize <= target.getSize(), "New size must not bigger than size."); try (var randomAccessFile = this.getRandomAccessFile("rw")) { target.setSize(newSize); this.trimFile(this.readBitMap(), target); // 更新InodeTable randomAccessFile.seek(this.readSystemInfo().getInodeIndexTableAddr()); randomAccessFile.write(InodeTable.getInstance().toBytes()); // 更新BitMap randomAccessFile.seek(this.readSystemInfo().getBitmapAddr()); randomAccessFile.write(BitMap.getInstance().toBytes()); } catch (Exception e) { throw new RuntimeException(e); } } /** * 从指定位置开始读指定个数字节 */ public byte[] readFile(Inode target, long offset, long length) { CommonUtils.require(length > 0, "Length must bigger than 0."); CommonUtils.require(offset >= 0, "Offset must not smaller than 0."); CommonUtils.require(offset + length <= target.getSize(), "Read too many bytes."); var byteOutputStream = new ByteArrayOutputStream(); try (var randomAccessFile = this.getRandomAccessFile("r")) { var currentOffset = offset; while (byteOutputStream.size() < length) { // 寻找要读的盘块号 var blockNum = target.getBlockNumber(currentOffset); // 移动到盘块内要写入的位置 randomAccessFile.seek(blockNum * Inode.BLOCK_SIZE + currentOffset % Inode.BLOCK_SIZE); // 计算块内剩余可用空间 var availableSize = Inode.BLOCK_SIZE - (currentOffset % Inode.BLOCK_SIZE); var bytes = new byte[(int) Math.min(availableSize, length - byteOutputStream.size())]; var readCount = randomAccessFile.read(bytes); byteOutputStream.write(bytes); // 更新当前offset currentOffset += readCount; } } catch (Exception e) { throw new RuntimeException(e); } return byteOutputStream.toByteArray(); } /** * 读一些目录项，从第offset个开始读，读count个 */ public List<IndexEntry> readSomeIndexEntry(Inode homeInode, long offset, long count) { CommonUtils.require(offset >= 0, "Offset must not less than 0."); if (count <= 0) { return Collections.emptyList(); } var emptyIndexEntry = new IndexEntry(); var entryCount = homeInode.getSize() / emptyIndexEntry.byteCount(); CommonUtils.require(offset + count <= entryCount, "Accessing invalid index entry."); var result = new ArrayList<IndexEntry>(); // 一次性读出所有目录项 try (var byteInput = new ByteArrayInputStream(this.readFile(homeInode, offset * emptyIndexEntry.byteCount(), count * emptyIndexEntry.byteCount()))) { var bytes = new byte[emptyIndexEntry.byteCount()]; for (int i = 0; i < entryCount; i++) { var indexEntry = new IndexEntry(); byteInput.read(bytes); indexEntry.loadFromBytes(bytes); result.add(indexEntry); } } catch (Exception e) { throw new RuntimeException(e); } return result; } /** * 根据filename寻找Inode */ public Inode findInodeByFilename(Inode homeInode, String filename) { var indexEntry = new IndexEntry(); var entryCount = homeInode.getSize() / indexEntry.byteCount(); var resultIndexEntry = this.readSomeIndexEntry(homeInode, 0, entryCount).parallelStream().filter((s) -> s.getFileName().equals(filename)); var inodeIndex = resultIndexEntry.findFirst().orElseThrow().getInodeIndex(); return InodeTable.getInstance().get(inodeIndex); } /** * 删除文件，true表示成功 */ public boolean deleteFile(String username, String filename) { var user = this.readUserTable().get(username); if (user == null) { return false; } var inodeTable = this.readInodeTable(); var homeInode = inodeTable.get(user.getHomeInodeIndex()); // 先搜索被删除文件的目录项位置 var indexEntry = new IndexEntry(); var entryCount = homeInode.getSize() / indexEntry.byteCount(); var index = 0; IndexEntry lastEntry; try { for (; index < entryCount; index++) { indexEntry = this.readSomeIndexEntry(homeInode, index, 1).get(0); if (indexEntry.getFileName().equals(filename)) { break; } } if (index == entryCount) { return false; } // 读最后一项 lastEntry = this.readSomeIndexEntry(homeInode, (int) (entryCount - 1), 1).get(0); } catch (Exception e) { throw new RuntimeException(e); } // 释放被删除文件占用的块 var bitmap = this.readBitMap(); var deleted = inodeTable.get(indexEntry.getInodeIndex()); for (Short blockNum : deleted.getBlocks()) { // 在BitMap中依次更新被占用的块 bitmap.clear(blockNum); } // 释放Inode inodeTable.delete(indexEntry.getInodeIndex()); try (var randomAccessFile = this.getRandomAccessFile("rw")) { // 被删除的不是最后一个，用最后一个替换被删除的 var offset = index * indexEntry.byteCount(); if (index != entryCount - 1) { randomAccessFile.seek( homeInode.getBlockNumber(offset) * Inode.BLOCK_SIZE + offset % Inode.BLOCK_SIZE ); randomAccessFile.write(lastEntry.toBytes()); } // 截断文件 homeInode.setSize(homeInode.getSize() - indexEntry.byteCount()); // 释放截断后未占用的块 this.trimFile(bitmap, homeInode); // 更新 randomAccessFile.seek(this.readSystemInfo().getInodeIndexTableAddr()); randomAccessFile.write(inodeTable.toBytes()); randomAccessFile.seek(this.readSystemInfo().getBitmapAddr()); randomAccessFile.write(bitmap.toBytes()); return true; } catch (IOException e) { throw new RuntimeException(e); } } /** * 整理文件，释放没有使用的块。需要调用者更新BitMap */ public void trimFile(BitMap bitmap, Inode target) { var blockNumNeeded = CommonUtils.divCeil(target.getSize(), Inode.BLOCK_SIZE); var actualBlockNum = target.getBlocks().size(); for (int i = 0; i < actualBlockNum - blockNumNeeded; i++) { var blockReleased = target.releaseLastBlock(); bitmap.clear(blockReleased); } } }-全文完-
本系列将记述使用Java实现一个简单的二级文件系统的过程。这个项目是本学期专业课操作系统的第三个实验。上一篇文章说明了项目要做的事情，并讲解基础工具类的设计。本文将记述元数据的代码描述。Inode和InodeTable本项目意图效仿Linux的文件管理，但是学的又没那么像，仅仅是一些皮毛。因此使用类似Inode的东西记录文件在磁盘上的信息。Inode每个Inode的描述如下：package info.skyblond.os.exp.experiment3.model; import info.skyblond.os.exp.experiment3.bytes.WrappedGenericType; import info.skyblond.os.exp.experiment3.bytes.array.WrappedString; import info.skyblond.os.exp.experiment3.bytes.primitive.WrappedLong; import info.skyblond.os.exp.experiment3.bytes.primitive.WrappedShort; import info.skyblond.os.exp.utils.CommonUtils; import java.util.ArrayList; import java.util.Arrays; import java.util.List; public class Inode extends WrappedGenericType { /** * 盘块大小：4KB */ public static final int BLOCK_SIZE = 4096; /** * 块号索引，都为直接索引。 * 用16位Short表示盘块号，最大可表示64K个盘块，文件系统最大支持256MiB容量。 * 该索引方式支持的文件容量：10 * 4KB = 40KiB */ private final WrappedShort[] blockNumbers = new WrappedShort[10]; /** * 文件所有者名称 */ private final WrappedString owner = new WrappedString(User.USER_NAME_LENGTH); /** * 文件大小（字节为单位） */ private final WrappedLong size = new WrappedLong(); Inode() { for (int i = 0; i < this.blockNumbers.length; i++) { this.blockNumbers[i] = new WrappedShort(); this.parameters.add(this.blockNumbers[i]); } this.parameters.addAll(Arrays.asList(this.owner, this.size)); } /** * 增加一个块 */ public boolean appendBlock(short blockNum) { for (WrappedShort blockNumber : this.blockNumbers) { if (blockNumber.getValue() == -1) { // 直接写入新的块号 blockNumber.setValue(blockNum); return true; } } return false; } /** * 释放最后一个占用的块，返回块号 */ public short releaseLastBlock() { if (this.blockNumbers[0].getValue() == -1) { // 如果没分配块，返回-1 return -1; } for (int i = 0; i < this.blockNumbers.length - 1; i++) { if (this.blockNumbers[i + 1].getValue() == -1) { // 如果下一个是未占用块，则当前是最后一个未使用的块 // 释放 var temp = this.blockNumbers[i].getValue(); this.blockNumbers[i].setValue((short) -1); return temp; } } // 全部都在占用，则释放最后一个块 var temp = this.blockNumbers[this.blockNumbers.length - 1].getValue(); this.blockNumbers[this.blockNumbers.length - 1].setValue((short) -1); return temp; } /** * 获取当前已经分配的块 */ public List<Short> getBlocks() { var result = new ArrayList<Short>(); for (WrappedShort blockNumber : this.blockNumbers) { if (blockNumber.getValue() != -1) { // 直接写入新的块号 result.add(blockNumber.getValue()); } } return result; } /** * 根据要访问的偏移量返回其所在的盘块号 */ public short getBlockNumber(long offset) { CommonUtils.require(offset <= this.size.getValue(), "Offset bigger than file size."); // 原始盘块数，如果小于5则为直接索引，直接返回对应盘块号 var rawBlock = offset / BLOCK_SIZE; return this.blockNumbers[(int) rawBlock].getValue(); } /** * 将所有索引项写为-1，重置 */ public void resetBlockNumber() { for (WrappedShort blockNumber : this.blockNumbers) { blockNumber.setValue((short) -1); } this.size.setValue((long) 0); } public String getOwner() { return this.owner.getValue(); } public long getSize() { return this.size.getValue(); } public void setOwner(String newOwner) { this.owner.setValue(newOwner); } public void setSize(long newSize) { this.size.setValue(newSize); } @Override public String toString() { return "Inode{" + "blockNumbers=" + Arrays.toString(this.blockNumbers) + ", owner=" + this.owner + ", size=" + this.size + '}'; } }每个Inode有10个直接索引，内容为盘块号，存储在blockNumbers中，盘块上就是文件的数据。本来之前还想做多级索引，后来觉得做起来太麻烦，又砍了。多级索引原理上就是重复几遍读盘块找索引的步骤，但是细节处理上还是挺费时间的，因此就没有做。每个Inode还存储文件节点所有者的用户名（owner），这个其实可有可无，一开始想加上文件的共享，其他用户可以通过Inode编号创建一个硬链接指向其他用户的文件，然后再加上读写权限标志什么的，这个功能后来也砍了。如果要加上的话也好办：所有者拥有全部读写权限，非所有者有三种权限：无权限、只读和读写（如果想再偷懒的话，默认非所有者只读就行了）。之后再在创建文件的时候询问创建普通文件还是硬链接，读写的时候检查权限即可。后文的模拟器是单道系统，同一时间只能登陆一个用户，操作一个文件，因此不必考虑加锁什么的。如果要考虑加锁，打开文件的时候检查Inode唯一性即可（逻辑上就是独占锁）。最后每个Inode还要记录文件的实际大小size，因为占用一个盘块不代表文件一定要用完这个盘块。除此之外这个类还定义了BLOCK_SIZE，即一个盘块（簇）大小为4KB。对于构造函数，为了保证Inode只能在InodeTable中被创建，因此让它的构造函数只在包内可见，到了模拟器（简单操作系统）那里就不能直接创建Inode了。原因后面再说。在构造函数里可以看到我们用到了上文中的WrappedGenericType，构造时维护this.parameters即可让我们在上文中编写的基类自动处理成员变量与字节数组之间的转换，而无需我们再操心了。值得一提的是我们需要修改IDEA默认生成的getter和setter，使之利用WrappedBaseType的setter和getter，避免外界代码能够直接操作对象，而只能操作对象里面包含的值。对于Inode的常用操作也提供了对应的成员方法。appendBlock会向索引中追加一个块，-1表示该索引未被使用（毕竟盘块号最小是0），代码将查找第一个为-1的索引，并将其修改为新追加的盘块号。如果找不到未使用的索引，说明文件大小已经达到极限，将返回false告诉调用者操作失败。除了追加一个块，还要能够释放块：releaseLastBlock方法将释放最后一个块，并返回被释放的块号。如果当前没有使用中的块（0号索引值为-1）则返回-1，否则将查找下一个索引为-1的块，即是最后一个使用中的块，取值后覆盖为-1，返回其值。除了释放最后一个块，在删除文件时需要撤销整个Inode，如果这时候再一个一个释放的话，会很浪费时间，因此提供查询所有占用的块的功能，通过操作系统直接释放占用的块并撤销Inode。getBlocks将返回所有使用中的块号。对于访问的需求，最直接的就是将文件内的偏移量转换为对应的块号：getBlockNumber根据传入的文件内偏移量，查询其对应的索引，并返回占用的盘块号。最后当需要回收Inode时，resetBlockNumber方法可以将索引全部置为-1。由Inode作为基本单位，多个Inode集合便成为一个InodeTable。InodeTable为了避免Inode对象的不一致性（即多个Inode对象对应磁盘中同一个Inode数据），所有创建Inode实例的行为都必须在InodeTable中进行，因此Inode的构造函数不能为public，只能包内可见。同时使用单例模式来避免InodeTable的不一致性（磁盘上就一个InodeTable）：package info.skyblond.os.exp.experiment3.model; import info.skyblond.os.exp.experiment3.bytes.WrappedGenericType; import info.skyblond.os.exp.experiment3.bytes.array.WrappedBitArray; import info.skyblond.os.exp.utils.CommonUtils; import java.util.Arrays; import java.util.Objects; /** * 用户表 */ public class InodeTable extends WrappedGenericType { /** * Inode数量：32768，最多可以有这么多个文件/目录。 */ public static final int INODE_NUM = 32768; private final int length; private final WrappedBitArray allocationMap; private final Inode[] inodes; // 使用单例模式 private static InodeTable instance; public static synchronized InodeTable getInstance() { if (instance == null) { instance = new InodeTable(INODE_NUM); } return instance; } /** * 定长索引表 */ private InodeTable(int length) { CommonUtils.require(length > 0, "Length must bigger than 0."); this.length = length; this.allocationMap = new WrappedBitArray(length); this.inodes = new Inode[length]; for (int i = 0; i < this.length; i++) { this.allocationMap.set(i, false); this.inodes[i] = new Inode(); this.parameters.add(this.inodes[i]); } this.parameters.add(this.allocationMap); } /** * 申请新inode，需要调用者更新Inode表 */ public int requestNewInode(String owner) { // 获取空闲的Inode节点索引 var iIndex = this.nextAvailableIndex(); if (iIndex < 0) { return -1; // 无可用Inode时返回-1表示创建失败 } // 获取节点 var inode = this.get(iIndex); // 清空 inode.resetBlockNumber(); // 设置属主 inode.setOwner(owner); // 设置占用 this.set(iIndex); return iIndex; } /** * 设置Inode已分配，修改和更新需要使用{@link #get(int)}获取Inode并修改 */ public void set(int inodeIndex) { this.allocationMap.set(inodeIndex, true); } /** * 获取下一个未分配的Inode索引号，-1表示全满了 */ public int nextAvailableIndex() { for (int i = 0; i < this.length; i++) { if (!this.allocationMap.get(i)) { return i; } } return -1; } /** * 搜索Inode，如果需要对inode进行更改，同样使用 */ public Inode get(int inodeIndex) { return this.inodes[inodeIndex]; } /** * 删除Inode，该函数并不会删除已经分配给该Inode的盘块，需要调用者使用bitmap重置盘块分配情况 */ public void delete(int inodeIndex) { this.allocationMap.set(inodeIndex, false); } public int getLength() { return this.length; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || this.getClass() != o.getClass()) { return false; } InodeTable that = (InodeTable) o; return this.length == that.length && this.allocationMap.equals(that.allocationMap) && Arrays.equals(this.inodes, that.inodes); } @Override public int hashCode() { int result = Objects.hash(this.length, this.allocationMap); result = 31 * result + Arrays.hashCode(this.inodes); return result; } @Override public String toString() { return "UserIndexTable{" + "length=" + this.length + ", allocationMap=" + this.allocationMap + ", inodes=" + Arrays.toString(this.inodes) + '}'; } }InodeTable类规定了该文件系统最大能够支持的Inode数量：INODE_NUM，该值将指导InodeTable初始化多少个Inode的位置。而为了标识哪一个Inode被使用，哪一个是空闲的，利用对应长度的WrappedBitArray追踪使用情况（allocationMap），而最终的Inode数据则存储在inodes，一个Inode数组。对象数组在初始化时需要一一赋值，否则后续操作会产生空指针异常（对象引用默认为null）。这些初始化的操作都在构造函数中进行了，单例模式保证只有一个对象会被创建。对于常用的操作也有对应的方法提供：nextAvailableIndex查询第一个未被使用的Inode节点（即allocationMap中值为false的）的索引，如果没有则返回-1。set、get和delete分别提供了基础的操作：set将allocationMap中对应给定索引的值置为true，表示已经分配；get则返回inodes中对应索引的对象，无论其是否已被分配；而delete则与set相反，将allocationMap中对应给定索引的值置为false，表示未在使用。有了这四个基本操作，可以在这基础上增加一些额外操作，而避免每个常用操作直接对底层数据修改，产生不一致性。requestNewInode将分配一个全新的Inode节点给指定用户。首先要查找一个空闲的Inode，将其清空并设定所有者，还要调用set方法告知allocationMap已经占用。如果上述操作全都成功，返回新分配的Inode的索引号，如果失败，返回-1。User和UserTable文件是磁盘上最基础的表现形式。现在Inode有了，我们来说一说用户。上一篇文章提到实验指导书还要求系统有对用户的基本操作，因此用户的数据也要在磁盘上管理。User每一个用户的定义如下：package info.skyblond.os.exp.experiment3.model; import info.skyblond.os.exp.experiment3.bytes.WrappedGenericType; import info.skyblond.os.exp.experiment3.bytes.array.WrappedString; import info.skyblond.os.exp.experiment3.bytes.primitive.WrappedInteger; import info.skyblond.os.exp.utils.CommonUtils; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Objects; public class User extends WrappedGenericType { /** * 用户名最大长度 */ public static final int USER_NAME_LENGTH = 10; /** * 密码最大长度 */ public static final int USER_PASSWORD_LENGTH = 20; /** * 用户名 */ private final WrappedString username = new WrappedString(USER_NAME_LENGTH); /** * 密码 */ private final WrappedString password = new WrappedString(USER_PASSWORD_LENGTH); /** * home目录对应的inode索引 */ private final WrappedInteger homeInodeIndex = new WrappedInteger(); User() { this.parameters.addAll(Arrays.asList( this.username, this.password, this.homeInodeIndex )); } /** * 获取用户名 */ public String getUsername() { return this.username.getValue(); } /** * 更新用户名。使用时需要注意修改用户名对于索引表的影响 */ public void setUsername(String username) { CommonUtils.require(username.getBytes(StandardCharsets.UTF_8).length <= USER_NAME_LENGTH, "User name reached max length."); this.username.setValue(username); } public String getPassword() { return this.password.getValue(); } public void setPassword(String password) { CommonUtils.require(password.getBytes(StandardCharsets.UTF_8).length <= USER_NAME_LENGTH, "Password reached max length."); this.password.setValue(password); } public int getHomeInodeIndex() { return this.homeInodeIndex.getValue(); } public void setHomeInodeIndex(int value) { this.homeInodeIndex.setValue(value); } /** * 用户名相同即为一个用户 */ @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || this.getClass() != o.getClass()) { return false; } User user = (User) o; return this.username.equals(user.username); } @Override public int hashCode() { return Objects.hash(this.username); } @Override public String toString() { return "User{" + "username=" + this.username + ", password=" + this.password + ", homeInodeAddr=" + this.homeInodeIndex + '}'; } }用户描述起来就比Inode简单许多：首先规定用户名和密码的最大长度，这些参数将传给WrappedString，它将在违反约定的时候抛出异常，因此我们不必显示的处理违约情况（但实践中还是建议检查用户输入的合规性，否则动不动就崩溃，最终用户会骂街的）。每个用户拥有一个username和password，并且拥有一个homeInodeIndex来记录该用户的目录文件对应的Inode的索引。目录文件也是普通的文件，它的内容将在稍后介绍。在getter和setter上和Inode一样，必须保证外界代码操作的是Wrapped的核心值，而不是Wrapped对象。在equal方法上，只要用户名相同就认为是同一个用户。有了User，我们就可以构建UserTable了。UserTableUserTable同样使用单例模式：package info.skyblond.os.exp.experiment3.model; import info.skyblond.os.exp.experiment3.bytes.WrappedGenericType; import info.skyblond.os.exp.experiment3.bytes.array.WrappedBitArray; import info.skyblond.os.exp.utils.CommonUtils; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Objects; /** * 用户表 */ public class UserTable extends WrappedGenericType { /** * 最大用户数量：16个 */ public static final int USER_NUM = 16; private final int length; private final WrappedBitArray allocationMap; private final User[] users; // 使用单例模式 private static UserTable instance; public static synchronized UserTable getInstance() { if (instance == null) { instance = new UserTable(USER_NUM); } return instance; } /** * 定长索引表 */ private UserTable(int length) { CommonUtils.require(length > 0, "Length must bigger than 0."); this.length = length; this.allocationMap = new WrappedBitArray(length); this.users = new User[length]; for (int i = 0; i < this.length; i++) { this.allocationMap.set(i, false); this.users[i] = new User(); this.parameters.add(this.users[i]); } this.parameters.add(this.allocationMap); } /** * 列出所有可用的用户名 */ public List<String> listUsername() { var result = new ArrayList<String>(); for (int i = 0; i < this.length; i++) { if (this.allocationMap.get(i)) { result.add(this.users[i].getUsername()); } } return result; } /** * 新增用户 */ public boolean set(String username, String password, int homeInodeIndex) { CommonUtils.require(homeInodeIndex >= 0, "homeInodeIndex must bigger than 0. Negative addr is meaningless."); // 查看是否已有索引，有则更新 for (int i = 0; i < this.length; i++) { if (this.allocationMap.get(i)) { // 已分配，搜索 if (this.users[i].getUsername().equals(username)) { this.users[i].setPassword(password); this.users[i].setHomeInodeIndex(homeInodeIndex); return true; } } } // 寻找空位，没有直接返回false int emptyIndex = 0; for (; emptyIndex < this.length; emptyIndex++) { if (!this.allocationMap.get(emptyIndex)) { break; } } if (emptyIndex >= this.length) { return false; } // 修改空位的内容以记录值 this.users[emptyIndex].setUsername(username); this.users[emptyIndex].setPassword(password); this.users[emptyIndex].setHomeInodeIndex(homeInodeIndex); this.allocationMap.set(emptyIndex, true); return true; } /** * 搜索用户 */ public User get(String username) { for (int i = 0; i < this.length; i++) { if (this.allocationMap.get(i)) { // 已分配，搜索 if (this.users[i].getUsername().equals(username)) { // Key匹配 return this.users[i]; } } } return null; } /** * 删除用户 */ public boolean delete(String username) { // 查看是否已有索引，有再删除 for (int i = 0; i < this.length; i++) { if (this.allocationMap.get(i)) { if (this.users[i].getUsername().equals(username)) { // 直接标记释放 this.allocationMap.set(i, false); return true; } } } return false; } public int getLength() { return this.length; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || this.getClass() != o.getClass()) { return false; } UserTable that = (UserTable) o; return this.length == that.length && this.allocationMap.equals(that.allocationMap) && Arrays.equals(this.users, that.users); } @Override public int hashCode() { int result = Objects.hash(this.length, this.allocationMap); result = 31 * result + Arrays.hashCode(this.users); return result; } @Override public String toString() { return "UserIndexTable{" + "length=" + this.length + ", allocationMap=" + this.allocationMap + ", users=" + Arrays.toString(this.users) + '}'; } }初始化与InodeTable类似，全部在构造函数中执行。常用操作有listUsername，该方法将列出所有已知的用户名。set则根据传入的用户名、密码和目录文件的Inode索引创建或更新一个用户。首先所有已分配的用户，如果找到同名的用户，则更新它的密码和目录文件Inode索引，否则查找空闲项，设定对应内容及allocationMap后返回。如果一切顺利，返回true，否则返回false。这里面唯一能够返回false的就是用户数量达到上限，UserTable满了，找不到可用空闲项。get则根据用户名搜索用户。如果找到了则返回User对象，找不到则返回一个null。delete根据用户名删除用户。首先查找用户，找到了直接在allocationMap中标记为未使用即可，最后返回true。如果找不到用户，将返回false。BitMap处理完了InodeTable和UserTable，我们还剩下一个BitMap和SystemInfo。BitMap定义如下：package info.skyblond.os.exp.experiment3.model; import info.skyblond.os.exp.experiment3.bytes.WrappedGenericType; import info.skyblond.os.exp.experiment3.bytes.array.WrappedBitArray; import info.skyblond.os.exp.utils.CommonUtils; public class BitMap extends WrappedGenericType { /** * 盘块数量：32769个，总文件大小：128MiB */ public static final int BLOCK_NUM = 32768; private final int length; private final WrappedBitArray internalBitMap; // 使用单例模式 private static BitMap instance; public static synchronized BitMap getInstance() { if (instance == null) { instance = new BitMap(BLOCK_NUM); } return instance; } /** * 定长索引表 */ private BitMap(int length) { CommonUtils.require(length > 0, "Length must bigger than 0."); this.length = length; this.internalBitMap = new WrappedBitArray(length); this.parameters.add(this.internalBitMap); } /** * 设置使用 */ public void set(int index) { this.internalBitMap.set(index, true); } /** * 清除 */ public void clear(int index) { this.internalBitMap.set(index, false); } /** * 查询 */ public boolean get(int index) { return this.internalBitMap.get(index); } /** * 获取下一个可用块号 */ public int nextAvailable() { for (int i = 0; i < this.length; i++) { if (!this.internalBitMap.get(i)) { return i; } } return -1; } public int getLength() { return this.length; } @Override public String toString() { return "BitMap{" + "length=" + this.length + ", internalBitMap=" + this.internalBitMap + '}'; } }这里定义了一个BLOCK_NUM来指定磁盘有多少个盘块。BitMap正如其名：使用位图法管理盘块，一个bit对应一个盘块的使用情况。其核心就是对WrappedBitArray（internalBitMap）的简单包装。由于其在磁盘上的唯一性，BitMap同样使用单例模式。在BitMap中，我们将WrappedBitArray的set细分为两个方法：set负责置位为true，clear表示置位为false。get则不变。除此之外还额外提供了nextAvailable方法，用于获取一个空闲盘块的盘块号，如果找不到空闲盘块，那么就返回一个-1。SystemInfo三大件（User表、Inode表和BitMap）都齐了，下面就可以说SystemInfo了。实际上SystemInfo非常简单：package info.skyblond.os.exp.experiment3.model; import info.skyblond.os.exp.experiment3.bytes.WrappedGenericType; import info.skyblond.os.exp.experiment3.bytes.primitive.WrappedLong; import java.util.Arrays; import java.util.Objects; /** * 系统信息只读不写，因此不做单例模式 */ public class SystemInfo extends WrappedGenericType { /** * 用户索引表的起始地址 */ private final WrappedLong userIndexTableAddr = new WrappedLong(); /** * i节点索引表的起始地址 */ private final WrappedLong inodeIndexTableAddr = new WrappedLong(); /** * 位图的起始地址 */ private final WrappedLong bitmapAddr = new WrappedLong(); /** * 实例化，内容全0 */ public SystemInfo() { this.parameters.addAll(Arrays.asList( this.userIndexTableAddr, this.inodeIndexTableAddr, this.bitmapAddr )); } /** * 实例化，根据内容大小填充内容 */ public SystemInfo( long userIndexTableByteCount, long inodeIndexTableByteCount ) { this(); long accu = this.byteCount(); this.userIndexTableAddr.setValue(accu); accu += userIndexTableByteCount; this.inodeIndexTableAddr.setValue(accu); accu += inodeIndexTableByteCount; this.bitmapAddr.setValue(accu); } public long getUserIndexTableAddr() { return this.userIndexTableAddr.getValue(); } public long getInodeIndexTableAddr() { return this.inodeIndexTableAddr.getValue(); } public long getBitmapAddr() { return this.bitmapAddr.getValue(); } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || this.getClass() != o.getClass()) { return false; } SystemInfo that = (SystemInfo) o; return this.userIndexTableAddr.equals(that.userIndexTableAddr) && this.inodeIndexTableAddr.equals(that.inodeIndexTableAddr) && this.bitmapAddr.equals(that.bitmapAddr); } @Override public int hashCode() { return Objects.hash(this.userIndexTableAddr, this.inodeIndexTableAddr, this.bitmapAddr); } @Override public String toString() { return "SystemInfo{" + "userIndexTableAddr=" + this.userIndexTableAddr + ", inodeIndexTableAddr=" + this.inodeIndexTableAddr + ", bitmapAddr=" + this.bitmapAddr + '}'; } }无非就是根据User表和Inode表的大小，分别计算出User表、Inode表和BitMap的起始位置，便于后面的代码读取时直接seek寻址过去。说完了物理上的数据结构，下面我们来说一说逻辑上的。IndexEntry上文提到：用户的目录文件也是普通文件，那它的内容是什么的？就是本节要说的IndexEntry：package info.skyblond.os.exp.experiment3.model; import info.skyblond.os.exp.experiment3.bytes.WrappedGenericType; import info.skyblond.os.exp.experiment3.bytes.array.WrappedString; import info.skyblond.os.exp.experiment3.bytes.primitive.WrappedInteger; import java.util.Arrays; /** * 目录项，是用户根目录文件的内容 */ public class IndexEntry extends WrappedGenericType { /** * 文件名最大字节数。60字节加上Inode索引的4字节正好64B，每个盘块正好记录64个文件。 */ public static final int FILENAME_LENGTH = 60; private final WrappedInteger inodeIndex = new WrappedInteger(); private final WrappedString fileName = new WrappedString(FILENAME_LENGTH); public IndexEntry() { this.parameters.addAll(Arrays.asList(this.inodeIndex, this.fileName)); } public int getInodeIndex() { return this.inodeIndex.getValue(); } public String getFileName() { return this.fileName.getValue(); } public void setInodeIndex(int newIndex) { this.inodeIndex.setValue(newIndex); } public void setFileName(String newName) { this.fileName.setValue(newName); } }这个结构也十分简单：就是文件名对Inode索引的映射。文件系统查询用户目录下的文件时，就会读取用户的目录文件，根据里面的内容知晓用户有什么文件。因此这里使得文件分享非常简单：只要把Inode索引写成别的用户的文件就可以了，只要在读写的时候检查权限即可。最后还要说一个只保存在内存中的数据结构。OpenedFile这个描述类只存在于内存中，它的作用就是记录已打开的文件的信息。例如当前文件的读写指针：package info.skyblond.os.exp.experiment3.model; /** * 已打开的文件，只驻留内存 */ public class OpenedFile { private final Inode inode; private long pos = 0L; public OpenedFile(Inode inode) { this.inode = inode; } public Inode getInode() { return this.inode; } public long getPos() { return this.pos; } public void setPos(long pos) { this.pos = pos; } @Override public String toString() { return "OpenedFile{" + "inode=" + this.inode + ", pos=" + this.pos + '}'; } }它只保存文件的Inode对象和一个指针，后者指示当前读写文件的哪一部分。总结至此我们已经完成了元数据的代码描述。其中一些操作的实现可以用其他方法完成，例如使用Boolean返回值表示一个可失败的操作并不严谨，实践中一般会使用自定义的异常来做。但是我觉得100行代码有50行都是try和catch，显得很不优雅。Java编程固然有许多范式来指导程序员如何极力避免bug的出现，但是我认为写了一定数量的代码之后，消除bug是习惯，代码写得优雅是追求。另外值得一提的是：本文中所有盘块号相关的都使用short存储，索引相关的全是Integer，地址相关的全是Long。下一篇文章我将介绍如何编写一个简单的文件系统，让它利用本文所定义的诸多数据结构正常工作。-全文完-
本系列将记述使用Java实现一个简单的二级文件系统的过程。这个项目是本学期专业课操作系统的第三个实验。本文将说明项目要做的事情，并讲解基础工具类的设计。二级文件系统实验指导书中的实验要求：用C语言（或其它语言，如Java）实现一个简单的二级文件系统设计，加深对文件系统的内部功能和内部实现的理解。要求模拟采用二级目录结构的磁盘文件系统的文件操作, 实现用户的创建、查询、登录、删除，和文件的添加、删除、打开、关闭、读、写等基本命令，用在屏幕上输入命令来模拟用户程序中调用的文件操作。其中文件的基本操作有：1）创建文件 在创建一个新文件时，系统首先要为新文件分配必要的外存空间，并在文件系统的目录中，为之建立一个目录项。目录项中应该记录新文件的文件名及其在外存的地址等属性。 2）删除文件 当已不再需要某文件时，可将它从文件系统中删除。在删除时，系统应该先从目录中找到要删除的文件的目录项，使之成为空项，然后回收该文件所占用的存储空间。 3）读文件 在读一个文件时，须在相应的系统调用中给出文件名和应该读入的内存目标地址。此时，系统同样要查找目录，找到制定的目录项，从中得到被读文件在外存中的位置。在目录项中，还有一个指针用于对文件的读/写。 4）写文件 在写一个文件时，须在相应的系统调用中给出该文件名及该文件在内存中的（源）地址。为此，也同样须先查找目录，找到指定文件的目录项，在利用目录中的写指针进行写操作。 5）截断文件 如果一个文件的内容已经陈旧而需要全部更新文件的内容时，一种方法是将此文件删除，再重新创建一个新文件。但如果文件名及其属性均无改变时，则可采取另一种所谓的截断文件的方法，此即将原有文件的长度设置为0，或说放弃原有的文件内容。 6）设置文件的读/写位置 前述的文件读/写操作，都只提供了对文件顺序存取的手段，即每次都是从文件的始端读或写。设置文件读/写位置的操作，用于设置文件读/写指针的位置，以便每次读/写文件时，不是从其始端而是从所设置的位置开始操作。也正因如此，才能改顺序存取为随机存取。 分析我将使用Java编写这个项目。其中涉及文件系统对磁盘的操作，肯定要支持随机存取。尽管Java中有常用的Stream的概念，但很遗憾的是Stream并不能随机读写。固然可以通过Stream的skip方法跳过前面的内容，找到我们需要的数据，但这样的操作方式更像是磁带，而非磁盘。如果使用一个二进制文件来模仿磁盘，则可以使用Java对文件的随机读写支持来实现我们的目标。因此在本项目中可能需要抛弃常用且便利的流，而另辟蹊径一种模式了。一个最简单的模式，也是本文使用的模式，就是利用RandomAccessFile，该类可以实现对文件的随机读写：RandomAccessFile randomAccessFile = new RandomAccessFile("path/to/some/file", "rw");其中"rw"与C语言类似，规定如下：r只读，此时试图写会引发异常rw读写，产生的修改不保证会同步写入磁盘rwd读写，对数据产生的修改会同步写入磁盘rws读写，对数据和元数据产生的修改会同步写入磁盘对文件的随机读写如下示范：// 将文件指针移动到200 randomAccessFile.seek(200); // 读取文件指针 long position = randomAccessFile.getFilePointer(); // 读一个字节 byte b = randomAccessFile.read(); // 读10个字节进字节数组 byte[] bytes = new byte[10]; randomAccessFile.read(bytes); // 读两个字节进字节数组，从5开始写 randomAccessFile.read(bytes, 5, 2); // 读一个基本类型 int i = randomAccessFile.readInt(); // 写一个字节 randomAccessFile.write(b); // 从字节数组写10个字节进文件 randomAccessFile.write(bytes); // 写两个字节进字节数组，从5开始读 randomAccessFile.write(bytes, 5, 2); // 写一个基本类型 randomAccessFile.writeInt(i);详细信息可以异步官方JavaDoc：「RandomAccessFile (Java Platform SE 8 )」。现在解决了文件随机读写的问题，现在来说一说如何模拟磁盘。磁盘管理实际上这个项目除了要写一个文件系统，还要模拟一个简单的操作系统来支持人机交互。因此用户信息和文件信息都要存储在磁盘上。暂定磁盘安排如下：|System Info|User Table|Inode Table|BitMap|Block|Block|Block|Block|...|Block|其中各项如下解释：System Info记录磁盘的元数据的元数据，即User Table的位置、Inode Table的位置、BitMap的位置User Table记录用户信息，内含一个分配表和多个User条目。每个User记录用户名、密码和用户目录的Inode节点号Inode Table记录Inode信息，内含一个分配表和多个Inode条目。每个Inode包含多个盘块号索引、所有者用户名和文件大小BitMap记录整个磁盘的盘块分配情况。0表示未分配，1表示已分配。Block即为盘块，用于存储一般性的数据关于上面各数据的代码设计将在后文说明。现在有一个需要优先考虑的问题：如何将Java的类像C的结构体一样写入文件？从Java对象到字节数组Java有一个Serializable接口，实现该接口的类可以通过ObjectOutputStream写入到文件。但是这样有一个问题：写出来的文件是带有Stream特征的，在之前的文章「【代码札记】排查 ObjectOutputStream 引起的内存泄漏」中提到过，为了节省带宽，ObjectOutputStream在写入时记录被写入对象，如果后续写入了同样的内容，它会偷懒不写入重复的内容，而做下一个标记，告诉读取者当前项是重复的哪一项，直接去找就是了。为了避免内存泄漏，需要定时调用reset方法，而这个方法同样会写入一个标志，告诉读取者也要reset，以此避免数据不同步。这样一来我们的磁盘就会被这些标志搞得乱七八糟，而最终磁盘的模样也会因为写入方式的不同而改变，这样我们在随机读取的时候也就没办法根据类的信息直接决定去哪里找，我们还需要写入时的信息，考虑标志的问题。十分难看。因此我想到的办法是直接抽象出一个接口，该接口直接实现Java数据和对象，与字节数组的相互转换：package info.skyblond.os.exp.experiment3.bytes; /** * 类型Wrapped，用于将值转换为字节数组 */ public interface Wrapped { /** * 转换为字节数组 */ byte[] toBytes(); /** * 占用的字节数 */ int byteCount(); /** * 从字节数组中载入值，失败直接抛异常 */ void loadFromBytes(byte[] bytes); }如果一个类要实现该接口，需要支持三个方法：toBytes负责将this对象转换为字节数组，转换方式由实现者自己决定byteCount返回this对象占用多少个字节，以便其他代码在读写时按照该大小分配缓冲区loadFromBytes从给定的字节数组中恢复数据，必须是就地恢复，不能返回一个新的对象。恢复方法由实现者自己决定，如果传入数据不满足要求或者恢复过程中出现异常，要不直接抛unchecked异常（即RuntimeException），要不自己想办法解决如果我们能够将Java的基本类型都实现了上述接口，后续一般性的对象只需要利用我们实现好的基本类就可以了。（不过有一说一，当时想的是对普通类做一个包装，因此接口名字就叫成Wrapped了，实践来说这并不是一个好的接口名称）Java基本类型到字节数组为了方便处理，我们可以将对基本类型的处理抽象成一个基类，再针对其中不同的地方分别Override，以此达到减少冗余代码、提高可读性与可维护性的目的：package info.skyblond.os.exp.experiment3.bytes.primitive; import info.skyblond.os.exp.experiment3.bytes.Wrapped; import info.skyblond.os.exp.utils.CommonUtils; import java.io.*; import java.util.Objects; /** * Java基本类型的Wrapper */ public abstract class WrappedBaseType<T> implements Wrapped { /** * 真正的值 */ private T value; /** * 通过默认值创建，默认值不得为空。由于只针对基本类型，因此本类不对外提供构造函数。 */ WrappedBaseType(T defaultValue) { this.value = Objects.requireNonNull(defaultValue); } /** * 使用{@link DataOutputStream}写入{@link ByteArrayOutputStream}以获得字节数组 */ @Override public byte[] toBytes() { var byteArrayOutputStream = new ByteArrayOutputStream(); var dataOutputStream = new DataOutputStream(byteArrayOutputStream); this.baseTypeWriteIntoDataOutput(dataOutputStream); try { dataOutputStream.close(); } catch (IOException e) { throw new RuntimeException(e); } return byteArrayOutputStream.toByteArray(); } /** * 具体将值写入{@link DataOutput} */ protected abstract void baseTypeWriteIntoDataOutput(DataOutput dataOutput); /** * 使用{@link DataInputStream}从{@link ByteArrayInputStream}中读取值 */ @Override public void loadFromBytes(byte[] bytes) { CommonUtils.require(Objects.requireNonNull(bytes).length == this.byteCount(), "Short supply of bytes."); var dataInputStream = new DataInputStream(new ByteArrayInputStream(bytes)); var result = this.baseTypeReadFromDataInput(dataInputStream); try { dataInputStream.close(); } catch (IOException e) { throw new RuntimeException(e); } this.value = result; } /** * 从给定{@link DataInput}中读取具体值 */ protected abstract T baseTypeReadFromDataInput(DataInput dataInput); /** * Getter，保证返回值不为空 */ public T getValue() { return Objects.requireNonNull(this.value); } /** * Setter，保证新的值不为空 */ public void setValue(T value) { this.value = Objects.requireNonNull(value); } /** * 核心值相同即认定相等 */ @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || this.getClass() != o.getClass()) { return false; } WrappedBaseType<?> that = (WrappedBaseType<?>) o; return this.value.equals(that.value); } @Override public int hashCode() { return Objects.hash(this.value); } @Override public String toString() { return "WrappedBaseType{" + "value=" + this.value + '}'; } }这个抽象基类带有一个泛型T，表示其包装的真正类型，例如WrappedBaseType<Integer>表示包装的int类型，在平时调用的过程中Java会自动帮我们处理基本类型到封装类的封箱拆箱。在抽象基类中有一个private T value;，用于存储真正的值，构造函数和Setter保证它永远不会为null。对于toBytes方法，我们借助ByteArrayOutputStream和DataOutputStream来转换。后者将被传递给一个特殊的成员方法baseTypeWriteIntoDataOutput，实现者需要在该方法中定义如何将value写入到给定的DataOutputStream中。而loadFromBytes也类似，实现者需要在特殊的成员方法中决定如何从给定的DataInputStream中读取值并写入value（当然，必须通过setter保证新的值不为null）。其中用到了我自己写的一个工具类：public static void require(boolean bool, String message) { if (!bool) { throw new IllegalStateException(message); } }写过Kotlin的话应该会知道，如果该函数传入的条件不满足（bool为false），那就当场抛一个异常，我认为这种略带暴力的条件检查十分优雅：不需要返回什么东西，也不需要包装什么自定义异常，更不需要复杂的流程处理和打印提示信息，直接一个Runtime异常崩了JVM，多好。这样一类编写基本类型的包装类就容易多了：package info.skyblond.os.exp.experiment3.bytes.primitive; import java.io.DataInput; import java.io.DataOutput; public class WrappedShort extends WrappedBaseType<Short> { /** * 默认值为0 */ public WrappedShort() { super((short) 0); } public WrappedShort(short b) { super(b); } @Override public void baseTypeWriteIntoDataOutput(DataOutput dataOutput) { try { dataOutput.writeShort(this.getValue()); } catch (Exception e) { throw new RuntimeException(e); } } @Override public Short baseTypeReadFromDataInput(DataInput dataInput) { try { return dataInput.readShort(); } catch (Exception e) { throw new RuntimeException(e); } } @Override public int byteCount() { return Short.BYTES; } }package info.skyblond.os.exp.experiment3.bytes.primitive; import java.io.DataInput; import java.io.DataOutput; public class WrappedInteger extends WrappedBaseType<Integer> { /** * 默认值为0 */ public WrappedInteger() { super(0); } public WrappedInteger(int b) { super(b); } @Override public void baseTypeWriteIntoDataOutput(DataOutput dataOutput) { try { dataOutput.writeInt(this.getValue()); } catch (Exception e) { throw new RuntimeException(e); } } @Override public Integer baseTypeReadFromDataInput(DataInput dataInput) { try { return dataInput.readInt(); } catch (Exception e) { throw new RuntimeException(e); } } @Override public int byteCount() { return Integer.BYTES; } }package info.skyblond.os.exp.experiment3.bytes.primitive; import java.io.DataInput; import java.io.DataOutput; public class WrappedLong extends WrappedBaseType<Long> { /** * 默认值为0 */ public WrappedLong() { super(0L); } public WrappedLong(long b) { super(b); } @Override public void baseTypeWriteIntoDataOutput(DataOutput dataOutput) { try { dataOutput.writeLong(this.getValue()); } catch (Exception e) { throw new RuntimeException(e); } } @Override public Long baseTypeReadFromDataInput(DataInput dataInput) { try { return dataInput.readLong(); } catch (Exception e) { throw new RuntimeException(e); } } @Override public int byteCount() { return Long.BYTES; } }虽然Java有8个基本类型，但是项目实际用得上的就Short、Integer和Long，分别用于存储盘块号、索引号和地址。如果使用Integer来存储盘块号，那么连Short都不用实现。对于其他的基本类型，想要拓展也很容易，只要定义好构造方法，然后DataInput和DataOutput支持，那么就直接调用对应的read和write方法即可。关于byteCount，直接返回基本类型的封装类里面事先定义好的常量即可。然而除了基本类型，项目里还常用一种数据结构：串。两种串到字节数组出于方便考虑，我没有实现变长列表（List），那样还要在数据之前存储元素个数，并且顺序存放变长数据，纯属是给自己找不痛快。因此我实现了串（数组）：在定义时说明元素个数。具体地说我实现了两种常用的数组数据：一个是字符串，用于用户名、密码和文件名的存储；另一种就是Bit串，用于BitMap和分配表的表示。字符串先来说说字符串吧，这个比较简单：package info.skyblond.os.exp.experiment3.bytes.array; import info.skyblond.os.exp.experiment3.bytes.Wrapped; import info.skyblond.os.exp.utils.CommonUtils; import java.nio.charset.StandardCharsets; import java.util.Objects; public class WrappedString implements Wrapped { /** * 这里的最大长度是指按UTF-8转换成字节后的长度。 * 通常纯英文字符没有问题，其他文字一个字对应多个字节（例如中文一个字对应三个字节） */ private final int maxLength; private String internalValue; /** * 长度检查，将字符串转化为字节后的长度与最大长度对比 */ private static void lengthCheck(int maxLength, String s) { var bytes = s.getBytes(StandardCharsets.UTF_8); CommonUtils.require(bytes.length <= maxLength, "Max length reached. Max: " + maxLength + ", actual: " + bytes.length); } public WrappedString(int maxLength) { this(maxLength, ""); } public WrappedString(int maxLength, String s) { CommonUtils.require(maxLength > 0, "Max length must bigger than 0."); lengthCheck(maxLength, s); this.internalValue = s; this.maxLength = maxLength; } @Override public byte[] toBytes() { return CommonUtils.pad(this.maxLength, this.internalValue.getBytes(StandardCharsets.UTF_8)); } @Override public void loadFromBytes(byte[] bytes) { var unpad = CommonUtils.unpad(bytes); CommonUtils.require(unpad.length <= this.maxLength, "Max length reached. Max: " + this.maxLength + ", actual: " + unpad.length); this.internalValue = new String(unpad, StandardCharsets.UTF_8); } @Override public int byteCount() { return this.maxLength; } public String getValue() { return Objects.requireNonNull(this.internalValue); } public void setValue(String value) { lengthCheck(this.maxLength, value); this.internalValue = Objects.requireNonNull(value); } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || this.getClass() != o.getClass()) { return false; } WrappedString that = (WrappedString) o; return this.maxLength == that.maxLength && this.internalValue.equals(that.internalValue); } @Override public int hashCode() { return Objects.hash(this.maxLength, this.internalValue); } @Override public String toString() { return "WrappedString{" + "maxLength=" + this.maxLength + ", internalValue='" + this.internalValue + '\'' + '}'; } }需要额外注意的就是由于Java底层支持UTF-8，因此字符串的长度在一些情况下并不严格对应写入磁盘的字节长度。因此字符串需要在一开始指定字节长度。在字节的转换上，我选择使用UTF-8编码进行转换，需要注意从文件读出来的字符串，以及其他代码通过setter设置的新字符串，都要将其转换为字节数组后再检查长度。而为了对齐设定的字节长度，我写了两个工具方法用于给字符串填充和删除0字符：/** * 按照最大长度将空缺的部分填0 */ public static byte[] pad(int maxLength, byte[] data) { CommonUtils.require(data.length <= maxLength, "The length of data is bigger than max length."); var result = new byte[maxLength]; int i = 0; for (; i < data.length; i++) { result[i] = data[i]; } for (; i < maxLength; i++) { result[i] = 0; } return result; } /** * 删掉末尾的连续的0 */ public static byte[] unpad(byte[] data) { int i = data.length - 1; while (i >= 0 && data[i] == 0) { i--; } var result = new byte[i + 1]; for (; i >= 0; i--) { result[i] = data[i]; } return result; }位串由于数据都是定长的，我们必须设定一个标志位来标记当前元素是否为已分配的状态，一般来说通常是Boolean类型来做这个事儿，但是在写入字节的时候，单独一个Boolean要占用一个字节，所以通常都是将他们集中存放，这样能够节省一些空间。实现如下：package info.skyblond.os.exp.experiment3.bytes.array; import info.skyblond.os.exp.experiment3.bytes.Wrapped; import info.skyblond.os.exp.utils.CommonUtils; import java.util.Arrays; import java.util.Objects; /** * 使用Byte数组表示Bit数组 */ public class WrappedBitArray implements Wrapped { /** * 内部的字节数组表示 */ private byte[] internalArray; /** * 总bit位数 */ private final int length; /** * 对应的字节数量 */ private final int internalLength; /** * 必须固定长度，且长度必须大于0 */ public WrappedBitArray(int length) { CommonUtils.require(length > 0, "Length must bigger than 0."); this.internalLength = CommonUtils.divCeil(length, Byte.SIZE); this.internalArray = new byte[this.internalLength]; this.length = length; } public int getLength() { return this.length; } @Override public byte[] toBytes() { return this.internalArray; } @Override public int byteCount() { return this.internalLength; } @Override public void loadFromBytes(byte[] bytes) { CommonUtils.require(bytes.length == this.internalLength, "Byte array length not match. Except: " + this.internalLength + ", actual: " + bytes.length); this.internalArray = bytes; } /** * 按bit索引获取，结果为Boolean，原Bit为1对应true */ public Boolean get(int index) { CommonUtils.require(index < this.length, "Index invalid. Length: " + this.length + ", actual: " + index); var b = this.internalArray[index / Byte.SIZE]; var i = 0x80 >> (index % Byte.SIZE); return (b & i) != 0; // not 0 -> 1 -> true; is 0 -> 0 -> false } /** * 按bit索引设置，传入为true，对应Bit写1 */ public void set(int index, boolean value) { CommonUtils.require(index < this.length, "Index invalid. Length: " + this.length + ", actual: " + index); var b = this.internalArray[index / Byte.SIZE]; var i = 0x80 >> (index % Byte.SIZE); if (value) { // writing 1 -> XXXXXXXX | 00010000 -> XXX1XXXX this.internalArray[index / Byte.SIZE] = (byte) (b | i); } else { // writing 0 -> XXXXXXXX & 11101111 -> XXX0XXXX this.internalArray[index / Byte.SIZE] = (byte) (b & ~i); } } /** * 获取值，将内部表示转换为boolean型数组 */ public Boolean[] getValue() { var result = new Boolean[this.length]; for (int i = 0; i < this.length; i++) { result[i] = this.get(i); } return result; } /** * 将Boolean数组解析成内部表示，传入的数组长度必须和bit的数量一样 */ public void setValue(Boolean[] value) { CommonUtils.require(value.length == this.length, "Boolean array length not match. Except: " + this.length + ", actual: " + value.length); for (int i = 0; i < this.length; i++) { this.set(i, value[i]); } } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || this.getClass() != o.getClass()) { return false; } WrappedBitArray that = (WrappedBitArray) o; return this.length == that.length && this.internalLength == that.internalLength && Arrays.equals(this.internalArray, that.internalArray); } @Override public int hashCode() { int result = Objects.hash(this.length, this.internalLength); result = 31 * result + Arrays.hashCode(this.internalArray); return result; } @Override public String toString() { StringBuilder stringBuilder = new StringBuilder(); for (int i = 0; i < this.length; i++) { if (this.get(i)) { stringBuilder.append("1"); } else { stringBuilder.append("0"); } } return "WrappedBitList{" + "length=" + this.length + ", " + "value=" + stringBuilder.toString() + '}'; } }该类的核心在于使用字节数组来表示位串，并且根据位串的索引定位需要操作的字节和字节内的位数。虽然写起来繁琐，但原理一点也不复杂，没什么需要多说的，可以参考注释阅读程序。其中用到一个工具方法：/** * 整数除法，结果返回向上取整的整型 */ public static int divCeil(int a, int b) { if (a % b == 0) { return a / b; } else { return a / b + 1; } } public static int divCeil(long a, int b) { if (a % b == 0) { return (int) (a / b); } else { return (int) (a / b + 1); } }这两个方法用于计算两个整型之间的除法，并将结果向上取整。比如存储9个bit，需要ceil(9, 8) = 2个字节才能储存。因此在余数不为0的情况加，自动给结果加1。至此Java中常用的基本类型已经经过包装，具备了转换到字节数组的能力，对于其他一般的类来说，我们可以利用已有的包装类组合来实现。任意类到字节数组为了简化这个过程，我编写了一个适用于任意类型的基类：package info.skyblond.os.exp.experiment3.bytes; import info.skyblond.os.exp.utils.CommonUtils; import java.io.*; import java.util.ArrayList; import java.util.List; import java.util.Objects; /** * Java泛型的Wrapper。限制比基本类型少需要，需要继承者处理大部分Null值和细节相关的东西。 */ public class WrappedGenericType implements Wrapped { /** * 参数列表 */ protected final List<Wrapped> parameters = new ArrayList<>(); /** * 使用{@link DataOutputStream}写入{@link ByteArrayOutputStream}以获得字节数组 */ @Override public byte[] toBytes() { var byteArrayOutputStream = new ByteArrayOutputStream(); var dataOutputStream = new DataOutputStream(byteArrayOutputStream); try { for (Wrapped w : this.parameters) { dataOutputStream.write(w.toBytes()); } dataOutputStream.close(); } catch (Exception e) { throw new RuntimeException(e); } return byteArrayOutputStream.toByteArray(); } @Override public int byteCount() { var result = 0; for (Wrapped w : this.parameters) { result += w.byteCount(); } return result; } /** * 使用{@link DataInputStream}从{@link ByteArrayInputStream}中读取值 */ @Override public void loadFromBytes(byte[] bytes) { CommonUtils.require(Objects.requireNonNull(bytes).length == this.byteCount(), "Supply bytes length not match."); var byteArrayInputStream = new ByteArrayInputStream(bytes); try { for (Wrapped w : this.parameters) { var temp = new byte[w.byteCount()]; CommonUtils.require(byteArrayInputStream.read(temp) == w.byteCount(), "Short supply of bytes."); w.loadFromBytes(temp); } byteArrayInputStream.close(); } catch (IOException e) { throw new RuntimeException(e); } } }这个类的核心在于List<Wrapped> parameters，继承者将维护这个列表，将其需要转换成字节数组的参数添加到这个列表中，在转换为字节数组的时候将会自动根据顺序依次拼接，实现了C语言中类似结构体写入文件的效果。而读取的时候也会根据顺序自动分配对应的字节供参数更新数据。而字节统计则自动根据提供的类型做累加。十分方便。总结至此我们已经完成了基础设施的构建工作。下一篇文章我将介绍磁盘的元数据如何描述，并定义一些基础操作和系统架构。-全文完-