PowerShell 技能连载 - 性能(第 3 部分):更快的管道函数

在之前的技能中我们演示了如何改进循环,尤其是管道操作。要将这个知识点转为函数,请看一下这个简单的管道相关的函数,它的功能是统计管道传送过来的元素数量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function Count-Stuff
{
param
(
# pipeline-aware input parameter
[Parameter(ValueFromPipeline)]
$InputElement
)

begin
{
# initialize counter
$c = 0
}
process
{
# increment counter for each incoming object
$c++
}
end
{
# output sum
$c
}
}

当您运行这个函数来统计一个非常大数量的对象时,可能会得到这样的结果:

1
2
3
4
5
6
7
8
9
PS> $start = Get-Date
1..1000000 | Count-Stuff
(Get-Date) - $start

1000000

...
TotalMilliseconds : 3895,5848
...

现在我们去掉属性,将这个函数转换为一个“简单函数”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Count-Stuff
{
begin
{
# initialize counter
$c = 0
}
process
{
# increment counter for each incoming object
$c++
}
end
{
# output sum
$c
}
}

由于没有定义管道参数,从管道的输入在 process 块中保存在 “$_“ 变量中以及在 end 块中,一个名为 “$input“ 的迭代器保存了所有收到的数据。请注意我们的计数示例并不需要这些变量,因为它只是统计了输入的数量。

以下是执行结果:

1
2
3
4
5
6
7
8
9
$start = Get-Date
1..1000000 | Count-Stuff
(Get-Date) - $start

1000000

...
TotalMilliseconds : 690,1558
...

显然,处理大量对象的时候,简单函数比管道函数的性能要高得多。

当然,仅当用管道传输大量对象的时候效果比较明显。但当做更多复杂操作的时候,效果会更加明显。例如,以下代码创建 5 位的服务器列表,用高级函数的时候,它在我们的测试系统上大约消耗 10 秒钟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Get-Servername
{
param
(
# pipeline-aware input parameter
[Parameter(ValueFromPipeline)]
$InputElement
)

process
{
"Server{0:n5}" -f $InputElement
}
}



$start = Get-Date
$list = 1..1000000 | Get-ServerName
(Get-Date) - $start

使用简单函数可以在 2 秒之内得到相同的结果(在 PowerShell 5.1 和 6.1 相同):

1
2
3
4
5
6
7
8
9
10
11
12
13
function Get-ServerName
{
process
{
"Server{0:n5}" -f $InputElement
}
}



$start = Get-Date
$list = 1..1000000 | Get-ServerName
(Get-Date) - $start

PowerShell 技能连载 - 性能(第 2 部分):从 2 秒到 200 毫秒

在前一个技能中我们对一种常见的脚本模式提升了它的性能。现在,我们用一个更不常见的技巧进一步挤出更多性能。以下是我们上次的进展:

1
2
3
4
5
6
7
8
9
$start = Get-Date

$bucket = 1..100000 | ForEach-Object {
"I am adding $_"
}

$bucket.Count

(Get-Date) - $start

我们已经将执行时间从 6+ 分钟降到在 PowerShell 5.1 中 46 秒,在 PowerShell 6.1 中 1.46 秒。

现在我们看一看这个小改动——它返回完全相同的结果:

1
2
3
4
5
6
7
8
9
$start = Get-Date

$bucket = 1..100000 | & { process {
"I am adding $_"
} }

$bucket.Count

(Get-Date) - $start

这段神奇的代码在 PowerShell 5.1 中只花了 0.2 秒,在 PowerShell 6.1 中只花了 0.5 秒。

如您所见,这段代码只是将 ForEach-Object cmdlet 替换为等价的 $ { process { $_ }}。结果发现,由于 cmdlet 绑定的高级函数,管道操作符的执行效率被严重地拖慢了。如果使用一个简单的函数(或是一个纯脚本块),就可以显著地加速执行速度。结合昨天的技能,我们已经设法将处理从 6+ 分钟提速到 200 毫秒,而得到完全相同的结果。

请注意一件事情:这些优化技术只适用于大量迭代的循环。如果您的循环只迭代几百次,那么感受不到显著的区别。然而,一个循环迭代越多次,错误的设计就会导致越严重的消耗。

PowerShell 技能连载 - 性能(第 1 部分):从 6 分钟到 2 秒钟

以下是一个在许多 PowerShell 脚本中常见的错误:

