Compartir a través de


Todo lo que quiso saber sobre las excepciones

El control de errores es solo parte de la vida cuando se trata de escribir código. A menudo, se pueden comprobar y validar las condiciones del comportamiento esperado. Cuando ocurre lo inesperado, acudimos al control de excepciones. Puede controlar fácilmente las excepciones generadas por el código de otras personas o puede generar sus propias excepciones para que las controlen otros usuarios.

Nota:

La versión original de este artículo apareció en el blog escrito por @KevinMarquette. El equipo de PowerShell agradece a Kevin que comparta este contenido con nosotros. Visite su blog en PowerShellExplained.com.

Terminología básica

Es necesario tratar algunos términos básicos antes de pasar a este.

Excepción

Una excepción es como un evento que se crea cuando el control de errores normal no puede resolver el problema. Intentar dividir un número por cero o quedarse sin memoria son ejemplos de situaciones que crean una excepción. En ocasiones, el autor del código que se usa crea excepciones para determinados problemas cuando se producen.

Throw (iniciar) y Catch (capturar)

Cuando se produce una excepción, decimos que se inicia una excepción. Para controlar una excepción iniciada, debe capturarla. Si se inicia una excepción y no se captura con algo, el script deja de ejecutarse.

La pila de llamadas

La pila de llamadas es la lista de funciones que se han llamado entre sí. Cuando se llama a una función, se agrega a la pila o a la parte superior de la lista. Cuando la función termina o devuelve un resultado, se quita de la pila.

Cuando se inicia una excepción, se comprueba esa pila de llamadas para que un controlador de excepciones la capture.

Errores de terminación y no terminación

Por lo general, una excepción es un error de terminación. Una excepción iniciada se captura o termina la ejecución actual. De forma predeterminada, Write-Error genera un error de no terminación y agrega un error al flujo de salida sin iniciar una excepción.

Conviene destacar este punto porque Write-Error y otros errores de no terminación no desencadenan una operación catch.

Tragarse una excepción

Es cuando se captura un error solo para suprimirlo. Hágalo con precaución, ya que puede dificultar la solución de problemas.

Sintaxis básica de comandos

Esta es una introducción rápida a la sintaxis básica de control de excepciones que se usa en PowerShell.

Throw

Para crear nuestro propio evento de excepción, iniciamos una excepción con la palabra clave throw.

function Start-Something
{
    throw "Bad thing happened"
}

Esta acción crea una excepción en tiempo de ejecución que es un error de terminación. Se controla mediante una operación catch en una función de llamada o sale del script con un mensaje similar a este.

PS> Start-Something

Bad thing happened
At line:1 char:1
+ throw "Bad thing happened"
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : OperationStopped: (Bad thing happened:String) [], RuntimeException
    + FullyQualifiedErrorId : Bad thing happened

Write-Error -ErrorAction Stop

He mencionado que Write-Error no produce un error de terminación de forma predeterminada. Si especifica -ErrorAction Stop, Write-Error genera un error de terminación que se puede controlar con catch.

Write-Error -Message "Houston, we have a problem." -ErrorAction Stop

Gracias a Lee Dailey por recordarnos el uso de -ErrorAction Stop de esta manera.

Cmdlet -ErrorAction Stop

Si especifica -ErrorAction Stop en cualquier función o cmdlet avanzados, todas las instrucciones Write-Error se convierten en errores de terminación que detienen la ejecución o que se pueden controlar mediante catch.

Start-Something -ErrorAction Stop

Para obtener más información sobre el parámetro ErrorAction, consulte about_CommonParameters. Para obtener más información sobre la variable $ErrorActionPreference, vea about_Preference_Variables.

Try/Catch

La forma en que funciona el control de excepciones en PowerShell (y muchos otros lenguajes) es que primero se usa try para probar una sección del código y, si se produce un error, puede usar catch para capturarlo. Este es un ejemplo rápido.

try
{
    Start-Something
}
catch
{
    Write-Output "Something threw an exception"
    Write-Output $_
}

try
{
    Start-Something -ErrorAction Stop
}
catch
{
    Write-Output "Something threw an exception or used Write-Error"
    Write-Output $_
}

El script catch solo se ejecuta si se produce un error de terminación. Si try se ejecuta correctamente, se omite catch. Puede acceder a la información sobre la excepción del bloque catch usando la variable $_.

Try/Finally

En ocasiones, no es necesario controlar un error, pero sí es necesario ejecutar código tanto si se produce una excepción como si no. Un script finally hace exactamente eso.

Eche un vistazo a este ejemplo:

$command = [System.Data.SqlClient.SqlCommand]::New(queryString, connection)
$command.Connection.Open()
$command.ExecuteNonQuery()
$command.Connection.Close()

Siempre que abra o se conecte a un recurso, debe cerrarlo. Si ExecuteNonQuery() inicia una excepción, la conexión no se cierra. Este es el mismo código dentro de un bloque try/finally.

$command = [System.Data.SqlClient.SqlCommand]::New(queryString, connection)
try
{
    $command.Connection.Open()
    $command.ExecuteNonQuery()
}
finally
{
    $command.Connection.Close()
}

En este ejemplo, la conexión se cierra si hay un error. También se cierra si no hay ningún error. El script finally se ejecuta cada vez.

Como no se captura la excepción, se sigue propagando a la pila de llamadas.

Try/Catch/Finally

Es absolutamente válido usar juntos catch y finally. Casi siempre usará uno u otro, pero puede encontrarse con escenarios en los que se usen ambos.

$PSItem

Ahora que tenemos los aspectos básicos, podemos escarbar un poco más.

Dentro del bloque catch, hay una variable automática ($PSItem o $_) de tipo ErrorRecord que contiene los detalles sobre la excepción. Esta es una introducción rápida a algunas de las propiedades principales.

En estos ejemplos, usé una ruta de acceso no válida en ReadAllText para generar esta excepción.

[System.IO.File]::ReadAllText( '\\test\no\filefound.log')

PSItem.ToString()

Proporciona el mensaje más limpio para usar en el registro y la salida general. ToString() se llama automáticamente si $PSItem se coloca dentro de una cadena.

catch
{
    Write-Output "Ran into an issue: $($PSItem.ToString())"
}

catch
{
    Write-Output "Ran into an issue: $PSItem"
}

$PSItem.InvocationInfo

Esta propiedad contiene información adicional recopilada por PowerShell sobre la función o el script en el que se inició la excepción. Este es el elemento InvocationInfo de la excepción de ejemplo que he creado.

PS> $PSItem.InvocationInfo | Format-List *

MyCommand             : Get-Resource
BoundParameters       : {}
UnboundArguments      : {}
ScriptLineNumber      : 5
OffsetInLine          : 5
ScriptName            : C:\blog\throwerror.ps1
Line                  :     Get-Resource
PositionMessage       : At C:\blog\throwerror.ps1:5 char:5
                        +     Get-Resource
                        +     ~~~~~~~~~~~~
PSScriptRoot          : C:\blog
PSCommandPath         : C:\blog\throwerror.ps1
InvocationName        : Get-Resource

Aquí, los detalles importantes muestran el elemento ScriptName, el elemento Line del código y el elemento ScriptLineNumber en el que se inició la invocación.

$PSItem.ScriptStackTrace

Esta propiedad muestra el orden de las llamadas de función que le han llevado al código donde se generó la excepción.

PS> $PSItem.ScriptStackTrace
at Get-Resource, C:\blog\throwerror.ps1: line 13
at Start-Something, C:\blog\throwerror.ps1: line 5
at <ScriptBlock>, C:\blog\throwerror.ps1: line 18

Solo hago llamadas a funciones del mismo script, pero si se invocaran varios scripts, se realizaría un seguimiento de las llamadas.

$PSItem.Exception

Esta es la excepción real que se inició.

$PSItem.Exception.Message

Este es el mensaje general que describe la excepción y es un buen punto de partida para la solución de problemas. La mayoría de las excepciones tienen un mensaje predeterminado, pero también se puede establecer en algo personalizado cuando se inicia la excepción.

PS> $PSItem.Exception.Message

Exception calling "ReadAllText" with "1" argument(s): "The network path was not found."

Este también es el mensaje que se devuelve al llamar a $PSItem.ToString() si no hay uno establecido en ErrorRecord.

$PSItem.Exception.InnerException

Las excepciones pueden contener excepciones internas. Este suele ser el caso cuando el código al que se llama captura una excepción e inicia una excepción diferente. La excepción original se coloca dentro de la nueva excepción.

PS> $PSItem.Exception.InnerExceptionMessage
The network path was not found.

