PowerShell 技能连载 - 将 PowerShell 结果发送到 PDF(第 1 部分)

Windows 10 和 Windows Server 2016 终于带来了内置的 PDF 打印机,名为 “Microsoft Print to PDF”。您可以在 PowerShell 中用它来创建 PDF 文件。请运行这段代码来测试您的 PDF 打印机:

1
2
3
4
5
6
7
8
9
$printer = Get-Printer -Name "Microsoft Print to PDF" -ErrorAction SilentlyContinue
if (!$?)
{
Write-Warning "Your PDF Printer is not yet available!"
}
else
{
Write-Warning "PDF printer is ready for use."
}

如果您的打印机不能用(或是暂时不能用),那么您可能使用的不是 Windows 10 或 Windows Server 2016,或者 PDF 打印功能尚未启用。请在管理员权限下运行 PowerShell 并执行这段命令来修复它:

1
PS> Enable-WindowsOptionalFeature -Online -FeatureName Printing-PrintToPDFServices-Features

请确保使用管理员权限运行上述代码,您也可以试着使用以下代码:

1
2
3
$code = 'Enable-WindowsOptionalFeature -Online -FeatureName Printing-PrintToPDFServices-Features'

Start-Process -Verb Runas -FilePath powershell.exe -ArgumentList "-noprofile -command $code"

当 PDF 打印机安装好后,从 PowerShell 中创建 PDF 文件十分简单。只需要将输出结果发送到 Out-Printer。以下是一个示例:

1
PS> Get-Service | Out-Printer -Name "Microsoft Print to PDF"

打印机驱动将会打开一个对话框,您可以选择输出的文件名。如果不希望显示这个对话框,而以无人值守的方式打印,我们将在明天介绍。

PowerShell 技能连载 - 在打印机上打印测试页

感谢 PrintManagement 模块为 Windows 10 和 Windows Server 2016 提供了大量的打印功能支持。如果希望打印官方的测试页,您还需要动用 WMI。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#requires -Version 3.0 -Modules CimCmdlets, PrintManagement

Get-Printer |
Out-GridView -Title 'Print test page on selected printers' -OutputMode Multiple |
ForEach-Object {
$printerName = $_.Name
$result = Get-CimInstance Win32_Printer -Filter "name LIKE '$printerName'" |
Invoke-CimMethod -MethodName printtestpage
if ($result.ReturnValue -eq 0)
{
"Test page printed on $printerName."
}
else
{
"Unable to print test page on $printerName."
"Error code $($result.ReturnValue)."
}
}

当这段代码运行时,将弹出一个对话框显示所有的打印机。请选择一个(或按住 CTRL 选择多个),将在选中的打印机上打印测试页。

PowerShell 技能连载 - 正确使用 FileSystemWatcher(第 2 部分)

在前一个技能中我们介绍了 FileSystemWatcher 并演示了当事件处理代码执行事件过长时会丢失文件系统改变事件。

要正确地使用 FileSystemWatcher,您需要异步地使用它并使用队列。这样即便脚本正忙于处理文件系统变更,也能够继续记录新的文件系统变更,并在一旦 PowerShell 处理完之前的变更时处理新的变更。

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
# make sure you adjust this to point to the folder you want to monitor
$PathToMonitor = "c:\test"

explorer $PathToMonitor

$FileSystemWatcher = New-Object System.IO.FileSystemWatcher
$FileSystemWatcher.Path = $PathToMonitor
$FileSystemWatcher.IncludeSubdirectories = $true

# make sure the watcher emits events
$FileSystemWatcher.EnableRaisingEvents = $true