1
2
3
4
5
6
7
8
9
10
11
$start = Get-Date

$bucket = @()

1..100000 | ForEach-Object {
$bucket += "I am adding $_"
}

$bucket.Count

(Get-Date) - $start

在这个设计中,脚本使用了一个空的数组,然后用某种循环向数组中增加元素。当运行它的时候,会发现它停不下来。实际上这段代码在我们的测试系统中需要超过 6 分钟时间,甚至有可能在您的电脑上要消耗更多时间。

以下是导致缓慢的元凶:对数组使用操作符 “+=“ 是有问题的。因为每次使用 “+=“ 时,它表面上动态扩展了该数组,实际上却是创建了一个元素数量更多一个的新数组。

要显著地提升性能,请让 PowerShell 来创建数组:当返回多个元素时,PowerShell 自动高速创建数组:

1
2
3
4
5
6
7
8
9
$start = Get-Date

$bucket = 1..10000 | ForEach-Object {
"I am adding $_"
}

$bucket.Count

(Get-Date) - $start

结果完全相同,但和消耗 6+ 分钟不同的是,它在 PowerShell 5.1 上只用了 46 秒钟,而在 PowerShell 6.1 上仅仅用了 1.46 秒。我们将会用另一个技巧来进一步提升性能。

PowerShell 技能连载 - 持续监视脚本的运行

以下是一段演示如何在 Windows 注册表中存储私人信息的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# store settings here
$Path = "HKCU:\software\powertips\settings"

# check whether key exists
$exists = Test-Path -Path $Path
if ($exists -eq $false)
{
# if this is first run, initialize registry key
$null = New-Item -Path $Path -Force
}

# read existing value
$currentValue = Get-ItemProperty -Path $path
$lastRun = $currentValue.LastRun
if ($lastRun -eq $null)
{
[PSCustomObject]@{
FirstRun = $true
LastRun = $null
Interval = $null
}
}
else
{
$lastRunDate = Get-Date -Date $lastRun
$today = Get-Date
$timeSpan = $today - $lastRunDate

[PSCustomObject]@{
FirstRun = $true
LastRun = $lastRunDate
Interval = $timeSpan
}
}

# write current date and time to registry
$date = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$null = New-ItemProperty -Path $Path -Name LastRun -PropertyType String -Value $date -Force

当运行这段代码时,它将返回一个对象,该对象告诉您上次运行此脚本是什么时候,以及从那以后运行了多长时间。

PowerShell 技能连载 - Retrieving Outlook Calendar Entries

If you use Outlook to organize your calendar events, here is a useful PowerShell function that connects to Outlook and dumps your calendar entries:

Function Get-OutlookCalendar
{
    # load the required .NET types
    Add-Type -AssemblyName 'Microsoft.Office.Interop.Outlook'

    # access Outlook object model
    $outlook = New-Object -ComObject outlook.application

    # connect to the appropriate location
    $namespace = $outlook.GetNameSpace('MAPI')
    $Calendar = [Microsoft.Office.Interop.Outlook.OlDefaultFolders]::olFolderCalendar
    $folder = $namespace.getDefaultFolder($Calendar)
    # get calendar items
    $folder.items |
      Select-Object -Property Start, Categories, Subject, IsRecurring, Organizer
}

Try this:

PS> Get-OutlookCalendar | Out-GridView

PowerShell 技能连载 - Getting AD Users with Selected First Letters

How would you query for all AD users with names that start with a “e”-“g”? You shouldn’t use a client-side filter such as Where-Object. One thing you can do is use the -Filter parameter with logical operators such as -and and -or:

Get-ADUser -filter {(name -lt 'E') -or (name -gt 'G')} |
  Select-Object -ExpandProperty Name

this example requires the free RSAT tools from Microsoft to be installed)

PowerShell 技能连载 - Adding New Incrementing Number Column in a Grid View Window

Maybe you’d like to add a column with incrementing indices to your objects. Try this:

$startcount = 0
Get-Service |
  Select-Object -Property @{N='ID#';E={$script:startcount++;$startcount}}, * |
  Out-GridView

When you run this chunk of code, you get a list of services in a grid view window, and the first column “ID#” is added with incrementing ID numbers.

The technique can be used to add arbitrary columns. Simply use a hash table with key N[ame] for the column name, and key E[xpression] with the script block that generates the column content.

PowerShell 技能连载 - 改进 Group-Object

