目录
一、前言介绍
二、功能展示
2.1选择所要查找的文件夹
2.2将所选目录下的所有文件进行属性展示
2.3支持搜索框查询文件(模糊查询)
2.4统计本次扫描信息
编辑
三、整体设计
3.1工具类的设计
3.1.1汉字转拼音工具类
3.1.2数据库工具类的实现
3.1.3界面的实现
3.2文件扫描及保存的设计
3.2.1回调函数的设计及文件扫描类的设计
四、项目总结
对于许多不爱整理电脑的小伙伴来说(自然我也是其中之一),在一堆堆的文件夹和文件里找到自己需要的文件是非常令人头疼的。而且当文件夹越来越多之后,总是会遗忘掉自己将文件塞在了哪个犄角旮旯,于是乎,本地文件搜索引擎就是非常关键的了!那么,为什么不用系统自带的搜索引擎呢?速度太慢!!!当搜索文件夹内容过多时,很久也无法完成。于是乎,在网络上寻找,找到了Everything这样的一个小工具,觉得十分好用,能快速通过关键字索引找到文件信息;不过用了一段时间之后,还是觉得差点意思。第一:查询不够方便,必须要记得完整的关键字才可以准确查找;第二:不支持跨系统;于是乎,决定自己实现一个文件搜索工具来解决这些问题。
那么首先需要搞清楚需要的功能有哪些,也非常简单:
所需功能明确之后,便开始根据所掌握技能进行整体思路设计,这里先给出功能实现截图:
因为我们的功能列表中提到了要支持汉字或者拼音、首字母等的模糊查询,所以我们需要有一个工具,能帮我们将任意的中文字符转变为字母字符串从而支持我们的模糊查找,如:快速排序 => kuaisupaixu / kspx;
这里我们在Maven项目的pom文件中引入一个jar包:
com.belerweb
pinyin4j
2.5.1
而所谓jar包其实就是一系列编译好的class文件,jar包其实就是一个压缩包;
引入之后,我们就可以进行拼音工具类的编码了,代码如下:
/**
* 拼音工具类
* 将汉语拼音的字符映射为字母字符串
**/
public class PinyinUtil {
//定义汉语拼音的配置,全局常量,必须在定义时初始化,全剧唯一。
//这个配置就表示将汉字字符转为拼音字符串时的一些设置。
private static final HanyuPinyinOutputFormat FORMAT;
//所有中文对应的Unicode编码区间
private static final String CHINESE_PATTERN = "[\\u4E00-\\u9FA5]";
//静态代码块,在进行一些项目配置时的初始化操作。
static {
//当PinyinUtil类加载的时候执行静态代码块,除了产生对象外,还可以进行一些配置相关的东西
FORMAT = new HanyuPinyinOutputFormat();
//设置转换后的英文字母为全小写
FORMAT.setCaseType(HanyuPinyinCaseType.LOWERCASE);
//设置不带音调
FORMAT.setToneType(HanyuPinyinToneType.WITHOUT_TONE);
//设置特殊拼音用V代替,绿
FORMAT.setVCharType(HanyuPinyinVCharType.WITH_V);
}
/**
* 判断给定的字符串是否包含中文
* @param str 要判断的字符串
* @return
*/
public static boolean containsChinese(String str) {
return str.matches(".*" + CHINESE_PATTERN + ".*");
}
/**
* 传入任意的文件名称,就能将该文件名称转为字母字符串全拼和首字母小写字符串
* 若文件名中包含其他字符,英文数字等,不需要做处理,直接保存
* @param fileName
* @return
*/
public static String[] getPinyinByFileName(String fileName) {
// 第一个字符串为文件名全拼,第二个字符串为首字母
String[] ret = new String[2];
StringBuilder allNameAppender = new StringBuilder();
StringBuilder firstCaseAppender = new StringBuilder();
for(char c : fileName.toCharArray()) {
//不考虑多音字,就使用第一个返回值作为我们的参数
try {
String[] pinyins = PinyinHelper.toHanyuPinyinStringArray(c, FORMAT);
if (pinyins == null || pinyins.length == 0) {
//碰到非中文字符,直接保留
allNameAppender.append(c);
firstCaseAppender.append(c);
} else {
//碰到中文字符,取第一个多音字的返回值 he -> [he,huo,hu]
allNameAppender.append(pinyins[0]);
//he = h
firstCaseAppender.append(pinyins[0].charAt(0));
}
}catch (BadHanyuPinyinOutputFormatCombination e) {
//碰到非中文字符,直接保留
allNameAppender.append(c);
firstCaseAppender.append(c);
}
}
ret[0] = allNameAppender.toString();
ret[1] = firstCaseAppender.toString();
return ret;
}
public static void main(String[] args) {
String s = "中华人民共和国China";
System.out.println(Arrays.toString(getPinyinByFileName(s)));
}
}
我们需要将扫描到的文件信息保存起来进行展示,既然要保存,那么首先想到的就是我们的数据库了。而数据库的选择又有很多种,这里我们选择SQLite数据库;那么问题来了?为什么不选择更熟悉的MySQL数据库呢?原因如下:
选择好数据库之后,我们开始进行数据源的创建和数据库连接的创建,代码如下:
/**
* SQLite数据库的工具类,创建数据源,创建数据库的连接
* 只向外部提供SQLite数据库的连接即可,数据源不提供(封装在工具类的内部)
* JDBC四步走
**/
public class DBUtil {
//单例数据源
private volatile static DataSource DATASOURCE;
//volatile,防止指令重排,被其他线程那拿到一个还没有完全创建完毕的DATASOURCE对象
//单例数据库连接
private volatile static Connection CONNECTION;
//获取数据源方法,使用double-check单例模式获取数据源对象(懒汉式单例)
private static DataSource getDataSource() {
if(DATASOURCE == null) {
//多线程场景下,只有一个线程能进入同步代码块
synchronized (DBUtil.class) {
//防止其他线程释放锁之后,再次进入同步代码块又创建了对象
if(DATASOURCE == null) {
// SQLite没有账户密码,只需要配置日期格式即可
SQLiteConfig config = new SQLiteConfig();
//yyyy-MM-dd HH:mm:ss(SQLite默认是时间戳,需要设置)
config.setDateStringFormat(Util.DATE_PATTERN);
DATASOURCE = new SQLiteDataSource(config);
//配置数据源的URL是是SQLite子类独有的方法,因此向下转型
((SQLiteDataSource)DATASOURCE).setUrl(getUrl());
}
}
}
return DATASOURCE;
}
/**
* 配置SQLite数据库的地址
* mysql: jdbc:mysql://127.0.0.1:3306/数据库名称?
* 对于SQLite数据库来说,没有服务端和客户端,因此只需要指定SQLite数据库的
* 地址即可
* @return
*/
private static String getUrl() {
// 改为本机的路径
String path = "D:\\java_project\\search_everything\\target";
//File.separator,不同操作系统的文件分隔符
String url = "jdbc:sqlite://" + path + File.separator + "search_everything.db";
System.out.println("获取数据库的连接为 : " + url);
return url;
}
/**
* 多线程场景下,SQLite要求多个线程使用同一个连接进行处理
* @return
* @throws SQLException
*/
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 ps, ResultSet rs) {
close(ps);
if (rs != null) {
try {
rs.close();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
}
}
创建好数据库连接和数据源后,我们来进行数据库的初始化操作,这里要用到文件IO,如图:
要初始化数据库,首先写好我们的sql语句,放在resources下,这里根据所需字段写出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)
);
初始化代码如下:
/**
* 在界面初始化时创建文件信息数据表
*/
public class DBInit {
/**
* 从resources路径下读取init.sql文件,加载到程序中
* 文件IO
* @return
*/
public static List readSQL() {
List ret = new ArrayList<>();
//从init.sql中获取内容,需要拿到文件的输入流
try{
//采用类加载器的方式引入资源文件,JVM在加载类时用到的ClassLoader类
//处理相对路径,无论是项目中或jar包都可找到,无论什么操作系统都适用
//任意一个类都可,都会出现在target的classes下
InputStream in = DBInit.class.getClassLoader().getResourceAsStream("init.sql");
//对于输入流来说,采用Scanner类来处理
//对于输出流来说,采用PrintStream类来处理
//从文件获取输入流(System.in是从键盘获取)
Scanner scanner = new Scanner(in);
//自定义分隔符
scanner.useDelimiter(";");//碰到;才做分隔
//nextLine默认碰到换行分割,next按照自定义的分隔符拆分
while(scanner.hasNext()) {
String str = scanner.next();
//碰到 和 \n 不做处理 继续
if("".equals(str) || "\n".equals(str)) {
continue;
}
//执行注释语句
if(str.contains("--")) {
str = str.replace("--","");
}
ret.add(str);
}
} catch (Exception e) {
throw new RuntimeException(e);
}
return ret;
}
/**
* 在界面初始化时先初始化数据库,创建数据表
*/
public static void init() {
Connection connection = null;
Statement statement = null;
try {
connection = DBUtil.getConnection();
//获取要执行的sql语句
List sqls = readSQL();
//使用普通的Statement接口,无需在获取连接对象的时候传入sql
statement = connection.createStatement();
for (String sql : sqls) {
System.out.println("执行sql语句" + sql);
statement.executeUpdate(sql);
}
} catch (SQLException e) {
System.err.println("数据库初始化失败");
//在命令行打印异常信息在程序中出错的位置及原因
e.printStackTrace();
}finally {
DBUtil.close(statement);
}
}
public static void main(String[] args) {
init();
}
}
JavaFX图形化编程采用类似HTML的方式:
而界面上的所有数据最终交给Controller类来处理:
public class Controller implements Initializable {
@FXML
private GridPane rootPane;
@FXML
private TextField searchField;
@FXML
private TableView fileTable;
@FXML
private Label srcDirectory;
private List fileMetas;
private Thread scanThread;
// 点击运行项目,界面初始化时加载的一个方法
// 相当于运行一个主类,首先要加载主类的静态块
public void initialize(URL location, ResourceBundle resources) {
// 在界面初始化时初始化数据库
DBInit.init();
// 添加搜索框监听器,内容改变时执行监听事件
searchField.textProperty().addListener(new ChangeListener() {
public void changed(ObservableValue 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;
// 获取选择的目录路径,并显示
String path = file.getPath();
// 在界面中显示路径的内容(选择框)
this.srcDirectory.setText(path);
// 获取要扫描的文件夹路径之后,进行文件的扫描工作
// 此时将文件信息保存到数据库中
FileScanner fileScanner = new FileScanner(new FileSave2DB());
if (scanThread != null) {
// 创建过任务,且该任务还没执行结束,中断当前正在扫描的任务
scanThread.interrupt();
}
// 开启新线程扫描新选择的目录
scanThread = new Thread(() -> {
fileScanner.scan(file);
// 刷新界面,展示刚才扫描到的文件信息
freshTable();
});
scanThread.start();
}
// 刷新表格数据
private void freshTable(){
ObservableList metas = fileTable.getItems();
metas.clear();
String dir = srcDirectory.getText();
if (dir != null && dir.trim().length() != 0) {
// 界面中已经选择了文件,此时已经将最新的数据保存到了数据库中,
// 只需要取出数据库中的内容展示到界面上即可
// 获取用户在搜索框中输入的内容
String content = searchField.getText();
// 根据选择的路径 + 用户的输入(若为空就展示所有内容) 将数据库中的指定内容刷新到界面中
List filesFromDB = FileSearch.search(dir,content);
metas.addAll(filesFromDB);
}
}
}
最后进行展示内容格式的规定及单位的换算:
/**
* 通用工具类
*/
public class Util {
public static final String DATE_PATTERN = "yyyy-MM-dd HH:mm:ss";
/**
* 根据传入的文件大小返回不同的单位
* 支持的单位如下 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 void main(String[] args) {
long size1 = 4366527;
long size2 = 682830;
System.out.println(parseSize(size1));
System.out.println(parseSize(size2));
}
public static String parseFileType(Boolean directory) {
return directory ? "文件夹" : "文件";
}
public static String parseDate(Date lastModified) {
return new SimpleDateFormat(DATE_PATTERN).format(lastModified);
}
}
至此,工具类已经完成。
这里采用回调接口(基于接口方式的回调函数使用),回调函数是一种程序设计的思想,将两个互相独立的功能拆分为不同的方法,解耦,但是这两个方法又是相互配合完成一个功能;(其实就是在一个类中调用另一个类的方法来辅助解决问题);
这里我们将数据保存至数据库中:
/**
* 文件信息保存到数据库的回调子类
**/
public class FileSave2DB implements FileScannerCallBack {
@Override
public void callback(File dir) {
// 列举出当前dir路径下的所有文件对象
File[] files = dir.listFiles();
// 边界条件
if (files != null && files.length != 0) {
// 1.先将当前dir下的所有文件信息保存到内存中,缓存中的信息一定是从os中读取到的最新数据 - 视图1
List locals = new ArrayList<>();
for (File file : files) {
FileMeta meta = new FileMeta();
if (file.isDirectory()) {
// 文件夹
setCommonFiled(file.getName(),file.getParent(),true,file.lastModified(),meta);
}else {
// 文件
setCommonFiled(file.getName(),file.getParent(),false,file.lastModified(),meta);
meta.setSize(file.length());
}
locals.add(meta);
}
// 2.从数据库中查询出当前路径下的所有文件信息 - 视图2
List dbFiles = query(dir);
// 3.对比视图1和视图2
// 本地有,数据库没有的,做插入 - a
// 遍历locals,若数据库不存在该FileMeta,就做插入
for (FileMeta meta : locals) {
if (!dbFiles.contains(meta)) {
save(meta);
}
}
// 数据库有的,本地没有,做删除 - b
// 遍历dbFiles,本地不存在,做删除
for (FileMeta meta : dbFiles) {
if (!locals.contains(meta)) {
delete(meta);
}
}
}
// 若files = null || files.length == 0 说明该文件夹下就没有文件或者dir压根就不是文件夹,直接啥也不干
}
/**
* 删除数据库中指定记录
* @param meta
*/
private void delete(FileMeta meta) {
Connection connection = null;
PreparedStatement ps = null;
try {
connection = DBUtil.getConnection();
// 此时删除的是文件本身!
String sql = "delete from file_meta where" +
" (name = ? and path = ?)";
if (meta.getIsDirectory()) {
// 还需要删除文件夹内部的子文件和子文件夹
sql += " or path = ?"; // 删除的第一级目录
sql += " or path like ?"; // 删除的是多级子目录
}
ps = connection.prepareStatement(sql);
ps.setString(1,meta.getName());
ps.setString(2, meta.getPath());
if (meta.getIsDirectory()) {
ps.setString(3,meta.getPath() + File.separator + meta.getName());
ps.setString(4,meta.getPath() + File.separator + meta.getName()
+ File.separator + "%");
}
System.out.println("执行删除操作,SQL为 : " + ps);
int rows = ps.executeUpdate();
if (meta.getIsDirectory()) {
System.out.println("删除文件夹 " + meta.getName() + "成功,共删除" + rows + "个文件");
}else {
System.out.println("删除文件 " + meta.getName() + "成功");
}
}catch (SQLException e) {
System.err.println("文件删除出错,请检查SQL语句");
e.printStackTrace();
}finally {
DBUtil.close(ps);
}
}
/**
* 将指定文件对象信息保存到数据库中
* @param meta
*/
private void save(FileMeta meta) {
Connection connection = null;
PreparedStatement ps = null;
try {
connection = DBUtil.getConnection();
String sql = "insert into file_meta values(?,?,?,?,?,?,?)";
ps = connection.prepareStatement(sql);
String fileName = meta.getName();
ps.setString(1,fileName);
ps.setString(2,meta.getPath());
ps.setBoolean(3,meta.getIsDirectory());
if (!meta.getIsDirectory()) {
// 只有是文件的时候才设置size值
ps.setLong(4,meta.getSize());
}
ps.setTimestamp(5,new Timestamp(meta.getLastModified().getTime()));
// 到底是否需要存入拼音,要看文件名是否包含中文
// 需要判断文件名是否包含中文的
if (PinyinUtil.containsChinese(fileName)) {
String[] pinyins = PinyinUtil.getPinyinByFileName(fileName);
ps.setString(6,pinyins[0]);
ps.setString(7,pinyins[1]);
}
System.out.println("执行文件保存操作,SQL为 : " + ps);
int rows = ps.executeUpdate();
System.out.println("成功保存 " + rows + "行文件信息");
}catch (SQLException e) {
System.err.println("保存文件信息出错,请检查SQL语句");
e.printStackTrace();
}finally {
DBUtil.close(ps);
}
}
/**
* 查询数据库中指定路径下的文件信息
* @param dir
* @return
*/
private List query(File dir) {
Connection connection = null;
PreparedStatement ps = null;
ResultSet rs = null;
List dbFiles = new ArrayList<>();
try {
connection = DBUtil.getConnection();
String sql = "select name,path,is_directory,size,last_modified from file_meta" +
// 切记sql拼接时,换行需要加空格
" where path = ?";
ps = connection.prepareStatement(sql);
ps.setString(1,dir.getPath());
rs = ps.executeQuery();
System.out.println("查询指定路径的SQL为 : " + ps);
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()));
// 只有是文件时才设置size大小,若是文件夹,不设置size大小
// 此处有个bug,数据库中文件夹的size大小为null,但是调用rs.getLong方法若返回值为null,返回0
if (!meta.getIsDirectory()) {
// 文件
meta.setSize(rs.getLong("size"));
}
dbFiles.add(meta);
}
}catch (SQLException e) {
System.err.println("查询数据库指定路径下的文件出错,请检查SQL语句");
e.printStackTrace();
}finally {
DBUtil.close(ps,rs);
}
return dbFiles;
}
private void setCommonFiled(String name, String path, boolean isDirectory, Long lastModified, FileMeta meta) {
meta.setName(name);
meta.setPath(path);
meta.setIsDirectory(isDirectory);
// file对象的lastModified是一个长整型,以时间戳为单位的
meta.setLastModified(new Date(lastModified));
}
}
文件扫描类的实现:
这里使用线程池创建线程对象,若扫描中遇到文件夹,则递归创建一个线程去扫描;
多线程下使用原子类来统计文件和文件夹的个数;以及使用原子类来统计子线程个数,只有当子线程个数为0时,主线程才再次执行;
代码如下:
/***
* 进行文件任务
*/
@Getter
public class FileScanner {
// 当前扫描的文件个数
private AtomicInteger fileNum = new AtomicInteger();
// 当前扫描的文件夹个数
// 最开始扫描的根路径没有统计,因此初始化文件夹的个数为1,表示从根目录下开始进行扫描任务
private AtomicInteger dirNum = new AtomicInteger(1);
// 所有扫描文件的子线程个数,只有当子线程个数为0时,主线程再继续执行
private AtomicInteger threadCount = new AtomicInteger();
// 当最后一个子线程执行完任务之后,再调用countDown方法唤醒主线程
private CountDownLatch latch = new CountDownLatch(1);
//信号量
// private Semaphore semaphore = new Semaphore(0);
// 获取当前电脑的可用CPU个数
private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
// 使用线程池创建对象
private ThreadPoolExecutor pool = new ThreadPoolExecutor(CPU_COUNT,CPU_COUNT * 2, 10,TimeUnit.SECONDS,
new LinkedBlockingQueue<>(),new ThreadPoolExecutor.AbortPolicy());
// 文件扫描回调对象
private FileScannerCallBack callBack;
public FileScanner(FileScannerCallBack callBack) {
this.callBack = callBack;
}
/**
* 根据传入的文件夹进行扫描任务
* @param filePath 要扫描的根目录
* 选择要扫描的菜单之后,执行的第一个方法,主线程需要等待所有子线程全部扫描结束之后再恢复执行
* scan方法是我们选择要扫描的文件夹之后的入口方法,所有文件夹和文件的具体扫描工作交给子线程,
* scan等待所有子线程执行结束之后,统计扫描到的文件夹和文件个数,计算扫描时间
*/
public void scan(File filePath) {
System.out.println("开始扫描文件任务,根目录为 :" + filePath );
//返回最准确的可用系统计时器的当前值,以毫秒为单位
long start = System.nanoTime();
//将具体的扫描任务交个子线程处理
//此时根目录下的扫描任务已经创建线程
scanInternal(filePath);
//统计根目录扫描的线程
threadCount.incrementAndGet();
try {
latch.await();
// semaphore.acquire();
} 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 filePath
*/
private void scanInternal(File filePath) {
if (filePath == null) {
return;
}
// 将当前要扫描的任务交给线程处理
pool.submit( () -> {
// 使用回调函数,将当前目录下的所有内容保存到指定终端
//所谓回调,其实就是在一个类中调用另一个类的方法来辅助解决问题
this.callBack.callback(filePath);
// 先将当前这一级目录下的file对象获取出来
File[] files = filePath.listFiles();
// 遍历这些file对象,根据是否是文件夹进行区别处理
for (File file : files) {
if (file.isDirectory()) {
// 等同于 ++i
dirNum.incrementAndGet();
// 将子文件夹的任务交给新线程处理
// 碰到文件夹递归创建新线程
threadCount.incrementAndGet();
scanInternal(file);
}else {
// 等同于 i++
fileNum.getAndIncrement();
}
}
// 当前线程将这一级目录下的文件夹(创建新线程递归处理)和文件的保存扫描任务执行结束
System.out.println(Thread.currentThread().getName() + "扫描 : " + filePath + "任务结束");
// 子线程数 --
threadCount.decrementAndGet();
if (threadCount.get() == 0) {
// 所有线程已经结束任务
System.out.println("所有扫描任务结束");
// 唤醒主线程
latch.countDown();
}
});
}
}
至此,该项目大体模块已经实现;
最终使用Java8+JavaFX+SQLite数据库+多线程+原子类+文件IO实现了上述功能;
在实现项目功能的过程中,也遇到了不少的问题;如:怎样实现线程阻塞,让子线程递归扫描完所有的子文件夹?查了资料,最后决定使用 CountDownLatch 的 await() 方法来实现,当最后统计到线程数为0的时候,调用一次 countDown() 唤醒主线程;以及在自己测试代码的过程中发现问题:若正在扫描的途中,重新选择了其他的文件夹还能否保持功能?结果就是虽然可以扫描新的文件夹,但旧的任务并不会终止,最终使用Threa类的interrupt()方法,在创建过任务,且该任务还没执行结束,中断当前正在扫描的任务;还有许多其他问题,这里不再一一赘述;