当前位置:网站首页 > 更多 > 玩电脑 > 正文

[玩转系统] 将 PowerShell 脚本转换为函数

作者:精品下载站 日期:2024-12-14 08:04:55 浏览:13 分类:玩电脑

将 PowerShell 脚本转换为函数


最近,我分享了一些将函数导出到文件的 PowerShell 代码。这是一个很受欢迎的帖子。当我的朋友理查德·希克斯(Richard Hicks)(没有亲属关系)询问如何将文件转换为函数时,他以为我们在开玩笑。他的想法是采用一堆 PowerShell 脚本,将它们转换成一组函数,然后将它们组织成一个模块。这并不是那么牵强。所以我最近几天花了一些时间想出了一个 PowerShell 函数来获取现有的 PowerShell 脚本文件并将其转换为 PowerShell 函数。

实际上,在函数内运行代码和在脚本内运行代码没有区别。函数的核心是一个脚本块,其名称使其更易于运行。脚本块内的代码与独立脚本中的代码没有什么不同。根据您的脚本,您可以简单地将脚本代码包装在函数声明中。当然,最佳实践是让函数执行单个任务并将单个类型的对象写入管道,因此您可能仍然需要编辑生成的函数。我想出了一个使用 AST 来加速这一过程的 PowerShell 工具。

基本示例

这是一个非常简单的 PowerShell 脚本。

#requires -version 3.0

#this is a sample script

Write-Host "this is a sample script that doesn't do anything but write a random number" -ForegroundColor Yellow

Get-Random -Minimum 1 -Maximum 1000

我想将其转换为 PowerShell 函数,因此我将使用我的新工具。

[玩转系统] 将 PowerShell 脚本转换为函数

我的 Convert-ScriptToFunction 命令显然需要文件的路径。我还需要为新函数指定一个名称。转换生成一个新的函数定义,并带有基于注释的帮助。原始脚本没有定义参数,因此转换定义了它们。如果我想将其保存到文件中,我需要做的就是运行命令并通过管道传输到 Out-File。然后我可以在脚本编辑器中打开该文件来完善它。

获取需求

您会注意到新代码包含原始文件中的 #requires 语句。我编写了一个单独的函数,也使用 AST 来获取任何需求。在我的 New-SystemReport.ps1 脚本中,我有这些要求。

#requires -version 5.1
#requires -module CimCmdlets

我可以使用这个功能来发现它们。

