PowerShell 技能连载 - More 命令的现代版替代品

在 PowerShell 控制台中,您仍然可以用 more 管道,就像在 cmd.exe 中一样一页一页查看结果。然而,more 不支持实时管道,所以所有数据需要首先收集好。这将占用很长时间和内存:

1
dir c:\windows -Recurse -ea 0  | more

一个更好的方法是使用 PowerShell 自带的分页功能:

1
dir c:\windows -Recurse -ea 0  | Out-Host -Paging

请注意这些都需要一个真正的控制台窗口,而在图形界面的宿主中不能工作。

PowerShell 技能连载 - 远程创建 SMB 共享

以下几行代码能在远程服务器上创建一个 SMB 共享:

1
2
3
4
5
6
7
8
#requires -Version 3.0 -Modules CimCmdlets, SmbShare -RunAsAdministrator
$computername = 'Server12'
$shareName = 'ScriptExchange'
$fullAccess = 'domain\groupName'

$session = New-CimSession -ComputerName $computername
New-SMBShare -Name $shareName -Path c:\Scripts -FullAccess $fullAccess -CimSession $session
Remove-CimSession -CimSession $session

您可以在客户端将该共享映射为一个网络驱动器。请注意这个网络共享是单用户的,所以如果您使用 Administrator 账户做了映射,那么无法在 Windows Explorer 中存取。

1
2
3
$computername = 'Server12'
$shareName = 'ScriptExchange'
net use * "\\$computername\$shareName"

PowerShell 技能连载 - 重要的 PowerShell 变量

以下是一个重要的 PowerShell 变量的列表:$pshome 表示 PowerShell 所在的位置。$home 是个人用户配置文件夹的路径。$PSVersionTable 返回 PowerShell 的版本和重要的子组件的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
PS> $pshome
C:\Windows\System32\WindowsPowerShell\v1.0

PS> $HOME
C:\Users\tweltner

PS> $PSVersionTable

Name Value
---- -----
PSVersion 5.1.14393.0
PSEdition Desktop
PSCompatibleVersions {1.0, 2.0, 3.0, 4.0...}
BuildVersion 10.0.14393.0
CLRVersion 4.0.30319.42000
WSManStackVersion 3.0
PSRemotingProtocolVersion 2.3
SerializationVersion 1.1.0.1

$profile 是您个人的自启动脚本所在的位置。每当您当前的 PowerShell 宿主启动时,自启动脚本就会自动加载(假设文件存在)。$profile.CurrentUserAllHosts 是任何宿主都会加载的配置文件脚本。并且 $env:PSModulePath 列出 PowerShell 可以自动发现的存放 PowerShell module 的文件夹:

1
2
3
4
5
6
7
8
9
10
11
12
PS> $profile
C:\Users\tweltner\Documents\WindowsPowerShell\Microsoft.PowerShellISE_profile.ps1

PS> $profile.CurrentUserAllHosts
C:\Users\tweltner\Documents\WindowsPowerShell\profile.ps1

PS> $env:PSModulePath -split ';'
C:\Users\tweltner\Documents\WindowsPowerShell\Modules
C:\Program Files\WindowsPowerShell\Modules
C:\WINDOWS\system32\WindowsPowerShell\v1.0\Modules

PS>

PowerShell 技能连载 - Read-Host 阻塞自动化操作

使用 Read-Host 向用户提示输入信息可能会造成问题,因为它影响了脚本的自动化运行。一个更好的方法是将 Read-Host 包装在 param() 代码块中。通过这种方式,该信息可以通过无人值守操作的参数传入,也可以通过交互式提示传入:

1
2
3
4
5
6
7
8
param
(
$Name = $(Read-Host -Prompt 'Enter your name'),
$Id = $(Read-Host -Prompt 'Enter your ID')
)


"You are $Name and your ID is $Id"

当您运行以上脚本时,它像 Read-Host 一模一样地显示提示信息。您也可以通过参数执行该脚本:

1
PS> C:\myscript.ps1 –Name test –Id 12

If you do not need custom prompting, you can go even simpler, and declare parameters as mandatory by adding [Parameter(Mandatory)] above each parameter variable.
如果您不需要自定义提示信息,您还可以更加简单,只需要在每个参数变量上加上 [Parameter(Mandatory)] 使它们变为必需参数。

掌握 PowerShell 的 XML 日常操作

PowerShell 处理 XML 之葵花宝典

PowerShell 对 XML 的支持非常酷。这篇文章整理了所有日常的 XML 任务,甚至非常复杂的任务,都可以轻松搞定。

我们从一个简单的例子开始,以免把脑子搞乱。

我们先创建一个 XML 文档,然后加入数据集合,改变一些信息,增加新数据,删除数据,最后将修改好的版本保存为一个格式化过的 XML 文件。

新建 XML 文档

从头开始创建新的 XML 文档是一件很乏味的事。许多写脚本的人干脆以纯文本的方式来创建 XML 文件。虽然这样也可以,但是容易出错。这样很有可能拼写错误导致问题,而且您会发现自己身处一个和 XML 很不友好的世界里。

其实不然,只要用 XMLTextWriter 对象就可以创建 XML 文档了。这个对象屏蔽了处理原生 XML 对象模型的细节,并且帮您将信息写入 XML 文件。

开始之前,我们用下面的代码创建一个 TMD 复杂的 XML 文档来玩。这段代码的目的是创建一个包含所有典型内容的 XML 文档,包括节点、属性、数据区、注释。

# 这是文档存储的路径:
$Path = "$env:temp\inventory.xml"

# 新建一个 XMLTextWriter 来创建 XML:
$XmlWriter = New-Object System.XMl.XmlTextWriter($Path,$Null)

# 设置排版参数:
$xmlWriter.Formatting = 'Indented'
$xmlWriter.Indentation = 1
$XmlWriter.IndentChar = "`t"

# 写入头部:
$xmlWriter.WriteStartDocument()

# 声明 XSL
$xmlWriter.WriteProcessingInstruction("xml-stylesheet", "type='text/xsl' href='style.xsl'")

# 创建根对象“machines”,并且添加一些属性
$XmlWriter.WriteComment('List of machines')
$xmlWriter.WriteStartElement('Machines')
$XmlWriter.WriteAttributeString('current', $true)
$XmlWriter.WriteAttributeString('manager', 'Tobias')

# 加入一些随机的节点
for($x=1; $x -le 10; $x++)
{
    $server = 'Server{0:0000}' -f $x
    $ip = '{0}.{1}.{2}.{3}' -f  (0..256 | Get-Random -Count 4)

    $guid = [System.GUID]::NewGuid().ToString()

    # 每个数据集的名字都是“machine”,并且增加一个随机的属性:
    $XmlWriter.WriteComment("$x. machine details")
    $xmlWriter.WriteStartElement('Machine')
    $XmlWriter.WriteAttributeString('test', (Get-Random))

    # 增加三条信息:
    $xmlWriter.WriteElementString('Name',$server)
    $xmlWriter.WriteElementString('IP',$ip)
    $xmlWriter.WriteElementString('GUID',$guid)

    # 增加一个含有属性和正文的节点:
    $XmlWriter.WriteStartElement('Information')
    $XmlWriter.WriteAttributeString('info1', 'some info')
    $XmlWriter.WriteAttributeString('info2', 'more info')
    $XmlWriter.WriteRaw('RawContent')
    $xmlWriter.WriteEndElement()

    # 增加一个含有 CDATA 段的节点:
    $XmlWriter.WriteStartElement('CodeSegment')
    $XmlWriter.WriteAttributeString('info3', 'another attribute')
    $XmlWriter.WriteCData('this is untouched code and can contain special characters /\@<>')
    $xmlWriter.WriteEndElement()

    # 关闭“machine”节点:
    $xmlWriter.WriteEndElement()
}

# 关闭“machines”节点:
$xmlWriter.WriteEndElement()

# 完成整个文档:
$xmlWriter.WriteEndDocument()
$xmlWriter.Flush()
$xmlWriter.Close()

notepad $path

这段脚本用随机数据生成了一个虚拟的服务器进货单。结果自动用记事本,看起来如下:

<?xml version="1.0"?>
<?xml-stylesheet type='text/xsl' href='style.xsl'?>
<!--List of machines-->
<Machines current="True" manager="Tobias">
 <!--1. machine details-->
 <Machine test="578163632">
  <Name>Server0001</Name>
  <IP>31.248.95.170</IP>
  <GUID>51cb0dfb-75ed-4967-8392-47d87596c73c</GUID>
  <Information info1="some info" info2="more info">RawContent</Information>
  <CodeSegment info3="another attribute"><![CDATA[this is untouched code and can contain special characters /\@<>]]></CodeSegment>
 </Machine>
 <!--2. machine details-->
 <Machine test="124214010">
  <Name>Server0002</Name>
  <IP>33.60.233.89</IP>
  <GUID>9618b8bc-c200-46ce-b423-ee030555242d</GUID>
  <Information info1="some info" info2="more info">RawContent</Information>
  <CodeSegment info3="another attribute"><![CDATA[this is untouched code and can contain special characters /\@<>]]></CodeSegment>
 </Machine>
(...)
</Machines>

这个 XML 文档有两个目的:一是提供了一个创建 XML 文件的代码模板二是为接下来的练习提供一个基础数据。

我们假想这个 XML 文件有重要的信息在里面,接下来要用各种方法来操作这个规范的 XML 文件。

注意:XMLTextWriter 做了很多智能的事情,不过你需要确保你创建的内容没问题。比如说一个常见的问题是节点名称非法。节点名称不得含有空格。

所以写成 CodeSegment 是正确的,而 Code Segment 是错误的。XML 将会试着将节点命名为 Code,然后增加一个名为 Segment 的属性,最后因为没有为属性设置值而出错。

查找 XML 文件中的信息

一个常见的任务是从 XML 文件中提取信息。我们假设您需要获得一个机器和 IP 地址的列表。加入您已经生成了上述的 XML 文件,那么以下是创建一个报告要做的所有事情:

# 这是 XML 例子文件的存储路径:
$Path = "$env:temp\inventory.xml"

# 将它加载入 XML 对象:
$xml = New-Object -TypeName XML
$xml.Load($Path)
# 注意:如果 XML 格式是非法的,这里会报异常
# 一定要注意节点名不能包含空格

# 只需要在节点中自由遍历,就能 select 获得您要的信息:
$Xml.Machines.Machine | Select-Object -Property Name, IP

结果大概是这样:

Name          IP
----          --
Server0001    31.248.95.170
Server0002    33.60.233.89
Server0003    226.6.1.30
Server0004    139.30.8.110
Server0005    94.104.253.8
Server0006    202.80.178.61
Server0007    22.217.227.159
Server0008    253.72.25.212
Server0009    233.147.116.60
Server0010    41.173.220.129

注意:有些人奇怪我为什么不直接用 XML 对象。这段是你们常见的代码:

# 这是 XML 例子文件的存储路径:
$Path = "$env:temp\inventory.xml"

# 将它读入一个 XML 对象:
[XML]$xml = Get-Content $Path

原因是性能问题。通过 Get-Content 将 XML 以纯文本文件的方式读入,第二步再将它转换为 XML 是一个非常消耗性能的过程。虽然我们的 XML 文件不算大,后面的方式也要比第一种方式消耗多大约 7 倍的时间,而且随着 XML 文件的增大,性能差异会更明显。

所以建议在任何读取 XML 文件的时候,都先创建 XML 对象,并调用它的 Load() 方法。这个方法能够智能地接受 URL,所以您也可以用 RSS feed 的 URL 地址(假设有现成的 Internet 连接,并且不需要设置代理服务器)。

筛选特定的内容

假设您不想要整个服务器列表,而只是想要列表中某台服务器的 IP 地址以及 info1 属性。你可以用类似这样的代码:

$Xml.Machines.Machine |
Where-Object { $_.Name -eq 'Server0009' } |
Select-Object -Property IP, {$_.Information.info1}

这段代码将获取 server0009 的 IP 地址以及 info1 属性。您也可以不用在客户端过滤所有的元素,可以用 XPath(一种 XML 查询语言)来做:

$item = Select-XML -Xml $xml -XPath '//Machine[Name="Server0009"]'
$item.Node | Select-Object -Property IP, {$_.Information.Info1}

这段 XPath 查询语句 //Machine[Name="Server0009"] 在所有的 Machine 节点中查找含有 Name 子节点,且它的值为 _Server009_。

强调: XPath 是大小写敏感的,所以如果节点名称是 _Machine_,那么您不能用 machine 来查询。

另外说一句,在这两种写法里,您都会用到脚本块来读写属性,这是因为 “info1” 属性是 “Information” 子节点的一部分。在这类场景中,您可以用哈希表来更好地呈现它的名字:

$info1 = @{Name='AdditionalInfo'; Expression={$_.Information.Info1}}
$item = Select-XML -Xml $xml -XPath '//Machine[Name="Server0009"]'
$item.Node | Select-Object -Property IP, $info1

结果看起来如下:

IP              AdditionalInfo
--              --------------
97.196.140.12   some info

XPath 是非常强大的 XML 查询语言。您在网上到处都可以找到它的语法介绍(比如这些链接:http://www.w3schools.com/xpath/http://go.microsoft.com/fwlink/?LinkId=143609)。当您阅读这些文档的时候,您会发现 XPath 可以使用一些称为“用户定义函数”的东西,比如 last()lowercase()。这里不支持这些函数。

改变 XML 内容

您常常需要更改 XML 文档的内容。与其手工解析 XML 文档,不如用上刚刚学到的技术。

假设您想修改 Server0006 并且为它赋值一个新的名称和一个新的 IP 地址,需要做以下事情:

$item = Select-XML -Xml $xml -XPath '//Machine[Name="Server0006"]'
$item.node.Name = "NewServer0006"
$item.node.IP = "10.10.10.12"
$item.node.Information.Info1 = 'new attribute info'

$NewPath = "$env:temp\inventory2.xml"
$xml.Save($NewPath)
notepad $NewPath

如您所见,修改信息十分简单,所有做出的改变会自动反映到相应的 XML 对象中。您所需要做的只是将修改后的 XML 对象保存到文件,将修改的地方持久化起来。结果将显示在记事本中,看起来类似这样:

<!--6. machine details-->
  <Machine test="559669990">
    <Name>NewServer0006</Name>
    <IP>10.10.10.12</IP>
    <GUID>cca8df99-78e1-48e0-8c4d-193c6d4acbd2</GUID>
    <Information info1="new attribute info" info2="more info">RawContent</Information>
    <CodeSegment info3="another attribute"><![CDATA[this is untouched code and can contain special characters /\@<>]]></CodeSegment>
  </Machine>

您不用任何解析工作就瞬间完成了对已有 XML 文档的修改,并且不会破坏 XML 的结构。

用同样的方式,您可以进行大量的修改。假设所有的服务器都要赋予一个新的名称。旧名称是 _“ServerXXXX”_,新名称是 _“Prod_ServerXXXX”_。以下是解决方法:

Foreach ($item in (Select-XML -Xml $xml -XPath '//Machine'))
{
    $item.node.Name = 'Prod_' + $item.node.Name
}

$NewPath = "$env:temp\inventory2.xml"
$xml.Save($NewPath)
notepad $NewPath

请注意 XML 文档中的所有服务器名称都更新了。Select-XML 这回不仅返回一个对象,而是返回多个,每个对象都是一个服务器。这是因为这回的 XPath 选择所有的“Machine”节点,并没有做特别的过滤。所以 foreach 循环里对所有节点都进行了操作。

在循环内部,Name 节点被赋予了一个新的值,当所有“Machine”节点都更新完以后,XML 文档被保存到文件并用记事本打开。

您可以能对这个例子有意见,为服务器名添加_“Prod_”_前缀,这点小改动太弱智了。不过我们在这里主要是向您介绍如何改变 XML 数据,而不是关注怎么做字符串操作。

不过,如果您坚持想知道如何实现例如将 “ServerXXXX” 替换成 _“PCXX”_(包括将四位数字转换为二位数字,这不算一个弱智的需求了吧),以下是解决方法:

foreach($item in (Select-XML -Xml $xml -XPath '//Machine'))
{
    if ($item.node.Name -match 'Server(\d{4})')
    {
      $item.node.Name = 'PC{0:00}' -f [Int]$matches[1]
    }
}
$NewPath = "$env:temp\inventory2.xml"
$xml.Save($NewPath)
notepad $NewPath

这次,我们用正则表达式以数字块的方式提取原先服务器的名称,然后用 -f 操作符重新格式化数字,并加上新的前缀。

我们这篇文章不关注正则表达式,也不关注数字的格式化。重要的是您能理解可以用任何技术来构造新的服务器名称。在剩下的部分,我们也遵循这一原则。

添加新数据

有些时候,改变数据还不够。您可能会需要向列表添加新的服务器。这也是十分简单的。您只需要选择一个已有的节点,把它克隆一份,然后更新它的内容,再将它附加到父节点上即可。通过这种方式,您无须自己创建复杂的节点结构,并且可以确保新节点的结构和已有的节点完全一致。

这段代码将向服务器列表添加一台新的机器:

# 克隆已有的节点结构
$item = Select-XML -Xml $xml -XPath '//Machine[1]'
$newnode = $item.Node.CloneNode($true)

# 根据需要更新信息
# 所有其它信息都和原始节点中的一致
$newnode.Name = 'NewServer'
$newnode.IP = '1.2.3.4'

# 获取您希望新节点所附加到的父节点:
$machines = Select-XML -Xml $xml -XPath '//Machines'
$machines.Node.AppendChild($newnode)

$NewPath = "$env:temp\inventory2.xml"
$xml.Save($NewPath)
notepad $NewPath

由于您新加的节点是从已有的节点克隆的,所以旧节点的所有信息都拷贝到了新的节点。您不想更新的信息可以保持原有的值。

那么如何向列表的顶部插入新的节点呢?只需要用 InsertBefore() 代替 AppendChild()

# 向列表的顶部添加节点:
$machines.Node.InsertBefore($newnode, $item.node)

类似地,您可以在任意处插入新的节点。以下代码将在 Server0007 之后插入:

# 在“Server0007”之后插入:
$parent = Select-XML -Xml $xml -XPath '//Machine[Name="Server0007"]'
$machines.Node.InsertAfter($newnode, $parent.node)

移除 XML 内容

从 XML 文件中删除数据也同样很简单。如果您想从列表中删掉 _Server0007_,以下是实现方法:

# 删除“Server0007”:
$item = Select-XML -Xml $xml -XPath '//Machine[Name="Server0007"]'
$null = $item.Node.ParentNode.RemoveChild($item.node)

您指尖上的强大力量

通过以上展现的例子,您可以通过几行代码实现常见的 XML 操作需求。值得投入一些时间来提高 XML 和 XPath 的熟练度,这样您可以通过它们实现令人惊叹的功能。

对于一路读到这儿的朋友,我为你们准备了一点小礼物:一个我常用的很棒的小工具,我相信应该对你们也十分有用。

_ConvertTo-XML 可以将所有的对象转换为 XML,并且由于 XML 是一个分层的数据格式,所以通过控制深度,能够很好地展现嵌套的对象属性。所以您可以“展开”一个对象的结构并且查看它的所有属性,甚至递归地查看嵌套的属性。

不用 XML 和 XPath 的话,您只能查看原始的 XML 并且靠自己查找信息。例如,如果您想查看 PowerShell 的颜色信息到底存储在 $host 对象的什么地方,您可以这么做(也许不是一个好方法,因为您可能会被原始的 XML 信息淹没):

$host | ConvertTo-XML -Depth 5 | Select-Object -ExpandProperty outerXML

通过刚才演示的知识,您现在可以读取原始的 XML ,然后解析并过滤对象的属性。

以下是称为 Get-ObjectProperty 的辅助函数,有点类似 Get-Member 的意思。它能告诉您对象中的哪个属性存放了您想要的值。让我们来看看:

PS> $host | Get-ObjectProperty -Depth 2 -Name *color*

Name                    Value                   Path                    Type
----                    -----                   ----                    ----
TokenColors                                     $obj1.PrivateData.To... Microsoft.PowerShel...
ConsoleTokenColors                              $obj1.PrivateData.Co... Microsoft.PowerShel...
XmlTokenColors                                  $obj1.PrivateData.Xm... Microsoft.PowerShel...
ErrorForegroundColor    #FFFF0000               $obj1.PrivateData.Er... System.Windows.Medi...
ErrorBackgroundColor    #FFFFFFFF               $obj1.PrivateData.Er... System.Windows.Medi...
WarningForegroundColor  #FFFF8C00               $obj1.PrivateData.Wa... System.Windows.Medi...
WarningBackgroundColor  #00FFFFFF               $obj1.PrivateData.Wa... System.Windows.Medi...
VerboseForegroundColor  #FF00FFFF               $obj1.PrivateData.Ve... System.Windows.Medi...
VerboseBackgroundColor  #00FFFFFF               $obj1.PrivateData.Ve... System.Windows.Medi...
DebugForegroundColor    #FF00FFFF               $obj1.PrivateData.De... System.Windows.Medi...
DebugBackgroundColor    #00FFFFFF               $obj1.PrivateData.De... System.Windows.Medi...
ConsolePaneBackgroun... #FF012456               $obj1.PrivateData.Co... System.Windows.Medi...
ConsolePaneTextBackg... #FF012456               $obj1.PrivateData.Co... System.Windows.Medi...
ConsolePaneForegroun... #FFF5F5F5               $obj1.PrivateData.Co... System.Windows.Medi...
ScriptPaneBackground... #FFFFFFFF               $obj1.PrivateData.Sc... System.Windows.Medi...
ScriptPaneForeground... #FF000000               $obj1.PrivateData.Sc... System.Windows.Medi...

这将返回 $host 中所有名字包含 “Color” 的嵌套属性。控制台输出很可能被截断,所以您最好将结果输出到 grid view 窗口:

$host | Get-ObjectProperty -Depth 2 -Name *color* | Out-GridView

请注意 “Path” 列:这个属性精确指示了您如何存取一个指定的嵌套属性。在这个例子里,Get-ObjectProperty 在对象层次中遍历两层。如果指定更深的便利层次,将会展开更多的信息,不过也会导致结果中含有更多的垃圾信息。

虽然您可以通过管道输入多个对象,但是最好一次只导入一个,以免产生大量的结果数据。这行代码将列出进程对象所有嵌套的属性,递归层次为 5,将产生大量的结果:

PS> Get-Process -id $pid | Get-ObjectProperty -Depth 5 -IsNumeric

Name                    Value                   Path                    Type
----                    -----                   ----                    ----
Handles                 684                     $obj1.Handles           System.Int32
VM                      1010708480              $obj1.VM                System.Int32
WS                      291446784               $obj1.WS                System.Int32
PM                      251645952               $obj1.PM                System.Int32
NPM                     71468                   $obj1.NPM               System.Int32
CPU                     161,0398323             $obj1.CPU               System.Double
BasePriority            8                       $obj1.BasePriority      System.Int32
HandleCount             684                     $obj1.HandleCount       System.Int32
Id                      4560                    $obj1.Id                System.Int32
Size                    264                     $obj1.MainModule.Size   System.Int32
ModuleMemorySize        270336                  $obj1.MainModule.Mod... System.Int32
FileBuildPart           9421                    $obj1.MainModule.Fil... System.Int32
FileMajorPart           6                       $obj1.MainModule.Fil... System.Int32
FileMinorPart           3                       $obj1.MainModule.Fil... System.Int32
ProductBuildPart        9421                    $obj1.MainModule.Fil... System.Int32
ProductMajorPart        6                       $obj1.MainModule.Fil... System.Int32
ProductMinorPart        3                       $obj1.MainModule.Fil... System.Int32
Size                    264                     $obj1.Modules[0].Size   System.Int32
ModuleMemorySize        270336                  $obj1.Modules[0].Mod... System.Int32
(...)

这行代码将返回 spooler 服务对象中所有“String”类型的嵌套属性:

PS> Get-Service -Name spooler | Get-ObjectProperty -Type System.String

Name                    Value                   Path                    Type
----                    -----                   ----                    ----
Name                    spooler                 $obj1.Name              System.String
Name                    RPCSS                   $obj1.RequiredServic... System.String
Name                    DcomLaunch              $obj1.RequiredServic... System.String
DisplayName             DCOM Server Process ... $obj1.RequiredServic... System.String
MachineName             .                       $obj1.RequiredServic... System.String
ServiceName             DcomLaunch              $obj1.RequiredServic... System.String
Name                    RpcEptMapper            $obj1.RequiredServic... System.String
DisplayName             RPC Endpoint Mapper     $obj1.RequiredServic... System.String
(...)

以下是 Get-ObjectProperty 的源代码。它虽然不只是几行代码,但仍然相当短小精悍。

它完全使用了刚才介绍的技术,所以如果您对以上的例子感到满意,您也可以尝试并消化它的代码,或只是把它当做一个工具,而不用关心它对 XML 做的魔法。

Function Get-ObjectProperty
{
  param
  (
    $Name = '*',
    $Value = '*',
    $Type = '*',
    [Switch]$IsNumeric,

    [Parameter(Mandatory=$true,ValueFromPipeline=$true)]
    [Object[]]$InputObject,

    $Depth = 4,
    $Prefix = '$obj'
  )

  Begin
  {
    $x = 0
    Function Get-Property
    {
      param
      (
        $Node,
        [String[]]$Prefix
      )

      $Value = @{Name='Value'; Expression={$_.'#text' }}
      Select-Xml -Xml $Node -XPath 'Property' | ForEach-Object {$i=0} {
        $rv = $_.Node | Select-Object -Property Name, $Value, Path, Type
        $isCollection = $rv.Name -eq 'Property'

        if ($isCollection)
        {
          $CollectionItem = "[$i]"
          $i++
          $rv.Path = (($Prefix) -join '.') + $CollectionItem
        }
        else
        {
          $rv.Path = ($Prefix + $rv.Name) -join '.'
        }

        $rv

        if (Select-Xml -Xml $_.Node -XPath 'Property')
        {
          if ($isCollection)
          {
            $PrefixNew = $Prefix.Clone()
            $PrefixNew[-1] += $CollectionItem
            Get-Property -Node $_.Node -Prefix ($PrefixNew )
          }
          else
          {
            Get-Property -Node $_.Node -Prefix ($Prefix + $_.Node.Name )
          }
        }
      }
    }
  }

  Process
  {
    $x++
    $InputObject |
    ConvertTo-Xml -Depth $Depth |
    ForEach-Object { $_.Objects } |
    ForEach-Object { Get-Property $_.Object -Prefix $Prefix$x  } |
    Where-Object { $_.Name -like "$Name" } |
    Where-Object { $_.Value -like $Value } |
    Where-Object { $_.Type -like $Type } |
    Where-Object { $IsNumeric.IsPresent -eq $false -or $_.Value -as [Double] }
  }
}

准备一场 PowerShell 技术面试

理解 Windows PowerShell 是什么

初学者的第一个问题总是“请告诉我,PowerShell 是什么?”

PowerShell 不仅仅是一个新的 shell(外壳)。PowerShell 是一个面向对象的分布式自动化引擎、脚本语言,以及命令行 shell。我不指望一个初学者能完全理解我刚才说的,不过至少,我希望您能理解 PowerShell 天生是面向对象的。如果说“因为 PowerShell 是基于 .NET 框架的,所以它是面向对象的”,这句话并不太准确。实际上,我们说 PowerShell 是面向对象的,是因为它处理的是对象而不是文本。让我们看一个例子。

这是我在 DOS 的批处理脚本中获取一个文件夹(包括子文件夹等)大小的方法:

@echo off
For /F "tokens=*" %%a IN ('"dir /s /-c /a | find "bytes" | find /v "free""') do Set xsummary=%%a
For /f "tokens=1,2 delims=)" %%a in ("%xsummary%") do set xfiles=%%a&set xsize=%%b
Set xsize=%xsize:bytes=%
Set xsize=%xsize: =%
Echo Size is: %xsize% Bytes

您看到这有多痛苦了吗?有多少人能理解这段批处理脚本到底在做什么?

好吧,让我们来看看用 PowerShell 如何实现。

Get-ChildItem –Recurse | Measure-Object -Property Length -Sum

很简单吧?至少,它看上去很清爽。这是因为 PowerShell 能处理对象——那些能自我描述的东西。这些对象拥有各种属性。文件对象有一个 Length 属性是代表文件的大小有多少字节的。所以,我们把文件夹下所有文件的大小加起来,就能得到文件夹的大小。如何你把这段 PowerShell 脚本和刚才的 DOS 批处理脚本做一个比较,可以看出我们用不着处理任何临时变量和解析文本。我们只需要将 Get-ChildItem cmdlet 的结果通过管道输出到 Measure-Object,并且将管道中传过来的每个对象的 Length 属性求和即可。

好了,这是一个解释 PowerShell 天生是面向对象的一个小例子。当您开始学习 PowerShell 之后,将可以举出更多类似的例子。

我们再假设一个问题“如何获得某个进程的 CPU 相关性?”,您会怎么实现?

有些人会这么做:

$process = Get-Process -Name notepad

目前这么操作 OK。然后他们接下来在键盘上敲:

$process.<Tab> <Tab> <Tab> … <Tab> 直到找到所需要的属性为止。

虽然这么做也没错,可以获得您要的属性,不过万一你要的属性是 100 个属性中的第 99 个呢?显然不太明智,是吧?现在学习 shell 的使用就很有帮助了。在 shell 中有更好的办法实现这个。

如果您知道精确的属性名:

Get-Process -Name Notepad | Select-Object ProcessorAffinity

或者

$process = Get-Process -Name Notepad
$process.ProcessorAffinity

如果您不知道精确的属性名,不要一直按 Tab!请用 PowerShell 的自我探索功能。这是接下来我们要讨论的问题。

学习如何探索 PowerShell

初学者最重要的事情是了解如何使用以下的 cmdlet:

  • Get-Command
  • Get-Member
  • Get-Help

Get-Command

Get-Command 返回一个 PowerShell 会话中可用的所有命令。据我观察,某些初学者在输入一个不存在的或者错误的 cmdlet 名字以后,一直在纠结为什么不能用。如果我们知道如何探索 PowerShell,我们就可以用 Get-Command cmdlet 来验证我们想要用的命令是否存在。例如:

PS C:\> Get-Command -Name Get-ComputerName
Get-Command : The term 'Get-ComputerName' is not recognized as the name of a cmdlet, function, script file, or operable program. Check
the spelling of the name, or if a path was included, verify that the path is correct and try again.
At line:1 char:1
+ Get-Command -Name Get-ComputerName
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : ObjectNotFound: (Get-ComputerName:String) [Get-Command], CommandNotFoundException
+ FullyQualifiedErrorId : CommandNotFoundException,Microsoft.PowerShell.Commands.GetCommandCommand

见到了吗?您还可以用 Get-Command 针对特定的动词或者名词来获取命令,甚至可以使用通配符来过滤。

Get-Command -Noun Host
Get-Command -Verb Write
Get-Command N*

Get-Member

在前面的段落中,我们学习了如何获得一个对象的某个属性。但是,只有您精确地知道属性名才能用。那么我们如何探索某个对象中有哪些属性和方法可用呢?Get-Member 这时候派上用场了。

Get-Process | Get-Member

以上命令可以获取某个对象类型的所有属性、方法和事件。您可以对它进一步地筛选来找到您感兴趣的成员。

Get-Process | Get-Member -MemberType Method
Get-Process | Get-Member -MemberType Property
Get-Process | Get-Member -MemberType Event

Get-Help

另外一个初学者常见的问题是对内置的帮助系统没什么概念。PowerShell 内置的 cmdlet 都带有如何使用的详细说明。当然,在 PowerShell 3.0 及更高的版本,您首先需要用 Update-Help cmdlet 来更新帮助内容。当您想了解某个 PowerShell cmdlet 的详细语法时,您就可以使用用 Get-Help cmdlet。它能告诉您 cmdlet 参数的信息、如何使用每个参数、以及提供一些例子。当不明白怎么使用时,您第一件事就应该是查看本地的帮助系统。

Get-Help Get-Command
Get-Help Get-Member -Detailed
Get-Help Get-Process –Examples
Get-Help Get-Service –Parameter InputObject

使用 shell

我不指望一个初学者能掌握编写脚本和模块的知识。但至少要掌握使用 shell 的技能。您必须亲自动手实践一下内置的 cmdlet。这包括了一些简单的操作,比如说列出服务、进程、文件和文件夹。至少要会用 Get-ChildItem 来递归地搜索文件,才算是接触到了 PowerShell 的表层,才算是一名初学者。

在学习 PowerShell 的过程中,您应该从 shell 开始学起。基本上在 PowerShell 命令行中能运行的一切命令,都能在脚本中运行。所以一开始可以在 shell 中用 PowerShell 语言和内置的 cmdlet 来做一些简单的任务。最终,您将会学习如何编写脚本。这是初学 PowerShell 很重要的一个方向。

某些人对我说,他们只了解一些特定产品专用的 PowerShell cmdlet,并且他们只要用哪些 cmdlet 就够了。那么能告诉我只用特定产品专用的 cmdlet 而不用任何内置的 cmdlet、管道以及 PowerShell 语言要怎么完成一个自动化任务呢?这是不可能做到的。如果有些人认为可能,那么很可能他们根本不了解基础只是或者从来没有用过 shell。

学习管道

在用 PowerShell 的过程中,如果要有效地使用它,您需要了解什么是管道,以及您可以将多个命令用管道连接起来。我在前面段落的例子中已经用到了管道,只是没有深入讲解它是什么。运行一个简单的命令不是什么难事。不过当您把多个命令用管道连接起来变成一个更大的任务时,您就会意识到管道的强大之处。要完整地讨论管道的知识,需要 50 - 75 页书才能讲完。让我们保持这篇文章简单易懂一些。

我们假想 PowerShell 的管道是一个制造单元的一条组装线。在一个制造单元中,部件从一个站点传递到另一个站点以及输出端,一路装配过来。我们可以在组装线的最末端看到装配完成的产品。通过类似这样的方式,当我们将多个 PowerShell cmdlet 用管道连接起来时,一个命令的输出结果会作为下一个命令的输入参数。例如:

Get-Process -Name s* | Where-Object { $_.HandleCount -lt 100 }

在上述命令中,Get-Process 命令输出的一个或多个对象会作为输入送给 Where-Object cmdlet。Where-Object 命令过滤出输入对象数组中 HandleCount 属性小于 100 的对象。您当然也可以不用管道来完成这个任务。让我们看看做起来是怎么样。

$process = Get-Process -name s*
foreach ($proc in $process) {
   if ($proc.HandleCount -lt 100) {
       $proc
   }
}

如您所见,要写更多的代码。这还不是大问题,您会看到生成输出时的区别。在这个例子里,许多人认为第一个命令执行完以后,所有的输出结果送入第二个命令。这样描述并不精确。在管道中,第一个命令每生成一个对象,就立即向第二个命令传递。

前一个例子只合并了两个命令,所以看起来很微不足道。让我们看看下一个例子:

Get-ChildItem -Recurse -Force |
Where-Object {$_.Length -gt 10MB} |
Sort-Object -Property Length -Descending |
Select-Object Name, @{name='Size (MB)'; expression={$_.Length/1MB}}, Extension |
Group-Object -Property extension

我不指望一名初学者能理解这段代码,或是写出这样的代码。不过,这段代码显示出管道的强大之处。上述的命令(或者说用管道连接的命令)获取所有大于 10MB 的文件,将它们按文件的大小降序排列,然后将它们按文件的扩展名分组。您敢不敢不用管道将这段代码的功能实现一遍?

不要过度设计

PowerShell 往往提供多种方法来实现同一件事情。当然,这些方法各有差别。有效率上的差别,有简单和复杂的差别。

所以,当我提出“请告诉我计算机名”的需求时,我不希望您开始写一段 WMI 查询语句:

Get-WmiObject -Class Win32_ComputerSystem | Select-Object -Property Name

Get-WmiObject -Class Win32_OperatingSystem | Select-Object -Property CSName

您可能坚称这些命令确实可以获取本地计算机名。但是,请您理解有更好的方法来实现:

$env:ComputerName

噢,用传统的 hostname 命令也得到了相同的结果。一切正确。我常常使用它。不过,我们现在关注的是 PowerShell,对吧?

编写脚本

这是另一个常见的问题。您也许知道如何运行别人写的脚本。不过,这样能使您成为一个脚本编写者吗?不可能。阅读别人写的脚本确实能帮您理解最佳和最差实践。但是,当您是一个初学者时,这不会有助于您的学习。只有您自己动手编写自己的脚本才能学到知识。

还有,不要轻易说您会编写高级的函数。除非您知道如何描述通用参数、参数类型、cmdletbinding,以及 BeginProcessEnd 代码块是如何工作和为什么需要它们。如果您不了解这些概念,就不要觉得自己写过 PowerShell 的高级函数。高级函数和我们平时在 PowerShell 中写的普通函数是不同的。当您在函数定义中增加了 CmdletBinding() 以后,函数的基本行为就改变了。我们来看一个例子吧?

以下是一个普通函数,接受两个数字输入参数,并且返回它们的和。

function sum {
    param (
       $number1,
       $number2
    )
    $number1 + $number2
}

PS C:\> sum 10 30
40

现在,用不同个数的参数来调用这个函数。

PS C:\> sum 10 30 40
40

见到了吗?虽然我们在函数定义中只有两个参数,但它也可以接受三个参数,并且只是把第三个参数忽略掉。现在,加入 CmdletBinding() 属性并看看行为发生什么变化。

function sum {
   [CmdletBinding()]
   param (
      $number1,
      $number2
   )
   $number1 + $number2
}

用先前一样的参数再次测试!

PS C:\> sum 10 30
40

PS C:\> sum 10 30 40
sum : A positional parameter cannot be found that accepts argument '40'.
At line:1 char:1
+ sum 10 30 40
+ ~~~~~~~~~~~~
+ CategoryInfo : InvalidArgument: (:) [sum], ParameterBindingException
+ FullyQualifiedErrorId : PositionalParameterNotFound,sum

见到了吗?加入了 CmdletBionding() 属性以后,处理输入参数的基本行为发生了变化。这是一个高级的 PowerShell 函数。但是,这只是开头。我们在这里不再深入下去,我希望您在告诉我写过高级函数之前知道这些。

不要依赖搜索引擎

我听很多人说他们写脚本的时候要依赖 Google。他们只是使用搜索引擎,查找问题现成的解决方案,并且直接拿来用。他们常常是开始编写脚本或者尝试做一个任务几分钟就放弃了。我只有在我彻底没有思路,实在继续不下去的时候才用搜索引擎。或者当我知道某些人已经开发了一个脚本并且我不想重复发明轮子的时候。但是,当我想学习 PowerShell 的时候,我不会用这种方式。搜索引擎是寻找解决方案的最简单方法,但是您从中学不到任何东西,除了怎么用搜索引擎。特别地,当您还是一个初学者时,直接使用现成的脚本对学习没有任何帮助。

祝您好运!

PowerShell ISE 4.0 完整快捷键清单

PowerShell ISE 是编写 PowerShell 脚本最重要的环境。熟练掌握 ISE 的快捷键有以下好处:

  1. 逼格高(这个很重要)
  2. 提高效率
  3. 预防腕管炎
  4. ……(请自行脑补)

其实,所有快捷键的定义都在 ISE 的 Microsoft.PowerShell.GPowerShell(DLL)中。我们首先需要获取这个 DLL 的引用。

1
2
3
4
5
6
PS> $gps = $psISE.GetType().Assembly
PS> $gps

GAC Version Location
--- ------- --------
True v4.0.30319 C:\Windows\Microsoft.Net\assembly\GAC_MSIL\Microsoft.PowerShell.GPowerShell\...

然后我们可以获取这个程序集的资源列表:

1
2
3
4
PS> $gps.GetManifestResourceNames()

Microsoft.PowerShell.GPowerShell.g.resources
GuiStrings.resources

然后我们创建一个 ResourceManager 对象来存取程序集中的资源。在构造函数中将需要打开的资源名(去掉 .resources 扩展名)以及包含资源的程序集对象传给它。

1
$rm = New-Object System.Resources.ResourceManager GuiStrings,$gps

剩下只要调用 GetResourceSet() 方法根据特定的文化信息获取资源。

1
2
3
4
5
6
7
8
9
10
11
12
  $rs = $rm.GetResourceSet((Get-Culture),$true,$true)
$rs

Name Value
---- -----
SnippetToolTipPath 路径: {0}
MediumSlateBlueColorName 中石板蓝色
> EditorBoxSelectLineDownShor... Alt+Shift+Down
NewRunspace 新建 PowerShell 选项卡(_E)
> EditorSelectToPreviousChara... Shift+Left
> RemoveAllBreakpointsShortcut Ctrl+Shift+F9
SaveScriptQuestion 是否保存 {0}?

查看输出结果,我们可以发现包含“>”的几行类似按键组合信息。如果您仔细查看输出结果,将会发现规律是 NameShortcut 结尾(有可能包含数字),以及以 F 开头加 1 至 2 位数字并带有 Keyboard 关键字的。通过下面一行代码,我们可以过滤出所有和键盘有关系的项目并对它们进行排序。

1
$rs | where Name -match 'Shortcut\d?$|^F\d+Keyboard' | Sort-Object Value

以下是完整的代码片段和完整的结果:

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
$gps = $psISE.GetType().Assembly
$rm = New-Object System.Resources.ResourceManager GuiStrings,$gps
$rs = $rm.GetResourceSet((Get-Culture),$true,$true)
$rs | where Name -match 'Shortcut\d?$|^F\d+Keyboard' | Sort-Object Value | Format-Table -AutoSize

Name Value
---- -----
EditorUndoShortcut2 Alt+Backspace
EditorSelectNextSiblingShortcut Alt+Down
ExitShortcut Alt+F4
EditorSelectEnclosingShortcut Alt+Left
EditorSelectFirstChildShortcut Alt+Right
EditorRedoShortcut2 Alt+Shift+Backspace
EditorBoxSelectLineDownShortcut Alt+Shift+Down
ToggleHorizontalAddOnPaneShortcut Alt+Shift+H
EditorBoxSelectToPreviousCharacterShortcut Alt+Shift+Left
EditorBoxSelectToNextCharacterShortcut Alt+Shift+Right
EditorTransposeLineShortcut Alt+Shift+T
EditorBoxSelectLineUpShortcut Alt+Shift+Up
ToggleVerticalAddOnPaneShortcut Alt+Shift+V
EditorSelectPreviousSiblingShortcut Alt+Up
ShowScriptPaneTopShortcut Ctrl+1
ShowScriptPaneRightShortcut Ctrl+2
ShowScriptPaneMaximizedShortcut Ctrl+3
EditorSelectAllShortcut Ctrl+A
ZoomIn1Shortcut Ctrl+Add
EditorMoveCurrentLineToBottomShortcut Ctrl+Alt+End
EditorMoveCurrentLineToTopShortcut Ctrl+Alt+Home
EditorDeleteWordToLeftShortcut Ctrl+Backspace
StopExecutionShortcut Ctrl+Break
StopAndCopyShortcut Ctrl+C
GoToConsoleShortcut Ctrl+D
EditorDeleteWordToRightShortcut Ctrl+Del
EditorScrollDownAndMoveCaretIfNecessaryShortcut Ctrl+Down
EditorMoveToEndOfDocumentShortcut Ctrl+End
FindShortcut Ctrl+F
ShowCommandShortcut Ctrl+F1
CloseScriptShortcut Ctrl+F4
GoToLineShortcut Ctrl+G
ReplaceShortcut Ctrl+H
EditorMoveToStartOfDocumentShortcut Ctrl+Home
GoToEditorShortcut Ctrl+I
Copy2Shortcut Ctrl+Ins
ShowSnippetShortcut Ctrl+J
EditorMoveToPreviousWordShortcut Ctrl+Left
ToggleOutliningExpansionShortcut Ctrl+M
ZoomOut3Shortcut Ctrl+Minus
NewScriptShortcut Ctrl+N
OpenScriptShortcut Ctrl+O
GoToMatchShortcut Ctrl+Oem6
ZoomIn3Shortcut Ctrl+Plus
ToggleScriptPaneShortcut Ctrl+R
EditorMoveToNextWordShortcut Ctrl+Right
SaveScriptShortcut Ctrl+S
ZoomIn2Shortcut Ctrl+Shift+Add
GetCallStackShortcut Ctrl+Shift+D
EditorSelectToEndOfDocumentShortcut Ctrl+Shift+End
RemoveAllBreakpointsShortcut Ctrl+Shift+F9
HideHorizontalAddOnToolShortcut Ctrl+Shift+H
EditorSelectToStartOfDocumentShortcut Ctrl+Shift+Home
ListBreakpointsShortcut Ctrl+Shift+L
EditorSelectToPreviousWordShortcut Ctrl+Shift+Left
ZoomOut4Shortcut Ctrl+Shift+Minus
StartPowerShellShortcut Ctrl+Shift+P
ZoomIn4Shortcut Ctrl+Shift+Plus
NewRemotePowerShellTabShortcut Ctrl+Shift+R
EditorSelectToNextWordShortcut Ctrl+Shift+Right
ZoomOut2Shortcut Ctrl+Shift+Subtract
EditorMakeUppercaseShortcut Ctrl+Shift+U
HideVerticalAddOnToolShortcut Ctrl+Shift+V
IntellisenseShortcut Ctrl+Space
ZoomOut1Shortcut Ctrl+Subtract
NewRunspaceShortcut Ctrl+T
EditorMakeLowercaseShortcut Ctrl+U
EditorScrollUpAndMoveCaretIfNecessaryShortcut Ctrl+Up
Paste1Shortcut Ctrl+V
CloseRunspaceShortcut Ctrl+W
Cut1Shortcut Ctrl+X
EditorRedoShortcut1 Ctrl+Y
EditorUndoShortcut1 Ctrl+Z
F1KeyboardDisplayName F1
HelpShortcut F1
StepOverShortcut F10
F10KeyboardDisplayName F10
StepIntoShortcut F11
F11KeyboardDisplayName F11
F12KeyboardDisplayName F12
F2KeyboardDisplayName F2
FindNextShortcut F3
F3KeyboardDisplayName F3
F4KeyboardDisplayName F4
RunScriptShortcut F5
F5KeyboardDisplayName F5
F6KeyboardDisplayName F6
F7KeyboardDisplayName F7
RunSelectionShortcut F8
F8KeyboardDisplayName F8
F9KeyboardDisplayName F9
ToggleBreakpointShortcut F9
EditorDeleteCharacterToLeftShortcut Shift+Backspace
Cut2Shortcut Shift+Del
EditorSelectLineDownShortcut Shift+Down
EditorSelectToEndOfLineShortcut Shift+End
EditorInsertNewLineShortcut Shift+Enter
StepOutShortcut Shift+F11
FindPreviousShortcut Shift+F3
StopDebuggerShortcut Shift+F5
EditorSelectToStartOfLineShortcut Shift+Home
Paste2Shortcut Shift+Ins
EditorSelectToPreviousCharacterShortcut Shift+Left
EditorSelectPageDownShortcut Shift+PgDn
EditorSelectPageUpShortcut Shift+PgUp
EditorSelectToNextCharacterShortcut Shift+Right
EditorSelectLineUpShortcut Shift+Up

用 PowerShell 查看安装的 .NET 框架

以下 PowerShell 代码最高支持 .NET 4.7 版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP' -recurse |
Get-ItemProperty -name Version,Release -EA 0 |
Where { $_.PSChildName -match '^(?!S)\p{L}'} |
Select PSChildName, Version, Release, @{
name="Product"
expression={
switch -regex ($_.Release) {
"378389" { [Version]"4.5" }
"378675|378758" { [Version]"4.5.1" }
"379893" { [Version]"4.5.2" }
"393295|393297" { [Version]"4.6" }
"394254|394271" { [Version]"4.6.1" }
"394802|394806" { [Version]"4.6.2" }
"460798" { [Version]"4.7" }
{$_ -gt 460798} { [Version]"Undocumented 4.7 or higher, please update script" }
}
}
}

参考:

PowerShell 技能连载 - 映射网络驱动器

PowerShell 提供很多种方式来连接到 SMB 文件共享。以下是三种不同的方法:

1
2
3
4
5
6
# adjust path to point to your file share
$UNCPath = '\\server\share'

net use * $UNCPath
New-PSDrive -Name y -PSProvider FileSystem -Root $UNCPath -Persist
New-SmbMapping -LocalPath 'x:' -RemotePath $UNCPath

Net.exe 是最多功能的方法,在 PowerShell 的所有版本中都有效。通过传入一个 “*”,它自动选择下一个有效的驱动器盘符。

New-PSDrive 从 PowerShell 3 起支持 SMB 共享。New-SmbMapping 需要 SmbShare 模块并且现在看来有点古怪:重启后才能在 Windows Explorer 中显示该驱动器。