PowerShell模块开发实战:从单个PS1脚本到可复用工具库
在前面的 Windows 开发环境实战中,我们写过用于检查 Git、Node.js、Python 和 PATH 的 PowerShell 脚本。
当工具只有一两个命令时,把逻辑写在 .ps1 文件中很方便。
但随着脚本越来越多,我们可能遇到以下问题:
- 多个脚本重复实现同一个函数。
- 修改了一个脚本,却忘记同步其他地方的代码。
- 函数、变量与其他脚本发生命名冲突。
- 无法明确判断工具当前使用的是哪个版本。
- 不知道应该将哪些函数提供给外部调用。
- 测试代码和业务代码混在一起。
- 想把工具分享给其他电脑,却需要手动复制很多文件。
这些问题并不只是代码数量增加,而是缺少统一的组织和管理机制。
这时就应该考虑 PowerShell 模块。
本文以一个只读开发环境诊断工具为例,逐步建立真正可导入、可测试、可按版本安装的 DevEnvTools 模块。
它只导出两个命令:
Get-DevCommandInfo:检查当前 PowerShell 能找到哪些命令入口。Get-DevPathEntry:检查 PATH 条目、顺序和重复配置。
模块本身不会修改 PATH、证书、文件权限或系统设置。
适用环境: Windows PowerShell 5.1、Windows 上的 PowerShell 7.x。
示例源码采用 ASCII 文本,避免 PowerShell 5.1 对包含非 ASCII 字符的 UTF-8 无 BOM 脚本可能存在的解析差异。代码按照官方语法编写并完成静态检查,Windows 实机执行和 Pester 测试仍需验证。
一、什么时候应该从PS1升级为模块?
1.1 四种常见文件与职责
| 文件 | 角色 | 是否必须 |
|---|---|---|
Check-Environment.ps1 | 可直接运行的任务脚本 | 非必须 |
DevEnvTools.psm1 | 模块入口、函数加载及导出控制 | 脚本模块所需 |
DevEnvTools.psd1 | 模块清单:版本、入口、兼容性、公开命令 | 推荐 |
Get-DevCommandInfo.ps1 | 单个函数的源码文件 | 可选组织方式 |
不应该把“有多个 .ps1”等同于“已经形成模块”。
真正有价值的模块应该具有稳定的公开接口、相对独立的内部实现、明确的版本信息和测试机制。
1.2 什么时候值得升级?
如果多个脚本需要反复使用同一个函数,希望通过 Get-Command -Module 查找功能,或者希望按版本发布、回退和测试,那么就适合抽象为模块。
如果只是一次性的简单任务,继续使用 .ps1 往往更加直接。
图1:从独立 PowerShell 脚本到可复用模块的演进过程。
二、定义实际需求:开发一个只读DevEnvTools
这次不开发复杂的文件权限工具,而是复用前一章中最常见的环境诊断功能。
这样可以把注意力放在模块结构、接口和版本管理上。
| 公开函数 | 输入 | 输出 |
|---|---|---|
Get-DevCommandInfo | git、node、python、codex 等名称 | 命令类型、来源和路径 |
Get-DevPathEntry | 可选 -CheckExistence | PATH 顺序、重复项和目录检查状态 |
内部还设计一个不对外导出的函数:
Get-DevPathKey
用于生成 PATH 重复检查时的比较键。
所有公共函数都返回 PowerShell 对象,而不是只能供人阅读的文本日志。
本次实验的验收条件是:导入后只公开两个函数;未找到的命令返回 Found=False;重复 PATH 可以识别;内部辅助函数不导出;模块导入不修改环境变量。
三、创建模块目录
首先建立以下工程结构:
DevEnvTools-Module-Lab/
├── DevEnvTools/
│ ├── DevEnvTools.psm1
│ ├── DevEnvTools.psd1
│ ├── Public/
│ │ ├── Get-DevCommandInfo.ps1
│ │ └── Get-DevPathEntry.ps1
│ └── Private/
│ └── Get-DevPathKey.ps1
├── tests/
│ └── DevEnvTools.Tests.ps1
├── examples/
│ └── Run-DevEnvChecks.ps1
└── README.md
其中,Public 和 Private 是开发者约定的目录结构。
PowerShell 不会仅仅因为文件位于 Private 文件夹,就自动禁止外部访问函数。
真正决定函数是否导出的,是模块内部的 Export-ModuleMember,以及模块清单中的 FunctionsToExport。
PS:可以选择直接下载实验资源:下载资源
四、编写模块入口PSM1
模块的核心入口是:
DevEnvTools.psm1
#Requires -Version 5.1
$files = @(
'Private/Get-DevPathKey.ps1'
'Public/Get-DevCommandInfo.ps1'
'Public/Get-DevPathEntry.ps1'
)
foreach ($relativeFile in $files) {
$fullPath = Join-Path `
-Path $PSScriptRoot `
-ChildPath $relativeFile
. $fullPath
}
Export-ModuleMember `
-Function Get-DevCommandInfo, Get-DevPathEntry
4.1 为什么使用Dot Sourcing?
这里的:
. $fullPath
属于 PowerShell 的 Dot Sourcing 语法。
它会在当前作用域执行对应脚本,从而加载其中定义的函数。
在本例中,作用域是模块自身的运行环境。
它不等于创建一个独立进程,也不等于把整个函数源码复制到调用方。
4.2 为什么使用PSScriptRoot?
$PSScriptRoot
表示当前脚本或模块文件所在目录。
这样无论用户从:
C:\Users\Dev
还是:
D:\Projects
导入模块,都可以正确定位模块自身的文件。
不必依赖当前工作目录。
4.3 为什么不递归加载所有PS1?
对于需要维护和审查的模块,我更推荐显式列出需要加载的源文件。
这样新增加一个 .ps1,不会意外成为模块导入时自动执行的代码。
4.4 导出函数与私有函数
最后一行:
Export-ModuleMember `
-Function Get-DevCommandInfo, Get-DevPathEntry
表示只公开两个函数。
内部的 Get-DevPathKey 可以被模块内其他函数调用,但不会作为普通公开命令导出。
注意:不导出函数是一种接口组织机制,不是安全隔离。 有权读取源码的用户仍然可以检查内部实现。
内部函数保存为 Private/Get-DevPathKey.ps1:
function Get-DevPathKey {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$candidate = $Path.Trim().Trim([char]'"')
if ($candidate.Length -gt 3) {
$candidate = $candidate.TrimEnd(
[char[]]@('\', '/')
)
}
return $candidate
}
这里只做基本字符串规范化,不解析符号链接、连接点或真实文件身份。
五、实现两个可复用的公开函数
5.1 Get-DevCommandInfo:检查命令解析结果
这个函数用于检查当前 PowerShell 如何解析一个命令名称。
例如:
Get-DevCommandInfo -Name git,node,python,codex
底层使用:
Get-Command -All
但不会主动运行 Git、Node.js 或 Codex 的可执行程序。
文件:
Public/Get-DevCommandInfo.ps1
function Get-DevCommandInfo {
<#
.SYNOPSIS
Shows command candidates in the current PowerShell session.
.EXAMPLE
Get-DevCommandInfo -Name git,node,python,codex
#>
[CmdletBinding()]
param(
[Parameter(
Mandatory = $true,
ValueFromPipeline = $true
)]
[ValidateNotNullOrEmpty()]
[string[]]$Name
)
process {
foreach ($toolName in $Name) {
$candidates = @(
Get-Command `
-Name $toolName `
-All `
-ErrorAction SilentlyContinue
)
if ($candidates.Count -eq 0) {
[PSCustomObject]@{
Name = $toolName
Found = $false
CommandType = $null
Source = $null
Path = $null
}
continue
}
foreach ($candidate in $candidates) {
$commandPath = $null
if ($candidate.PSObject.Properties['Path']) {
$commandPath = $candidate.Path
}
[PSCustomObject]@{
Name = $toolName
Found = $true
CommandType = [string]$candidate.CommandType
Source = $candidate.Source
Path = $commandPath
}
}
}
}
}
模块导入后,可以执行:
Get-DevCommandInfo -Name git,node,python,codex |
Format-Table -AutoSize
还可以使用管道:
'git','node' | Get-DevCommandInfo
注意:Get-Command 可以发现别名、函数、Cmdlet 和外部程序,也可能涉及已安装模块的发现或加载。
因此,Found=True 只能说明找到了命令候选项,不能证明程序已经成功运行。
5.2 Get-DevPathEntry:检查当前进程PATH
第二个函数负责查看 PATH 的条目和顺序。
默认情况下,它只读取当前进程中的 PATH,不进行目录存在性检查。
文件:
Public/Get-DevPathEntry.ps1
function Get-DevPathEntry {
<#
.SYNOPSIS
Lists PATH entries in search order.
.DESCRIPTION
Reports PATH entries and simple duplicates.
.EXAMPLE
Get-DevPathEntry | Format-Table
#>
[CmdletBinding()]
param(
[switch]$CheckExistence
)
$seen = [System.Collections.Generic.HashSet[string]]::new(
[System.StringComparer]::OrdinalIgnoreCase
)
$delimiter = [regex]::Escape(
[string][System.IO.Path]::PathSeparator
)
$entries = @($env:PATH -split $delimiter)
$index = 0
foreach ($rawEntry in $entries) {
$index++
if ([string]::IsNullOrWhiteSpace($rawEntry)) {
continue
}
$folder = $rawEntry.Trim().Trim([char]'"')
if (-not $folder) {
continue
}
$key = Get-DevPathKey -Path $folder
$repeated = -not $seen.Add($key)
$exists = $null
$checkStatus = 'NotChecked'
if ($CheckExistence) {
if ($folder.StartsWith('\\')) {
$checkStatus = 'SkippedUNC'
}
else {
try {
$exists = [bool](
Test-Path `
-LiteralPath $folder `
-PathType Container `
-ErrorAction Stop
)
$checkStatus = 'Checked'
}
catch {
$checkStatus = 'CheckError'
}
}
}
[PSCustomObject]@{
Index = $index
Path = $folder
Repeated = $repeated
Exists = $exists
CheckStatus = $checkStatus
}
}
}
加载后运行:
Get-DevPathEntry |
Format-Table Index, Path, Repeated -AutoSize
如果需要检查本地目录是否存在:
Get-DevPathEntry -CheckExistence |
Format-Table `
Index, Path, Repeated, Exists, CheckStatus `
-AutoSize
这里使用不区分大小写的 HashSet 检测简单重复项。
需要特别注意,比较键只是轻量字符串规范化,不意味着能识别所有等价文件系统路径。
另外,UNC 形式的 PATH 条目会跳过存在性检查,但映射网络驱动器仍可能对应远程资源。因此,-CheckExistence 应在可信环境中使用。
六、使用PSD1管理模块版本
.psm1 用于加载函数并实现功能。
.psd1 则用于声明模块的元数据。
例如:
- 模块名称;
- 版本;
- 模块入口;
- 最低 PowerShell 版本;
- 导出哪些函数;
- 模块描述。
建立:
DevEnvTools.psd1
@{
RootModule = 'DevEnvTools.psm1'
ModuleVersion = '0.1.0'
GUID = 'd2a582ac-a028-4cf9-8e9d-6d69f149f18d'
Author = 'DevEnvTools tutorial'
Description = 'Read-only developer CLI and PATH inspection.'
PowerShellVersion = '5.1'
FunctionsToExport = @(
'Get-DevCommandInfo',
'Get-DevPathEntry'
)
CmdletsToExport = @()
AliasesToExport = @()
VariablesToExport = @()
}
6.1 主要字段说明
| 字段 | 作用 |
|---|---|
| RootModule | 指向主要模块入口 |
| ModuleVersion | 声明模块版本 |
| GUID | 模块标识 |
| Author | 作者信息 |
| Description | 模块简介 |
| PowerShellVersion | 最低 PowerShell 版本 |
| FunctionsToExport | 允许导出的函数 |
模块清单不是必须存在,但当模块需要版本管理、正式分发或长期维护时,使用清单更合适。
正式发布时,还应该根据项目情况补充许可证、仓库地址和必要的发布元数据。
6.2 使用New-ModuleManifest创建清单
PowerShell 也提供:
New-ModuleManifest
例如,在新建且尚无清单的模块目录中:
New-ModuleManifest `
-Path .\DevEnvTools\DevEnvTools.psd1 `
-RootModule 'DevEnvTools.psm1' `
-ModuleVersion '0.1.0' `
-PowerShellVersion '5.1' `
-FunctionsToExport Get-DevCommandInfo,Get-DevPathEntry
这条命令适用于初始化新的模块清单。
如果清单已经存在,应先备份或直接编辑,不要用生成命令覆盖已有内容。
七、第一次导入模块并验证
完成模块源码后,建议先不要安装到系统模块目录。
直接从当前项目目录进行验证。
7.1 验证模块清单
在项目根目录执行:
Test-ModuleManifest `
-Path .\DevEnvTools\DevEnvTools.psd1
如果清单无效,应先修复错误,再继续导入。
7.2 导入模块
Import-Module `
.\DevEnvTools\DevEnvTools.psd1 `
-Force `
-ErrorAction Stop
Import-Module 会把模块导入当前 PowerShell 会话。
-Force 可用于开发阶段重新导入模块,但不代表它会自动覆盖所有旧版本安装或解决所有命令冲突。
7.3 检查模块状态
Get-Module DevEnvTools
检查导出的命令:
Get-Command -Module DevEnvTools
预期只看到:
Get-DevCommandInfo
Get-DevPathEntry
内部的 Get-DevPathKey 不应作为普通公共命令出现。
7.4 查看帮助
Get-Help Get-DevCommandInfo -Full
这是因为函数中编写了:
<#
.SYNOPSIS
...
.EXAMPLE
...
#>
这种方式称为 Comment-Based Help。
它能够让 PowerShell 函数像普通 Cmdlet 一样提供帮助信息。
7.5 卸载当前会话中的模块
Remove-Module DevEnvTools `
-ErrorAction SilentlyContinue
这只从当前会话中卸载模块,不删除磁盘文件。
八、如何安装模块并管理多个版本?
仅通过完整路径导入模块,适合开发阶段。
如果希望在不同项目中直接运行:
Import-Module DevEnvTools
就可以将模块安装到 PowerShell 的模块搜索路径中。
8.1 PSModulePath是什么?
执行:
$env:PSModulePath -split [IO.Path]::PathSeparator
可以查看当前 PowerShell 搜索模块的目录。
PowerShell 5.1 和 PowerShell 7 的默认用户模块目录通常不同。
| 版本 | 用户模块目录 |
|---|---|
| Windows PowerShell 5.1 | Documents\WindowsPowerShell\Modules |
| PowerShell 7 | Documents\PowerShell\Modules |
需要注意,Windows 的“文档”目录可能被 OneDrive 或企业策略重定向,因此不要在代码中直接假定它始终位于 $HOME\Documents。
8.2 推荐使用版本目录
Modules/
└── DevEnvTools/
├── 0.1.0/
│ ├── DevEnvTools.psd1
│ ├── DevEnvTools.psm1
│ ├── Public/
│ └── Private/
└── 0.2.0/
└── ...
这样就可以保留多个模块版本,而不是每次升级都直接覆盖旧版本。
8.3 手动安装当前版本
在解压后的项目根目录执行:
$editionFolder = if ($PSVersionTable.PSVersion.Major -ge 6) {
'PowerShell'
} else {
'WindowsPowerShell'
}
$documents = [Environment]::GetFolderPath('MyDocuments')
$moduleRoot = Join-Path `
(Join-Path $documents $editionFolder) `
'Modules'
$target = Join-Path `
(Join-Path $moduleRoot 'DevEnvTools') `
'0.1.0'
$source = (Resolve-Path .\DevEnvTools).ProviderPath
if (Test-Path -LiteralPath $target) {
throw "Target version already exists: $target"
}
New-Item `
-ItemType Directory `
-Path $target `
-Force | Out-Null
Copy-Item `
-Path (Join-Path $source '*') `
-Destination $target `
-Recurse
这段命令会创建模块安装目录并复制文件,属于明确的文件写入操作。
因此,执行前应检查实际目标路径。
8.4 验证安装
Get-Module -ListAvailable DevEnvTools |
Select-Object Name, Version, Path
指定版本导入:
Import-Module -FullyQualifiedName @{
ModuleName = 'DevEnvTools'
RequiredVersion = '0.1.0'
} -ErrorAction Stop
当电脑同时存在多个版本时,指定版本更加可靠。
不能仅仅因为磁盘中已经放入了新文件,就认为当前 PowerShell 会话中的函数已经自动更新。
九、通过Pester建立自动化测试
模块开发不仅需要验证函数能运行,还应该验证公开接口符合设计要求。
本篇提供:
tests/DevEnvTools.Tests.ps1
设计六个测试项目:
| 测试 | 验证内容 |
|---|---|
| 1 | PSD1 清单有效 |
| 2 | 只导出两个公开函数 |
| 3 | 能发现 PowerShell 内置命令 |
| 4 | 找不到的命令返回 Found=False |
| 5 | 可以识别重复 PATH |
| 6 | 私有函数不会公开导出 |
9.1 测试未安装命令
例如:
It 'returns a structured not-found result' {
$name = 'DevEnvTools-NotInstalled-' +
[guid]::NewGuid().ToString('N')
$result = Get-DevCommandInfo -Name $name
$result.Found | Should -BeFalse
}
这个测试不依赖 Git、Node.js 或 Python 是否已经安装。
因此,即使在一台全新的 Windows 测试电脑中,也可以检查该函数的基本行为。
9.2 测试PATH重复项
It 'recognizes repeated PATH entries' {
$oldPath = $env:PATH
try {
$env:PATH = 'C:\DemoToolA;C:\DemoToolB;C:\DemoToolA'
$entries = @(Get-DevPathEntry)
$entries.Count | Should -Be 3
$entries[2].Repeated | Should -BeTrue
}
finally {
$env:PATH = $oldPath
}
}
这个测试有一个非常值得注意的设计:
先保存当前进程 PATH,测试结束后通过 finally 恢复。
即使测试中途失败,也会尽量恢复原来的环境。
同时,测试不会修改 User 或 Machine 级 PATH。
9.3 执行Pester测试
首先检查 Pester:
Get-Module Pester -ListAvailable |
Sort-Object Version -Descending |
Select-Object Name, Version, Path
确认安装了兼容的 Pester 5 或更高版本后,在项目根目录执行:
Invoke-Pester `
-Path .\tests\DevEnvTools.Tests.ps1 `
-Output Detailed
这里不提供虚构的 6 Passed 结果。
应分别在 Windows PowerShell 5.1 和 PowerShell 7 中执行,记录真实测试结果。
十、模块版本、签名与发布
10.1 建立版本管理约定
对于这个实验模块,可以采用:
| 版本 | 含义 |
|---|---|
| 0.1.0 | 初始公开功能 |
| 0.1.1 | 修复已有功能 |
| 0.2.0 | 增加兼容功能 |
| 1.0.0 | 形成稳定接口 |
这是项目约定,不代表 PowerShell 会自动强制执行语义化版本规则。
发布新版本时,应同步修改:
ModuleVersion
同时更新源码、测试、README 和变更说明。
10.2 模块需要数字签名吗?
模块和脚本一样,也需要考虑来源可信性。
尤其在企业使用 AllSigned 等策略的环境中,还可能需要对实际加载的脚本文件进行签名和信任验证。
但数字签名并不等于模块功能安全。
正式发布至少应该考虑:
- 源码审查;
- 自动化测试;
- 版本固定;
- 受控签名;
- 下载来源;
- 文件完整性验证;
- 回滚方案。
模块导入时会执行 .psm1 的顶层代码,因此不应随意导入来源不明的模块。
十一、常见问题排查
| 问题 | 可能原因 | 建议检查 |
|---|---|---|
Import-Module 找不到模块 | 目录结构、PSModulePath | Get-Module -ListAvailable |
| 模块导入成功但找不到函数 | 未导出、清单限制 | Get-Command -Module |
| 修改源码后还是旧行为 | 旧版本仍在内存中 | Get-Module、重新导入 |
Test-ModuleManifest 失败 | PSD1 语法或路径错误 | 完整错误记录 |
| PowerShell 5.1 中文解析异常 | 源文件编码不兼容 | 检查 BOM 与编码 |
| 模块版本混乱 | 安装多个版本 | 明确 RequiredVersion |
另外,不建议把所有函数无差别地写入 PowerShell Profile。
Profile 适合交互式配置,模块适合组织可复用工具。
两者承担不同职责。
十二、总结
从 .ps1 升级到 PowerShell 模块,不是简单地更改文件扩展名。
真正有价值的是建立以下能力:
明确的公共接口、独立的内部函数、统一的模块入口、可识别的版本、自动化测试和可重复的安装方式。
本篇通过 DevEnvTools 实现了两个只读诊断函数,并将它们组织成一个完整的 PowerShell 模块。
这个示例虽然简单,但已经具备长期维护工具库所需要的基本结构。
后续可以继续加入更多开发环境检查功能,或者将其他经过验证的工具整理为独立模块。
下一篇将进一步介绍:
《PowerShell自动化测试与CI实践:Pester、Git及持续集成》
重点建立从代码提交、自动测试、质量检查到版本发布的完整工作流。
参考资料
- Microsoft — PowerShell Modules
- Microsoft — PSModulePath
- Microsoft — Export-ModuleMember
- Microsoft — New-ModuleManifest
- Microsoft — Test-ModuleManifest
- Microsoft — Import-Module
- Microsoft — Comment-Based Help
- Pester — Testing modules
本文属于《Windows 开发环境与 PowerShell 实战》系列。
本文以DevEnvTools只读诊断工具为例,系统讲解PowerShell模块开发全流程:从PS1脚本升级动机、模块目录结构设计、PSM1入口编写与Dot Sourcing机制、函数导出控制、PSD1清单版本管理、模块导入验证、PSModulePath安装策略,到Pester自动化测试及数字签名考量。强调接口稳定性、版本可追溯性与可复用性,适用于Windows PowerShell 5.1及PowerShell 7.x。
1024

被折叠的 条评论
为什么被折叠?