Function Get-PSRequirements {
    [cmdletbinding()]
    Param(
        [Parameter(Position = 0, Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$Path
    )
    Begin {
        Write-Verbose "Starting $($MyInvocation.MyCommand)"
        New-Variable astTokens -Force
        New-Variable astErr -Force
    }
    Process {
        $Path = Convert-Path $path
        Write-Verbose "Processing $path"

        $AST = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$astTokens, [ref]$astErr)
        #add the Path as a property
        if ($ast.ScriptRequirements) {
            $ast.ScriptRequirements | Add-Member -MemberType NoteProperty -Name "Path" -Value $Path-Force -PassThru
        }
        else {
            Write-Verbose "No requirements detected in $Path."
        }
    }
    End {
        Write-Verbose "Ending $($MyInvocation.MyCommand)"
    }
}

[玩转系统] 将 PowerShell 脚本转换为函数

基于评论的帮助

我还希望能够生成基于注释的帮助(如果尚未定义)。我使用 AST ParamBlock 创建了这样一个块。我总是告诉人们使用 HelpMessage 属性,如果你这样做,它将用于 .Parameter 定义。

Function New-CommentHelp {
    [cmdletbinding()]
    [OutputType("string")]
    Param([System.Management.Automation.Language.ParamBlockAst]$ParamBlock)

    $h = [System.Collections.Generic.List[string]]::new()
    $h.Add("<#")
    $h.Add("`t.Synopsis")
    $h.Add("`t  <short description>")
    $h.add("`t.Description")
    $h.add("`t  <long description>")

    foreach ($p in $ParamBlock.Parameters) {
        $paramName = $p.name.variablepath.userpath
        $h.Add("`t.Parameter $paramName")
        $paramHelp = $p.Attributes.namedArguments.where({ $_.argumentname -eq 'helpmessage' })
        if ($paramHelp) {
            $h.add("`t  $($paramHelp.argument.value)")
        }
        else {
            $h.Add("`t  <enter a parameter description>")
        }
    }
    $h.add("`t.Example")
    $h.Add("`t  PS C:\> $Name")
    $h.Add("`t  <output and explanation>")
    $h.Add("`t.Link")
    $h.Add("`t  <enter a link reference>")
    $h.Add("#>")

    $h
}

这是一个示例脚本。

#requires -version 3.0

#this is a sample script

Param (
[Parameter(Position = 0,HelpMessage = "How many numbers do you want?")]
[int]$Count = 1,
[string]$Name,
[switch]$Demo
)

Write-Host "this is a sample script that doesn't do anything but write a random number" -ForegroundColor Yellow
    
#get numbers
1..$count | Foreach-Object {
Get-Random -Minimum 1 -Maximum 1000
}
    
Write-Host "Ending script" -ForegroundColor yellow

#eof

使用我的转换函数我得到这个输出。

#requires -version 3.0


# Function exported from C:\scripts\SampleScript3.ps1

Function Invoke-Sample {

<#
	.Synopsis
	  <short description>
	.Description
	  <long description>
	.Parameter Count
	  How many numbers do you want?
	.Parameter Name
	  <enter a parameter description>
	.Parameter Demo
	  <enter a parameter description>
	.Example
	  PS C:\> Invoke-Sample
	  <output and explanation>
	.Link
	  <enter a link reference>
#>


	[cmdletbinding()]
	Param (
	[Parameter(Position = 0,HelpMessage = "How many numbers do you want?")]
	[int]$Count = 1,
	[string]$Name,
	[switch]$Demo
	)


Write-Host "this is a sample script that doesn't do anything but write a random number" -ForegroundColor Yellow
1..$count | Foreach-Object {
Get-Random -Minimum 1 -Maximum 1000
}
Write-Host "Ending script" -ForegroundColor yellow

} #close Invoke-Sample

函数名称

因为我必须定义一个新的函数名称,所以我希望它尽可能简单。我使用这个辅助函数将名称格式化为正确的大小写。我假设采用动词-名词命名约定。

Function Format-FunctionName {
    [cmdletbinding()]
    Param (
        [ValidateScript({
        if ($_ -match "^\w+-\w+$") {
            $true
        }
        else {
            Throw "Your function name should have a Verb-Noun naming convention"
            $False
        }
    })]
    [string]$Name
    )

    $split = $name -split "-"
    "{0}{1}-{2}{3}" -f $split[0][0].ToString().ToUpper(), $split[0].Substring(1).Tolower(),$split[1][0].ToString().ToUpper(), $split[1].Substring(1).ToLower()

}

[玩转系统] 将 PowerShell 脚本转换为函数

它并不总是完美的,特别是当你的名词像这个例子一样复杂时。该函数也不会验证您是否使用标准动词。但是,我有一个参数完成器,它将插入一个标准动词。

Register-ArgumentCompleter -CommandName Convert-ScriptToFunction -ParameterName Name -ScriptBlock {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter)

    #PowerShell code to populate $wordtoComplete
    Get-Verb | Where-Object {$_.verb -match "^$wordToComplete"} |
    ForEach-Object {
        #this will autocomplete with Verb-
        [System.Management.Automation.CompletionResult]::new("$($_.verb)-", $_.verb, 'ParameterValue', $_.Group)
    }
}

[玩转系统] 将 PowerShell 脚本转换为函数

我需要做的就是开始输入动词名称并按 Tab 键。补全将插入动词和破折号。我需要输入的只是名词。

使用输出

我的函数完全按照它所说的那样执行。它转换。由您决定如何使用输出。您可以通过管道传输到 Out-File 或 Set-Clipboard。但由于我预计您将立即编辑输出,因此我添加了一个名为 ToEditor 的动态参数(使用我的 New-PSDynamicParameterForm 函数)。如果您在 PowerShell ISE 或 VS Code 中运行该函数,则会定义此参数。输出将在一个新的、未保存的文件中打开。

同样,这是源文件。这些是我创建的脚本文件只是为了测试我的功能。

#requires -version 4.0
#requires -runasAdministrator

