首页 文件哈希比较的代码优化一例
文章
取消

文件哈希比较的代码优化一例

背景

某个场景,需要手动对业务系统替换几个文件,文件位于不同的路径下,且文件量大。

在替换结束后,需要检查文件是否真的被替换了。是否被替换了合适的版本。

实现

检查文件有没有货不对板有多种方式。如果是exe这类正经封装过的文件,一般会有FileVersionRaw属性,属性中会有版本号,我们基于此比较就可以。但如果是脚本、文本文件这种没有版本号的,这个方法则不适用了。另外版本号也有可能会骗人,版本起名全靠作者自觉。

基于此,可以使用PowerShell内置命令Get-FileHASH来比对每一个文件的哈希值,通过比对哈希值(默认SHA256),来判断文件是否被替换成正确的版本。

1
2
3
4
5
PS C:\windows\system32> Get-FileHash $a[888]

Algorithm       Hash                                                                   Path                                                                  
---------       ----                                                                   ----                                                                  
SHA256          B93A3F3DC478B4D167F26BCDEB4E4984DA997665AC79B91BCA3F1DF4E36A7E5E       C:\windows\system32\dafWCN.dll 

Get-FileHASH除了支持使用SHA256来计算外,也支持如下格式

  • SHA1
  • SHA256
  • SHA384
  • SHA512
  • MD5

代码实现

首先准备两个函数,一个用来抓数据