Más adelante volveremos a tratar este tema al hablar sobre el inicio repetido de excepciones.

$PSItem.Exception.StackTrace

Es el elemento StackTrace de la excepción. Anteriormente mostré un elemento ScriptStackTrace, pero este es para las llamadas a código administrado.

at System.IO.FileStream.Init(String path, FileMode mode, FileAccess access, Int32 rights, Boolean
 useRights, FileShare share, Int32 bufferSize, FileOptions options, SECURITY_ATTRIBUTES secAttrs,
 String msgPath, Boolean bFromProxy, Boolean useLongPath, Boolean checkHost)
at System.IO.FileStream..ctor(String path, FileMode mode, FileAccess access, FileShare share, Int32
 bufferSize, FileOptions options, String msgPath, Boolean bFromProxy, Boolean useLongPath, Boolean
 checkHost)
at System.IO.StreamReader..ctor(String path, Encoding encoding, Boolean detectEncodingFromByteOrderMarks,
 Int32 bufferSize, Boolean checkHost)
at System.IO.File.InternalReadAllText(String path, Encoding encoding, Boolean checkHost)
at CallSite.Target(Closure , CallSite , Type , String )

Solo se obtiene este seguimiento de la pila cuando se inicia el evento desde código administrado. Voy a llamar directamente a una función de .NET Framework, así que eso es todo lo que podemos ver en este ejemplo. Por lo general, cuando se examina un seguimiento de la pila, se busca dónde se detiene el código y comienza la llamada del sistema.

Trabajo con excepciones

Hay más excepciones que las propiedades básicas de excepción y sintaxis.

Captura de excepciones con tipo

Puede ser selectivo con las excepciones que capture. Las excepciones tienen un tipo y puede especificar el tipo de excepción que desea capturar.

try
{
    Start-Something -Path $path
}
catch [System.IO.FileNotFoundException]
{
    Write-Output "Could not find $path"
}
catch [System.IO.IOException]
{
        Write-Output "IO error with the file: $path"
}

El tipo de excepción se comprueba para cada bloque catch hasta que se encuentre uno que coincida con la excepción. Es importante saber que las excepciones pueden heredar de otras excepciones. En el ejemplo anterior, FileNotFoundException hereda de IOException. Por lo tanto, si IOException fue primero, esta sería la excepción a la que se llamaría. Solo se invoca un bloque catch aunque haya varias coincidencias.

Si tuviéramos un System.IO.PathTooLongException, el IOException coincidiría pero si tuviéramos un InsufficientMemoryException entonces nada lo detectaría y se propagaría la pila.

Captura de varios tipos a la vez

Es posible capturar varios tipos de excepción con la misma instrucción catch.

try
{
    Start-Something -Path $path -ErrorAction Stop
}
catch [System.IO.DirectoryNotFoundException],[System.IO.FileNotFoundException]
{
    Write-Output "The path or file was not found: [$path]"
}
catch [System.IO.IOException]
{
    Write-Output "IO error with the file: [$path]"
}

Gracias Redditor u/Sheppard_Ra por sugerir esta adición.

Inicio de excepciones con tipo

Puede iniciar excepciones con tipo en PowerShell. En lugar de llamar a throw con una cadena:

throw "Could not find: $path"

Use un acelerador de excepciones como este:

throw [System.IO.FileNotFoundException] "Could not find: $path"

Sin embargo, tendrá que especificar un mensaje cuando lo haga.

También puede crear una instancia de una excepción que se va a iniciar. El mensaje es opcional cuando hace esto porque el sistema tiene mensajes predeterminados para todas las excepciones integradas.

throw [System.IO.FileNotFoundException]::new()
throw [System.IO.FileNotFoundException]::new("Could not find path: $path")

Si no usa PowerShell 5.0 o posterior, debe utilizar el método New-Object anterior.

throw (New-Object -TypeName System.IO.FileNotFoundException )
throw (New-Object -TypeName System.IO.FileNotFoundException -ArgumentList "Could not find path: $path")

Mediante una excepción con tipo, usted (u otros) puede capturar la excepción por el tipo, como se ha mencionado en la sección anterior.

Error de escritura: excepción

Podemos agregar estas excepciones con tipo a Write-Error y todavía podemos usar catch para capturar los errores por tipo de excepción. Use Write-Error como en estos ejemplos:

# with normal message
Write-Error -Message "Could not find path: $path" -Exception ([System.IO.FileNotFoundException]::new()) -ErrorAction Stop

# With message inside new exception
Write-Error -Exception ([System.IO.FileNotFoundException]::new("Could not find path: $path")) -ErrorAction Stop

# Pre PS 5.0
Write-Error -Exception ([System.IO.FileNotFoundException]"Could not find path: $path") -ErrorAction Stop

Write-Error -Message "Could not find path: $path" -Exception (New-Object -TypeName System.IO.FileNotFoundException) -ErrorAction Stop

A continuación, podemos capturarlo de la siguiente manera:

catch [System.IO.FileNotFoundException]
{
    Write-Log $PSItem.ToString()
}

La lista grande de excepciones de .NET

He compilado una lista maestra con la ayuda de la comunidad Reddit r/PowerShell que contiene cientos de excepciones de .NET para complementar esta publicación.

Comencemos por buscar en esa lista las excepciones que podrían ser una buena opción para mi situación. Debe intentar usar excepciones en el espacio de nombres System base.

Las excepciones son objetos

Si empieza a usar muchas excepciones con tipo, recuerde que son objetos. Diferentes excepciones tienen diferentes constructores y propiedades. Si examinamos la documentación de FileNotFoundException para System.IO.FileNotFoundException, vemos que se puede pasar un mensaje y una ruta de acceso de archivo.

[System.IO.FileNotFoundException]::new("Could not find file", $path)

Además, tiene una propiedad FileName que expone esa ruta de acceso de archivo.

catch [System.IO.FileNotFoundException]
{
    Write-Output $PSItem.Exception.FileName
}

Consulte la documentación de .NET para información sobre otros constructores y propiedades de objeto.

Inicio repetido de una excepción

Si todo lo que va a hacer en el bloque catch es la acción throw con la misma excepción, no use catch con ella. Solo debe usar catch con una excepción que planee controlar o si va a realizar alguna acción cuando se produzca.

Hay ocasiones en las que querrá realizar una acción en una excepción, pero volver a iniciar esta para que un elemento de nivel inferior pueda encargarse de ella. Podríamos escribir un mensaje o registrar el problema cerca de donde lo detectamos, pero tratarlo mejor arriba de la pila.

catch
{
    Write-Log $PSItem.ToString()
    throw $PSItem
}

Curiosamente, podemos llamar a throw desde dentro de catch y volver a iniciar la excepción actual.

catch
{
    Write-Log $PSItem.ToString()
    throw
}

Queremos volver a iniciar la excepción para conservar la información de ejecución original como el script de origen y el número de línea. Si se inicia una nueva excepción en este punto, se oculta dónde comenzó.

Inicio repetido de una nueva excepción

Si captura una excepción, pero quiere iniciar una diferente, debe anidar la excepción original dentro de la nueva. De esta manera, cualquier usuario de debajo de la pila puede acceder a ella como $PSItem.Exception.InnerException.

catch
{
    throw [System.MissingFieldException]::new('Could not access field',$PSItem.Exception)
}

$PSCmdlet.ThrowTerminatingError()

Lo que no me gusta de usar throw para las excepciones sin procesar es que el mensaje de error señala a la instrucción throw e indica que la línea es el lugar donde se encuentra el problema.

Unable to find the specified file.
At line:31 char:9
+         throw [System.IO.FileNotFoundException]::new()
+         ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : OperationStopped: (:) [], FileNotFoundException
    + FullyQualifiedErrorId : Unable to find the specified file.

Recibir el mensaje de error me dice que el script se ha interrumpido porque llamé a throw en la línea 31 y este es un buen mensaje que deban ver los usuarios del script. No les dice nada útil.

Dexter Dhami señaló que puedo usar ThrowTerminatingError() para corregirlo.

$PSCmdlet.ThrowTerminatingError(
    [System.Management.Automation.ErrorRecord]::new(
        ([System.IO.FileNotFoundException]"Could not find $Path"),
        'My.ID',
        [System.Management.Automation.ErrorCategory]::OpenError,
        $MyObject
    )
)

Si damos por hecho que se llamó a ThrowTerminatingError() dentro de una función llamada Get-Resource, este es el error que veremos.

