Funciones avanzadas
El propósito de las funciones avanzadas
El objetivo de las funciones avanzadas es crear funciones que se comporten del mismo modo que los cmdlets. Anteriormente (v1) los cmdlets sólo podrían crearse mediante .NET. En PowerShell V2 ya era posible crear cmdlets en .NET y en PowerShell. En V3, además, es posible crear cmdlets en Windows Workflow o en WMI.
Una plantilla de función avanzada
Como hemos visto en los módulos anteriores, una función avanzada incluye parámetros definidos por el usuario, parámetros comunes -agregados automáticamente por PowerShell, ayuda generada automáticamente a través de la descripción…
ISE proporciona plantillas de funciones avanzadas como parte de sus snippets (Ctrl+J), pero vamos a escribir una plantilla con los elementos esenciales:
1 <#
2 Ayuda basada en la información del comentario inicial de la función
3 #>
4 Function Verbo-Nombre {
5 [CmdletBinding()]
6 Param(
7 [Parameter()][tipo]$Variable1,
8 [Parameter()][tipo]$Variable2,
9 )
10
11 Begin {
12 <# Codigo #>
13 }
14 Process {
15 <# Codigo #>
16 }
17 End {
18 <# Codigo #>
19 }
20 }
Encontramos nuevos bloques: Begin, Process, End. Estas palabras claves marcan la diferencia entre una función (o un script basado en funciones) y un cmdlet normal. Básicamente, tienen que ver con el pipeline.
Vamos a descubrir para qué sirven estos bloques a través de la experimentación.
1 <#
2 Ayuda basada en la información del comentario inicial de la función
3 #>
4 Function Verbo-Nombre {
5 [CmdletBinding()]
6 Param(
7 [Parameter()][string]$Variable1,
8 [Parameter()][int]$Variable2
9 )
10
11 Begin {
12 "Begin"
13 }
14 Process {
15 "Process"
16 }
17 End {
18 "End"
19 }
20 }
Si ejecutamos en la consola:
1 PS> .\template.ps1
2 PS> Verbo-Nombre
3 Begin
4 Process
5 End
Nada espectacular.
Para simplificar, nos quedamos con una sola variable y la agregamos en los diferentes bloques:
1 <#
2 Ayuda basada en la información del comentario inicial de la función
3 #>
4 Function Verbo-Nombre {
5 [CmdletBinding()]
6 Param(
7 [Parameter()][string]$Variable1
8 )
9
10 Begin {
11 "Begin $Variable1"
12 }
13 Process {
14 "Process $Variable1"
15 }
16 End {
17 "End $Variable1"
18 }
19 }
Si ejecutamos la función, vemos que la variable pasada como parámetro está disponible en los tres bloques.
A continuación modificamos el parámetro añadiendo ValueFromPipeline=$True:
1 <#
2 Ayuda basada en la información del comentario inicial de la función
3 #>
4 Function Verbo-Nombre {
5 [CmdletBinding()]
6 Param(
7 [Parameter(ValueFromPipeline=$true)]
8 [string]$Variable1
9 )
10
11 Begin {
12 "Begin $Variable1"
13 }
14 Process {
15 "Process $Variable1"
16 }
17 End {
18 "End $Variable1"
19 }
20 }
Si ejecutamos directamente en la consola no vemos ninguna diferencia. Pero si lo ejecutamos como parte del pipeline:
1 PS> 1..5 | Verbo-Nombre
2 Begin
3 Process 1
4 Process 2
5 Process 3
6 Process 4
7 Process 5
8 End 5
Vemos que en Begin no tenemos el valor de la variable, ya que no tiene un valor por defecto y por tanto no está definido.
A continuación, tenemos tantas ejecuciones del bloque Process como objetos están llegando a través del pipeline.
Los bloques
BeginyEndsólo se ejecutan una vez.
El bloque Begin está orientado a configurar código necesario para que se realice el procesado, configurando todo lo necesario para la ejecución del script.
El bloque End realiza tareas de limpieza una vez se ha realizado el trabajo. Y el bloque Process es el que realiza el procesado de la información.
Veamos un ejemplo:
1 <# Help #>
2 Function Get-Total {
3 [cmdletbinding()]
4 Param(
5 [Parameter(ValueFromPipeline = $true)]
6 [int]$x
7 )
8 Begin { $total = 0 }
9 Process { $total += $x }
10 End { "Total = $total" }
11 }
Si ejecutamos la función usando el pipeline:
1 PS> 1..5 | Get-Total
2 Total = 15
Y si la ejecutamos directamente en la línea de comandos:
1 PS> Get-Total -x 2
2 Total = 2
En un caso más general, suponemos que el script va a insertar una serie de valores en una base de datos SQL. En el bloque Begin estableceríamos la cadena de conexión con la base de datos, en el bloque Process haríamos la inserción de valores, etc. Y en el bloque End cerraríamos la conexión y haríamos la lipieza de variables.
Por tanto, una vez entendemos qué es lo que hace cada bloque, incluso si utilizamos una simple línea de código encadenando varios cmdlets, la mejor práctica es copiar y pegar el código en el bloque Process y empezar a refinar la función a partir de ahí.
El bloque
Processejecuta el código en serie, es decir, no lo hace en paralelo, sino un objeto tras otro. Si queremos lanzar código en paralelo, usaríamosinvoke-command -ComputerName (get-content servers.txt) {<# code #>}.
Creando y probando parámetros
En este apartado vamos a crear y probar parámetros en una función. Partimos de un script que obtendrá información sobre varios equipos.
1 <#
2 .Synopsis
3 Obtiene la información de uno o varios equipos
4 .DESCRIPTION
5 Obtiene la información de uno o varios equipos
6 .EXAMPLE
7 #>
8 function Get-CompInfo
9 {
10 [CmdletBinding()]
11 Param
12 (
13 # Queremos soporte para varios equipos
14 [string]$ComputerName
15 # Interruptor para activar el log de errores
16 [switch]$ErrorLog
17 [string]$LogFile = 'c:\errorlog.txt'
18 )
19
20 Begin { }
21 Process { }
22 End { }
23 }
Si ejecutamos get-help get-compinfo veremos que PowerShell está construyendo el fichero de ayuda con la información que hemos proporcionado en el primer bloque de comentarios. Más adelante nos centraremos en esta capacidad de PowerShell de facilitar la creación de la documentación de ayuda para nuestros scripts.
Volviendo al trabajo; el tipo del parámetro $ComputerName es del tipo <string>, pero no acepta valores múltiples. Para que el parámetro acepte múltiples valores, añadimos [] tras el tipo de la variable:
1 ...
2 # Queremos soporte para varios equipos
3 [string[]]$ComputerName
4 ...
De esta forma tan sencilla hacemos que el parámetro acepte múltiples valores; nuestro trabajo consistirá en controlar si el script está recibiendo uno o más valores y cómo gestionarlos en el código.
Durante la fase de desarrollo del script es habitual mostrar por pantalla qué está haciendo el script (para depurar errores cuando no hace lo que queremos).
Una de las ventajas de PowerShell a la hora de probar los parámetros de las funciones es que podemos usar cmdlets como Write-Verbose para comprobar que todo funciona, sin necesidad de usar Write-Output (que después habrá que comentar en la versión final para que no se muestre durante la ejecución).
1 Begin {
2 If($ErrorLog) {
3 Write-Verbose 'Registro de errores activado.'
4 else {
5 Write-Verbose 'Registro de errores desactivado.'
6 }
7 }
Usando Write-Verbose, PowerShell muestra mensajes por pantalla únicamente si ejecutamos el script con el parámetro -Verbose.
Para comprobar si la activación del registro de errores funciona correctamente mediante el parámetro $ErrorLog, ejecutamos:
1 PS> Get-CompInfo -ErrorLog -verbose
2 VERBOSE: Registro de errores activado.
3
4 PS> Get-CompInfo -verbose
5 VERBOSE: Registro de errores desactivado.
El parámetro $ErrorLog es del tipo Switch, de manera que sólo tiene dos valores ($true, si está presente, o $false si no lo está)
Para que el script soporte múltiples valores para el parámetro $ComputerName, usamos un bucle forearch; así funcionará tanto con un nombre de equipo o con múltiples:
1 ...
2 Begin {
3 If($ErrorLog) {
4 Write-Verbose 'Registro de errores activado.'
5 } else {
6 Write-Verbose 'Registro de errores desactivado.'
7 }
8 Foreach ($Computer in $ComputerName) {
9 Write-Verbose "Computer: $Computer"
10 }
11 }
12 ...
Añadiendo código
En el apartado anterior hemos montado la infraestructura necesaria para gestionar los diferentes parámetros de la función. Una vez hemos comprobado que todo funciona como esperamos, vamos a empezar a escribir el código de la función.
El código debe escribirse en el bloque Process, porque aunque no vayamos a utilizar la entrada a través del pipeline, quizás más adelante querramos hacerlo.
Vamos con el código:
1 ...
2 Process {
3 foreach ($Computer in $ComputerName) {
4 $os = Get-WmiObject -ComputerName $Computer -Class win32_operatingsys\
5 tem
6 $Disk = Get-WmiObject -ComputerName $Computer -Class win32_logicaldis\
7 k -Filter "DeviceID = 'c:'"
8 }
9 }
10 ...
Usamos el mismo esquema que en el bloque Begin para gestionar múltiples elementos, usando un bucle foreach. A continuación lanzamos una consulta wmi para cada uno de los elementos.
Escribiendo objetos en el pipeline
Parte de la potencia de PowerShell se basa en la capacidad de enlazar la salida de un comando con la entrada del siguiente. Al tratarse de objetos, cada cmdlet puede modificar el objeto y pasarlo a través del pipeline.
Para que nuestro script se comporte como un cmdlet de primera clase, debe expresar el resultado en forma de objeto.
Así pues, vamos a construir un objeto que almacene la información que queremos de la manera que queremos.
Comenzamos con una hash table (un diccionario) con las propiedades que nos interesan de la información obtenida de las consultas wmi:
1 ...
2 $Prop = @{
3 'ComputerName' = $Computer
4 'OS Name' = $os.caption
5 'OS Build' = $os.buildnumber
6 'FreeSpace' = $Disk.freespace / 1gb -as [int]
7 }
8 ...
No es necesario poner ‘;’ al final de cada una de las propiedades en
$Propya que;es un terminador de expresiones y sólo es necesario si queremos poner varias expresiones en una misma línea.
$Prop es una hash table, con valores en pares, de la forma key = value. No son conjuntos de valores ordenados, por lo que al mostrar las propiedades en la salida de un comando no se mostrarán necesariamente en el orden en el que hemos especificado las propiedades en la definición.
Podemos añadir [ordered] antes de la definición de la hash table @{...}:
1 ...
2 $Prop = [ordered]@{
3 'ComputerName' = $Computer
4 'OS Name' = $os.caption
5 'OS Build' = $os.buildnumber
6 'FreeSpace' = $Disk.freespace / 1gb -as [int]
7 }
8 ...
En este caso, las propiedades sí que se mostrarán en el mismo orden en el que han sido definidas, aunque es más costoso a nivel computacional, ya que PowerShell debe ordenar las propiedades antes de mostrarlas.
El siguiente paso es crear un objeto con los datos de interés para poder enviarlo a través del pipeline.
Para crear un objeto en PowerShell, usamos el cmdlet New-Object. Aunque no es necesario, se recomienda especificar el tipo mediante -TypeName PSObject.
El objeto creado estará vacío, ya que no contiene ni propiedades ni métodos. Por tanto, será necesario asignar las propiedades y métodos que queremos asociar al objeto recién creado.
Una forma de hacerlo es en la definición del objeto, asignando una hash table:
1 $obj = New-Object -TypeName PSObject -Property $Prop
Ahora, al ejecutar de nuevo el script, el resultado es:
1 FreeSpace ComputerName OS Name OS B\
2 uild
3 --------- ------------ ------- ----\
4 ----
5 11 localhost Microsoft Windows Server 2016 Technical Preview 4 1058\
6 6
Este es el aspecto que queremos que tenga… Pero podemos comprobar que es un objecto de verdad mediante:
1 PS> get-compinfo | get-member
2 TypeName: System.Management.Automation.PSCustomObject
Y como ahora el cmdlet devuelve un objecto, podemos seleccionar una de las propiedades o convertir la salida a CSV o JSON, etc usando el pipeline para pasar nuestro objeto a cualquier otro cmdlet:
1 PS> Get-CompInfo -ComputerName localhost | select -Property "os name"
2
3 OS Name
4 -------
5 Microsoft Windows Server 2016 Technical Preview 4
6
7 PS> Get-CompInfo -ComputerName localhost | ConvertTo-Json
8 {
9 "FreeSpace": 11,
10 "ComputerName": "localhost",
11 "OS Name": "Microsoft Windows Server 2016 Technical Preview 4",
12 "OS Build": "10586"
13 }
Finalmente, el código completo del cmdlet es:
1 <#
2 .Synopsis
3 Obtiene la información de uno o varios equipos
4 .DESCRIPTION
5 Obtiene la información de uno o varios equipos
6 .EXAMPLE
7 #>
8 function Get-CompInfo
9 {
10 [CmdletBinding()]
11 Param
12 (
13 # Queremos soporte para varios equipos
14 [string[]]$ComputerName,
15 # Interruptor para activar el log de errores
16 [switch]$ErrorLog,
17 [string]$LogFile = 'c:\errorlog.txt'
18 )
19
20 Begin {
21 If($ErrorLog) {
22 Write-Verbose 'Registro de errores activado.'
23 } else {
24 Write-Verbose 'Registro de errores desactivado.'
25 }
26 foreach ($Computer in $ComputerName) {
27 Write-Verbose "Computer: $Computer"
28 }
29 }
30 Process {
31 foreach ($Computer in $ComputerName) {
32 $os = Get-WmiObject -ComputerName $Computer -Class win32_operatin\
33 gsystem
34 $Disk = Get-WmiObject -ComputerName $Computer -Class win32_logica\
35 ldisk -Filter "DeviceID = 'c:'"
36
37 $Prop = @{
38 'ComputerName' = $Computer;
39 'OS Name' = $os.caption;
40 'OS Build' = $os.buildnumber;
41 'FreeSpace' = $Disk.freespace / 1gb -as [int]
42 }
43 }
44 $obj = New-Object -TypeName PSObject -Property $Prop
45 Write-Output $obj
46 }
47 End { }
48 }