1
2
3
4
5
6
7
8
function OutputHASHCheckFile ($Filename, $CheckPath,$OutputPath) {
  $CheckPath=$CheckPath.replace(':','$')
  $FullFile = Get-ChildItem $("\\" + $Filename + "\" + $CheckPath) -Recurse
  $Files = $FullFile | Where-Object { $_.Attributes -eq "Archive" }
  $FileHash = $Files | ForEach-Object { Get-FileHASH $_.FullName }
  "HASH,path,FileVersion" | Out-File $($OutputPath + $Filename + ".csv") -Encoding utf8  
  $FileHash | ForEach-Object { $_.HASH + "," + $_.path.split('$')[-1] + "," + (Get-Item ($_.path)).VersionInfo.FileVersionRaw } | Out-File $($OutputPath + $Filename + ".csv") -Encoding utf8 -Append
}

这里有一个大问题,就是类似C:\windows\system32\dafWCN.dll 这种路径格式,当包含\符号时,PowerShell的解析是有问题的,这个时候不能用match,但是可以用EQ ,非常之神奇。

1
2
3
4
5
6
7
8
9
10
11
\Windows\System32\zh-CN\acledit.dll.mui
PS C:\windows\system32> 
PS C:\windows\system32> $Basefile.path
\Windows\System32\zh-CN\aadtb.dll.mui
\Windows\System32\zh-CN\aadWamExtension.dll.mui
\Windows\System32\zh-CN\AboutSettingsHandlers.dll.mui
...........
PS C:\windows\system32>   $Basefile.path -match $Targetfile[4].path
#不同操作系统的返回不太一样,但都是返回错误
PS C:\windows\system32>   $Basefile.path -eq $Targetfile[4].path
\Windows\System32\zh-CN\acledit.dll.mui

再做一个函数用来比对哈希值,这里用到了相对很复杂的例子,首先从目标目录中找出和基线目录中一致的目录(也就是挑出来),然后从整个目标目录中,找出来这条完整的数据(包含目录、哈希值、版本号)

1
$Targetfile | Where-Object {$_.path -eq ($Targetfile.path -eq $temp.path) }

完整代码如下

1
2
3
4
5
6
7
8
9
10
11
12
function HASHCheckFile ($baselineFile, $targetFile) {
  Write-Host $_ -ForegroundColor Green
  $Basefile = Import-Csv  $baselineFile  
  $Targetfile = Import-Csv  $targetFile 
  $Basefile | ForEach-Object {
    $temp=$_
    $identicalfile = $Targetfile | Where-Object {$_.path -eq ($Targetfile.path -eq $temp.path) }
    if ($identicalfile.HASH -ne $_.HASH) {
      Write-Host ($_.path + " | " + $_.Fileversion) -ForegroundColor Red
    }
  }   
}

优化1

上面代码是可以成功的,但是问题是速度太慢了。我插入了一条获取当前时间的逻辑,可以看到每一次比对文件都需要消耗1秒多,对于有1700多个文件的目录而言,就是需要消耗1700多秒。显然是无法接受的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function HASHCheckFile ($baselineFile, $targetFile) {
  Write-Host $_ -ForegroundColor Green
  $Basefile = (Import-Csv  $baselineFile) 
  $Targetfile = (Import-Csv  $targetFile) 
  $Basefile | ForEach-Object {
    $temp=$_
    $identicalfile = $Targetfile | Where-Object {$_.path -eq ($Targetfile.path -eq $temp.path) }
    (Get-Date).TimeOfDay.TotalSeconds #检查时间信息
    if ($identicalfile.HASH -ne $_.HASH) {
      Write-Host ($_.path + " | " + $_.Fileversion) -ForegroundColor Red
    }
  }   
}
#可以看到输出结果,每一次查询,会消耗1秒多
64498.099711
64499.1767082
64500.2567158

为什么会这么慢的?这完全是查询方式的锅。

1
$Targetfile | Where-Object {$_.path -eq ($Targetfile.path -eq $temp.path) }

那么这里面是谁慢导致的呢?我们看下面的例子。当我们单纯用EQ的方式来匹配数据的时候,只用了2ms,但是换成管道,然后在右侧使用Where-Object来进行查询呢?达到了惊人的90ms,这就差了几十倍。

1
2
3
4
5
6
7
8
9
$a=1..10000

PS C:\windows\system32> (Measure-Command {$a -eq 22}).TotalMilliseconds

2.1583

PS C:\windows\system32> (Measure-Command { $a | Where-Object { $_ -eq 22 } }).TotalMilliseconds

90.061

如果我们换成foreach这种循环来做呢?只需要22ms。

1
2
3
4
5
6
7
(Measure-Command { foreach ($item in $a) {
      if ($item -eq 22) {
        Write-Host $item 
      }
    } }).TotalMilliseconds
22
22.3433

解题思路就是尽量不要在这里用管道,实在需要遍历的话,用foreach也可以。由于路径格式,包含\符号。所以至少需要使用一次循环,这里先尝试下用foreach

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#脚本1
function HASHCheckFile ($baselineFile, $targetFile) {
  Write-Host $_ -ForegroundColor Green
  $Basefile = (Import-Csv  $baselineFile) 
  $Targetfile = (Import-Csv  $targetFile) 
  $Basefile | ForEach-Object {
    $temp = $_
    $identicalfile = foreach ($item in  $Targetfile ) {
      if ($item.path -eq ($Targetfile.path -eq $temp.path))
      {
        $item
      }
    }
     # $Targetfile | Where-Object {$_.path -eq ($Targetfile.path -eq $temp.path) }
    (Get-Date).TimeOfDay.TotalSeconds #检查时间信息
    if ($identicalfile.HASH -ne $_.HASH) {
      Write-Host ($_.path + " | " + $_.Fileversion) -ForegroundColor Red
    }
  }   
}

试试看运行效果

1
2
3
4
5
6
PS C:\windows\system32> HASHCheckFile C:\localhost.csv C:\localhostold.csv

66598.8235475
66599.847084
66600.8921025
66601.9200949

看起来还是没什么改进。

优化2

仔细观察逻辑

1
2
3
4
5
6
7
8
$Basefile | ForEach-Object {
    $temp = $_
    $identicalfile = foreach ($item in  $Targetfile ) {
      if ($item.path -eq ($Targetfile.path -eq $temp.path))
      {
        $item
      }
    }

会发现, if ($item.path -eq ($Targetfile.path -eq $temp.path))中的$Targetfile.path -eq $temp.path无论再简单,运算再快,还是无可避免的在循环中计算了一次。我们把它存储在变量中,重复调用一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#脚本2
function HASHCheckFile ($baselineFile, $targetFile) {
  Write-Host $_ -ForegroundColor Green
  $Basefile = (Import-Csv  $baselineFile) 
  $Targetfile = (Import-Csv  $targetFile) 
  $Basefile | ForEach-Object {
    $temp = $_
    $temppath = $Targetfile.path -eq $temp.path
     $identicalfile = foreach ($item in  $Targetfile ) {
      if ($item.path -eq $temppath) {
        $item
      }
    }
    (Get-Date).TimeOfDay.TotalSeconds #检查时间信息
    if ($identicalfile.HASH -ne $_.HASH) {
      Write-Host ($_.path + " | " + $_.Fileversion) -ForegroundColor Red
    }
  }   
}

肉眼可见的速度飞快起来,执行一条大约需要0.01多一点秒(10ms)

1
2
3
4
PS C:\windows\system32> HASHCheckFile C:\localhost.csv C:\localhostold.csv
66844.7875269
66844.8005286
66844.8115265

再把逻辑里面的管道换成foreach

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#脚本3
function HASHCheckFile ($baselineFile, $targetFile) {
  Write-Host $_ -ForegroundColor Green
  $Basefile = (Import-Csv  $baselineFile) 
  $Targetfile = (Import-Csv  $targetFile) 
  foreach ($itemnew in   $Basefile) {
    $temp = $itemnew
    $temppath = $Targetfile.path -eq $temp.path
    $identicalfile = foreach ($item in  $Targetfile ) {
      if ($item.path -eq $temppath) {
        $item
      }
    }
    (Get-Date).TimeOfDay.TotalSeconds #检查时间信息
    if ($identicalfile.HASH -ne $temp.HASH) {
      Write-Host ($temp.path + " | " + $temp.Fileversion) -ForegroundColor Red
    }
  }   
 
 
}

看起来接近0.006秒了(6ms)

1
2
3
4
5
PS C:\windows\system32> HASHCheckFile C:\localhost.csv C:\localhostold.csv

67540.2396194
67540.2476195
67540.2526189

可能是样本量太少,我将脚本完整跑一次得出结果。

脚本执行时间
脚本214.0271825
脚本310.4654441秒
脚本1单条执行速度太慢,不参与评比

优化3

10秒跑一个1700多文件的对比,基本上可以算是符合要求了,生产中用起来也没问题。不过有没有更快的呢?由于我用的是import-csv的方式加载的对象,每个对象包含了路径、哈希值以及版本号。

但是对我的需求而言,如果要检查某个文件的哈希值是否匹配,那么他在(基准文件)中的记录,一定有一条完全一模一样的数据同时存在于(待查询文件)中,但凡差一个字,都不是真的一样。

基于这个原理,只需要查询一次,不需要先查路径再查哈希值。也就是循环只需要做一次。所以导入数据的时候用GC进行原样导入,不转换成对象。

1
2
3
4
5
6
7
8
9
10
11
12
#代码4
function HASHCheckFile ($baselineFile, $targetFile) {
  Write-Host $_ -ForegroundColor Green
  $Basefile = Get-Content   $baselineFile 
  $Targetfile = Get-Content   $targetFile 
  foreach ($item in   $Basefile) {
    $temppath = $Targetfile -eq $$item 
    if ([string]$temppath -eq "") {
      Write-Host $$item -ForegroundColor Red
    }
  }   
}

看看消耗时间,达到了惊人的0.48秒,把其他方式按在地上摩擦。

1
2
3
0E259FD07FF6D8D38BB529724E965A11037AF4ED260EEF3CB3692B53B8D3BDF4,\Windows\System32\zh-CN\HVDirectAD.ps1,0.0.0.0
F32626EE7F8FF0CB4A7CB5D5026E88F7F9857780CEA1D613B73D722375F8BCEF,\Windows\System32\zh-CN\PS代码格式化临时.ps1,0.0.0.0
0.4824708

当然这种输出格式需要一点点美化,不过和效率比起来也没什么了。

总结

  • 不要反复的计算某一件事情,如果计算结果阶段一致,就一定要把它放在变量里。
  • foreach替换管道,可以明显提升效率。需要注意,foreachforeach-object是完全不同的两个东西
  • 用蠢萌蠢萌的字符串处理,只要能保证循环的次数少,那效率就是最高的。
本文由作者按照 CC BY 4.0 进行授权

PowerShell快速将多行字符串转成对象

PowerShell与i18n的思考