Get-Resource : Could not find C:\Program Files (x86)\Reference
Assemblies\Microsoft\Framework\.NETPortable\v4.6\System.IO.xml
At line:6 char:5
+     Get-Resource -Path $Path
+     ~~~~~~~~~~~~
    + CategoryInfo          : OpenError: (:) [Get-Resource], FileNotFoundException
    + FullyQualifiedErrorId : My.ID,Get-Resource

¿Ve cómo apunta a la función Get-Resource como el origen del problema? Esto indica al usuario algo útil.

Dado que $PSItem es un elemento ErrorRecord, también podemos usar ThrowTerminatingError de esta manera para volver a iniciarla.

catch
{
    $PSCmdlet.ThrowTerminatingError($PSItem)
}

Esto cambia el origen del error al cmdlet y oculta los elementos internos de la función a los usuarios del cmdlet.

Try puede crear errores de terminación

Kirk Munro señala que algunas excepciones son solo errores de terminación cuando se ejecutan dentro de un bloque try/catch. Este es el ejemplo que él me proporcionó que genera una excepción de tiempo de ejecución de división por cero.

function Start-Something { 1/(1-1) }

Luego, se invoca así para ver cómo se genera el error y se envía el mensaje.

&{ Start-Something; Write-Output "We did it. Send Email" }

Sin embargo, al colocar ese mismo código dentro de un bloque try/catch, vemos que sucede otra cosa.

try
{
    &{ Start-Something; Write-Output "We did it. Send Email" }
}
catch
{
    Write-Output "Notify Admin to fix error and send email"
}

Vemos que el error se convierte en un error de terminación y no genera el primer mensaje. Lo que no me gusta de esto es que puede tener este código en una función y actúa de forma diferente si alguien usa try/catch.

No he tenido problemas con esto, pero es una situación que puede darse.

$PSCmdlet.ThrowTerminatingError() dentro de try/catch

Un matiz de $PSCmdlet.ThrowTerminatingError() es que crea un error de terminación dentro del cmdlet, pero se convierte en un error de no terminación después de salir de él. Esto deja en manos del autor de la llamada de la función la decisión de cómo controlar el error. Se puede volver a convertir en un error de terminación mediante -ErrorAction Stop o si se llama desde try{...}catch{...}.

Plantillas de función públicas

Una última conclusión que saque de mi conversación con Kirk Munro era que coloca un elemento try{...}catch{...} alrededor de cada bloque begin, process y end en todas sus funciones avanzadas. En esos bloques catch genéricos, tiene una sola línea que usa $PSCmdlet.ThrowTerminatingError($PSItem) para tratar todas las excepciones que abandonan sus funciones.

function Start-Something
{
    [CmdletBinding()]
    param()

    process
    {
        try
        {
            ...
        }
        catch
        {
            $PSCmdlet.ThrowTerminatingError($PSItem)
        }
    }
}

Dado que todo está en una instrucción try dentro de sus funciones, todo funciona de forma coherente. Esto también proporciona al usuario final errores limpios que ocultan el código interno del error generado.

Trap

Me he centrado en el aspecto try/catch de las excepciones. Pero hay una característica heredada que es necesario mencionar antes de terminar esta parte.

Un elemento trap se coloca en un script o en una función para capturar todas las excepciones que se producen en ese ámbito. Cuando se produce una excepción, se ejecuta el código de trap y, luego, continúa el código normal. Si se producen varias excepciones, se llama a la captura una y otra vez.

trap
{
    Write-Log $PSItem.ToString()
}

throw [System.Exception]::new('first')
throw [System.Exception]::new('second')
throw [System.Exception]::new('third')

Personalmente, nunca adopté este enfoque, pero puedo ver el valor en los scripts de administrador o controlador que registran todas las excepciones y, luego, se siguen ejecutando.

Comentarios de cierre

Agregar el control de excepciones adecuado a los scripts no solo hace que sean más estables, sino que también facilita la solución de problemas de esas excepciones.

Pasé mucho tiempo hablando de throw porque es un concepto básico al hablar sobre el control de excepciones. PowerShell también nos ha proporcionado Write-Error que controla todas las situaciones en las que usaría throw. Por lo tanto, no debe pensar que necesita usar throw después de leer esto.

Ahora que he tenido tiempo de escribir sobre el control de excepciones en este detalle, voy a cambiar al uso de Write-Error -Stop para generar errores en mi código. También voy a seguir los consejos de Kirk y a hacer de ThrowTerminatingError mi controlador de excepciones goto para cada función.