在每一个技能中我们解释了 Group-Object 能为您做什么,以及它有多么好用。不幸的是,Group-Object 的性能不理想。如果您试图对大量对象分组,该 cmdlet 可能会消耗大量时间。

以下是一行按文件大小对您的用户文件夹中所有文件排序的代码。当您希望检测重复的文件时,这将是一个十分重要的先决条件。由于这行代码将在最终返回结果,所以将会消耗大量的时间,甚至数小时:

1
2
3
4
5
6
$start = Get-Date
$result = Get-ChildItem -Path $home -Recurse -ErrorAction SilentlyContinue -File |
Group-Object -Property Length
$stop = Get-Date

($stop - $start).TotalSeconds

由于这些限制,我们创建了一个基于 PowerShell 的 Group-Object 的实现,并称它为 Group-ObjectFast。它基本上做相同的事请,只是速度更快。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
function Group-ObjectFast
{
param
(
[Parameter(Mandatory,Position=0)]
[Object]
$Property,

[Parameter(ParameterSetName='HashTable')]
[Alias('AHT')]
[switch]
$AsHashTable,

[Parameter(ValueFromPipeline)]
[psobject[]]
$InputObject,

[switch]
$NoElement,

[Parameter(ParameterSetName='HashTable')]
[switch]
$AsString,

[switch]
$CaseSensitive
)


begin
{
# if comparison needs to be case-sensitive, use a
# case-sensitive hash table,
if ($CaseSensitive)
{
$hash = [System.Collections.Hashtable]::new()
}
# else, use a default case-insensitive hash table
else
{
$hash = @{}
}
}

process
{
foreach ($element in $InputObject)
{
# take the key from the property that was requested
# via -Property

# if the user submitted a script block, evaluate it
if ($Property -is [ScriptBlock])
{
$key = & $Property
}
else
{
$key = $element.$Property
}
# convert the key into a string if requested
if ($AsString)
{
$key = "$key"
}

# make sure NULL values turn into empty string keys
# because NULL keys are illegal
if ($key -eq $null) { $key = '' }

# if there was already an element with this key previously,
# add this element to the collection
if ($hash.ContainsKey($key))
{
$null = $hash[$key].Add($element)
}
# if this was the first occurrence, add a key to the hash table
# and store the object inside an arraylist so that objects
# with the same key can be added later
else
{
$hash[$key] = [System.Collections.ArrayList]@($element)
}
}
}

end
{
# default output are objects with properties
# Count, Name, Group
if ($AsHashTable -eq $false)
{
foreach ($key in $hash.Keys)
{
$content = [Ordered]@{
Count = $hash[$key].Count
Name = $key
}
# include the group only if it was requested
if ($NoElement -eq $false)
{
$content["Group"] = $hash[$key]
}

# return the custom object
[PSCustomObject]$content
}
}
else
{
# if a hash table was requested, return the hash table as-is
$hash
}
}
}

只需要将上述例子中的 Group-Object 替换为 Group-ObjectFast,就可以体验它的速度:

1
2
3
4
5
6
$start = Get-Date
$result = Get-ChildItem -Path $home -Recurse -ErrorAction SilentlyContinue -File |
Group-ObjectFast -Property Length
$stop = Get-Date

($stop - $start).TotalSeconds

在我们的测试中,Group-ObjectFastGroup-Object 快了大约 10 倍。

PowerShell 技能连载 - 探索 Group-Object

Group-Object 是一个好用的 cmdlet:它可以方便地可视化分组。请查看以下示例:

1
2
3
Get-Process | Group-Object -Property Company
Get-Eventlog -LogName System -EntryType Error | Group-Object -Property Source
Get-ChildItem -Path c:\windows -File | Group-Object -Property Extension

Basically, the cmdlet builds groups based on the content of a given property. You can also omit the group, and just look at the count if all that matters to you are the number distributions:
基本上,该 cmdlet 基于指定的属性内容创建分组。当您只关注数量分布时,也可以忽略该分组,而只查看总数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
PS C:\> Get-ChildItem -Path c:\windows -File | Group-Object -Property Extension -NoElement | Sort-Object -Property Count -Descending



Count Name
----- ----
10 .exe
10 .log
5 .ini
4 .xml
3 .dll
2 .txt
1 .dat
1 .bin
1 .tmp
1 .prx