# define the code that should execute when a file change is detected
$Action = {
$details = $event.SourceEventArgs
$Name = $details.Name
$FullPath = $details.FullPath
$OldFullPath = $details.OldFullPath
$OldName = $details.OldName
$ChangeType = $details.ChangeType
$Timestamp = $event.TimeGenerated
$text = "{0} was {1} at {2}" -f $FullPath, $ChangeType, $Timestamp
Write-Host ""
Write-Host $text -ForegroundColor Green

# you can also execute code based on change type here
switch ($ChangeType)
{
'Changed' { "CHANGE" }
'Created' { "CREATED"}
'Deleted' { "DELETED"
# uncomment the below to mimick a time intensive handler
<#
Write-Host "Deletion Handler Start" -ForegroundColor Gray
Start-Sleep -Seconds 4
Write-Host "Deletion Handler End" -ForegroundColor Gray
#>
}
'Renamed' {
# this executes only when a file was renamed
$text = "File {0} was renamed to {1}" -f $OldName, $Name
Write-Host $text -ForegroundColor Yellow
}
default { Write-Host $_ -ForegroundColor Red -BackgroundColor White }
}
}

# add event handlers
$handlers = . {
Register-ObjectEvent -InputObject $FileSystemWatcher -EventName Changed -Action $Action -SourceIdentifier FSChange
Register-ObjectEvent -InputObject $FileSystemWatcher -EventName Created -Action $Action -SourceIdentifier FSCreate
Register-ObjectEvent -InputObject $FileSystemWatcher -EventName Deleted -Action $Action -SourceIdentifier FSDelete
Register-ObjectEvent -InputObject $FileSystemWatcher -EventName Renamed -Action $Action -SourceIdentifier FSRename
}

Write-Host "Watching for changes to $PathToMonitor"

try
{
do
{
Wait-Event -Timeout 1
Write-Host "." -NoNewline

} while ($true)
}
finally
{
# this gets executed when user presses CTRL+C
# remove the event handlers
Unregister-Event -SourceIdentifier FSChange
Unregister-Event -SourceIdentifier FSCreate
Unregister-Event -SourceIdentifier FSDelete
Unregister-Event -SourceIdentifier FSRename
# remove background jobs
$handlers | Remove-Job
# remove filesystemwatcher
$FileSystemWatcher.EnableRaisingEvents = $false
$FileSystemWatcher.Dispose()
"Event Handler disabled."
}

当您运行这段代码时,将监控 $PathToMonitor 中定义的文件夹的改变,并且当变更发生时,会触发一条消息。当您按下 CTRL+C 时,脚本停止执行,并且所有事件处理器将在 finally 代码块中清理。

更重要的是:这段代码内部使用队列,所以当短时间内有大量修改发生时,它们将会等到 PowerShell 不忙碌的时候立即执行。您可以取消代码中的注释来演唱处理时间。现在,当一个文件删除后,处理器需要 4 秒钟的额外处理时间。

即便删除了大量文件,它们最终仍将显示出来。这里展示的方式比起前一个技能中基于 WaitForChanged() 的同步处理器更可靠。

PowerShell 技能连载 - 正确使用 FileSystemWatcher(第 1 部分)

FileSystemWatcher 可以监控一个文件或文件夹的改变。当有新文件复制到文件夹,或有文件删除或改变,您的 PowerShell 代码会立刻收到通知。

时常会见到这样的同步监控示例代码:

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
# make sure you adjust this
$PathToMonitor = "c:\test"


$FileSystemWatcher = New-Object System.IO.FileSystemWatcher
$FileSystemWatcher.Path = $PathToMonitor
$FileSystemWatcher.IncludeSubdirectories = $true

Write-Host "Monitoring content of $PathToMonitor"
explorer $PathToMonitor
while ($true) {
$Change = $FileSystemWatcher.WaitForChanged('All', 1000)
if ($Change.TimedOut -eq $false)
{
# get information about the changes detected
Write-Host "Change detected:"
$Change | Out-Default

# uncomment this to see the issue
#Start-Sleep -Seconds 5
}
else
{
Write-Host "." -NoNewline
}
}

这个示例可以正常工作。当您向监控的文件夹增加文件,或者作出改变时,将会监测到改变的类型。您可以容易地得到该信息并采取操作。例如,对于 IT 部门,人们可以向一个投放文件夹投放文件和说明,您的脚本可以自动处理这些文件。

