Everything原理及实现
网上找了很多的资料,还是没有很清楚的了解Everything这款软件。所以自己研究了以下,并记录下来。
Everything是一个搜索文件速度超快的软件,相比Windows自带的搜索功能,Everything可以做到在数十万文件中做到秒搜。
本文来分析一下Everything背后的原理以及自己实现一个Everything。
Everything的工作原理
Everything在第一次打开程序时会扫描整个磁盘,并建立一个索引库。需要注意的是,Everything并不是像Windows文件夹遍历那样一个文件一个文件的搜索并记录。而是通过NTFS文件系统的特性,MFT和USN journal。这也是Everything仅支持NTFS文件系统的原因。
Master File Table (MTF)
在NTFS文件系统中,有一个特殊的表,称为MTF表。所有文件夹和文件的名称都被存储在该表中,Everything通过遍历这个表的所有内容,实现在不遍历文件系统就能获取当前磁盘中的所有文件的名称和路径。
USN journal
NTFS的日志功能。所有对文件系统的修改操作都被记录在了一个journal日志文件中。Everything通过监控这个日志文件实现对文件修改的监控。
文件查找
通过字符串匹配算法从之前建立的索引中对字符串进行匹配,并显示文件名称和路径。
自己实现一个Everything
我自己已经实现了一个完整的类似于Everything的软件,代码已经在GitHub上开源,欢迎各位去点个star呀。
XUANXUQAQ/File-Engine: An app launcher && efficiency tool (github.com)
一、建立数据库索引
首先,我们需要做的就是对文件的路径进行索引。我这里选用SQLite。不知道Everything是使用了什么数据库才使他们的索引文件做到这么小,至于为什么这么快,推测可能是先将索引放入内存,索引可用后,程序在后台慢慢同步到硬盘的吧。
搜索的核心方法是通过创建USN日志并进行读取,然后将数据还原成文件路径并写入数据库。
1. 首先是获取驱动盘的句柄
通过CreateFile方法打开磁盘,并返回磁盘句柄。
关于CreateFile的用法详见MSDNCreateFileW function (fileapi.h) - Win32 apps | Microsoft Docs
inline bool volume::getHandle()
{
// 为\\.\C:的形式
CString lpFileName(_T("\\\\.\\c:"));
lpFileName.SetAt(4, vol);
hVol = CreateFile(lpFileName,
GENERIC_READ | GENERIC_WRITE, // 可以为0
FILE_SHARE_READ | FILE_SHARE_WRITE, // 必须包含有FILE_SHARE_WRITE
nullptr,
OPEN_EXISTING, // 必须包含OPEN_EXISTING, CREATE_ALWAYS可能会导致错误
FILE_ATTRIBUTE_READONLY, // FILE_ATTRIBUTE_NORMAL可能会导致错误
nullptr);
if (INVALID_HANDLE_VALUE != hVol)
{
return true;
}
return false;
}
2. 之后开始创建USN日志
通过DeviceIoControl方法向磁盘发送FSCTL_CREATE_USN_JOURNAL命令,创建USN日志。
关于DeviceIoControl的用法,详见MSDNDeviceIoControl function (ioapiset.h) - Win32 apps | Microsoft Docs
DeviceIoControl的各个命令操作,详见MSDNVolume Management Control Codes - Win32 apps | Microsoft Docs
这里创建USN日志使用的是FSCTL_CREATE_USN命令FSCTL_CREATE_USN_JOURNAL - Win32 apps | Microsoft Docs
inline bool volume::createUSN()
{
cujd.MaximumSize = 0; // 0表示使用默认值
cujd.AllocationDelta = 0; // 0表示使用默认值
DWORD br;
if (
DeviceIoControl(hVol, // handle to volume
FSCTL_CREATE_USN_JOURNAL, // dwIoControlCode
&cujd, // input buffer
sizeof(cujd), // size of input buffer
nullptr, // lpOutBuffer
0, // nOutBufferSize
&br, // number of bytes returned
nullptr) // OVERLAPPED structure
)
{
return true;
}
return false;
}
3. 获取USN日志信息
还是通过DeviceIoControl方法发送FSCTL_QUERY_USN_JOURNAL命令,获取当前卷USN日志的各项信息,检查USN日志是否获取成功。
FSCTL_QUERY_USN_JOURNAL - Win32 apps | Microsoft Docs
inline bool volume::getUSNInfo()
{
DWORD br;
if (
DeviceIoControl(hVol, // handle to volume
FSCTL_QUERY_USN_JOURNAL, // dwIoControlCode
nullptr, // lpInBuffer
0, // nInBufferSize
&ujd, // output buffer
sizeof(ujd), // size of output buffer
&br, // number of bytes returned
nullptr) // OVERLAPPED structure
)
{
return true;
}
return false;
}
4. 获取 USN Journal 文件的基本信息
仍然通过DeviceIoControl发送FSCTL_ENUM_USN_DATA遍历USN日志。
FSCTL_ENUM_USN_DATA - Win32 apps | Microsoft Docs
bool volume::getUSNJournal()
{
MFT_ENUM_DATA med;
med.StartFileReferenceNumber = 0;
med.LowUsn = ujd.FirstUsn;
med.HighUsn = ujd.NextUsn;
// 根目录
CString tmp(_T("C:"));
tmp.SetAt(0, vol);
frnPfrnNameMap[0x20000000000005].filename = tmp;
frnPfrnNameMap[0x20000000000005].pfrn = 0;
constexpr auto BUF_LEN = 0x3900; // 尽可能地大,提高效率;
CHAR Buffer[BUF_LEN];
DWORD usnDataSize;
while (0 != DeviceIoControl(hVol,
FSCTL_ENUM_USN_DATA,
&med,
sizeof(med),
Buffer,
BUF_LEN,
&usnDataSize,
nullptr))
{
DWORD dwRetBytes = usnDataSize - sizeof(USN);
// 找到第一个 USN 记录
auto UsnRecord = reinterpret_cast<PUSN_RECORD>(static_cast<PCHAR>(Buffer) + sizeof(USN));
while (dwRetBytes > 0)
{
// 获取到的信息
const CString CfileName(UsnRecord->FileName, UsnRecord->FileNameLength / 2);
pfrnName.filename = CfileName;
pfrnName.pfrn = UsnRecord->ParentFileReferenceNumber;
frnPfrnNameMap[UsnRecord->FileReferenceNumber] = pfrnName;
// 获取下一个记录
const auto recordLen = UsnRecord->RecordLength;
dwRetBytes -= recordLen;
UsnRecord = reinterpret_cast<PUSN_RECORD>(reinterpret_cast<PCHAR>(UsnRecord) + recordLen);
}
// 获取下一页数据
med.StartFileReferenceNumber = *reinterpret_cast<USN*>(&Buffer);
}
return true;
}
这里我将获取到的信息放入了frnPfrnNameMap中
typedef struct _pfrn_name
{
DWORDLONG pfrn = 0;
CString filename;
} pfrn_name;
typedef unordered_map<DWORDLONG, pfrn_name> Frn_Pfrn_Name_Map;
这里不得不说USN日志文件的数据结构。
typedef struct {
DWORD RecordLength;
WORD MajorVersion;
WORD MinorVersion;
DWORDLONG FileReferenceNumber;
DWORDLONG ParentFileReferenceNumber;
USN Usn;
LARGE_INTEGER TimeStamp;
DWORD Reason;
DWORD SourceInfo;
DWORD SecurityId;
DWORD FileAttributes;
WORD FileNameLength;
WORD FileNameOffset;
WCHAR FileName[1];
} USN_RECORD_V2, *PUSN_RECORD_V2;
这里我们需要用到FileReferenceNumber、ParentFileReferenceNumber、FileName。
USN日志在Windows中也有对应的命令,使用fsutil就可以创建日志
fsutil usn createJournal C:
fsutil usn enumData 1 0 1 C:
此时,可以看到USN日志的数据。
包含文件参照号,父文件参照号,以及文件名。
也就是上文提到的FileReferenceNumber、ParentFileReferenceNumber、FileName
USN日志是一个树状结构,通过文件号和父文件号不断向上找,最终可以拼接出一个完整的路径。获得路径后即可存入数据库。
最后删除USN日志。
fsutil usn deleteJournal /D C:
5. 删除USN日志文件
因为USN日志默认并不存在,所以在程序创建之后可以将它删除。
仍然是通过DeviceIoControl方法发送命令。
FSCTL_DELETE_USN_JOURNAL - Win32 apps | Microsoft Docs
inline bool volume::deleteUSN() const
{
DELETE_USN_JOURNAL_DATA dujd;
dujd.UsnJournalID = ujd.UsnJournalID;
dujd.DeleteFlags = USN_DELETE_FLAG_DELETE;
DWORD br;
if (DeviceIoControl(hVol,
FSCTL_DELETE_USN_JOURNAL,
&dujd,
sizeof(dujd),
nullptr,
0,
&br,
nullptr)
)
{
CloseHandle(hVol);
return true;
}
CloseHandle(hVol);
return false;
}
6. 此时就可以通过遍历frnPfrnNameMap还原出每个文件的路径,并存入数据库
通过不断向上遍历查找,最后拼接出文件的路径。
inline void volume::getPath(DWORDLONG frn, CString& _path)
{
const auto end = frnPfrnNameMap.end();
while (true)
{
auto it = frnPfrnNameMap.find(frn);
if (it == end)
{
_path = L":" + _path;
return;
}
_path = _T("\\") + it->second.filename + _path;
frn = it->second.pfrn;
}
}
此时,我们已经实现了实现Everything的第一步,建立数据库索引。
二、监控文件的变化
监控文件的变化在这里我采用的是ReadDirectoryChangesW方法进行监控。ReadDirectoryChangesW function (winbase.h) - Win32 apps | Microsoft Docs
文件操作类型有四种,分别为 创建、更改、删除、重命名
对应到Windows API里是FILE_ACTION_ADDED、FILE_ACTION_MODIFIED、FILE_ACTION_REMOVED、FILE_ACTION_RENAMED_OLD_NAME。
通过监控这四个操作,我们可以实现监控文件的变化,并更新数据库索引。
void monitor_path(const char* path)
{
DWORD cb_bytes;
char file_name[1000]; //设置文件名
char file_rename[1000]; //设置文件重命名后的名字;
char _path[1000];
char notify[1024];
memset(_path, 0, 1000);
strcpy_s(_path, 1000, path);
WCHAR _dir[1000];
memset(_dir, 0, sizeof(_dir));
MultiByteToWideChar(CP_ACP, 0, _path, 1000, _dir,
sizeof(_dir) / sizeof(_dir[0]));
HANDLE dirHandle = CreateFile(_dir,
GENERIC_READ | GENERIC_WRITE | FILE_LIST_DIRECTORY,
FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr,
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS,
nullptr);
if (dirHandle == INVALID_HANDLE_VALUE) //若网络重定向或目标文件系统不支持该操作,函数失败,同时调用GetLastError()返回ERROR_INVALID_FUNCTION
{
cout << "error " << GetLastError() << endl;
exit(0);
}
auto* pnotify = reinterpret_cast<FILE_NOTIFY_INFORMATION*>(notify);
while (is_running)
{
if (ReadDirectoryChangesW(dirHandle, ¬ify, 1024, true,
FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_DIR_NAME | FILE_NOTIFY_CHANGE_SIZE,
&cb_bytes, nullptr, nullptr))
{
//转换文件名为多字节字符串;
if (pnotify->FileName)
{
memset(file_name, 0, sizeof(file_name));
memset(fileName, 0, sizeof(fileName));
wcscpy_s(fileName, pnotify->FileName);
WideCharToMultiByte(CP_ACP, 0, pnotify->FileName, pnotify->FileNameLength / 2, file_name, 250, nullptr,
nullptr);
}
//获取重命名的文件名;
if (pnotify->NextEntryOffset != 0 && (pnotify->FileNameLength > 0 && pnotify->FileNameLength < 1000))
{
auto p = reinterpret_cast<PFILE_NOTIFY_INFORMATION>(reinterpret_cast<char*>(pnotify) + pnotify->
NextEntryOffset);
memset(file_rename, 0, 1000);
memset(fileRename, 0, 1000);
wcscpy_s(fileRename, pnotify->FileName);
WideCharToMultiByte(CP_ACP, 0, p->FileName, p->FileNameLength / 2, file_rename, 250, nullptr, nullptr);
}
if (file_name[strlen(file_name) - 1] == '~')
{
file_name[strlen(file_name) - 1] = '\0';
}
if (file_rename[strlen(file_rename) - 1] == '~')
{
file_rename[strlen(file_rename) - 1] = '\0';
}
//设置类型过滤器,监听文件创建、更改、删除、重命名等;
switch (pnotify->Action)
{
case FILE_ACTION_ADDED:
if (strstr(file_name, "$RECYCLE.BIN") == nullptr)
{
string data;
data.append(_path);
data.append(file_name);
#ifdef TEST
cout << "file add : " << data << endl;
#endif
add_record(to_utf8(StringToWString(data)));
}
break;
case FILE_ACTION_MODIFIED:
if (strstr(file_name, "$RECYCLE.BIN") == nullptr && strstr(file_name, "fileAdded.txt") == nullptr &&
strstr(file_name, "fileRemoved.txt") == nullptr)
{
string data;
data.append(_path);
data.append(file_name);
#ifdef TEST
cout << "file add : " << data << endl;
#endif
add_record(to_utf8(StringToWString(data)));
}
break;
case FILE_ACTION_REMOVED:
if (strstr(file_name, "$RECYCLE.BIN") == nullptr)
{
string data;
data.append(_path);
data.append(file_name);
#ifdef TEST
cout << "file removed : " << data << endl;
#endif
delete_record(to_utf8(StringToWString(data)));
}
break;
case FILE_ACTION_RENAMED_OLD_NAME:
if (strstr(file_name, "$RECYCLE.BIN") == nullptr)
{
string data;
data.append(_path);
data.append(file_name);
delete_record(to_utf8(StringToWString(data)));
data.clear();
data.append(_path);
data.append(file_rename);
#ifdef TEST
cout << "file renamed : " << data << "->" << data << endl;
#endif
add_record(to_utf8(StringToWString(data)));
}
break;
default:
cout << "Unknown command!" << endl;
}
}
}
CloseHandle(dirHandle);
cout << "stop monitoring " << _path << endl;
return;
}
至此,实现Everything第二步,监控文件变化完成。
三、实现UI界面
由于项目是我几年前写的,所以当时用了Java的Swing框架。上文写到的创建数据库索引的工具编译成了exe,由Java调用。而监控文件变化的工具编译成了dll,通过jni进行调用。
1. 首先,制作一个搜索框
使用JFrame创建窗口,JTextField放上面作为搜索框,下面放JLabel作为结果的显示就好,这里就不再贴代码。
2. 监听用户输入,并查询数据库
private void addTextFieldDocumentListener() {
textField.getDocument().addDocumentListener(new DocumentListener() {
private boolean isSendSignal;
@Override
public void insertUpdate(DocumentEvent e) {
changeFontOnDisplayFailed();
clearAllAndResetAll();
isSendSignal = setRunningMode();
if (isSendSignal) {
startTime = System.currentTimeMillis();
startSignal.set(true);
isSqlNotInitialized.set(true);
}
if (runningMode == Constants.Enums.RunningMode.PLUGIN_MODE && currentUsingPlugin != null) {
currentUsingPlugin.textChanged(getSearchBarText());
currentUsingPlugin.clearResultQueue();
}
}
@Override
public void removeUpdate(DocumentEvent e) {
changeFontOnDisplayFailed();
clearAllAndResetAll();
isSendSignal = setRunningMode();
if (getSearchBarText().isEmpty()) {
lastMousePositionX = 0;
lastMousePositionY = 0;
listResultsNum.set(0);
currentResultCount.set(0);
startTime = System.currentTimeMillis();
startSignal.set(false);
isSqlNotInitialized.set(false);
} else {
if (isSendSignal) {
startTime = System.currentTimeMillis();
startSignal.set(true);
isSqlNotInitialized.set(true);
}
if (runningMode == Constants.Enums.RunningMode.PLUGIN_MODE && currentUsingPlugin != null) {
currentUsingPlugin.textChanged(getSearchBarText());
currentUsingPlugin.clearResultQueue();
}
}
}
@Override
public void changedUpdate(DocumentEvent e) {
startTime = System.currentTimeMillis();
startSignal.set(false);
isSqlNotInitialized.set(false);
}
});
}
这里采用记录startTime和startSignal的方式,通过线程池来检测搜索信号,并进行数据库的搜索。
/**
* 生成未格式化的sql
* 第一个map中key保存未格式化的sql,value保存表名称,第二个map为搜索结果的暂时存储容器
*
* @return map
*/
private LinkedHashMap<LinkedHashMap<String, String>, ConcurrentSkipListSet<String>> getNonFormattedSqlFromTableQueue() {
if (isDatabaseUpdated.get()) {
isDatabaseUpdated.set(false);
initPriority();
}
LinkedHashMap<LinkedHashMap<String, String>, ConcurrentSkipListSet<String>> sqlColumnMap = new LinkedHashMap<>();
if (priorityMap.isEmpty()) {
return sqlColumnMap;
}
initTableQueueByPriority();
int asciiSum = 0;
if (keywords.get() != null) {
for (String keyword : keywords.get()) {
int ascII = GetAscII.INSTANCE.getAscII(keyword);
asciiSum += Math.max(ascII, 0);
}
}
int asciiGroup = asciiSum / 100;
String firstTableName = "list" + asciiGroup;
if (searchCase.get() != null && Arrays.asList(searchCase.get()).contains("d")) {
LinkedHashMap<String, String> _priorityMap = new LinkedHashMap<>();
String _sql = "SELECT %s FROM " + firstTableName + " WHERE PRIORITY=" + 0;
_priorityMap.put(_sql, firstTableName);
tableQueue.stream().filter(each -> !each.equals(firstTableName)).forEach(each -> {
String sql = "SELECT %s FROM " + each + " WHERE PRIORITY=" + 0;
_priorityMap.put(sql, each);
});
ConcurrentSkipListSet<String> container;
container = new ConcurrentSkipListSet<>();
sqlColumnMap.put(_priorityMap, container);
} else {
for (Pair i : priorityMap) {
LinkedHashMap<String, String> eachPriorityMap = new LinkedHashMap<>();
String _sql = "SELECT %s FROM " + firstTableName + " WHERE PRIORITY=" + i.priority;
eachPriorityMap.put(_sql, firstTableName);
tableQueue.stream().filter(each -> !each.equals(firstTableName)).forEach(each -> {
String sql = "SELECT %s FROM " + each + " WHERE PRIORITY=" + i.priority;
eachPriorityMap.put(sql, each);
});
ConcurrentSkipListSet<String> container;
container = new ConcurrentSkipListSet<>();
sqlColumnMap.put(eachPriorityMap, container);
}
}
tableQueue.clear();
return sqlColumnMap;
}
这一步生成所有的查询SQL,简单来说就是生成SELECT * FROM [数据表] 命令并执行,这里因为后面是异步多线程查询,所以为每一个SQL语句都分配了一个用来装数据的容器,后续再进行合并,提高效率,%s字符串格式化主要是为后来可能增加的字段留出升级空间。
3. 字符串匹配
运行SQL后,文件路径将被查出来,通过文件名匹配。
这里我添加了路径搜索,以及是否忽略大小写和拼音搜索的功能。如果只是单纯的匹配字符串,直接用string.contains()或者string.indexof()即可。
关于为什么不使用KMP算法而只是暴力匹配。因为文件路径中并不存在大量重复字符串,并且路径字符串一般都不会很长。此时使用KMP算法由于大量运算反而会起反效果。
/**
* 判断文件路径是否满足当前匹配结果(该方法由check方法使用),检查文件路径使用check方法。
*
* @param path 文件路径
* @param isIgnoreCase 是否忽略大小谢
* @return true如果匹配成功
* @see #check(String, String[], String, String[]) ;
*/
private static boolean notMatched(String path, boolean isIgnoreCase, String[] keywords) {
String matcherStrFromFilePath;
boolean isPath;
for (String eachKeyword : keywords) {
if (eachKeyword == null || eachKeyword.isEmpty()) {
continue;
}
char firstChar = eachKeyword.charAt(0);
if (firstChar == '/' || firstChar == File.separatorChar) {
//匹配路径
isPath = true;
Matcher matcher = RegexUtil.slash.matcher(eachKeyword);
eachKeyword = matcher.replaceAll(Matcher.quoteReplacement(""));
//获取父路径
matcherStrFromFilePath = FileUtil.getParentPath(path);
} else {
//获取名字
isPath = false;
matcherStrFromFilePath = FileUtil.getFileName(path);
}
//转换大小写
if (isIgnoreCase) {
matcherStrFromFilePath = matcherStrFromFilePath.toLowerCase();
eachKeyword = eachKeyword.toLowerCase();
}
//开始匹配
if (matcherStrFromFilePath.indexOf(eachKeyword) == -1) {
if (isPath) {
return true;
} else {
if (PinyinUtil.isStringContainChinese(matcherStrFromFilePath)) {
if (PinyinUtil.toPinyin(matcherStrFromFilePath, "").indexOf(eachKeyword) == -1) {
return true;
}
} else {
return true;
}
}
}
}
return false;
}
4. 显示结果
最后直接将搜索出来的结果显示在JLabel上就好了,这里就不再粘贴代码。
至此,一个最基本的Everything制作完成
项目源代码在Github开源。XUANXUQAQ/File-Engine: An app launcher && efficiency tool (github.com)