一、项目介绍
项目名称:SearchEverything
项目简介:SearchEverything是仿照Everything实现的一款桌面级的文件搜索软件,它是Everything的增强版,支持跨平台的使用。
项目功能:
1.选择文件夹后,多线程扫描文件夹下的子文件夹,显示文件的名称、路径、文件类型(文件夹还是文件)、文件大小、上次修改时间。
2.通过选择文件夹查看到文件信息后,支持搜索相关文件内容(支持模糊搜索)
3.文件夹扫描完毕后,显示搜索的所有文件和文件夹个数,以及总耗时。
项目难点:
1. 如何进行多线程的运用,在提高效率的同时保证线程安全,并且在何时关闭线程池。
2. 使用回调函数进行文件的解耦
3.如何知道数据库的内容是否已经过期(是否需要删除)以及 如何使用一条sql语句删除文件夹极其文件夹下的所有子文件和子文件夹。
4.如何进行模糊搜索
项目环境:Windows,IDEA,Maven,JDK1.8
项目技术:JavaFX,多线程,IO流,SQLite数据库
界面用的是JavaFX(图形化界面),JavaFX是继spring后Java1.8发布的一个图形化的平台)JavaFX必须在Java8下使用,超过Java11,就不再是内置的了,而是变成一个独立的项目去运营。
二、项目搭建
(一)准备工作(3+3)(pom.xml-->放在resource包下)
前置知识:
maven:项目管理工具,方便第三方jar包的导入和管理,方便对当前项目的整个生命周期(打包,测试,发布等)进行跟踪。
jar包:jar包就是一个压缩包,存放一系列编译好的class文件。
可执行jar包 :jar包中指定入口类和主方法,通过这个主类和主方法可以将整个程序运行起来。
导入3个jar包:
1. pinyin4j : 汉语拼音的处理工具
2.sqlite-jdbc :SQLite数据库
3.lombok:Lombok能通过注解的方式,在编译时自动为属性生成构造方法、getter/setter、equals、hashcode、toString等方法。
添加3个插件:
1. maven-compiler-plugin :指示maven用什么版本的jdk编译
2. maven-jar-plugin : 是一个打包插件
3. maven-dependency-plugin: 执行package命令将项目打包时也将第三方的jar包打包进来,这样在执行可执行jar时就会找到相应的第三方jar包(放在/target/lib下)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.hy</groupId>
<artifactId>search_everything1</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
<dependency>
<!-- 汉语拼音的处理工具-->
<groupId>com.belerweb</groupId>
<artifactId>pinyin4j</artifactId>
<version>2.5.1</version>
</dependency>
<dependency>
<!-- SQLite数据库-->
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.36.0.3</version>
</dependency>
<dependency>
<!-- Lombok能通过注解的方式,在编译时自动为属性生成构造方法、getter/setter、equals、hashcode、toString方法。-->
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.28</version>
</dependency>
</dependencies>
<properties>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<!-- maven-jar-plugin是一个打包插件-->
<!--打一个可执行jar包-->
<!--jar包就是一个压缩包,存放一系列编译好的class文件-->
<!--可执行jar包指的是,jar中指定入口类和主方法,通过这个主类和主方法将整个程序运行起来-->
<artifactId>maven-jar-plugin</artifactId>
<version>2.4</version>
<configuration>
<archive>
<manifest>
<!-- 指定入口类 ,如util/Test就是util包下的Test 类就是指定的入口类-->
<mainClass>util/DBUtil</mainClass>
<!-- 在jar的MF文件中生成classpath属性 -->
<addClasspath>true</addClasspath>
<!-- classpath前缀,即依赖jar包的路径 -->
<classpathPrefix>lib/</classpathPrefix>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<!--引入的第三方jar包执行package命令打包时也将第三方的jar包打包进来,这样在执行可执行jar时就会找到相应jar-->
<!--通过maven-dependency-plugin,我们导入了pinyin4j 和 sqlite-jdbc这两个包,这两个是我们在idea中导入的。
如果我们要把search-everything中的程序打包成一个包,我们也要把导入的第三方的那两个包打包进这个包中。这样打包之后的文件依然可以使用第三方包
因为我们在编写程序时用到这些第三方包,如果不一起打包,在执行可执行jar包时,就会找不到相应的jar,编译报错-->
<!-- 意思就是把自己交给别人用的同时,也要把自己用别人的东西一块拿出来-->
<!-- 所有依赖的第三方包都可以在target下的lib下找到-->
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<!-- 指定依赖包的输出路径,需与上方的classpathPrefix保持一致 -->
<outputDirectory>${project.build.directory}/lib</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
(二)项目的设计和实现
(二 · 一)项目的指定入口类
public class Main extends Application {
@Override
public void start(Stage primaryStage) throws Exception{
//FXMLLoader:从 XML 文档加载对象层次结构
//load(...):从 FXML 文档中加载对象层次结构。
//参数 inputStream - 包含要加载的 FXML 数据的输入流。
//返回 加载的对象层次结构。
//反射:getClass().getClassLoader().getResource("app.fxml")
//Parent类:javafx.scene.Parent
Parent root = FXMLLoader.load(getClass().getClassLoader().getResource("app.fxml"));
primaryStage.setTitle("search_everything");
primaryStage.setScene(new Scene(root, 1000, 800));
primaryStage.show();
}
public static void main(String[] args) {
/**
* 启动独立应用程序。该方法通常从主方法()中调用。该方法不得被调用多次,否则将产生异常。
* 该方法等同于 launch(TheClass.class,args),其中 TheClass 是调用 launch 的方法的外层类。
* 它必须是 Application 的子类,否则将抛出 RuntimeException。
* 在应用程序退出(调用 Platform.exit 或关闭所有应用程序窗口)之前,launch 方法不会返回。
*/
launch(args);
}
}
在JavaFX中,图形化界面也是线程,此处的start方法就是加载app.fxml这个界面样式,启动界面的线程。
(二 · 二)项目的工具类(util包)
1.PinyinUtil--拼音的工具类
功能一:判断给定的字符串是否包含中文
功能二:将传入的文件名转换为拼音全拼和拼音的首字母拼写
public class PinyinUtil {
//全局常量,
// 一、在定义时就赋初始值
private static final HanyuPinyinOutputFormat FORMAT;
// 二、使用静态块(在类加载时,除了产生对象外,还可以进行一些配置相关的工作)
// FORMAT 这个配置就表示将汉字字符转为拼音字符串时的一些设置
static {
FORMAT = new HanyuPinyinOutputFormat();
FORMAT.setCaseType(HanyuPinyinCaseType.LOWERCASE);//拼音全转为全小写
FORMAT.setToneType(HanyuPinyinToneType.WITHOUT_TONE);//拼音无声调
FORMAT.setVCharType(HanyuPinyinVCharType.WITH_V);//将特殊字母转为v
}
//汉字的正则表达式
//所有的中文对应的Unicode编码区间
private static final String CHINESE_PATTERN = "[\\u4E00-\\u9FA5]";
/**
* 判断给定的字符串是否包含中文
* @param str 要判断的字符串
* @return
*/
public static boolean containsChinese(String str){
// 特殊符号: .* 可以匹配除了换行符以外的任何字符
//matches:说明该字符串是否与给定的正则表达式匹配。
return str.matches(".*" + CHINESE_PATTERN + ".*");
}
/**
* 将文件名转为两个拼音字符串--1.拼音全拼字符串---2.拼音首字母的字符串
* 核心操作: 遍历文件名中的每个字符,碰到非中文就直接保留,中文处理
* @param fileName
* @return
*/
public static String[] getPinyinByFileName(String fileName){
if (fileName == null || fileName.trim().length() == 0){
return null;
}
String[] ret = new String[2];
StringBuilder allNameAppender = new StringBuilder();
StringBuilder firstCaseAppender = new StringBuilder();
if (containsChinese(fileName)){
for(int i = 0 ; i < fileName.length(); i ++){
char c = fileName.charAt(i);
try {
// 任意一个汉语字符转为字母字符串,得到的都是一个字符串数组,因为存在多音字
//如 '和' --> pinyins = ['he','huo','hu'...]
String[] pinyins = PinyinHelper.toHanyuPinyinStringArray(c,FORMAT);
// 核心操作: 遍历文件名中的每个字符,碰到非中文就直接保留,中文处理
// 如果拼音数组为空或者拼音数组的长度为0,说明此时的字符是一个非中文的字符,直接保留即可
if (pinyins == null || pinyins.length == 0){
allNameAppender.append(c);
firstCaseAppender.append(c);
}else {
allNameAppender.append(pinyins[0]);//拼音数组的第一个拼音 'he'
firstCaseAppender.append(pinyins[0].charAt(0));//拼音数组的第一个拼音的首字母 'he'-->'h'
}
} catch (BadHanyuPinyinOutputFormatCombination e) {
// 碰到非中文字符,直接保留
allNameAppender.append(c);
firstCaseAppender.append(c);
}
}
ret[0] = allNameAppender.toString();
ret[1] = firstCaseAppender.toString();
}
return ret;
}
}
2.DBUtil -- 数据库的工具类(创建数据源,获取数据库的连接)
创建SQLite数据库的目的:在选择文件夹,启动文件的扫描任务后,将当前所选择的文件夹下的所有文件和子文件夹信息都保存到数据库中。在搜索框查询时,可以直接从数据库中查询,不用再次进行扫描,提高效率。
为什么使用SQLite,而不使用MySQL呢?
SearchEverything 是一个桌面级的软件,这个项目本来就比较小,属于工具类的项目,而MySQL就比较大了,放在这个项目中就不是很合适。
SQLite是嵌入式数据库,一个文件就是一个数据库,没有服务端和客户端,体量较小,适合当前项目。
/**
* Created with IntelliJ IDEA.
* Description:
* SQLite数据库的工具类 ,
* 1.创建数据源,获取数据库的连接
* 无论是什么类型的数据库,操作的流程都是JDBC 四步走
*
* 2. 多线程版本
* (1)因为sqlite 是单文件的数据库,因此在多线程场景下,必须保证多线程使用的是同一个数据库连接(单例)
* (2)子线程在操作完毕后,不能直接关闭连接,其他线程就没法使用了,因为只有一个连接
* 最后在程序结束的时候(进程(窗口)关闭),连接自动关闭
*/
// 只向外部提供SQLite数据库的连接即可,数据源不提供(封装在工具类的内部)(不用写构造方法)
public class DBUtil {
private volatile static DataSource DataSource;
private volatile static Connection CONNECTION;
// 懒汉式单例
// 获取数据源的方法,使用double-check单例模式获取数据对象
private static DataSource getDataSource(){
//当数据源为空时,才能去创建数据源
if (DataSource == null){
synchronized (DBUtil.class){
// 多线程场景下,只有一个线程能进入同步代码块,
// 这里再加个if条件句判断,是为了防止DataSource对象创建完毕后,阻塞结束,
// 之前阻塞在这的其它线程可能会恢复执行,创建多个对象
// 防止其他线程恢复执行后多次创建单例对象
if (DataSource == null){
// SQLite没有账户密码,只需要配置日期格式即可
// SQLite默认的日期格式是一个时间戳,要想对它指定日期格式,需要额外配置(在一个指定的工具类Util中配置)
// SQLiteConfig 是SQLite数据源的一个配置
SQLiteConfig config = new SQLiteConfig();
config.setDateStringFormat(Util.DATE_FORMAT);
DataSource = new SQLiteDataSource(config);
// 获取数据库的路径
// 配置数据源的URL 是SQLite的子类SQLiteDataSource独有的方法,因此要向下转型为SQLiteDataSource
// 向下转型:子类名称 子类引用 = (子类名称)父类引用;
( (SQLiteDataSource) DataSource).setUrl(getUrl());
}
}
}
return DataSource;
}
// 配置SQLite数据库的地址
// mysql的配置是 jdbc:mysql://127.0.0.1:3306/......
// 对于SQLite数据库而言,没有服务器和客户端,因此只需要指定SQLite数据库的地址即可
private static String getUrl(){
// 将路径放在 target 路径下
String path = "D:\\javaCode\\search_everything\\target";
// File.separator:分隔符
// 数据库的地址
String url = "jdbc:sqlite://"+path+ File.separator + "search_everything.db";
System.out.println("获取数据库的连接为"+url);
return url;
}
// 获取数据库的连接
public static Connection getConnection() throws SQLException {
if(CONNECTION == null){
synchronized (DBUtil.class){
if (CONNECTION == null){
CONNECTION = getDataSource().getConnection();
}
}
}
return CONNECTION;
}
public static void main(String[] args) throws SQLException {
System.out.println(getConnection());
}
public static void close( Statement statement) {
if (statement != null){
try {
statement.close();
}catch (SQLException e){
throw new RuntimeException(e);
}
}
}
public static void close(PreparedStatement statement, ResultSet rs) {
close(statement);
if (rs!= null){
try {
rs.close();
}catch (SQLException e){
throw new RuntimeException(e);
}
}
}
}
volatile : 防止指令重排(内存屏障) (必须是按顺序执行代码,最后才执行return语句, 不然有的线程在进入第二个if语句时,就已经拿到了一个没有创建完毕的DataSource对象, 若没有volatile指令重排,线程可能会先执行return语句,由此创建多个对象)
double-check单例模式获取数据对象 第一个if语句:判断当前数据源是否为空,只有当数据源为空时,才需要进行创建数据源。 第二个if语句: 防止其他线程恢复执行后多次创建单例对象。 多线程场景下,只有一个线程能进入同步代码块,获取锁,其他线程进入阻塞队列,当第一个线程创建好数据源,释放锁时,将创建好的单例对象返回给主内存,但是对于其他处于阻塞状态的线程来说(工作内存),单例对象仍然为空,因此要多加一个if条件,判断单例对象是否为空,避免多次创建。
3. DBInit -- 初始化数据库 的工具类
目的:在界面初始化时初始化数据库
/**
* Created with IntelliJ IDEA.
* Description:创建一个数据库的初始化方法
* 在界面初始化时创建文件信息数据表
* 1.首先要创建一个init.sql文件
* 2.从resources路径下读取init.sql文件,加载到程序中(就是文件的IO)
*/
public class DBInit {
/**
* 1.读取SQL语句
* @return
*/
// 从resources路径下读取init.sql文件,加载到程序中,
// 文件IO
public static List<String> readSQL() {
List<String> ret = new ArrayList<>();
// 这里体现了Java的可移植性,无论是什么类型的操作系统还是电脑,一写百通!!!
InputStream inputStream = DBInit.class.getClassLoader().
getResourceAsStream("init.sql");
// 从文件获取输入流
// 对于输入流来说,一律采用Scanner 类来处理
Scanner scanner = new Scanner(inputStream);
// scanner.useDelimiter自定义分隔符,即以“;”作为分隔符
scanner.useDelimiter(";");
// 经过自定义分隔符后, hasNext()方法会判断接下来是否有“;”分隔符.如果有,则返回true,否则返回false
while (scanner.hasNext()) {
String str = scanner.next();
// 如果碰到换行符或者为空,不保存,直接continue
if ("".equals(str) || "\n".equals(str)) {
// 跳过本次循环,进入下次循环
continue;
}
if (str.contains("--")) {
//把更新后的数据存放在str中
str = str.replaceAll("--", "");
}
ret.add(str);
}
return ret;
}
/**
* 2. 在界面初始化时先初始化数据库,创建数据表 (执行SQL)
*
*/
public static void init(){
Connection connection = null;
Statement statement = null;
// 资源的连接与释放操作
try {
// 首先在DBUtil类中获取数据库的连接
connection = DBUtil.getConnection();
// 获取要执行的SQL语句
//调用readSQL()方法,就可以读取init.sql文件中的内容,按照分隔符进行拆分,并将其保存到结果集中
List<String> sqls = readSQL();
// 获取statement对象的连接
// 1个preparedStatement对象就对应一个sql,创建preparedStatement对象就要传入一个sql
// 这里有多个sql语句,如果使用preparedStatement不方便(在获取preparedStatement对象时就要把sql语句传进去),所以采用statement对象,对应多个sql语句
statement = connection.createStatement();
for (String sql :sqls) {
System.out.println("执行sql操作" + sql);
//executeUpdate:增删改
statement.executeUpdate(sql);
}
}catch (SQLException e){
System.err.println("数据库初始化失败");
e.printStackTrace();
}finally {
// 资源的关闭
DBUtil.close(statement);
}
}
public static void main(String[] args) {
init();
}
}
使用InputStream 读取init.sql文件:
1.常规操作
InputStream is = new FileInputStream("init.sql");
但这种方式在打包后是找不到init.sql文件的,因为在打包后,会默认init.sql是与src目录同级,因此程序会直接从search_everything目录下寻找init.sql文件。
2.
InputStream is = new FileInputStream("src/main/resource/init.sql");
通过这种方式,把项目打包以后,仍然会出现问题,因为search_everything-SNAPSHOT.jar是在 target路径下,而init.sql在src目录下,target与src属于同级目录,所以打包后找到不init.sql。
3.通过类加载器引入资源文件 √
InputStream inputStream = DBInit.class.getClassLoader(). getResourceAsStream("init.sql");
所有加载的类编译后都会放在target路径下的classes根目录下
4.Util -- 通用工具类
public class Util {
public static final String DATA_FORMAT = "yyyy-MM-dd HH:mm:ss";
}
Util类中有很多与FileMeta类相关的方法,因此放在FileMeta类后实现。
(二 · 三)资源文件类(resource包)
1.init.sql(sql文件--数据库中保存的核心文件信息)
-- drop table if exists file_meta;
create table if not exists file_meta(
name varchar(50) not null,
path varchar(100) not null,
is_directory boolean not null,
size bigint,
last_modified timestamp not null,
pinyin varchar(200),
pinyin_first varchar(50)
);
2.app.fxml(界面样式)
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.geometry.*?>
<?import javafx.scene.layout.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.control.cell.*?>
<GridPane fx:id="rootPane" alignment="center" hgap="10" vgap="10" xmlns:fx="http://javafx.com/fxml/1" xmlns="http://javafx.com/javafx/8" fx:controller="core.Controller">
<children>
<Button onMouseClicked="#choose" prefWidth="90" text="选择目录" GridPane.columnIndex="0" GridPane.rowIndex="0" />
<Label fx:id="srcDirectory">
<GridPane.margin>
<Insets left="100.0" />
</GridPane.margin>
</Label>
<TextField fx:id="searchField" prefWidth="900" GridPane.columnIndex="0" GridPane.rowIndex="1" />
<TableView fx:id="fileTable" prefHeight="1000" prefWidth="1300" GridPane.columnIndex="0" GridPane.columnSpan="2" GridPane.rowIndex="2">
<columns>
<TableColumn fx:id="nameColumn" prefWidth="220" text="名称">
<cellValueFactory>
<PropertyValueFactory property="name" />
</cellValueFactory>
</TableColumn>
<TableColumn prefWidth="400" text="路径">
<cellValueFactory>
<PropertyValueFactory property="path" />
</cellValueFactory>
</TableColumn>
<TableColumn fx:id="isDirectory" prefWidth="90" text="文件类型">
<cellValueFactory>
<PropertyValueFactory property="isDirectoryText" />
</cellValueFactory>
</TableColumn>
<TableColumn fx:id="sizeColumn" prefWidth="90" text="大小(B)">
<cellValueFactory>
<PropertyValueFactory property="sizeText" />
</cellValueFactory>
</TableColumn>
<TableColumn fx:id="lastModifiedColumn" prefWidth="160" text="修改时间">
<cellValueFactory>
<PropertyValueFactory property="lastModifiedText" />
</cellValueFactory>
</TableColumn>
</columns>
</TableView>
</children>
</GridPane>
前后端交互: fx:controller="core.Controller : 界面上的所有数据最终交给core包下的Controller类来处理 <Button onMouseClicked="#choose" /> 点击按钮之后会触发操作,这个操作和core/Controller类下的同名choose方法一一对应,在choose方法中会获取到刚才选择的目录路径,选择目录之后会执行一个弹出窗口的操作,确定本地文件夹的路径。
(二 · 四)回调接口(callback包)
1、FileScannerCallBack -- 文件信息扫描的回调接口
scanThread()方法就是在指定的文件夹中扫描所有的子文件和子文件夹---功能1
callback()方法就是将扫描的文件夹信息保存到终端(此时我们要保存到数据库)中 -- 功能2
|| (共同搭配)
||
将指定目录下的所有文件和文件夹扫描出来之后保存到数据库中
回调函数的使用:
- 在FileScanner类中,接收一个回调的接口对象(属性)
- 接口对象什么时候传进来 ----通过构造方法传入
- 在进行扫描任务的时候(scanThread()--子线程负责具体的扫描任务),每次碰到一个文件夹,就调用this.callbak.callback(filepath),callback对象调用callback方法,将当前文件夹路径传进去,即将当前目录下的所有内容保存到指定终端
public interface FileScannerCallBack {
/**
* 文件扫描的回调接口,扫描文件时由具体的子类决定将当前目录下的文件信息持久化到哪个终端
* 可以是数据库,可以通过网络传输
* @param dir
*/
void callback(File dir);
}
2. FileSaveToDB类 -- 文件信息保存到数据库的回调子类
难点1:当再次扫描一个文件夹时,其中一个文件信息发生变化了,数据库如何知道原先的文件没了(保存的修改前的文件信息需要删除,修改后的文件信息需要保存),并且对于其他已经扫描过的且保存到数据库的内容还不产生影响。
使用FileMeta的equals方法来判断。
视图1 -- 从OS中扫描的文件信息(保存到内存中,一定是最新数据,这里需要注意的是:保存的路径是该文件的父路径,模仿OS)
视图2 -- 从数据库查询路径为“XXX”的所有文件信息
对比视图1 和视图2,内存中有,数据库中没有的,需要保存;数据库中有,内存中没有的,需要删除(插入和删除的顺序没有关系)
难点2:如果数据库中需要删除的是文件夹,如何使用一条sql语句将该文件夹本身以及文件夹下的子文件和子文件夹全部删除?
String sql = "delete from file_meta" + "where (name = ? and path = ?)";//父路径 + 文件名 -->指定唯一的文件 if (meta.getIsDirectory()){ sql += " or path = ?" ; sql += " or path like ?"; statement.setString(1, meta.getName());//文件名 tatement.setString(2, meta.getPath());//父路径 //父路径 + 文件名 -->指定唯一的文件 statement.setString(3, meta.getPath() + File.separator + meta.getName());//文件夹下的一级目录 statement.setString(4, meta.getName() +File.separator + meta.getName() + File.separator + "%"); //文件夹下的多级目录
/**
* Created with IntelliJ IDEA.
* Description: 文件信息保存到数据库的回调子类
*/
public class FileSaveToDB implements FileScannerCallBack {
@Override
public void callback(File dir) {
// 0.边界处理
File[] files = dir.listFiles();
if (files != null && files.length != 0) {
// 1. 先将当前dir下的所有文件信息保存到内存中,缓存中的信息一定是从OS中读取到的最新数据--视图1
List<FileMeta> locals = new ArrayList<>();
// 2.从数据库中查询出当前路径下的所有文件信息--视图2
List<FileMeta> dbFiles = query(dir);
for (File file :files) {
FileMeta meta = new FileMeta();
if (file.isDirectory()){
//file.getPath():表示file这个文件的父路径 + file的name,也就是说file文件的全路径
//file.getParent():表示file文件的父路径
setCommonField(file.getName(), file.getParent(), true, file.lastModified(),meta );
}else {
setCommonField(file.getName(), file.getParent(), false, file.lastModified(),meta );
meta.setSize(file.length());
}
locals.add(meta);
}
// 3.对比视图1 和视图2
// 3.1 内存中有,而数据库中没有的作插入
for (FileMeta meta :locals) {
if (!dbFiles.contains(meta)){
save(meta);
}
}
// 3.2 内存中没有,而数据库中有的作删除
for (FileMeta meta :dbFiles) {
if (!locals.contains(meta)){
delete(meta);
}
}
}
//else {files == null && files.length == 0}说明该文件夹下就没有文件或者dir压根就不是文件夹
}
/**
* 删除数据库中指定文件信息
* @param meta
*/
private void delete(FileMeta meta) {
Connection connection = null;
PreparedStatement statement = null;
try {
connection = DBUtil.getConnection();
//删除文件(文件夹)本身
String sql = "delete from file_meta where (name = ? and path = ?)";
//如果待删除的是一个文件夹,该文件夹下的子文件和子文件夹也需要删除(根据path来模糊删除)
if (meta.getIsDirectory()){
sql += " or path = ?";//删除一级目录
sql += " or path like ?";//删除文件夹的多级目录
}
statement = connection.prepareStatement(sql);
statement.setString(1, meta.getName());
statement.setString(2, meta.getPath());
if (meta.getIsDirectory()){
statement.setString(3, meta.getPath() + File.separator + meta.getName());
statement.setString(4, meta.getPath() + File.separator + meta.getName() + File.separator +"%");//删除一级目录
}
int rows = statement.executeUpdate();
} catch (SQLException e) {
System.err.println("文件删除出错,请检查SQL语句");
e.printStackTrace();
}finally {
DBUtil.close(statement);
}
}
/**
* 将指定文件信息保存到数据库中
* @param meta
*/
private void save(FileMeta meta) {
Connection connection = null;
PreparedStatement statement = null;
try {
connection = DBUtil.getConnection();
String sql = "insert into file_meta values(?,?,?,?,?,?,?)";
statement = connection.prepareStatement(sql);
String fileName = meta.getName();
statement.setString(1, fileName);
statement.setString(2, meta.getPath());
statement.setBoolean(3, meta.getIsDirectory());
if (!meta.getIsDirectory()){
statement.setLong(4,meta.getSize());
}
statement.setTimestamp(5,new Timestamp(meta.getLastModified().getTime()));
// 只有fileName文件名中包含中文字符,才需要存入拼音
if(PinYinUtil.containsChinese(fileName)) {
String[] pinyins = PinYinUtil.getPinYinByFileName(fileName);
statement.setString(6,pinyins[0]);
statement.setString(7,pinyins[1]);
}
int rows = statement.executeUpdate();
} catch (SQLException e) {
System.err.println("保存文件信息出错,请检查SQL语句");
e.printStackTrace();
}finally {
DBUtil.close(statement);
}
}
private List<FileMeta> query(File dir) {
Connection connection = null;
PreparedStatement statement = null;
ResultSet rs = null;
List<FileMeta> dbFile = new ArrayList<>();
try{
connection = DBUtil.getConnection();
String sql = "select name,path,is_directory,size,last_modified from file_meta" +
// 切记sql拼接时,换行需要加空格
" where path = ?";
statement = connection.prepareStatement(sql);
statement.setString(1, dir.getPath());
rs = statement.executeQuery();
while (rs.next()){
FileMeta meta = new FileMeta();
meta.setName(rs.getString("name"));
meta.setPath(rs.getString("path"));
meta.setIsDirectory(rs.getBoolean("is_directory"));
meta.setLastModified(new Date(rs.getTimestamp("last_modified").getTime()));
if (!meta.getIsDirectory()){
// 只有是文件时才设置size大小,若是文件夹,不设置size大小
// 此处有个bug,数据库中文件夹的size大小为null,但是调用rs.getLong方法若返回值为null,返回0
meta.setSize(rs.getLong("size"));
}
}
}catch (SQLException e){
System.err.println("查询数据库指定路径下的文件出错,请检查SQL语句");
e.printStackTrace();
}finally {
//这里数据库的连接为什么不能关闭
DBUtil.close(statement,rs);
}
return dbFile;
}
// 设置公有属性
// 设置文件名,路径,是否是一个文件夹,上次修改时间 ,以及给哪个meta对象设置属性
private void setCommonField(String name, String path, boolean isDirectory, Long lastModified, FileMeta meta){
meta.setName(name);
meta.setPath(path);
meta.setIsDirectory(isDirectory);
//file对象的lastModified是一个以时间戳为单位的长整型,因此要先转换成Data类型
meta.setLastModified(new Date(lastModified));
}
}
(二 · 五)辅助核心实现类的任务工具类(task包)
1、FileScanner类(文件的扫描任务类---功能3的实现)
核心方法1:根据传入的文件夹进行多线程的扫描 scan(File filePath)
1. 将具体的扫描任务交给子线程处理
2. 主线程等在子线程全部处理结束后再继续执行
2.1 如果子线程任务还没有扫描完毕,就中断了当前的扫描任务,使用 shutdownNow()关闭线程池(正常执行--所有任务线程都已经执行完毕/异常--立即关闭所有正在执行的任务线程)
3.记录所有扫描的所有文件、文件夹个数 ,以及总耗时情况(---功能3的实现)
核心方法2:子线程的扫描 scanThread(File file)
提交线程任务:
1. 首先要将每一个文件夹下的子文件信息都保存到回调函数中
2. 然后判断子文件是否是文件夹,如果是文件夹,就递归调用 scanThread方法,继续创建线程,提交线程任务
3. 如果for循环结束,也就是当前线程将这一级目录下的文件夹(创建新线程递归处理)和文件的保存扫描任务执行结束,将线程任务数-1
4.判断线程任务数是否为0 ,若为0 ,说明扫描任务全部结束,唤醒主线程继续执行。
/**
* Created with IntelliJ IDEA.
* Description: 进行文件的扫描任务
*/
@Getter //程序外部不能修改这些属性值,只能获取
public class FileScanner {
// 当前扫描的文件个数
private AtomicInteger fileNum = new AtomicInteger();
// 当前扫描的文件夹个数
// 最开始扫描的根路径没有统计,因此初始化文件夹的个数为1,表示从根目录下开始扫描任务
private AtomicInteger dirNum = new AtomicInteger();
// 所有扫描文件的子线程个数,只有当子线程个数为0时,主线程再继续执行
private AtomicInteger threadCount = new AtomicInteger();
// 当最后一个子线程执行完任务之后,再调用countDown方法唤醒主线程
private CountDownLatch latch = new CountDownLatch(1);
// 获取当前电脑的可用CPU个数
private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
private ThreadPoolExecutor pool = new ThreadPoolExecutor(CPU_COUNT,2*CPU_COUNT,10,
TimeUnit.SECONDS,new LinkedBlockingQueue<>(),new ThreadPoolExecutor.AbortPolicy());
// 文件扫描回调对象
private FileScannerCallBack callBack;
public FileScanner(FileScannerCallBack callBack) {
this.callBack = callBack;
}
/**
* 根据传入的文件夹进行扫描任务(递归)
* @param filePath 要扫描的根目录
* 选择要扫描的菜单之后,执行的第一个方法---主线程
*/
public void scan(File filePath){
System.out.println("开始文件扫描任务,根目录为 : " + filePath);
long start = System.nanoTime();
// 将具体的扫描任务交给子线程处理
// 此时根目录下的扫描任务已经创建线程处理
scanThread(filePath);
threadCount.incrementAndGet();
try {
//主线程等在子线程全部处理结束后再继续执行
latch.await();
} catch (InterruptedException e) {
System.err.println("扫描任务中断,根目录为 : " + filePath);
}finally {
System.out.println("关闭线程池......");
// 当所有子线程已经执行结束,就是正常关闭
// 中断任务,需要立即停止所有还在扫描的子线程
pool.shutdownNow();
}
long end = System.nanoTime();
System.out.println("文件扫描任务结束,共耗时 : " + (end - start) * 1.0 / 1000000 + "ms");
System.out.println("文件扫描任务结束,根目录为 : " + filePath);
System.out.println("共扫描到 : " + fileNum.get() + "个文件");
System.out.println("共扫描到 : " + dirNum.get() + "个文件夹");
}
/**
* ----子线程
* @param file
*/
private void scanThread(File file){
if (file == null){
return;
}
pool.submit(()->{
//首先将当先文件夹下的所有子文件信息全都保存到数据库中
this.callBack.callback(file);
File[] files = file.listFiles();
//遍历当前文件下所有的文件/文件夹
for (File f :files) {
if (f.isDirectory()){
dirNum.incrementAndGet();
threadCount.incrementAndGet();
scanThread(f);
}else {
fileNum.incrementAndGet();
}
}
// 当前线程将这一级目录下的文件夹(创建新线程递归处理)和文件的保存扫描任务执行结束
System.out.println(Thread.currentThread().getName() + "扫描 : " + file + "任务结束");
threadCount.decrementAndGet();
if (threadCount.get() == 0){
// 所有线程已经结束任务
System.out.println("所有扫描任务结束");
// 唤醒主线程
latch.countDown();
}
});
}
}
2. FileSearch类 --(功能2的实现)
根据选择的文件夹路径和用户输入的内容从数据库中查找出指定的内容并返回(这里支持模糊搜索)
1. 首先根据用户选择的文件夹路径dir查询内容
String sql = "select name,path,size,is_directory,last_modified from file_meta " +
" where (path = ? or path like ?)";| |
(一级目录)该文件夹下的子文件信息 (二级目录--多级目录下的文件信息)
2.如果搜索框的内容(中文,拼音,或是拼音首字母)不为空,再根据搜索框的内容在检索文件信息。
3.将文件夹路径和搜索框内容共同检索到的文件信息保存到list集合中,并返回。
3-1:设置FileMeta对象的属性
public class FileSearch {
/**
*
* @param dir 用户选择的检索的文件夹路径 一定是不为空的
* @param content 用户搜索框中的内容 - 可能为空,若为空就展示当前数据库中选择的路径下的所有内容即可
* @return
*/
public static List<FileMeta> search(String dir, String content) {
List<FileMeta> result = new ArrayList<>();
Connection connection = null;
PreparedStatement ps = null;
ResultSet rs = null;
try {
connection = DBUtil.getConnection();
// 先根据用户选择的文件夹dir查询内容
String sql = "select name,path,size,is_directory,last_modified from file_meta " +
" where (path = ? or path like ?)";
if (content != null && content.trim().length() != 0) {
// 此时用户搜索框中的内容不为空,此处支持文件全名称,拼音全名称,以及拼音首字母的模糊查询
sql += " and (name like ? or pinyin like ? or pinyin_first like ?)";
}
ps = connection.prepareStatement(sql);
ps.setString(1,dir);
ps.setString(2,dir + File.separator + "%");
// 根据搜索框的内容查询数据库,都是模糊匹配
if (content != null && content.trim().length() != 0) {
ps.setString(3,"%" + content + "%");
ps.setString(4,"%" + content + "%");
ps.setString(5,"%" + content + "%");
}
rs = ps.executeQuery();
while (rs.next()) {
FileMeta meta = new FileMeta();
meta.setName(rs.getString("name"));
meta.setPath(rs.getString("path"));
meta.setIsDirectory(rs.getBoolean("is_directory"));
if (!meta.getIsDirectory()) {
// 是文件,保存大小
meta.setSize(rs.getLong("size"));
}
meta.setLastModified(new Date(rs.getTimestamp("last_modified").getTime()));
result.add(meta);
}
}catch (SQLException e) {
System.err.println("从数据库中搜索用户查找内容时出错,请检查SQL语句");
e.printStackTrace();
}finally {
DBUtil.close(ps,rs);
}
return result;
}
}
(二 · 六)项目的核心实现类(core包)
1.Controller类
1.首先,进行界面的初始化(初始化前,初始化数据库),并添加搜索框监听器,当内容改变时,刷新界面。
2.在choose()方法中进行文件的扫描任务
3.界面的刷新(当搜索框内容改变时,从数据库中去搜索对应的文件信息)
/**
* app.fxml中的 fx:id的名称要和app包下的Controller类中的属性名称完全一致,
* 这样的话界面中的内容才会正确地被Controller类所接收
*/
public class Controller implements Initializable {
@FXML
private GridPane rootPane;
@FXML
private TextField searchField;
@FXML
private TableView<FileMeta> fileTable;
@FXML
private Label srcDirectory;
private Thread scanThread;
// 界面的初始化方法
// 点击运行项目,界面初始化时加载一个方法
// 就相当于运行一个主类,首先要加载主类的静态块,一个道理
public void initialize(URL location, ResourceBundle resources) {
// 想要在界面初始化时就初始化数据库
DBInit.init();
// 添加搜索框监听器,内容改变时执行监听事件
// 在搜索框内输入一个内容,表格会有刷新,(界面的内容一作修改,就会捕捉到这个修改,刷新界面)
searchField.textProperty().addListener(new ChangeListener<String>() {
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
freshTable();
}
});
}
// 点击选择目录,就会获取到最终页面上选择的是哪个文件夹
public void choose(Event event) {
// 选择文件目录
DirectoryChooser directoryChooser = new DirectoryChooser();
Window window = rootPane.getScene().getWindow();
File file = directoryChooser.showDialog(window);
if(file == null)
return;
// 1.获取选择的目录路径,并显示
String path = file.getPath();
// 2.在界面中显示路径的内容(选择目录的旁边)
// srcDirectory 对应app.fxml文件中 <Label fx:id="srcDirectory">
this.srcDirectory.setText(path);
// 3.获取要扫描的文件夹路径之后,进行文件的扫描工作
System.out.println("开始进行文件扫描任务,根路径为: " +path);
//3.1 创建一个类task.FileScanner类 进行文件扫描
//3.2产生一个文件扫描的对象
//3.3 此处进行文件扫描任务,要决定信息到底保存到哪个终端
// 此时将文件信息保存到数据库中
FileScanner fileScanner = new FileScanner(new FileSaveToDB());
//3.4进行文件扫描
if (scanThread != null){
scanThread.interrupted();
// 修复多次中断后选择同一文件夹导致的内容显示重复问题
fileTable.getItems().clear();
}
scanThread = new Thread(()->{
fileScanner.scan(file);
// TODO 在数据表中展示文件内容(刷新界面,获取扫描到的文件信息(freshTable))
freshTable();
});
scanThread.start();
}
// 刷新表格数据
private void freshTable(){
ObservableList<FileMeta> metas = fileTable.getItems();
metas.clear();
// TODO 扫描文件夹之后刷新界面
String dir = srcDirectory.getText();
//界面中已经已经选择了文件,此时已经将最新的数据保存到了数据库中
// 只需要取出数据库中的内容展示到界面上即可
if (dir != null && dir.trim().length() != 0){
//获取用户在搜索框中输入的内容
String content = searchField.getText();
//根据选择的路径 + 用户的输入(若为空就展示所有内容),将数据库中的指定内容刷新到界面中
List<FileMeta> filesFromDB = FileSearch.search(dir,content);
metas.addAll(filesFromDB);
}
}
}
2.FileMeta类
FileMeta类是和数据库打交道的类,最终程序中获取数据库的记录就通过本类来描述 (实体类)
这个类就对应我们的数据库表名,数据表中的一行记录就对应我们这个类的一个对象
该类的一个对象就对应数据表的一行
数据表的所有内容就对应FileMeta这个类的对象数组
public class FileMeta {
private String name;
private String path;
private Boolean isDirectory;
private Long size;
private Date lastModified;
// 若包含中文名称,名称全拼
private String pinYin;
// 拼音首字母
private String pinYinFirst;
// 创建FileMeta对象时,将name,path, isDirectory,size, lastModified信息传进来
// 一个FileMeta对象 对应 数据表中的一行记录
public FileMeta(String name, String path, Boolean isDirectory, Long size, Date lastModified) {
this.name = name;
this.path = path;
this.isDirectory = isDirectory;
this.size = size;
this.lastModified = lastModified;
}
}
数据表到Java类的映射中,基本类型使用包装类 为什么不使用基本数据类型呢?基本数据类型有默认值 在某些场景下(文件夹的size)就需要为空,使用基本数据类型就会很麻烦
演示:
以上FileMeta类中的三个属性名称与app.fxml文件中的属性名称不一致,因此需要将FileMeta的属性做一些处理之后才能展示出来(这些属性名要和app.fxml中保持一致)
修改之后:
FileMeta类:
@Data
@NoArgsConstructor
@ToString
@EqualsAndHashCode
public class FileMeta {
private String name;
private String path;
private Boolean isDirectory;
private Long size;
private Date lastModified;
// 若包含中文名称,名称全拼
private String pinYin;
// 拼音首字母
private String pinYinFirst;
// 以下三个属性需要在界面中展示,将当前属性值做处理之后展示
// 这些属性名要和app.fxml中保持一致
// 文件类型
private String isDirectoryText;
// 文件大小
private String sizeText;
// 上次修改时间
private String lastModifiedText;
public void setSize(Long size) {
this.size = size;
this.sizeText = Util.parseSize(size);
}
public void setIsDirectory(Boolean directory) {
isDirectory = directory;
this.isDirectoryText = Util.parseFileType(directory);
}
public void setLastModified(Date lastModified) {
this.lastModified = lastModified;
this.lastModifiedText = Util.parseDate(lastModified);
}
public FileMeta(String name, String path, Boolean isDirectory, Long size, Date lastModified) {
this.name = name;
this.path = path;
this.isDirectory = isDirectory;
this.size = size;
this.lastModified = lastModified;
}
}
Util类
package util;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* Created with IntelliJ IDEA.
* Description: 通用工具类
*/
public class Util {
// 日期格式
public static final String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss";
public static String parseFileType(Boolean directory) {
return directory ? "文件夹" :"文件";
}
/**
* 根据传入的文件大小返回不同的单位
* 支持的单位如下 B,KB,MB,GB
* @param size
* @return
*/
public static String parseSize(Long size) {
String[] unit = {"B","KB","MB","GB"};
int flag = 0;
while (size > 1024){
size /= 1024;
flag++;
}
return size + unit[flag];
}
public static String parseDate(Date lastModified) {
return new SimpleDateFormat(DATE_FORMAT).format(lastModified);
}
}
修改后的演示: