背景
各文件系统下, 都有提供文件查找的功能, 但是一般而言搜索速度很慢
本项目仿照 everything 工具, 实现本地文件的快速搜索
实现功能
- 选择指定本地目录, 根据输入的信息, 进行搜索, 显示指定目录下的匹配文件信息
- 文件夹包含中文时, 支持汉语拼音搜索 (全拼 / 首字母匹配)
相关技术
Java + Servlet + Pinyin4j
JDBC + SQLite (SQLite 相对于 MySQL 更加轻量, 并且引入 jar 包即可使用, 不必安装配套应用)
JavaFx
数据库设计
SQLite 创建 SQL 的语句如下
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 文件
显示界面的图画化结构
<?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 文件配套使用, 该类离实现了 界面中按键的绑定事件, 以及对搜索框内容进行监听, 当搜索框内容改变时, 重新搜索, 并将结果返回到查询结果显示处 (实现动态搜索功能)
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 方法来启动整个程序
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 方法
// 本类的示例就代表 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 的 连接/关闭/管理 操作
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 类
通过该类来实现数据库的增删改查操作
// 通过这个类来封装针对 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 的功能
汉字 => 拼音 (全拼 / 首字母)
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(); } }
功能处理
扫描文件目录, 将目录下所有文件/目录信息存储到数据库中
当选择搜索路径后, 会递归的扫描指定路径下的所有目录及文件, 并将扫描到的 所有文件/目录信息 存储到数据库中
(查数据库比查文件系统要快, 因此其实每次查找指定文件在文件系统中出现的位置, 都是查询数据库中预存的信息)
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()); } }
搜索框内容发生改变后, 自动进行数据库搜索, 将匹配内容展示到页面
当搜索框内容改变时, 会被系统绑定的事件监听到, 重新进行数据库搜索, 并将匹配信息作为结果返回到查询结果显示处
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); } }