#this is a sample script

Param (
[Parameter(Position = 0,HelpMessage = "How many numbers do you want?")]
[ValidateRange(1,100)]
[int]$Count = 1
)
DynamicParam {
    #this is a sample dynamic parameter
    If ($True) {

    $paramDictionary = New-Object -Type System.Management.Automation.RuntimeDefinedParameterDictionary

    # Defining parameter attributes
    $attributeCollection = New-Object -Type System.Collections.ObjectModel.Collection[System.Attribute]
    $attributes = New-Object System.Management.Automation.ParameterAttribute
    $attributes.ParameterSetName = '__AllParameterSets'
    $attributeCollection.Add($attributes)

    # Defining the runtime parameter
    $dynParam1 = New-Object -Type System.Management.Automation.RuntimeDefinedParameter('Demo', [String], $attributeCollection)
    $paramDictionary.Add('Demo', $dynParam1)

    return $paramDictionary
} # end if
} #end DynamicParam

Begin {
    Write-Host "this is a sample script that doesn't do anything but write a random number" -ForegroundColor Yellow
}
Process {
    #get numbers
    1..$count | Foreach-Object {
    Get-Random -Minimum 1 -Maximum 1000
    }
}
End {
    write-host "Ending script" -ForegroundColor yellow
}
#eof

我将在 VS Code 的集成 PowerShell 终端中运行此代码以创建一个新文件。该函数还将获得一个新的别名。

Convert-ScriptToFunction .\SampleScript4.ps1 -Name Invoke-Sample -Alias ins -ToEditor

[玩转系统] 将 PowerShell 脚本转换为函数

然后,我可以使用 VSCode 清理文件,例如格式化、扩展别名以及将制表符转换为空格。然后我就可以保存文件了。输出包括对原始源文件的引用。

将脚本转换为函数

这是转换函数。

