PowerShell模块开发实战:从单个PS1脚本到可复用工具库

本文以DevEnvTools只读诊断工具为例,系统讲解PowerShell模块开发全流程:从PS1脚本升级动机、模块目录结构设计、PSM1入口编写与Dot Sourcing机制、函数导出控制、PSD1清单版本管理、模块导入验证、PSModulePath安装策略,到Pester自动化测试及数字签名考量。强调接口稳定性、版本可追溯性与可复用性,适用于Windows PowerShell 5.1及PowerShell 7.x。

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 往往更加直接。

否

是

单个 PS1 脚本

多个项目需要复用?

保留独立脚本

提取公开函数与内部辅助函数

PSM1 模块入口

PSD1 清单与版本

Pester 自动化测试

按版本安装与发布

图1:从独立 PowerShell 脚本到可复用模块的演进过程。


二、定义实际需求:开发一个只读DevEnvTools

这次不开发复杂的文件权限工具,而是复用前一章中最常见的环境诊断功能。

这样可以把注意力放在模块结构、接口和版本管理上。

公开函数输入输出
Get-DevCommandInfogit、node、python、codex 等名称命令类型、来源和路径
Get-DevPathEntry可选 -CheckExistencePATH 顺序、重复项和目录检查状态

内部还设计一个不对外导出的函数:

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.1Documents\WindowsPowerShell\Modules
PowerShell 7Documents\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

设计六个测试项目:

测试验证内容
1PSD1 清单有效
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 找不到模块目录结构、PSModulePathGet-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及持续集成》

重点建立从代码提交、自动测试、质量检查到版本发布的完整工作流。


参考资料

  1. Microsoft — PowerShell Modules
  2. Microsoft — PSModulePath
  3. Microsoft — Export-ModuleMember
  4. Microsoft — New-ModuleManifest
  5. Microsoft — Test-ModuleManifest
  6. Microsoft — Import-Module
  7. Microsoft — Comment-Based Help
  8. Pester — Testing modules

本文属于《Windows 开发环境与 PowerShell 实战》系列。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值