如果您对实际对象的分组更感兴趣,可以通过 Group-Object 来返回一个哈希表。通过这种方式,您可以通过他们的键访问每个特定的分组:

1
2
3
4
$hash = Get-ChildItem -Path c:\windows -File |
Group-Object -Property Extension -AsHashTable

$hash.'.exe'

执行的结果将转储 Windows 目录下所有扩展名为 “.exe” 的文件。请注意键(查询的属性)不为字符串的情况。

类似地,如果您使用 PowerShell 远程操作并且扇出到多台计算机来平行地获取信息,当获取到结果时,Group-Object 将会把结果重新分组。这个示例同时从三台机器获取服务信息,而且结果将以随机顺序返回。Group-Object 将会对输入的数据分组,这样您可以操作计算机的结果。和哈希表的操作方法一样,您可以用方括号或点号来存取哈希表的键:

1
2
3
4
$services = Invoke-Command -ScriptBlock { Get-Service } -ComputerName server1, server2, server3 | Group-Object -Property PSComputerName -AsHashTable

$services["server1"]
$services."server2"

以下是一个类似的示例,但有一个 bug。您能指出错误吗?

1
2
3
4
$hash = Get-Service |
Group-Object -Property Status -AsHashTable

$hash.'Running'

当您查看哈希表时,您可能希望获取正在运行的服务:

1
2
3
4
5
6
PS C:\> $hash

Name Value
---- -----
Running {AdobeARMservice, AGMService, AGSService, App...
Stopped {AJRouter, ALG, AppIDSvc, AppMgmt...}

When you look at the hash table, you would expect to get back the running services:
然而,您并不会获得任何结果。那是因为哈希表中的那个键并不是字符串而是一个 “ServiceControllerStatus” 对象:

1
2
3
4
PS C:\> $hash.Keys | Get-Member


TypeName: System.ServiceProcess.ServiceControllerStatus

要确保获得到的是可存取的键,请总是将 -AsHashTable-AsString 合并使用。后者确保把键转换为字符串。现在示例代码可以按预期工作:

1
2
3
4
$hash = Get-Service |
Group-Object -Property Status -AsHashTable -AsString

$hash.'Running'

PowerShell 技能连载 - 自动化操作网站

有些时候,需要自动化操作某些已经人工打开的网站。也许您需要先用 WEB 表单登录到内部的网页。假设网站是通过 Internet Explorer 加载的(不是 Edge 或任何第三方浏览器),您可以使用 COM 接口来访问浏览器的实时内容。

当您访问动态网页时,纯 HTML 元素可能会更有用。一个纯 WebClient(或是 Invoke-WebRequest cmdlet)只会返回静态 HTML,并不是用户在浏览器中看到的内容。当使用一个真实的浏览器显示网页内容时,您的脚本需要访问驱动显示内容的完整 HTML。

要测试这一点,请打开 Internet Explorer 或者 Edge,并浏览到需要的网站。在我们的例子中,我们导航到 www.powershellmagazine.com

1
2
3
4
5
6
7
8
$obj = New-Object -ComObject Shell.Application
$browser = $obj.Windows() |
Where-Object FullName -like '*iexplore.exe' |
# adjust the below to match your URL
Where-Object LocationUrl -like '*powershellmagazine.com*' |
# take the first browser that matches in case there are
# more than one
Select-Object -First 1

$browser 中,您可以访问打开的浏览器中的对象模型。如果 $browser 为空,请确保您调整了 LocationUrl 的过滤条件。不要忘了两端的星号。

如果您希望挖掘网页中的所有图片,以下是获取所有图片列表的方法:

1
$browser.Document.images | Out-GridView

类似地,如果您希望挖掘网页的内容信息,以下代码返回页面的 HTML:

1
PS> $browser.Document.building.innerHTML

您可以使用正则表达式来挖掘内容。不过有一个限制:如果您需要以已登录的 WEB 用户的上下文来进行额外的操作,那么别指望了。例如,如果您需要下载一个需要登录才能获取的文件,那么您需要通过对象模型调用 Internet Explorer 的下载操作。

您可能无法通过 Invoke-WebRequest 或是其它简单的 WEB 客户端来下载文件,因为 PowerShell 运行在它自己的上下文中。而对于网站而言,看到的是一个匿名访问者。

使用 Internet Explorer 对象模型来进行更多高级操作,例如下载文件或视频,并不是完全不可行。但基本上是十分复杂的,您需要向用户界面发送点击和按键动作。