Function Convert-ScriptToFunction {
    <#
    .Synopsis
    Convert a script file to a PowerShell function.
    .Description
    This command takes the body of a script file and wraps it in a function
    declaration. The command will insert missing elements like [cmdletbinding()]
    and comment-based help. You will most likely need to edit and clean up the
    result in your scripting editor.

    If you run this command in the PowerShell ISE or the VS Code PowerShell
    integrated terminal, you can use the dynamic parameter ToEditor to open a
    new file with with the output. You can edit and save the file manually.

    It is assumed that your script file is complete and without syntax errors.
    .Parameter Path
    Enter the path to your PowerShell script file.
    .Parameter Name
    What is the name of your new function? It should have a Verb-Noun name.
    .Parameter Alias
    Define an optional alias for your new function.
    .Parameter ToEditor
    If you run this command in the PowerShell ISE or the VS Code PowerShell
    integrated terminal, you can use this dynamic parameter to open a new
    file with with the output. You can edit and save the file manually.
    .Example
    PS C:\> Convert-ScriptToFunction c:\scripts\Daily.ps1 -name Invoke-DailyTask | Set-Clipboard

    Convert Daily.ps1 to a function called Invoke-DailyTask and copy the
    results to the Windows clipboard. You can then paste the results into
    scripting editor.
    .Example
    PS C:\> Convert-ScriptToFunction c:\scripts\systemreport.ps1 -name New-SystemReport | Out-File c:\scripts\New-SystemReport.ps1

    Convert the SystemReport.ps1 script file to a function called
    New-SystemReport and save the results to a file.
    .Example
    PS C:\> Convert-ScriptToFunction c:\scripts\systemreport.ps1 -name New-System -alias nsr | Tee-Object -variable f

    Convert the script to a function called New-System and tee the output to $f.
    This will also define an function alias of nsr.
    #>
    [cmdletbinding()]
    [Outputtype("System.String")]
    [alias('csf')]
    Param(
        [Parameter(
            Position = 0,
            Mandatory,
            ValueFromPipelineByPropertyName,
            HelpMessage = "Enter the path to your PowerShell script file."
        )]
        [ValidateScript({Test-Path $_ })]
        [ValidatePattern("\.ps1$")]
        [string]$Path,

        [Parameter(
            Position = 1,
            Mandatory,
            ValueFromPipelineByPropertyName,
            HelpMessage = "What is the name of your new function?")]
        [ValidateScript({
            if ($_ -match "^\w+-\w+$") {
                $true
            }
            else {
                Throw "Your function name should have a Verb-Noun naming convention"
                $False
            }
        })]
        [string]$Name,

        [Parameter(ValueFromPipelineByPropertyName,HelpMessage = "Specify an optional alias for your new function. You can define multiple aliases separated by commas.")]
        [ValidateNotNullOrEmpty()]
        [string[]]$Alias
    )
    DynamicParam {
        <#
        If running this function in the PowerShell ISE or VS Code,
        define a ToEditor switch parameter
        #>
        If ($host.name -match "ISE|Code") {

            $paramDictionary = New-Object -Type System.Management.Automation.RuntimeDefinedParameterDictionary

            # Defining parameter attributes
            $attributeCollection = New-Object -Type System.Collections.ObjectModel.Collection[System.Attribute]
            $attributes = New-Object System.Management.Automation.ParameterAttribute
            $attributes.ParameterSetName = '__AllParameterSets'
            $attributeCollection.Add($attributes)

            # Defining the runtime parameter
            $dynParam1 = New-Object -Type System.Management.Automation.RuntimeDefinedParameter('ToEditor', [Switch], $attributeCollection)
            $paramDictionary.Add('ToEditor', $dynParam1)

            return $paramDictionary
        } # end if
    } #end DynamicParam
    Begin {
        Write-Verbose "Starting $($MyInvocation.MyCommand)"
        Write-Verbose "Initializing"
        New-Variable astTokens -Force
        New-Variable astErr -Force
        $new = [System.Collections.Generic.list[string]]::new()
    } #begin
    Process {
        #normalize
        $Path = Convert-Path $path
        $Name = Format-FunctionName $Name

        Write-Verbose "Processing $path"
        $AST = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$astTokens, [ref]$astErr)

        if ($ast.extent) {
            Write-Verbose "Getting any comment based help"
            $ch = $astTokens | Where-Object { $_.kind -eq 'comment' -AND $_.text -match '\.synopsis' }

            if ($ast.ScriptRequirements) {
                Write-Verbose "Adding script requirements"
               if($ast.ScriptRequirements.RequiredPSVersion) {
                   $new.Add("#requires -version $($ast.ScriptRequirements.RequiredPSVersion.ToString())")
               }
               if ($ast.ScriptRequirements.RequiredModules) {
                    Foreach ($m in $ast.ScriptRequirements.RequiredModules) {
                        #test for version requirements
                        $ver = $m.psobject.properties.where({$_.name -match 'version' -AND $_.value})
                        if ($ver) {
                            $new.Add("#requires -module @{ModuleName = '$($m.name)';$($ver.Name) = '$($ver.value)'}")
                        }
                        else {
                            $new.add("#requires -module $($m.Name)")
                        }
                    }
               }
               if ($ast.ScriptRequirements.IsElevationRequired) {
                    $new.Add("#requires -RunAsAdministrator")
               }
               If ($ast.ScriptRequirements.requiredPSEditions) {
                    $new.add("#requires -PSEdition $($ast.ScriptRequirements.requiredPSEditions)")
               }

               $new.Add("`n")
            }
            else {
                Write-Verbose "No script requirements found"
            }


           $head = @"
# Function exported from $Path

Function $Name {

"@
        $new.add($head)

            if ($ch) {
                $new.Add($ch.text)
                $new.Add("`n")
            }
            else {
                Write-Verbose "Generating new comment based help from parameters"
                New-CommentHelp -ParamBlock $ast.ParamBlock | Foreach-Object { $new.Add("$_")}
                $new.Add("`n")
            }

            [regex]$rx = "\[cmdletbinding\(.*\)\]"
            if ($rx.Ismatch($ast.Extent.text)) {
                Write-Verbose "Using existing cmdletbinding"
                #use the first match
                $cb = $rx.match($ast.extent.text).Value
                $new.Add("`t$cb")
            }
            else {
                 Write-Verbose "Adding [cmdletbinding()]"
               $new.Add("`t[cmdletbinding()]")
            }

           if ($alias) {
                Write-Verbose "Adding function alias definition $($alias -join ',')"
                $new.Add("`t[Alias('$($alias -join "','")')]")
           }
            if ($ast.ParamBlock) {
                Write-Verbose "Adding defined Param() block"
                [void]($ast.ParamBlock.tostring().split("`n").Foreach({$new.add("`t$_")}) -join "`n")
                $new.Add("`n")
            }
            else {
                Write-Verbose "Adding Param() block"
                $new.add("`tParam()")
            }
            if ($ast.DynamicParamBlock) {
                #assumes no more than 1 dynamic parameter
                Write-Verbose "Adding dynamic parameters"
                [void]($ast.DynamicParamBlock.tostring().split("`n").Foreach({$new.Add($_)}) -join "`n")
            }

            if ($ast.BeginBlock.Extent.text) {
                Write-Verbose "Adding defined Begin block"
                [void]($ast.BeginBlock.Extent.toString().split("`n").Foreach({$new.Add($_)}) -join "`n")
                $UseBPE = $True
            }

            if ($ast.ProcessBlock.Extent.text) {
                Write-Verbose "Adding defined Process block"
                [void]($ast.ProcessBlock.Extent.ToString().split("`n").Foreach({$new.add($_) }) -join "`n")
            }

            if ($ast.EndBlock.Extent.text) {
                if ($UseBPE) {
                    Write-Verbose "Adding opening End{} block"
                    $new.Add("`tEnd {")
                }
                    Write-Verbose "Adding the remaining code or defined endblock"
                    [void]($ast.Endblock.Statements.foreach({ $_.tostring() }).Foreach({ $new.Add($_)}))
                if ($UseBPE) {
                Write-Verbose "Adding closing End {} block"
                    $new.Add("`t}")
                }
            }
            else {
                $new.Add("End { }")
            }
            Write-Verbose "Closing the function"
           $new.Add( "`n} #close $name")

           if ($PSBoundParameters.ContainsKey("ToEditor")) {
                Write-Verbose "Opening result in editor"
                if ($host.name -match "ISE") {
                    $newfile = $psise.CurrentPowerShellTab.Files.add()
                    $newfile.Editor.InsertText(($new -join "`n"))
                    $newfile.editor.select(1,1,1,1)
                }
                elseif ($host.name -match "Code") {
                    $pseditor.Workspace.NewFile()
                    $ctx = $pseditor.GetEditorContext()
                    $ctx.CurrentFile.InsertText($new -join "`n")
                }
                else {
                    $new -join "`n" | Set-Clipboard
                    Write-Warning "Can't detect the PowerShell ISE or VS Code. Output has been copied to the clipboard."
                }
        }
        else {
            Write-Verbose "Writing output [$($new.count) lines] to the pipeline"
            $new -join "`n"
        }
        } #if ast found
        else {
            Write-Warning "Failed to find a script body to convert to a function."
        }

    } #process
    End {
        Write-Verbose "Ending $($MyInvocation.mycommand)"
    }
}

该函数假设您的脚本文件在语法上是完整的并且没有错误。了解并非每个脚本文件都可以转换为可以立即使用的 PowerShell 函数。转换功能正在尽最大努力。您应该将其视为加速脚本编写工作的工具。我想我需要编辑新文件。但这是一个好的开始。

我可以使用一个旧的 PowerShell 脚本,如下所示:

#requires -version 3.0

#Basic-HotFixReport.ps1

Param([string[]]$Computername = $env:COMPUTERNAME)

$ErrorActionPreference = "SilentlyContinue"

Get-Hotfix -ComputerName $Computername | 
Select-Object -Property PSComputername,HotFixID,Description,InstalledBy,InstalledOn,
@{Name="Online";Expression={$_.Caption}}

并创建一个新函数:

csf .\Basic-HotfixReport.ps1 -Name Get-HotFixReport -Alias ghfr | out-file c:\scripts\get-hotfixreport.ps1

我的函数有一个别名 csf。编辑文件使其更新后,我有这样的东西。

#requires -version 5.1

# Function exported from C:\scripts\Basic-HotfixReport.ps1

Function Get-HotfixReport {

    <#
    .Synopsis
      Get a hotfix report
    .Description
      Use this command to get a report of installed hotfixes on a computer.
    .Parameter Computername
     Enter the name of a computer.
    .Example

PS C:\scripts> Get-HotfixReport thinkp1 | format-table


Computername HotFixID  Description InstalledBy         InstalledOn            Online
------------ --------  ----------- -----------         -----------            ------
THINKP1      KB5006363 Update      NT AUTHORITY\SYSTEM 11/6/2021 12:00:00 AM  http://support.microsoft.com/?kbid=5006363
THINKP1      KB5004567 Update      NT AUTHORITY\SYSTEM 7/4/2021 12:00:00 AM   https://support.microsoft.com/help/5004567
THINKP1      KB5008295 Update      NT AUTHORITY\SYSTEM 11/6/2021 12:00:00 AM  https://support.microsoft.com/help/5008295
THINKP1      KB5007262 Update      NT AUTHORITY\SYSTEM 11/22/2021 12:00:00 AM https://support.microsoft.com/help/5007262
THINKP1      KB5007414 Update      NT AUTHORITY\SYSTEM 11/13/2021 12:00:00 AM

    .Link
      Get-HotFix
#>

    [cmdletbinding()]
    [Alias('ghfr')]
    Param([string[]]$Computername = $env:COMPUTERNAME)

    Try {
        Get-HotFix -ComputerName $Computername -ErrorAction Stop |
        Select-Object -Property @{Name = "Computername"; Expression = { $_.CSName } },
        HotFixID, Description, InstalledBy, InstalledOn,
        @{Name = "Online"; Expression = { $_.Caption } }
    }
    Catch {
        Throw $_
    }

} #close Get-Hotfixreport

