找出占用Installer 目录空间的元凶
为什么谈这个话题
今天看到一台windows 7 的计算机,C盘分了50GB,结果installer 目录有47GB,幸亏我对该目录启用过压缩,压缩后实际占用32GB的样子,但也足够大了,已经导致C盘满了,我删了下TEMP目录,清了c:\users\下一些很久没用的用户配置文件,救回2GB出来。但这个Installer目录为什么会占用这么多空间?什么样的靠谱方法可以缩减该尺寸。
你以为我没做过尝试
列举下一些之前尝试的方法,这些方法安全,但是收效甚微。
-
windows 清理程序,即使使用了隐藏的高级功能,但是清理掉的空间不是很多。
cmd.exe /c Cleanmgr /sageset:65535 /sagerun:65535
sageset会弹出窗口让选择清理的项目,选择后会保留在注册表中,后面sagerun就会使用这个注册表里存储的选项执行静默的清理。 - ntfs压缩installer 目录。文章开头已经说了,47GB,压缩到32GB,虽然压缩比率也算较大了,但是其实并没有解决回答为什么增长这么大的问题,其实我觉得应该有办法可以清理。只是不好操作而已,一旦我们必要的信息收集的差不多了,清理这个操作虽然危险,但是应该是可以做的。
不靠谱的清理软件
OK,一般这种状况下会找些流行的专用软件来干这个事,毕竟术业有专攻,第一次使用了WICleanup。
WICleanup列出了冗余文件,而且我的文件清单上有多个文件大小都是一个尺寸,我说这个软件难道是可以算出重复文件的功能,然后把重复文件删掉?!,然后鉴于桌面说用过,我觉得应该至少问题不大吧,看了下目录下有个命令行的版本带-s 可以静默清理,我试了一下,发现清掉30GB多的空间,到installer 目录一看,我就知道坏了,里面的MSP、MSI文件全干掉了。
这个软件我后面看开发时间也是超级古老了,最近还有人发Blog介绍这个工具,而且评论区还有好多人反馈清理了好多………….没发现副作用很大么?。
pjl6523853 爱武侠的程序员2018-04-09 20:37:08#4楼 太感谢博主了!帮我清理了30G!请问博主可以转载嘛 Maxwell_STU Maxwell_STU2018-03-11 00:45:01#3楼 感谢博主分享,突然就清理出来10G以上的空间,感觉清爽了超级多,压力一下子就小了。 KEVIN_LI_MY KEVIN_LI_MY2017-11-29 08:59:26#2楼 我清理出了3g,也不少了。
我稍后测试了下用Wicleanup清理过的计算机,控制面板中部分程序的卸载、修复,windows 更新均有问题,主要问题是弹对话框提示找文件。
不靠谱的参考文档
一篇看起很高深的文章指引,又是解构msi文件,又是C++清注册表,主要是一个操作,就是删除HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Installer\Products\***********\Patches
注册表,然后一股脑删installer目录的下的MSP、MSI文件。
- 删Patch 注册表这事,会导致系统里对历史更新的记录不存在。在更新软件时会直接安装新软件进行覆盖,而不会清理历史版本,这样可能因为残留导致问题。
- 第三方软件也放MSP、MSI文件在这个目录,但是产品注册表不一定老老实实的写在上面那个位置,比如Adobe。
- 还被系统正常使用的MSP、MSI等文件不应当被删除(且看后面的软件如何确认孤立的安装文件,应该有区分的清理,而不是一股脑全删)。
一个看起来可能还靠谱的软件patchCleaner
有了上面教训,我想我得了解下此类软件的原理,然后确定可行后才能使用。首先我参考了微软的员工的解决方法,算是廖胜于无吧,大概意思就是官方仅支持通过卸载软件的方式来清理installer目录,这个Blog在评论区讨论了很多次,但似乎没有什么好的结论,也没有提供太多有价值的信息。
在我访问类似superuser 上的讨论时,我发现了这个软件patchCleaner,为啥说可能还靠谱,因为下面:
- 最近更新时间 3/03/2016,相对较新
- 有官方网站还可以访问
- 有FAQ,还在sourceforge上有个portable版本,尽管我还是没有找到源码。
- 有原理解释,它说使用WMI来查询程序产品的信息,然后匹对installer 目录下的文件,清理没有在WMI中登记的信息(实际发现应该是用windows installer 的COM对象来查询的相关msi、msp文件的信息)。
- 我在程序目录找到了一个WMIProducts.vbs 程序,它会使用installer的COM来列出一些程序和组件所使用的MSI、MSP信息。
- 提供两种选项,DELETE 和MOVE,但是MOVE是推荐的操作,给用户选择且考虑了风险,所以这是我觉得靠谱的地方。我觉得可以提供第三种,COMPRESS(利用ntfs压缩技术可以节省一部分空间)或者第四种,删除前压缩文件到一个文件,这个文件可以后续用于还原。这个文件可以丢在其他地方,比如移动硬盘或者备份的地方。
- 程序的界面还利用了一些MSI的技术可以显示MSI、MSP文件的详细信息。
准备让数据说话
总结了上面的一些信息,我目前有下列问题,需要让实际的数据说话:
- 登记在系统中的MSP、MSI文件和孤立的MSP、MSI文件大小汇总占的比率有多大,比如我这个47GB installer的文件夹的情况下孤立的MSP、MSI文件尺寸是什么比率?知道这个信息可以让我们确定如果我们的工具只清理孤立的MSP,MSI的话,这个工具的作用程度可能会有多大。
- 多个这种非常大的installer 的计算机中,孤立的MSP、MSI文件尺寸是什么比率?有什么特征么? 如果孤立安装文件所占比率较大在多个计算机有共性,那么说明这种工具有普遍需求性。
- 孤立的MSP、MSI文件的相关信息有哪些共同点,多台计算机上这些匹配出的孤立文件的相关信息有哪些共同点,比如发行者,文件版本等等。是否特定的程序造成了以上问题,如果多个计算机都因为某个软件或者更新导致Installer目录较大,那么我们是否可以通过集中的策略进行控制或者避免?
我的计划:
- 随机抽取一批windows 计算机,按照操作系统大版本分类。 测试验证了自己的win10
- 使用patchCleaner提供的VBS文件,搜集多个计算机上的在WMI和MSI数据库中已经登记的MSP、MSI文件列表。 自己写了一个ps 可以统计以上信息
- 统计每个计算机上的installer 目录下的文件、位置、尺寸,发行者、文件版本、title等信息。 大部分是微软自己的更新。
- 汇总统计每个计算机上的已登记的MSP、MSI文件的总尺寸,孤立的MSP、MSI文件的总尺寸,两者的尺寸比率分布。 个人计算机上孤立的尺寸占了总空间的一半多,而且大部分是一个KB2345678 的更新,我在已安装的更新中搜索了这个KB,发现这个更新是没有装的(这些文件的日期是2015-2017都有,这个补丁应该出来很久了)
自己写了powershell 脚本,按照patchCleaner的思路,自己过滤出孤立的安装文件,这部分孤立文件我后来只过滤出MSI、MSP后缀的文件(这部分文件占用最大)。其他后缀的Installer目录下的文件,我们不去动它(因为可能被引用,比如ICON文件或者EXE等文件)。MSI,MSP文件当中会有一些除了发布者为微软的安装文件,比如Adobe的文件用get-msisummaryinfo 获取不到信息,我们也过滤掉(在PatchCleaner中也默认过滤掉了adobe的安装文件),过滤后的孤立安装文件大概如下图所示。
下面截图中时我的win10 的installer目录的分析情况。使用之前,用过windows 清理程序的高级功能清过。即使使用清理程序清理过,我们也可以看见孤立的文件还有大概4GB,我手动测试了两个安装程序在清理后的工作情况,一个是AMD的显卡软件的,一个是微软的c++ 2012 redistribution,我先把文件从installer 目录剪切走,然后执行卸载或者修复功能,都没有报错或者弹框要文件。
自己的工具?
当上面数据很明了清晰时,我们是否可以写出自己的工具来..大致臆想了自己工具的功能和执行步骤
- 首先使用cleanmgr 清理一次,看看用户是否还要继续。
- 使用压缩功能,压缩一次,看看用户是否还要继续。
- 算出孤立文件的数量(需要验证安全性),优先MOVE (注意juncpoint的兼容问题),再进行删除。我觉得删除的时候是否可以提供选项把删除的文件打包存放在其他位置,出问题时可以用来恢复。
- 导出孤立文件列表、记录删除的孤立文件清单,用于后续分析。
列下PatchCleaner存在的不足的地方:
1.没有办法导出列表。
2.需要.net framwork4,不便于携带。
3.似乎没有办法可以对筛选后的孤立的文件再做选择性操作。
由于windows 上有PSMSI 这个powershell 模组,所以最开始省去我大部分代码,把主要精力放在测试上(反复考虑后,还是自己写powershell 调用Installer Com 接口的函数用于获取信息,虽然比较困难,全程要用反射功能来操作Installer COM,而且读取MSP文件额度问题已经解决,读MSP时,数据库的Openmode需要指定其他值,这样可以不依赖外部模组)。
$Installer = New-Object -ComObject WindowsInstaller.Installer $Type = $Installer.GetType() function Get-MsiProducts { $Products = $Type.InvokeMember('Products',"GetProperty",$null,$Installer,$null) foreach ($Product In $Products) { $hash = @{} $hash.ProductCode = $Product $Attributes = @('Language','ProductName','PackageCode','Transforms','AssignmentType','PackageName','InstalledProductName','VersionString','RegCompany','RegOwner','ProductID','ProductIcon','InstallLocation','InstallSource','InstallDate','Publisher','LocalPackage','HelpLink','HelpTelephone','URLInfoAbout','URLUpdateInfo') foreach ($Attribute In $Attributes) { $hash."$($Attribute)" = $null } foreach ($Attribute In $Attributes) { try { $hash."$($Attribute)" = $Type.InvokeMember('ProductInfo',@($Product,$Attribute)) } catch [System.Exception] { } } if($hash."LocalPackage"){ if(test-path $hash."LocalPackage"){ $hash.size=$(get-item $hash."LocalPackage").Length } } New-Object -TypeName PSObject -Property $hash } } function Get-MsiPatch { [cmdletbinding()] param( $product ) $Patches = $Type.InvokeMember('Patches',@($product)) foreach ($Patch In $Patches) { $hash = @{} $hash.ProductCode = $Product $hash.PatchCode=$Patch $Attributes = @('LocalPackage') foreach ($Attribute In $Attributes) { $hash."$($Attribute)" = $null } foreach ($Attribute In $Attributes) { try { $hash."$($Attribute)" = $Type.InvokeMember('PatchInfo','GetProperty',@($Patch,$Attribute)) } catch [System.Exception] { #$error[0]|format-list –force } } if($hash."LocalPackage"){ if(test-path $hash."LocalPackage"){ $hash.size=$(get-item $hash."LocalPackage").Length } } New-Object -TypeName PSObject -Property $hash } } function Get-MSIFileInfo { [cmdletbinding()] param ( [Parameter(Mandatory = $true)]$Path ) try { if(test-path $path){ $path=get-item $path $extension=$path.Extension.ToLower() $DBOPENMODE=0 $TABLENAME='Property' if($extension -eq '.msp'){ $DBOPENMODE=32 $TABLENAME="MsiPatchMetadata" } $msiProps = @{} $Database = $Type.InvokeMember("OpenDatabase","InvokeMethod",$Null,@($Path.FullName,$DBOPENMODE)) $Query = "SELECT Property,Value FROM $TABLENAME" $View = $Database.GetType().InvokeMember("OpenView",$Database,($Query)) $View.GetType().InvokeMember("Execute",$View,$null)|Out-Null $record=$view.gettype().invokemember("Fetch",$view,$null) # Loop thru the table while($record -ne $null) { $propName=$null $propValue=$null $propName=$record.gettype().invokeMember("StringData",$record,1) $propValue= $record.gettype().invokeMember("StringData",2) $msiProps[$propName] =$propValue $record=$view.gettype().invokemember("Fetch",$null) } $view.gettype().invokemember("Close",$null)|Out-Null # Compose a unified object to express the MSI and MSP information # MSP 'DisplayName','ManufacturerName','Description','MoreInfoURL','TargetProductName' # MSI 'ProductName','Manufacturer','ProductVersion','ProductCode','UpgradeCode' if($extension -eq '.msi'){ New-Object -TypeName PSObject -Property @{ 'DisplayName'=$msiProps['ProductName'] 'Manufacturer'=$msiProps['Manufacturer'] 'Version'=$msiProps['ProductVersion'] 'PackageCode'=$msiProps['ProductCode'] 'Description'=$msiProps['Description'] 'TargetProductName'=$msiProps['TargetProductName'] 'MoreInfoURL'=$msiProps['MoreInfoURL'] 'Size'=$path.Length 'Path'=$path.FullName 'CreationTime'=$path.CreationTime } }elseif($extension -eq ".msp"){ New-Object -TypeName PSObject -Property @{ 'DisplayName'=$msiProps['DisplayName'] 'Manufacturer'=$msiProps['ManufacturerName'] 'Version'=$msiProps['BuildNumber'] 'PackageCode'=$msiProps['ProductCode'] 'Description'=$msiProps['Description'] 'TargetProductName'=$msiProps['TargetProductName'] 'MoreInfoURL'=$msiProps['MoreInfoURL'] 'Size'=$path.Length 'Path'=$path.FullName 'CreationTime'=$path.CreationTime } } } } catch { Write-Error $_.Exception.Message } } function filter_product{ param( $productName ) $PRODUCT_FILTER=@("adobe") $r=$PRODUCT_FILTER|?{$productName -like "*$_*"} if($r){ return $true }else{ return $false } } $products=Get-MsiProducts $patches=$products|%{Get-MsiPatch -product $_.ProductCode} $productsHash=@{} $products|?{$_.LocalPackage}|%{$productsHash.add($_.LocalPackage,$true)} $patchesHash=@{} $patches|?{$_.LocalPackage}|%{if(!$patchesHash.ContainsKey($_.localPackage)){$patchesHash.add($_.LocalPackage,$true)}} $InstallFolder="$($env:SystemRoot)\installer" $files=dir -Recurse -Include "*.msi","*.msp" -path $InstallFolder $Files2=$files|%{ if($productsHash.ContainsKey($_.FullName)){ $_|Add-Member -MemberType NoteProperty -Name "installerState" -Value "InstalledProduct" }elseif($patchesHash.ContainsKey($_.FullName)){ $_|Add-Member -MemberType NoteProperty -Name "installerState" -Value "InstalledPatch" }else{ $_|Add-Member -MemberType NoteProperty -Name "installerState" -Value "Orphaned" } $_ } $groups=$files2|Group-Object -Property "installerState" $groups|%{ @{$($_.name)=($_.group|Measure-Object -Property Length -Sum).Sum} } $OrphanedFiles=$($groups|?{$_.name -eq 'Orphaned'}).Group if($OrphanedFiles){ $ValidOrphanedFiles=($OrphanedFiles|%{ $item=Get-MSIFileInfo -path $_.FullName; if((filter_product $item.DisplayName) -or (filter_product $item.Manufacturer)){ # do nothing for this filtered products }else{ $item } }) $selectedOrphanedFiles=$ValidOrphanedFiles|select DisplayName,Manufacturer,Size,Path,CreationTime|Out-GridView -PassThru -Title "select the Orphaned Files to delete" if($ValidOrphanedFiles){ $ValidOrphanedFiles|Export-Csv -Path $PSScriptRoot\ValidOrphanedFiles.$((get-date).ToString('yyyyMMddhhmmss')).csv -NoClobber -NoTypeInformation -Encoding UTF8 } if($selectedOrphanedFiles){ $selectedOrphanedFiles|Export-Csv -Path $PSScriptRoot\CleanedOrphanedFiles.$((get-date).ToString('yyyyMMddhhmmss')).csv -NoClobber -NoTypeInformation -Encoding UTF8 # delete code #$selectedOrphanedFiles|remove-item -Force } }
需要进一步深挖
使用上面的powershell 脚本在另外一台计算机运行,发现输出如下图,大部分是office的更新,还有4个关于7zip的,所以我又瞄了一眼添加删除程序里的信息。