背景
各文件系统下, 都有提供文件查找的功能, 但是一般而言搜索速度很慢
本项目仿照 everything 工具, 实现本地文件的快速搜索
实现功能
- 选择指定本地目录, 根据输入的信息, 进行搜索, 显示指定目录下的匹配文件信息
- 文件夹包含中文时, 支持汉语拼音搜索 (全拼 / 首字母匹配)
相关技术
Java + Servlet + Pinyin4j
JDBC + SQLite (SQLite 相对于 MySQL 更加轻量, 并且引入 jar 包即可使用, 不必安装配套应用)
JavaFx
数据库设计
SQLite 创建 SQL 的语句如下
java
create table if not exists file_meta (
id INTEGER primary key autoincrement,
name varchar(50) not null,
path varchar(512) not null,
is_directory boolean not null,
pinyin varchar(100) not null,
pinyin_first varchar(50) not null,
size BIGINT not null,
last_modified timestamp not null
);
项目的基本框架
前端页面
app.fxml 文件
显示界面的图画化结构
java
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.cell.PropertyValueFactory?>
<GridPane fx:controller="gui.GUIController" fx:id="gridPane" vgap="10" alignment="center" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1">
<children >
<Button fx:id="button" onMouseClicked="#choose" prefWidth="90" text="选择目录" GridPane.rowIndex="0" GridPane.columnIndex="0"></Button>
<Label fx:id="label" text="当前未选择目录" GridPane.rowIndex="0" GridPane.columnIndex="0">
<GridPane.margin>
<Insets left="100"></Insets>
</GridPane.margin>
</Label>
<TextField fx:id="textField" prefWidth="900" GridPane.rowIndex="1" GridPane.columnIndex="0" ></TextField>
<TableView fx:id="tableView" prefWidth="900" prefHeight="700" GridPane.rowIndex="2" GridPane.columnIndex="0">
<columns>
<TableColumn prefWidth="220" text="文件名">
<cellValueFactory>
<PropertyValueFactory property="name"></PropertyValueFactory>
</cellValueFactory>
</TableColumn>
<TableColumn prefWidth="400" text="路径">
<cellValueFactory>
<PropertyValueFactory property="path"></PropertyValueFactory>
</cellValueFactory>
</TableColumn>
<TableColumn prefWidth="100" text="大小">
<cellValueFactory>
<PropertyValueFactory property="sizeText"></PropertyValueFactory>
</cellValueFactory>
</TableColumn>
<TableColumn prefWidth="180" text="修改时间">
<cellValueFactory>
<PropertyValueFactory property="lastModifiedText"></PropertyValueFactory>
</cellValueFactory>
</TableColumn>
</columns>
</TableView>
</children>
</GridPane>
GUIController 类
与 app.fxml 文件配套使用, 该类离实现了 界面中按键的绑定事件, 以及对搜索框内容进行监听 , 当搜索框内容改变时, 重新搜索, 并将结果返回到查询结果显示处 (实现动态搜索功能)
java
public class GUIController implements Initializable {
@FXML
private Label label;
@FXML
private GridPane gridPane;
@FXML
private Button button;
@FXML
private TextField textField;
@FXML
private TableView<FileMeta> tableView;
private SearchService searchService = null;
@Override
public void initialize(URL location, ResourceBundle resources) {
// 在这里对 输入框 加一个监听器
// 需要指定对 text 这个内容属性进行监听
// textField.textProperty() 获取输入框里的内容
textField.textProperty().addListener(new ChangeListener<String>() {
/**
* 会在用户每次修改 输入框内容 的时候, 被自动调用到
* @param observable
* @param oldValue 输入框被修改之前的值
* @param newValue 输入框被修改之后的值
*/
@Override
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
// 此处要干的事情, 是根据新的值, 重新进行查询操作
freshTable(newValue);
}
});
}
private void freshTable(String query) {
// 重新查询数据库, 把查询结果, 设置到表格中
if(searchService == null) {
System.out.println("searchService 尚未初始化, 不能查询!");
return;
}
// 把之前表里的内容清空掉
ObservableList<FileMeta> fileMetas = tableView.getItems();
fileMetas.clear();
List<FileMeta> results = searchService.search(query);
fileMetas.addAll(results);
}
/**
* 使用该方法, 作为鼠标点击事件的回调函数
* @param mouseEvent
*/
public void choose(MouseEvent mouseEvent) {
// 创建一个 目录选择器
DirectoryChooser directoryChooser = new DirectoryChooser();
// 把该对话框显示出来
Window window = gridPane.getScene().getWindow();
File file = directoryChooser.showDialog(window);
if(file == null) {
System.out.println("用户选择的路径为空");
} else {
System.out.println(file.getAbsolutePath());
}
// 把用户选择的路径,显示到 label 中
label.setText(file.getAbsolutePath());
// 如果不是首次扫描, 就应该停止上次扫描任务, 执行本次扫描任务
if(searchService != null) {
searchService.shutdown();
}
// 对用户选择的路径进行扫描, 初始化
searchService = new SearchService();
searchService.init(file.getAbsolutePath());
}
}
GUIClient
继承 Application 方法, 为界面启动类, 调用 javafx 提供的 launch 方法来启动整个程序
java
public class GUIClient extends Application {
/**
* 程序启动时, 会立即执行的方法
* @param primaryStage
* @throws Exception
*/
@Override
public void start(Stage primaryStage) throws Exception {
// 加载 fxml 文件, 把 fxml 文件里的内容, 给设置到舞台中
Parent parent = FXMLLoader.load(GUIClient.class.getClassLoader().getResource("app.fxml"));
primaryStage.setScene(new Scene(parent, 1000, 800));
primaryStage.setTitle("文件搜索工具");
// 准备工作完成, 显示场景界面
primaryStage.show();
}
public static void main(String[] args) {
// 调用 javafx 提供的 launch 方法来启动整个程序
launch(args);
}
}
后端代码
实体类
FileMeta
本类对应着数据库的 file_meta 表
因为没引入 lombok, 因此只能手写 Setter 和 Getter 方法
java
// 本类的示例就代表 file_meta 表里的每个记录.
public class FileMeta {
private int id;
private String name;
private String path;
private boolean isDirectory;
// 这里存储的 size 是字节, 但是界面上输出的不应该以字节位单位, k, m, g
private long size;
// 这个存储的是时间戳(机器能看懂)
private long lastModified;
// 这个是进行格式化转换之后的时间格式(人能看懂的)
// private long lastModifiedText;
// 构造方法
public FileMeta(String name, String path, boolean isDirectory, long size, long lastModified) {
this.name = name;
this.path = path;
this.isDirectory = isDirectory;
this.size = size;
this.lastModified = lastModified;
}
public FileMeta(File f) {
this(f.getName(), f.getParent(), f.isDirectory(), f.length(), f.lastModified());
}
public String getPinyin() {
return PinyinUtil.get(name, true);
}
public String getPinyinFirst() {
return PinyinUtil.get(name, false);
}
public String getSizeText() {
// 常见单位: Byte, KB, MB, GB, TB
// 如果 size < 1024, 使用 Byte
// 如果 1024 <= size < 1024*1024, 使用 MB
// ...
double curSize = size;
String[] units = {"Byte", "KB", "MB", "GB", "TB"};
for(int i=0;i<units.length;i++) {
if(curSize < 1024) {
return String.format("%.2f " + units[i], new BigDecimal(curSize));
}
curSize /= 1024;
}
return String.format("%.2f TB", new BigDecimal(curSize));
}
public String getLastModifiedText() {
DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
return dateFormat.format(lastModified);
}
// -------------------------------
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public boolean isDirectory() {
return isDirectory;
}
public void setDirectory(boolean directory) {
isDirectory = directory;
}
public long getSize() {
return size;
}
public void setSize(long size) {
this.size = size;
}
public long getLastModified() {
return lastModified;
}
public void setLastModified(long lastModified) {
this.lastModified = lastModified;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
FileMeta fileMeta = (FileMeta) o;
return isDirectory == fileMeta.isDirectory && name.equals(fileMeta.name) && path.equals(fileMeta.path);
}
@Override
public int hashCode() {
return Objects.hash(name, path, isDirectory);
}
}
DBUtil
本类封装 JDBC 的 连接/关闭/管理 操作
java
public class DBUtil {
// 使用 单例模式(懒汉模式) 来提供 DataSource
private static volatile DataSource dataSource = DBUtil.getDataSource();
// 创建数据源: Datasource
public static DataSource getDataSource() {
if (dataSource == null) { //外层 if 判断是否要加锁 (加锁是要消耗资源的, if判断一下比 synchronized 加一次锁消耗资源要少的多)
synchronized (DBUtil.class) {
if(dataSource == null ) { //内层 if 判断是否要创建 DataSource
dataSource = new SQLiteDataSource();
((SQLiteDataSource)dataSource).setUrl("jdbc:sqlite://D:/AAASpringBootProject/sqlite/fileSearcher.db");
}
}
}
return dataSource;
}
// 建立连接
public static Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
// 断开连接
public static void close(Connection connection, Statement statement, ResultSet resultSet) {
if(resultSet != null) {
try {
resultSet.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if(statement != null) {
try {
statement.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if(connection != null ) {
try {
connection.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
FileDao 类
通过该类来实现数据库的增删改查操作
java
// 通过这个类来封装针对 file_meta 表的操作
public class FileDao {
// 1.初始化数据库 (建表)
public void initDB() {
// 1) 先能够读取到 db.sql 中的 SQL 语句
// 2) 根据 SQL 语句调用 jdbc 执行操作
Connection connection = null;
Statement statement = null;
try {
connection = DBUtil.getConnection();
// 使用 connection.createStatement() 来执行建库建表 sql
statement = connection.createStatement();
String[] sqls = getInitSql();
for(String sql : sqls) {
System.out.println("[initDB] sql:" + sql);
statement.executeUpdate(sql); //该方式用来执行一些基本不变的sql语句
}
} catch (SQLException | IOException e) {
e.printStackTrace();
} finally {
DBUtil.close(connection, statement, null);
}
}
// 从 db.sql 中读取文件内容
// 一个 sql 语句对应一个 String, 多个 sql 语句对应 String[]
private String[] getInitSql() throws IOException {
// 用这个 StringBuilder 来存储最终结果
StringBuilder stringBuilder = new StringBuilder();
// 此处需要动态获取到 db.sql 文件的路径, 而不是一个写死的绝对路径(运行在别人的电脑上的)
try(InputStream inputStream = FileDao.class.getClassLoader().getResourceAsStream("db.sql")) {
// 这里是字节流到字符流的转换(对字符能轻松的进行操作, 对字节的操作要难得多)
try(InputStreamReader inputStreamReader = new InputStreamReader(inputStream, "utf8")) {
while(true) {
// char 类型读取不到 -1, 也就没有文件读取结束的标记了, 所以这里使用 int 来接收
int ch = inputStream.read(); //inputStream.read() 读取到文件结束符会返回 -1
if( ch == -1) break; //文件读取完毕
stringBuilder.append((char) ch);
}
}
} catch (IOException e) {
e.printStackTrace();
}
// sql 语句以 ';' 结束, 我们就以 ';' 来拆分字符串, 一句 sql 就是一个 String
return stringBuilder.toString().split(";");
}
// 2.插入文件/目录数据到数据库中
// 此处提供"批量插入"操作
public void add(List<FileMeta> fileMetas) {
Connection connection = null;
PreparedStatement statement = null;
try {
connection = DBUtil.getConnection();
// 关闭自动提交功能
// (一次插入多条数据, 如果一个一个插,数据库就要打开-关闭-打开-关闭... 因此一次插入多条数据, 会减小数据库的资源消耗)
connection.setAutoCommit(false);
String sql = "insert into file_meta values(null, ?, ?, ?, ?, ?, ?, ?)";
statement = connection.prepareStatement(sql);
for(FileMeta fileMeta : fileMetas) {
// 把当前 FileMeta 对象, 替换到 SQL 语句中.
statement.setString(1,fileMeta.getName());
statement.setString(2,fileMeta.getPath());
statement.setBoolean(3, fileMeta.isDirectory());
statement.setString(4,fileMeta.getPinyin());
statement.setString(5,fileMeta.getPinyinFirst());
statement.setLong(6, fileMeta.getSize());
statement.setTimestamp(7, new Timestamp(fileMeta.getLastModified()));
// 使用 addBatch 把构造好的片段连接起来
// addBatch 会把已经构造好的 SQL 保存起来, 同时又会允许重新构造一个新的 SQL 出来
statement.addBatch();
System.out.println("[insert] 插入文件: " + fileMeta.getPath() + File.separator + fileMeta.getName());
}
// 执行所有的 sql 片段
statement.executeBatch();
// 执行完毕后, 通过 commit 告诉数据库, 添加完毕, 执行上述 batch 操作(自动提交已经关闭了)
connection.commit();
} catch (SQLException e) {
try {
if(connection != null) {
// 如果连接已建立, 并且出现异常, 那就是提交的内容有错误, 此时进行回滚操作
connection.rollback();
}
} catch (SQLException ex) {
ex.printStackTrace();
}
} finally {
DBUtil.close(connection, statement, null);
}
}
/**
* 3.按照特定的关键词进行查询
* 此处查询 pattern , 可能是文件名的一部分, 可能是文件名拼音的一部分, 也可能是拼音首字母的一部分 ...
* @param pattern 根据 pattern 查询数据库匹配内容
* @return
*/
public List<FileMeta> searchByPattern(String pattern) {
List<FileMeta> fileMetas = new ArrayList<>();
Connection connection = null;
PreparedStatement statement = null;
ResultSet resultSet = null;
try {
connection = DBUtil.getConnection();
String sql = "select name, path, is_directory, size, last_modified from file_meta " +
" where name like ? or pinyin like ? or pinyin_first like ? " +
" order by path, name";
statement = connection.prepareStatement(sql);
statement.setString(1,"%" + pattern + "%");
statement.setString(2,"%" + pattern + "%");
statement.setString(3,"%" + pattern + "%");
resultSet = statement.executeQuery();
while(resultSet.next()) {
String name = resultSet.getString("name");
String path = resultSet.getString("path");
boolean isDirectory = resultSet.getBoolean("is_directory");
long size = resultSet.getLong("size");
Timestamp lastModified = resultSet.getTimestamp("last_modified");
FileMeta fileMeta = new FileMeta(name, path, isDirectory, size, lastModified.getTime());
fileMetas.add(fileMeta);
}
} catch (SQLException e) {
e.printStackTrace();
} finally {
DBUtil.close(connection, statement, resultSet);
}
return fileMetas;
}
/**
* 根据给定路径查询结果.
* @param targetPath 给定路径
* @return 该路径下的所有文件信息(一层)
*/
public List<FileMeta> searchByPath(String targetPath) {
List<FileMeta> fileMetas = new ArrayList<>();
Connection connection = null;
PreparedStatement statement = null;
ResultSet resultSet = null;
try {
connection = DBUtil.getConnection();
String sql = "select name, path, is_directory, size, last_modified from file_meta " +
" where path = ?";
statement = connection.prepareStatement(sql);
statement.setString(1, targetPath);
resultSet = statement.executeQuery();
while(resultSet.next()) {
String name = resultSet.getString("name");
String path = resultSet.getString("path");
boolean isDirectory = resultSet.getBoolean("is_directory");
long size = resultSet.getLong("size");
Timestamp lastModified = resultSet.getTimestamp("last_modified");
FileMeta fileMeta = new FileMeta(name, path, isDirectory, size, lastModified.getTime());
fileMetas.add(fileMeta);
}
} catch (SQLException e) {
e.printStackTrace();
} finally {
DBUtil.close(connection, statement, resultSet);
}
return fileMetas;
}
/**
* 发现某个文件已从磁盘上删除, 此时需要把对应表里的内容删除掉
* @param fileMetas 删除的 文件/目录
*/
public void delete(List<FileMeta> fileMetas) {
Connection connection = null;
PreparedStatement statement = null;
try {
connection = DBUtil.getConnection();
connection.setAutoCommit(false); // 将自动提交关闭, 把下列批量删除操作看作一个事务进行
for(FileMeta fileMeta : fileMetas) {
String sql = null;
if(!fileMeta.isDirectory()) {
// 对文件的sql语句构造
sql = "delete from file_meta where name = ? and path = ?";
statement = connection.prepareStatement(sql);
statement.setString(1, fileMeta.getName());
statement.setString(2, fileMeta.getPath());
} else {
// 对目录的sql语句构造
sql = "delete from file_meta where (name = ? and path = ?) or (path like ?)";
statement = connection.prepareStatement(sql);
statement.setString(1, fileMeta.getName());
statement.setString(2, fileMeta.getPath());
statement.setString(3, fileMeta.getPath() + File.separator + fileMeta.getName() + File.separator + "%");
}
statement.executeUpdate();
System.out.println("[delete] " + fileMeta.getPath() + "\\" + fileMeta.getName());
// 此处对于每个 statement 对象都要单独关闭
// (每个 statement 都可能是不同的 sql 语句, 以前可以统一关闭是因为 sql 模板相同, 只是填充参数不同, 修改一下参数就可以接着用)
statement.close();
}
// 告知数据库, 事务构造完毕, 进行统一提交
connection.commit();
} catch (SQLException e) {
try {
connection.rollback();
} catch (SQLException ex) {
ex.printStackTrace();
}
} finally {
DBUtil.close(connection, null, null);
}
}
}
工具类
PinyinUtil
封装 Pinyin4j 的功能
汉字 => 拼音 (全拼 / 首字母)
java
public class PinyinUtil {
/**
* 获取字符串的拼音
*
* @param src 第一个参数表示要获取拼音的字符串
* @param fullSpell 第二个参数表示是否是全拼.
* 比如针对"你好啊"该字符串, true 对应全拼: nihaoa, false 对应首字母: nha
* 此处针对多音字不做过多考虑, 采用第一个元素代表的发音(也是最常用的发音)
* @return 字符串的拼音
*/
public static String get(String src, Boolean fullSpell) {
// trim() 去除字符串两侧的空白字符. eg: \t \n \f \v 空格 ...
if(src == null || src.trim().length() == 0) {
// 空字符不做处理
return null;
}
// 针对 Pinyin4j 做一些配置, 让他将拼音 yu 使用 v 表示
HanyuPinyinOutputFormat format = new HanyuPinyinOutputFormat();
format.setVCharType(HanyuPinyinVCharType.WITH_V);
// 遍历字符串的每个字符, 针对每个字符分别进行转换, 将转换得到的拼音, 拼接到 StringBuilder 里
StringBuilder stringBuilder = new StringBuilder();
src = src.trim();
for(int i=0;i<src.length();i++) {
// 针对单个字符进行转换
char ch = src.charAt(i);
String[] tmp = null;
try {
tmp = PinyinHelper.toHanyuPinyinStringArray(ch, format);
} catch (BadHanyuPinyinOutputFormatCombination e) {
e.printStackTrace();
}
if(tmp == null || tmp.length == 0) {
// 拼音转换失败, 返回空数组
// 说明当前字符就不是汉字, 可能是字母,数字或符号, eg: a, b, c, 1, 2, 3
// 此时保留原始字符就好
stringBuilder.append(ch);
}else if(fullSpell) {
stringBuilder.append(tmp[0]);
}else {
stringBuilder.append(tmp[0].charAt(0));
}
}
return stringBuilder.toString();
}
}
功能处理
扫描文件目录, 将目录下所有文件/目录信息存储到数据库中
当选择搜索路径后, 会递归的扫描指定路径下的所有目录及文件, 并将扫描到的 所有文件/目录信息 存储到数据库中
(查数据库比查文件系统要快, 因此其实每次查找指定文件在文件系统中出现的位置, 都是查询数据库中预存的信息)
java
public class GUIController implements Initializable {
@FXML
private Label label;
@FXML
private GridPane gridPane;
@FXML
private Button button;
@FXML
private TextField textField;
@FXML
private TableView<FileMeta> tableView;
private SearchService searchService = null;
/**
* 使用该方法, 作为鼠标点击事件的回调函数
* @param mouseEvent
*/
public void choose(MouseEvent mouseEvent) {
// 创建一个 目录选择器
DirectoryChooser directoryChooser = new DirectoryChooser();
// 把该对话框显示出来
Window window = gridPane.getScene().getWindow();
// 获取选定的文件
File file = directoryChooser.showDialog(window);
if(file == null) {
System.out.println("用户选择的路径为空");
} else {
System.out.println(file.getAbsolutePath());
}
// 把用户选择的路径,显示到 label 中
label.setText(file.getAbsolutePath());
// 如果不是首次扫描, 就应该停止上次扫描任务, 执行本次扫描任务
if(searchService != null) {
searchService.shutdown();
}
// 对用户选择的路径进行扫描, 初始化
searchService = new SearchService();
searchService.init(file.getAbsolutePath());
}
}
搜索框内容发生改变后, 自动进行数据库搜索, 将匹配内容展示到页面
当搜索框内容改变时, 会被系统绑定的事件监听到, 重新进行数据库搜索, 并将匹配信息作为结果返回到查询结果显示处
java
public class GUIController implements Initializable {
@FXML
private Label label;
@FXML
private GridPane gridPane;
@FXML
private Button button;
@FXML
private TextField textField;
@FXML
private TableView<FileMeta> tableView;
private SearchService searchService = null;
@Override
public void initialize(URL location, ResourceBundle resources) {
// 在这里对 输入框 加一个监听器
// 需要指定对 text 这个内容属性进行监听
// textField.textProperty() 获取输入框里的内容
textField.textProperty().addListener(new ChangeListener<String>() {
/**
* 会在用户每次修改 输入框内容 的时候, 被自动调用到
* @param observable
* @param oldValue 输入框被修改之前的值
* @param newValue 输入框被修改之后的值
*/
@Override
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
// 此处要干的事情, 是根据新的值, 重新进行查询操作
freshTable(newValue);
}
});
}
private void freshTable(String query) {
// 重新查询数据库, 把查询结果, 设置到表格中
if(searchService == null) {
System.out.println("searchService 尚未初始化, 不能查询!");
return;
}
// 把之前表里的内容清空掉
ObservableList<FileMeta> fileMetas = tableView.getItems();
fileMetas.clear();
List<FileMeta> results = searchService.search(query);
fileMetas.addAll(results);
}
}