然而,这种方式又一个副作用:当监测到一个变更时,控制权返回到您的脚本,这样它可以处理这些变更。如果这时候另一个文件发生改变,而您的脚本并不是正在等待事件,那么将错过这个事件。您可以很容易地自我检查:

向变更发生时的执行代码中添加一些耗时的语句,例如 “Start-Sleep -Seconds 5“,然后对文件夹做多个改变。如您所见,检测到了第一个变更,但是 PowerShell 会持续五分钟忙碌,而其它改变事件都丢失了。

就在明天的技能中我们将确保您的 FileSystemWatcher 不会跳过任何变更!

PowerShell 技能连载 - 响应新的事件日志条目(第 2 部分)

以下是另一个文件系统任务,听起来复杂,实际并没有那么复杂。假设您需要移除一个文件夹结构中指定层次之下的所有文件夹。以下是实现方法:

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
# set the event log name you want to subscribe to
# (use Get-EventLog -AsString for a list of available event log names)
$Name = 'Application'

# get an instance
$Log = [System.Diagnostics.EventLog]$Name

# determine what to do when an event occurs
$Action = {
# get the original event entry that triggered the event
$entry = $event.SourceEventArgs.Entry

# log all events
Write-Host "Received from $($entry.Source): $($entry.Message)"

# do something based on a specific event
if ($entry.EventId -eq 1 -and $entry.Source -eq 'WinLogon')
{
Write-Host "Test event was received!" -ForegroundColor Red
}

}

# subscribe to its "EntryWritten" event
$job = Register-ObjectEvent -InputObject $log -EventName EntryWritten -SourceIdentifier 'NewEventHandler' -Action $Action

# now whenever an event is written to the log, $Action is executed
# use a loop to keep PowerShell busy. You can abort via CTRL+C

Write-Host "Listening to events" -NoNewline

try
{
do
{
Wait-Event -SourceIdentifier NewEventHandler -Timeout 1
Write-Host "." -NoNewline

} while ($true)
}
finally
{
# this executes when CTRL+C is pressed
Unregister-Event -SourceIdentifier NewEventHandler
Remove-Job -Name NewEventHandler
Write-Host ""
Write-Host "Event handler stopped."
}

由于事件处理器是活跃的,PowerShell 每秒输出一个“点”,表示它仍在监听。现在打开另一个 PowerShell 窗口,并且运行以下代码:

1
Write-EventLog -LogName Application -Source WinLogon -EntryType Information -Message test -EventId 1

当写入一个新的应用程序事件日志条目时,事件处理器显示事件的详情。如果事件的 EventID 等于 1 并且事件源为 “WinLogon“,例如我们的测试事件条目,那么将会输出一条红色的信息。

要停止事件监听器,请按 CTRL+C。代码将自动清理并从内存中移除事件监听器。

这都是通过 Wait-Event 实现的:这个 cmdlet 可以等待特定的事件发生。并且当它等待时,PowerShell 可以继续执行事件处理器。当您指定一个超时(以秒为单位),该 cmdlet 将控制权返回给脚本。在我们的栗子中,控制权每秒钟都会返回,使脚本有机会以点的方式输出进度指示器。

如果用户按下 CTRL+C,该脚本并不会立即停止。相反,它会先执行 finally 语句块并确保该事件处理器已清除和移除。

PowerShell 技能连载 - 响应新的事件日志条目(第 1 部分)

如果您希望实时处理新的事件日志条目,以下是当新的事件条目写入时,PowerShell 代码能自动收到通知的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# set the event log name you want to subscribe to
# (use Get-EventLog -AsString for a list of available event log names)
$Name = 'Application'

# get an instance
$Log = [System.Diagnostics.EventLog]$Name

# determine what to do when an event occurs
$Action = {
# get the original event entry that triggered the event
$entry = $event.SourceEventArgs.Entry

# do something based on the event
if ($entry.EventId -eq 1 -and $entry.Source -eq 'WinLogon')
{
Write-Host "Test event was received!"
}

}

