1. 认识文件
狭义上的文件(file):针对硬盘这种持久化存储的 IO 设备,当我们想要进行数据保存时,往往不是保存成一个整体,而是独立成一个个的单位进行保存,这种独立的单位就被抽象成文件的概念
文件除了有数据内容之外,还有一部分信息,例如:文件名、文件类型、文件大小等并不作为文件的数据而存在,称这部分信息为文件的元信息
2. 树形结构组织和目录
随着文件越来越多,出现了采用树形结构这种层级结构来组织文件的方法,这样专门用来存放管理信息的特殊文件称为文件夹(folder)或目录(dierctory)
3. 文件路径(Path)
在树形结构的角度来看,树中的每个节点都可以被 一条从根开始,直到节点 的路径所描述,这种描述方式被称为文件的绝对路径(absolute path)
除了可以从根开始进行路径的描述,还可以从任意节点出发,进行路径的描述,这种描述方式就称为相对路径(relative path),相当于当前所在节点的一条路径(. 表示当前目录,.. 表示当前目录的上一级目录)
tip:
普通文件根据其保存数据的不同,被分为不同的类型
文本文件:保存被字符集(UTF8 / GBK)编码的文本
二进制文件:按照标准格式保存的非被字符集编码过的文件(如:.exe .dll .mp3 .mp4 .class)
补充:
Windows 操作系统上,会按照文件名后的后缀来确定文件类型及该类型文件的默认打开程序,但这个习惯不是通用的,在 OSX、Unix、Linux 等操作系统上,一般不会对文件类型做如此精确的分类
文件由于被操作系统进行了管理,所以可以根据不同的用户,赋予其不同的对该文件的权限,一般有 可读、可写、可执行 权限
Windows 操作系统上,还有一类文件比较特殊,就是快捷方式(shortcut),这种文件只是对真实文件的一种引用,其他操作系统上也有类似的概念,如:软连接(soft link)
很多系统为了实现接口的统一性,将所有的 IO 设备都抽象成了文件的概念,使用这一理念最为知名的就是 Unix、Linux 操作系统——万物皆文件
4. Java中操作文件
Java 中通过 java.io.File 类来对一个文件(包括目录)进行抽象的描述
tip:有 File 对象,并不代表真实存在该文件
文件的操作分为两大类
1) 文件系统操作:创建文件、删除文件、创建目录、重命名文件、判定文件存在…
2) 文件内容:读文件、写文件(Java中提供了 File 类进行文件系统操作,这个对象会使用“路径”进行初始化,从而表示一个具体的文件(这个文件可以存在,也可以不存在),基于这个对象进行后续操作)
5. File 类(文件系统操作)
5.1 属性
修饰符及类型 | 属性 | 说明 |
static String | pathSeparator | 依赖于系统的路径分隔符,String 类型的表示 |
static char | pathSeparator | 依赖于系统的路径分隔符,char 类型的表示 |
5.2 构造方法
签名 | 说明 |
File(File parent, String child) | 根据父目录 + 孩子文件路径,创建一个新的 File 实例 |
File(String pathname) | 根据文件路径创建一个新的 File 实例,路径可以是绝对路径或者相对路径(此处写作相对路径的时候,需要明确基准目录是啥) |
File(String parent, String child) | 根据父目录 + 孩子文件路径,创建一个新的 File 实例,父目录用路径表示 |
tip:代码中写的相对路径的基准目录是什么取决于运行程序的方式
- 直接在 IDEA 中运行,此时基准路径就是该项目所在的目录
- 在命令行中,通过 Java 命令来运行,此时基准路径就是 Java 命令所在的目录
- 某个程序,可能是被其他进程调用的,进程1通过创建子进程的方式,运行进程2(虽然在 Java 中很少见,但是可以做到),进程2的基准路径就和进程1相同
- 代码执行过程中,还可以通过一些 api 修改基准路径,改成我们指定的某个路径
5.3 方法
修饰符及返回值类型 | 方法签名 | 说明 |
String | getParent() | 返回 File 对象的父目录文件路径 |
String | getName() | 返回 File 对象的纯文件名称 |
String | getPath() | 返回 File 对象的文件路径 |
String | getAbsolutePath() | 返回 File 对象的绝对路径 |
String | getCanonicalPath() | 返回 File 对象修饰过的绝对路径 |
boolean | exists() | 判断 File 对象描述的文件是否真实存在 |
boolean | isDirectory() | 判断 File 对象代表的文件是否是一个目录 |
boolean | isFile() | 判断 File 对象代表的文件是否是一个普通文件 |
boolean | createNewFile() | 根据 File 对象,自动创建一个空文件,成功创建后返回 true |
boolean | delete() | 根据 File 对象,删除该文件,成功删除后返回 true |
void | deleteOnExit() | 根据 File 对象,标注文件被删除,删除动作会到 JVM 运行结束时才会进行 |
String[ ] | list() | 返回 File 对象代表的目录下所有的文件名 |
File[ ] | listFiles() | 返回 File 对象代表的目录下所有的文件,以 File 对象表示 |
boolean | mkdirs() | 创建 File 对象代表的目录 |
boolean | mkdirs() | 创建 File 对象代表的目录,如果必要,会创建中间目录 |
boolean | renameTo(File dest) | 进行文件改名,也可以视为我们平时的剪切、粘贴操作 A.renameTo(B) 将 A 的名字改为 B |
boolean | canRead() | 判断用户是否对文件有可读权限 |
boolean | canWrite() | 判断用户是否对文件有可写权限 |
示例:
1) 绝对路径:
import java.io.File;
import java.io.IOException;
public class Demo1 {
public static void main(String[] args) throws IOException {
File file = new File("D:\\haha\\test.txt");
System.out.println(file.getParent());//父目录文件路径
System.out.println(file.getName());//文件名
System.out.println(file.getPath());//文件路径
System.out.println(file.getAbsolutePath());//绝对路径
System.out.println(file.getCanonicalPath());//修饰过的绝对路径
}
}
2) 相对路径:
import java.io.File;
import java.io.IOException;
public class Demo1 {
public static void main(String[] args) throws IOException {
//File file = new File("./test.txt");
File file = new File("../test.txt");//返回上一级
System.out.println(file.getParent());//父目录文件路径
System.out.println(file.getName());//文件名
System.out.println(file.getPath());//文件路径
System.out.println(file.getAbsolutePath());//绝对路径
System.out.println(file.getCanonicalPath());//修饰过的绝对路径
}
}
3) 创建文件:
import java.io.File;
import java.io.IOException;
public class Demo2 {
public static void main(String[] args) throws IOException {
File file = new File("./test.txt");
boolean ok = file.createNewFile();//创建空文件夹
System.out.println(ok);//判断是否创建成功
System.out.println(file.exists());//判断文件是否存在
System.out.println(file.isFile());//判断是否为普通文件
System.out.println(file.isDirectory());//判断是否为目录
}
}
4) 删除文件:
import java.io.File;
public class Demo3 {
public static void main(String[] args) {
File file = new File("./test.txt");
boolean ok = file.delete();//删除该文件
System.out.println(ok);
}
}
5) 进程结束后删除文件:
import java.io.File;
import java.util.Scanner;
public class Demo4 {
public static void main(String[] args) {
File file = new File("./test.txt");
file.deleteOnExit();//在进程退出的时候删除
System.out.println("删除操作执行完毕");
Scanner scanner = new Scanner(System.in);
scanner.next();//用一个输入操作使进程不立即结束
}
}
该方法存在的意义:用来构造“临时文件”
6) 返回 File 对象代表目录下所有文件名:
import java.io.File;
import java.util.Arrays;
public class Demo5 {
public static void main(String[] args) {
File file = new File("./src");
//返回当前目录下所有文件名
System.out.println(Arrays.toString(file.list()));
}
}
7) 以 File 对象表示:
import java.io.File;
import java.util.Arrays;
public class Demo5 {
public static void main(String[] args) {
File file = new File("./src");
//返回当前目录下所有文件名
System.out.println(Arrays.toString(file.listFiles()));
}
}
tip:直接使用 list / listFile 只能看到当前目录中的内容,要想看到某个目录下所有的目录和文件,就需要递归完成
8) 递归打印当前目录下所有目录和文件:
import java.io.File;
public class Demo6 {
public static void main(String[] args) {
File f = new File("./");
scan(f);
}
private static void scan(File currentDir) {
//1.先判断是否是目录
if (!currentDir.isDirectory()) {
return;
}
//2.列出当前目录中包含的内容
File[] files = currentDir.listFiles();
//当前数组为空或者数组不为空,但里面没有元素
if(files == null || files.length == 0) {
//不存在的路径 / 空目录
return;
}
//3.打印当前目录
System.out.println(currentDir.getAbsolutePath());
//4.遍历当前目录所有内容,依次进行判定
for (File f : files) {
if(f.isFile()) {
//如果是普通文件,直接打印文件路径
System.out.println(f.getAbsolutePath());
} else {
//如果是目录,就继续递归
scan(f);
}
}
}
}
9) 创建目录
import java.io.File;
public class Demo7 {
public static void main(String[] args) {
File file = new File("./abc/def/ghi/jkl");
boolean ok = file.mkdirs();
System.out.println(ok);
}
}
10) 移动文件
也就是修改文件所在的路径,文件路径的修改,也可以视为一种“重命名”
import java.io.File;
public class Demo8 {
public static void main(String[] args) {
File srcFile = new File("./abc");
File destFile = new File("./abc1234");
boolean ok = srcFile.renameTo(destFile);
System.out.println(ok);
}
}
6. 数据流(文件内容的读写)
文件内容操作就是读文件和写文件,操作系统提供了 API,Java 对其进行了封装,将这些封装的类称为“文件流” / “ IO 流”
Java 实现 IO 流的类有很多,主要分为两个大类:
- 字节流(二进制):读写数据的基本单位就是字节(InputStream、OutputStream)
- 字符流(文本):读写数据的基本单位就是字符,字符流内部做的工作会更多一些,会自动查询码表,把二进制数据转换成对应的字符(Reader、Writer)
上面四个类都是“抽象类”,真正干活的并不是他们,在 Java 中提供了很多类来实现上述四个抽象类,虽然类的种类很多,但是用法都是差不多的
tip:数据远离 cpu 就是输出,数据靠近 cpu 就是输入
但凡类的名字以 Reader、Writer 结尾的,就是实现了 Reader 和 Writer 的字符流对象
但凡类的名字以 InputStream、OutputStream 结尾的,就是实现了 InputStream 和 OutputStream 的字节流对象
6.1 InputStream 概述
方法
返回值类型 | 方法名 | 说明 |
int | read() | 读取一个字节的数据,返回 -1 代表已经读完了 |
int | read(byte [ ] b) | 最多读取 b.length 字节的数据到 b 中,返回实际读到的数量,-1 代表已经读完了 |
int | read(byte[ ] b, int off, int len) | 最多读取 len - off 字节的数据到 b 中,放在从 off 开始,返回实际读到的数量,-1 代表已经读完了 |
void | close() | 关闭字节流 |
说明
InputStream 只是一个抽象类,要使用还需要具体的实现类,关于 InputStream 的实现类有很多,基本可以认为不同的输入设备都可以对应一个 InputStream 类,我们现在只关心从文件中读取,所以使用 FileInputStream
FileInputStream 概述
构造方法
方法名 | 说明 |
FileInputStream(File file) | 利用 File 构造输入流 |
FileInoutStream(String name) | 利用文件路径构造文件输入流 |
示例:
完全读取文件
第一步:打开文件
//该代码只是打开文件和关闭文件,没有读取
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
public class Demo9 {
public static void main(String[] args) {
InputStream inputStream = null;
try {
inputStream = new FileInputStream("./test.txt");//打开文件
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
} finally {
try {
inputStream.close();//关闭文件
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
}
隐含打开操作
有打开就必然有关闭
打开文件,其实是在 该进程的 文件描述符表 中创建了一个新的表项
- 进程:PCB(进程控制块)
- 文件描述符表:描述了该进程都要操作哪些文件,可以认为其是一个数组,数组的每个元素就是一个 struct file 对象(Linux 内核),每个结构体都描述了对应操作的文件的信息,数组的下标就称为“文件描述符”
文件资源泄露问题
而每次打开一个文件,就相当于在数组上占用了一个位置,在系统内核中,文件描述符表数组是固定长度 & 不可扩容的,除非主动调用 close 关闭文件,否则就会使这里的资源越来越少,若数组满了,后续再打开文件就会失败
上述问题就称为“文件资源泄露”,该问题隐蔽性很高,在编写相关代码时,一定要注意关闭操作
在本例中,虽然要求使用完毕后要关闭,但是本代码不写 close 也行,因为 close 后面进程就结束了
close 就是释放文件描述符表里的元素,这里进程结束,意味着整个 pcb 销毁了,pcb 上的文件描述符表整个释放了
关闭文件常用写法:
本例中,将关闭操作放在 finally 中保证其能运行,虽然很严谨,但是比较麻烦,下面一种简单 & 可靠的方法:
try (InputStream inputStream = new FileInputStream("./test.txt")) {
} catch (IOException e) {
e.printStackTrace();
}
前提:必须是实现了 Closeable 接口的类才能放到 try() 里面
这种写法,既不必写 finally,也不必写 close 了
try with resources 这里的()中创建的资源(可以是多个),try 的 { } 执行完毕后,会自动执行这里的 close
第二步:读取文件 read()
该方法调用一次,读一个字节,返回值就是这个字节的内容(byte)
这里之所以返回值是 int 的原因是因为:byte => 8 比特位,表示的数据范围(不考虑符号位):0 -> 255
因为 read 读取到流的末尾发现没有字节可用的话,就会返回 -1,byte 表达不了,只能用 int(short不做考虑)
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
public class Demo10 {
public static void main(String[] args) {
try (InputStream inputStream = new FileInputStream("./test.txt")) {
while (true) {
int b = inputStream.read();
if (b == -1) {
//读取完了
break;
}
//表示字节,使用十六进制打印显示
System.out.printf("0x%x\n", b);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
读取字符:hello
读取字符:你好
上述是 read 的无参版本,一次只读一个字节,这样的效率是很低的,频繁读取多次硬盘,硬盘的 IO 是耗时比较大的,希望能减少 IO 的次数
使用到 read(byte [ ] b)
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
public class Demo11 {
public static void main(String[] args) {
try (InputStream inputStream = new FileInputStream("./test.txt")) {
while (true) {
byte[] buffer = new byte[1024];
int n = inputStream.read(buffer);
if(n == -1) {
break;
}
for (int i = 0; i < n; i++) {
System.out.printf("0x%x\n", buffer[i]);
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
此处把 buffer 形参当成了“输出型参数”,使用参数来表示输出的结果(“输入性参数”使用返回值表示输出结果)
这个操作会把硬盘中读到的对应数据填充到 buffer 内存的字节数组中,一次 IO 尽可能填满 buffer 数组,这样虽然一次读取的内容变多了,但是比一次读取一个,分很多次读,效率高很多
tip:while 循环在进行第二次循环时,由于已无字节可读,n 被赋值为 -1,break 循环结束
使用 read(byte[ ] b, int off, int len)
这个版本类似于上面的,也是把数据往字节数组 b 里填充,但不是使用整个数组了,而是使用数组中 [off, off + len] 范围的区间(offset 是“偏移量”)
6.2 OutputStream 概述
方法
返回值类型 | 方法名 | 说明 |
void | write(int b) | 写入要给字节的数据 |
void | write(byte[ ] b) | 将 b 这个字符数组中的数据全部写入到 os 中 |
int | write(byte[ ] b, int off, int len) | 将 b 这个字符数组中从 off 开始的数据写入 os 中,一共写 len 个 |
void | close() | 关闭字节流 |
void | flush() | 刷新操作,将数据刷到设备中 |
tip:IO 的速度是很慢的,所以大多的 OutputStream 为了减少设备操作的次数,在写数据时都会将数据先暂时写入内存的一个指定区域内,直到该区域满了或其他指定条件时才真正将数据写入设备中,这个区域一般称为缓冲区,但这样做造成一个结果:写的数据,可能会遗留一部分在缓冲区中,需要在最后或者合适的位置,调用 flush(刷新)操作,将数据刷到设备中
说明:OutputStream 同样只是⼀个抽象类,要使⽤还需要具体的实现类。我们现在还是只关⼼写⼊⽂件中,所以使⽤ FileOutputStream
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
public class Demo12 {
public static void main(String[] args) {
try (OutputStream outputStream = new FileOutputStream("./test.txt")) {
outputStream.write(97);
} catch (IOException e) {
e.printStackTrace();
}
}
}
可以看到 test 文件中被写入了小写的 a,下面再写入字符“你好”的十六进制码
发现写操作会将之前的内容清空,再进行写操作,只要使用 OutputStream 打开文件,内容就无了
追加写操作
OutputStream 默认是写入之前会清空原来的内容,还有一个操作“追加写”,保持原内容不变,在末尾继续写入新内容
开启追加写模式,并以一次多字节方式写入:
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
public class Demo12 {
public static void main(String[] args) {
try (OutputStream outputStream = new FileOutputStream("./test.txt",true)) {
byte[] buffer = new byte[] { (byte)0xe4, (byte)0xbd, (byte)0xa0, (byte)0xe5, (byte)0xa5, (byte)0xbd };
outputStream.write(buffer);
} catch (IOException e) {
e.printStackTrace();
}
}
}
当利用 write(byte[ ] b, int off, int len) 进行字符输入时,一定要确保在 utf8 中能查找到,否则就会出现乱码:
字符流
InputStream / OutputStream 读写数据是按照字节来操作的,如果要读写字符(中文)的话,此时就需要我们手动来区分哪几个字节是一个字符,再确保把这几个字节作为整体来写入,这样太麻烦,为了方便处理字符,引入了字符流
示例:读取 test 文件中的每一个字符
import java.io.FileReader;
import java.io.IOException;
import java.io.Reader;
public class Demo13 {
public static void main(String[] args) {
try (Reader reader = new FileReader("./test.txt")) {
while (true) {
int c = reader.read();
if(c == -1) {
return;
}
char ch = (char)c;
System.out.println(ch);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
tip:在上面读取字符“你好”的时候可以发现,其是按照 utf8 编码来存储的,每个汉字是 3 个字节
而在 Java 中的 char 是 2 个字节,这是因为当使用 char 表示这里的汉字时,不再使用 utf8 而是使用 unicode,在unicode 中,一个汉字就是 2 个字节,如下图
使用字符流读取数据的过程中,Java 标准库内部就自动针对数据进行编码进行转码了
字符数组方式代码:
import java.io.FileReader;
import java.io.IOException;
import java.io.Reader;
public class Demo14 {
public static void main(String[] args) {
try (Reader reader = new FileReader("./test.txt")) {
char[] buffer = new char[1024];
int n = reader.read(buffer);
System.out.println(n);
for (int i = 0; i < n; i++) {
System.out.println(buffer[i]);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
运行结果:
写入操作 Writer
import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;
public class Demo15 {
public static void main(String[] args) {
try (Writer writer = new FileWriter("./test.txt")) {
writer.write("你好世界");
} catch (IOException e) {
e.printStackTrace();
}
}
}
运行结果:
也是通过传入参数 true 来设置为追加写
利用 Scanner 进行字符读取
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Scanner;
public class Demo16 {
public static void main(String[] args) {
try (InputStream inputStream = new FileInputStream("./test.txt")) {
Scanner scanner = new Scanner(inputStream);
while (scanner.hasNextInt()) {
System.out.println(scanner.nextInt());
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
7. 实际应用:
示例 1:扫描指定目录,找到名称中包含指定字符的所有普通文件(不含目录),并询问用户是否要删除该文件
import java.io.File;
import java.util.Scanner;
public class Demo17 {
public static void scan(File currentFile, String key) {
if(!currentFile.isDirectory()) {
return;
}
File[] files = currentFile.listFiles();
if(files == null || files.length == 0) {
return;
}
for (File f : files) {
if(f.isFile()) {
//针对普通文件进行处理
//判断文件名是否符合要求并提示用户删除
doDelete(f, key);
} else {
//针对目录进行处理
//继续递归
scan(f, key);
}
}
}
private static void doDelete(File f, String key) {
if(!f.getName().contains(key)) {
//文件名中不包含指定关键字
return;
}
//提示用户是否确认删除
Scanner scanner = new Scanner(System.in);
System.out.println(f.getAbsolutePath() + " 是否确认删除 Y / N");
String choice = scanner.next();
if(choice.equals("Y") || choice.equals("y")) {
f.delete();
}
}
public static void main(String[] args) {
System.out.println("请输入要搜索的路径:");
Scanner scanner = new Scanner(System.in);
String rootPath = scanner.next();
File rootFile = new File(rootPath);
if(!rootFile.isDirectory()) {
System.out.println("输入路径不存在!");
return;
}
System.out.println("请输入要删除文件名的关键字:");
String key = scanner.next();
//进行递归查找
scan(rootFile, key);
}
}
示例2:进行普通文件的复制
import java.io.*;
import java.util.Scanner;
public class Demo18 {
public static void main(String[] args) {
//1.输入路径并作校验
Scanner scanner = new Scanner(System.in);
System.out.println("请输入源文件的路径:");
String srcPath = scanner.next();
File srcFile = new File(srcPath);
if (!srcFile.isFile()) {
System.out.println("源文件路径有误!");
return;
}
System.out.println("请输入目标文件的路径:");
String destPath = scanner.next();
File destFile = new File(destPath);
if (!destFile.getParentFile().isDirectory()) {
System.out.println("目标文件路径有误!");
return;
}
//2.执行复制过程
try (InputStream inputStream = new FileInputStream(srcFile);
OutputStream outputStream = new FileOutputStream(destFile)) {
while (true) {
byte[] buffer = new byte[1024];
int n = inputStream.read(buffer);
System.out.println("n = " + n);
if(n == -1) {
break;
}
//将 buffer 写入 outputStream 中
outputStream.write(buffer, 0, n);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
tip:
示例3:扫描指定目录,并找到名称或者内容中包含指定字符的所有普通文件(不包含目录)
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.io.Reader;
import java.util.Scanner;
public class Demo19 {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入要搜索的路径:");
String rootPath = scanner.next();
File rootFile = new File(rootPath);
if (!rootFile.isDirectory()) {
System.out.println("要搜索的路径有误!");
return;
}
System.out.println("请输入要搜索的查询词:");
String key = scanner.next();
//进行递归
scan(rootFile, key);
}
public static void scan(File rootFile, String key) {
if(!rootFile.isDirectory()) {
return;
}
File[] files = rootFile.listFiles();
if (files == null || files.length == 0) {
return;
}
for (File f : files) {
if (f.isFile()) {
//若是文件,进行后续操作
doSearch(f, key);
} else {
//递归
scan(f, key);
}
}
}
public static void doSearch(File f, String key) {
//打开文件,读取文件内容,判断文件内容是否包含 key
StringBuilder stringBuilder = new StringBuilder();
try (Reader reader = new FileReader(f)) {
char[] buffer = new char[1024];
while (true) {
int n = reader.read(buffer);
if(n == -1) {
break;
}
String s = new String(buffer, 0, n);
stringBuilder.append(s);
}
} catch (IOException e) {
e.printStackTrace();
}
if(stringBuilder.indexOf(key) == -1) {
//未找到
return;
}
//找到了
System.out.println("找到匹配的文件:" + f.getAbsolutePath());
}
}
tip:这里的代码逻辑是非常低的,每次查询都会涉及到大量的硬盘 IO 操作
这种思路不适合频繁查询的场景,也不适合目录中文件数目特别多的场景
搜索引擎就是这种基于内容的查询,但是它不是通过上述“遍历文件”的方式实现的,而是引入了一个数据结构:“倒排索引”