我可能会继续完善该功能。

构建模块

我将用概念验证来总结这一点。假设我知道要使用的脚本文件和要分配的函数名称,我可以使用 PowerShell 脚本快速构建模块。

#a proof of concept to convert scripts to a new module

#dot source the conversion functions
. C:\scripts\dev-scripttofunction.ps1

$NewModuleName = "PSMagic"
$Description = "A sample module"
$ParentPath = "C:\work"
$path = New-Item -Name $NewModuleName -Path $ParentPath -ItemType Directory -Force

#create the module structure
"docs", "functions", "en-us", "formats" |
ForEach-Object { New-Item -Path $path -Name $_ -ItemType Directory }

#file data
$data = @"
"Path","Name"
"C:\scripts\SampleScript.ps1","Get-Foo"
"C:\scripts\SampleScript2.ps1","Set-Foo"
"C:\scripts\SampleScript3.ps1","Invoke-Foo"
"C:\scripts\SampleScript4.ps1","Remove-Foo"
"C:\scripts\SampleScript5.ps1","Test-Foo"
"@

$csv = $data | ConvertFrom-Csv
foreach ($item in $csv) {
    $out = Join-Path $path\functions "$($item.name).ps1"
    $item | Convert-ScriptToFunction |  Out-File -FilePath $out
    Get-Item $out

} #foreach item

#create the root module
$psm1 = @"

Get-Childitem `$psscriptroot\functions\*.ps1 |
Foreach-Object {
. `$_.FullName
}

"@

$psm1 | Out-File "$path$newmodulename.psm1"

#create the module manifest
$splat = @{
    Path                 = "$path$newmodulename.psd1"
    RootModule           = "$path$newmodulename.psm1"
    ModuleVersion        = "0.1.0"
    Author               = "Jeff Hicks"
    Description          = $Description
    FunctionsToExport    = $csv.name
    PowerShellVersion    = "5.1"
    CompatiblePSEditions = "Desktop"
}
New-ModuleManifest @splat

Get-ChildItem $path

运行这个脚本可以快速构建我的模块。

[玩转系统] 将 PowerShell 脚本转换为函数

[玩转系统] 将 PowerShell 脚本转换为函数

当然,仍然会有编辑和修改,但这给了我在这个过程中一个巨大的跳跃。

下一步

我希望你们中的一些人能够尝试一下这段代码,并让我知道您的想法。请记住,它可能不会生成完美的 PowerShell 函数。我想我现在有足够的命令,可以将所有这些命令捆绑到一个新模块中。事实上,我可以使用这些工具本身来构建模块。谈论元!

您需要 登录账户 后才能发表评论

取消回复欢迎 发表评论:

关灯