# subscribe to its "EntryWritten" event
$job = Register-ObjectEvent -InputObject $log -EventName EntryWritten -SourceIdentifier 'NewEventHandler' -Action $Action

这段代码片段安装了一个后台的事件监听器,每当事件日志产生一个 “EntryWritten” 事件时会触发。当发生该事件时,$Action 中的代码就会执行。它通过查询 $event 变量获取触发该操作的事件。在我们的示例中,当 EventId 等于 1,并且事件源是 “WinLogon“ 时,会写入一条信息。当然,您可以发送一封邮件,写入一条日志,或做任何其它有用的事情。

要查看事件处理器的效果,只需要写一个符合所有要素的测试事件:

1
2
# write a fake test event to trigger
Write-EventLog -LogName Application -Source WinLogon -EntryType Information -Message test -EventId 1

当您运行这段代码时,事件处理器将自动执行并向控制台输出自己的信息。

请注意这个例子将安装一个异步的处理器,并且当 PowerShell 不忙碌的时候会在后台执行,并且在 PowerShell 运行期间都有效。您不能通过 Start-Sleep 或一个循环来保持 PowerShell 忙碌(因为 PowerShell 会忙碌而无法在后台处理事件处理器)。要保持这个事件处理器可响应,您可以通过 -noexit 参数启动脚本:

1
Powershell.exe -noprofile -noexit -file “c:\yourpath.ps1”

要移除事件处理器,请运行以下代码:

1
2
PS> Unregister-Event -SourceIdentifier NewEventHandler
PS> Remove-Job -Name NewEventHandler

PowerShell 技能连载 - 直接存取事件日志

通过 Get-EventLog 命令,您可以轻松地转储任意给定的事件日志内容,然而,如果您希望直接存取某个指定的事件日志,您只能使用 -List 参数来完全转储它们,然后选择其中一个您所关注的:

1
2
$SystemLog = Get-EventLog -List | Where-Object { $_.Log -eq 'System' }
$SystemLog

一个更简单的方式是使用强制类型转换,类似这样:

1
2
$systemLogDirect = [System.Diagnostics.EventLog]'System'
$systemLogDirect

Simply “convert” the event log name into an object of “EventLog” type. The result looks similar to this and provides information about the number of entries and the log file size:
只需要将时间日志名称“转换”为一个 “EventLog“ 类型的对象。结果类似这样,并且提供了条目的数量和日志文件尺寸等信息:

1
2
3
4
5
PS> $systemLogDirect

Max(K) Retain OverflowAction Entries Log
------ ------ -------------- ------- ---
20.480 0 OverwriteAsNeeded 19.806 System

PowerShell 技能连载 - 使用一个停表

在 PowerShell 中,要测量时间,您可以简单将一个 datetime 值减去另一个 datetime 值:

1
2
3
4
5
6
7
$Start = Get-Date

$null = Read-Host -Prompt "Press ENTER as fast as you can!"

$Stop = Get-Date
$TimeSpan = $Stop - $Start
$TimeSpan.TotalMilliseconds

一个优雅的实现是用停表:

1
2
3
4
5
6
$StopWatch = [Diagnostics.Stopwatch]::StartNew()

$null = Read-Host -Prompt "Press ENTER as fast as you can!"

$StopWatch.Stop()
$StopWatch.ElapsedMilliseconds

使用停表的好处是可以暂停和继续。

PowerShell 技能连载 - 使用 $MyInvocation 的固定替代方式

类似 $MyInvocation.MyCommand.Definition 的代码对于确定当前脚本存储的位置十分有用,例如需要存取同一个文件夹下其它资源的时候。

然而,从 PowerShell 3 开始,有一个更简单的替代方式可以查找当前脚本的名称和/或当前脚本文件夹的路径。请自己运行以下代码测试:

1
2
3
4
5
$MyInvocation.MyCommand.Definition
$PSCommandPath

Split-Path -Path $MyInvocation.MyCommand.Definition
$PSScriptRoot

如果您交互式运行这段代码(或在一个“无标题”脚本中),它们都不会返回任何内容。但是当您将脚本保存后执行,这两行代码将返回脚本的路径,并且后两行代码将返回脚本所在文件夹的路径。

$PSCommandPath$PSScriptRoot 的好处在于它们总是包含相同的信息。相比之下,$MyInvocation 可能会改变,而且当从一个函数中读取这个变量时,它就会发生改变。

1
2
3
4
5
6
7
8
9
10
function test
{
$MyInvocation.MyCommand.Definition
$PSCommandPath

Split-Path -Path $MyInvocation.MyCommand.Definition
$PSScriptRoot
}

test

现在,$MyInvocation 变得没有价值,因为它重视返回调用本脚本块的调用者信息。

PowerShell 技能连载 - 查找打开的防火墙端口

以下是一段连接到本地防火墙并转储所有打开的防火墙端口的 PowerShell 代码:

1
2
3
$firewall = New-object -ComObject HNetCfg.FwPolicy2
$firewall.Rules | Where-Object {$_.Action -eq 0} |
Select-Object Name, ApplicationName,LocalPorts

结果看起来类似这样:

Name           ApplicationName                                         LocalPorts
----           ---------------                                         ----------
pluginhost.exe C:\users\tobwe\appdata\local\skypeplugin\pluginhost.exe *
pluginhost.exe C:\users\tobwe\appdata\local\skypeplugin\pluginhost.exe *
spotify.exe    C:\users\tobwe\appdata\roaming\spotify\spotify.exe      *
spotify.exe    C:\users\tobwe\appdata\roaming\spotify\spotify.exe      *

在 Windows 10 和 Server 2016 中,有一系列现成的跟防火墙有关的 cmdlet:

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
PS> Get-Command -Noun *Firewall*

CommandType Name Version Source
----------- ---- ------- ------
Function Copy-NetFirewallRule 2.0.0.0 NetSecurity
Function Disable-NetFirewallRule 2.0.0.0 NetSecurity
Function Enable-NetFirewallRule 2.0.0.0 NetSecurity
Function Get-NetFirewallAddressFilter 2.0.0.0 NetSecurity
Function Get-NetFirewallApplicationFilter 2.0.0.0 NetSecurity
Function Get-NetFirewallInterfaceFilter 2.0.0.0 NetSecurity
Function Get-NetFirewallInterfaceTypeFilter 2.0.0.0 NetSecurity
Function Get-NetFirewallPortFilter 2.0.0.0 NetSecurity
Function Get-NetFirewallProfile 2.0.0.0 NetSecurity
Function Get-NetFirewallRule 2.0.0.0 NetSecurity
Function Get-NetFirewallSecurityFilter 2.0.0.0 NetSecurity
Function Get-NetFirewallServiceFilter 2.0.0.0 NetSecurity
Function Get-NetFirewallSetting 2.0.0.0 NetSecurity
Function New-NetFirewallRule 2.0.0.0 NetSecurity
Function Remove-NetFirewallRule 2.0.0.0 NetSecurity
Function Rename-NetFirewallRule 2.0.0.0 NetSecurity
Function Set-NetFirewallAddressFilter 2.0.0.0 NetSecurity
Function Set-NetFirewallApplicationFilter 2.0.0.0 NetSecurity
Function Set-NetFirewallInterfaceFilter 2.0.0.0 NetSecurity
Function Set-NetFirewallInterfaceTypeFilter 2.0.0.0 NetSecurity
Function Set-NetFirewallPortFilter 2.0.0.0 NetSecurity
Function Set-NetFirewallProfile 2.0.0.0 NetSecurity
Function Set-NetFirewallRule 2.0.0.0 NetSecurity
Function Set-NetFirewallSecurityFilter 2.0.0.0 NetSecurity
Function Set-NetFirewallServiceFilter 2.0.0.0 NetSecurity
Function Set-NetFirewallSetting 2.0.0.0 NetSecurity
Function Show-NetFirewallRule 2.0.0.0 NetSecurity