Combinando código de CSharp y PowerShell

Escrito por  Gustavo Velez

Aunque cuando se necesita programar algo nuevo en cualquier tecnología de Microsoft se pueden utilizar muchos lenguajes de programación, los idiomas preferidos por los desarrolladores que trabajan con software de Microsoft en ambientes empresariales son solo unos cuantos: CSharp para programas de código manejado, PowerShell para scripting y TypeScript para JavaScript, principalmente.

Los equipos de desarrollo en empresas manejan normalmente depósitos completos de código en CSharp y PowerShell. Como una de las estrategias más importantes para producción es la reutilización al máximo de código, es importante poder institucionalizar que las librerías disponibles de CSharp y PowerShell sean utilizadas indiscriminadamente (cuando sea posible) en cualquiera de los dos lenguajes.

Por el hecho de que PowerShell está basado en los Frameworks .NET de Microsoft y que está escrito en CSharp, tenemos asegurado cierto grado de compatibilidad entre los lenguajes. Y, como este articulo muestra, es bastante fácil llamar código de PowerShell desde CSharp, y código manejado de CSharp desde PowerShell.

Adicionalmente, hay situaciones en donde la combinación de código de los dos lenguajes puede ser conveniente. Por ejemplo, en ambientes de producción altamente controlados, en donde no es posible ejecutar nuevos archivos .exe, ejecutar un archivo .ps1 desde un programa .exe proporciona flexibilidad (aunque también un riesgo de seguridad añadido). O ejecutar programas .exe disponible para software que no tiene la posibilidad de crear cmdlets de PowerShell, ofrece la posibilidad a administradores de crear scripts de PowerShell que de otra forma seria imposible.

Usando código de CSharp en PowerShell

Existen principalmente cinco formas de usar código de CSharp en un script de PowerShell: usando el cmdlet Invoke-Expression, usando el cmdlet Start-Process, cargando DLLs con el cmdlet Add-Type, cargando DLLs usando .Net Reflection y compilando el código de CSharp dinámicamente.

Ejemplo de código de CSharp

El siguiente ejemplo (muy sencillo) de código fuente en CSharp se va a llamar desde PowerShell. El código consiste en una Class Library (.Net 7.0), compilada como DLL, con una clase que simula un calculador:

1namespace CalculatorCSharpDll
2{
3 public class Calculator
4 {
5 public int NumberOne { get; set; }
6 public int NumberTwo { get; set; }
7 public int AddNumbers(int FirstNumber, int SecondNumber)
8 {
9 int intReturn = 0;
10 intReturn = FirstNumber + SecondNumber;
11 return intReturn;
12 }
13 }
14}
15

Y una aplicación de consola (también .Net 7.0), compilada como un EXE, que utiliza el DLL anterior. Esta aplicación requiere una referencia al DLL anterior:

1using CalculatorCSharpDll;
2
3Calculator myCalculator = new Calculator();
4
5if (args.Length == 0 || string.IsNullOrEmpty(args[0]))
6{ // Using no input parameters
7 myCalculator.NumberOne = 1;
8 myCalculator.NumberTwo = 2;
9
10 int myResult = myCalculator.AddNumbers(myCalculator.NumberOne, myCalculator.NumberTwo);
11
12 Console.WriteLine("Using no input params - " + myResult);
13}
14else
15{ // Using console input parameters (no validation of parameters)
16 int myResult = myCalculator.AddNumbers(int.Parse(args[0]),
17 int.Parse(args[1]));
18
19 Console.WriteLine("Using input params - " + myResult);
20}
21

La aplicación de consola funciona sin parámetros de entrada, en cuyo caso siempre se suman los números 1 y 2, o funciona aceptando parámetros de entrada por medio de argumentos en la llamada al EXE (los parámetros no son validados para simplificar el código).

cmdlet Invoke-Expression

Con el cmdlet Invoke-Expression (https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/invoke-expression?view=powershell-7.2) se puede hacer ejecutar un EXE directamente y el resultado puede ser capturado en una variable:

1$ExeFilePath =
2"C:ProjectsCalculatorCSharpExebinDebugnet6.0CalculatorCSharpExe.exe"
3
4$myResult01 = Invoke-Expression -Command $ExeFilePath
5
6Write-Host $myResult01
7

Invoke-Expression acepta un parámetro, "-Command", que es un string indicando la ruta al ejecutable. Si el ejecutable acepta parámetros, agréguelos en la ruta al EXE de la siguiente forma, en donde se utilizan como parámetros de entrada los números 3 y 4:

1$ExeFilePathWithParams =
2"C:ProjectsCalculatorCSharpExebinDebugnet6.0CalculatorCSharpxe.exe
33 4"
4
5Invoke-Expression -Command $ExeFilePathWithParams
6

Invoke-Expression es un cmdlet general que se puede utilizar también para ejecutar otros cmdlets e, inclusive, para ejecutar un script de PowerShell dentro de un script de PowerShell. Microsoft recomienda NO utilizar Invoke-Expression por razones de seguridad (https://learn.microsoft.com/en-us/powershell/scripting/learn/deep-dives/avoid-using-invoke-expression?view=powershell-7.2).

Cmdlet Start-Process

El cmdlet Start-Process (https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/start-process?view=powershell-7.2) inicia un proceso asincrónicamente en el computador local. El comando contiene varios parámetros para configurar las llamadas. De nuevo, el resultado puede ser renderizado de inmediato, o capturado en una variable para procesamiento posterior. El siguiente ejemplo muestra tres formas de utilización del cmdlet:

1$ExeFilePath =
2"C:ProjectsCalculatorCSharpExebinDebugnet6.0CalculatorCSharpExe.exe"
3
4Start-Process -FilePath $ExeFilePath -NoNewWindow -Wait
5
6Start-Process -FilePath $ExeFilePath -NoNewWindow -Wait -ArgumentList
7"5 6"
8
9$myResult02 = Start-Process -FilePath $ExeFilePath -NoNewWindow -Wait
10-ArgumentList "6","7"
11
12Write-Host $myResult02
13

"Start-Process" por defecto inicia una nueva pantalla de PowerShell para ejecutar el programa, lo ejecuta totalmente asincrónicamente y solo permite capturar los resultados en un archivo. En el ejemplo se utiliza el parámetro "-NoNewWindow" para obligar a ejecutar el programa en la misma ventana y el parámetro "-Wait" para hacer que ejecute sincrónicamente. Si se quieren utilizar input parámetros en la llamada al programa EXE, agréguelos al final del comando como una string, o como un string array, como muestra el ejemplo.

Cargar DLLs con Reflexión

PowerShell no solo permite hacer llamadas a un ejecutable (EXE), sino también a una Class Library (DLL) directamente. El siguiente ejemplo utiliza .Net Reflexión para convertir el DLL en un Byte Array, cargarlo en memoria, crear un objeto basado en la clase y ejecutar sus métodos (y, eventualmente, darles valores a sus propiedades, si es necesario):

1$DllFilePath =
2"C:ProjectsCalculatorCSharpDllbinDebugnet6.0CalculatorCSharpDll.dll"
3
4$CalculatorByte = [System.IO.File]::ReadAllBytes($DllFilePath)
5
6[System.Reflection.Assembly]::Load($CalculatorByte)
7
8$myCalculatorObjRefl = New-Object CalculatorCSharpDll.Calculator
9
10$myResult03 = $myCalculatorObjRefl.AddNumbers(10, 11)
11
12Write-Host $myResult03
13

Cargar DLLs con Add-Type

El cmdlet "Add-Type" permite también utilizar un DLL dentro de PowerShell. Este cmdlet simplifica la llamada por medio de reflexión mostrada en la sección anterior, pero, en realidad, realiza la misma función:

1$DllFilePath =
2"C:ProjectsCalculatorCSharpDllbinDebugnet6.0CalculatorCSharpDll.dll"
3
4Add-Type -Path $DllFilePath
5
6$myCalculatorObj = New-Object CalculatorCSharpDll.Calculator
7
8$myResult04 = $myCalculatorObj.AddNumbers(8, 9)
9
10Write-Host $myResult04
11

Compilar dinámicamente código de CSharp

Porque PowerShell es una aplicación .Net, tiene también la capacidad de trabajar con código manejado y hacer ejecución de código en memoria. Esto significa que el cmdlet "Add-Type" se puede utilizar para leer un string que contenga código de CSharp, compilarlo on-time y ejecutarlo, como muestra el siguiente ejemplo:

1$myCsCode = @"
2
3namespace CalculatorCSharpDll
4{
5 public class Calculator
6 {
7
8 public int AddNumbers(int FirstNumber, int SecondNumber)
9 {
10 int intReturn = 0;
11 intReturn = FirstNumber + SecondNumber;
12 return intReturn;
13 }
14 }
15}
16
17"@
18
19Add-Type -TypeDefinition $myCsCode -Language CSharp
20
21$myCalculatorObjComp = New-Object CalculatorCSharpDll.Calculator
22
23$myResult05 = $myCalculatorObjComp.AddNumbers(12, 13)
24
25Write-Host $myResult05
26

Si es necesario referenciar DLLs desde el código dinámico, defina las referencias en una variable y agregue la variable al cmdlet "Add-Type" por medio del parámetro "-ReferencedAssemblies":

1$myAssemblies =
2("System.Core","System.Xml.Linq","System.Data","System.Xml",
3"System.Data.DataSetExtensions", "Microsoft.CSharp")
4
5Add-Type -ReferencedAssemblies $myAssemblies -TypeDefinition $myCsCode
6-Language CSharp
7
8

Usando código de PowerShell en CSharp

Hay principalmente dos formas para ejecutar código de PowerShell en una aplicación de CSharp: usando el namespace System.Diagnostics.Process o utilizando el namespace System.Management.Automation.PowerShell. Hay algunas otras formas, pero son más workarounds que funcionalidad de Windows y CSharp (por ejemplo, usando directamente rundll32.exe y PowerShdll.dll, o utilizando SyncAppvPublishingServer.exe y SyncAppvPublishingServer.vbs en Windows 10).

Ejemplo de código de PowerShell

El siguiente ejemplo (también muy sencillo) de código fuente de PowerShell simula un calculador que recibe dos integers como parámetros de entrada y retorna su suma. Los parámetros no son validados para simplificar el código:

1Param($myArg01, $myArg02)
2
3if(($myArg01 -eq $null) -and ($myArg02 -eq $null)) {
4 Write-Host "No args"
5}
6else {
7 Write-Host([int]$myArg01 + [int]$myArg02)
8}
9

Usando namespace System.Diagnostics.Process

La clase "Process" permite iniciar cualquier proceso (.exe) de Windows desde CSharp. PowerShell es literalmente un .exe en el computador, por lo que se puede iniciar sin problemas con esta clase. Agregue una directiva a "using System.Diagnostics;" al principio del código.

1void CallPowerShellWithProcess()
2{
3string strPowerShellFilePath =
4 @"C:ProjectsCalculatorPowerShell.ps1";
5 if (File.Exists(strPowerShellFilePath))
6 {
7 Process myScript = new Process();
8 myScript.StartInfo.UseShellExecute = false;
9 myScript.StartInfo.RedirectStandardOutput = true;
10 myScript.StartInfo.FileName = "powershell.exe";
11 myScript.StartInfo.ArgumentList.Add(strPowerShellFilePath);
12 myScript.StartInfo.ArgumentList.Add("3 4");
13 myScript.StartInfo.ArgumentList.Add("-File");
14 myScript.StartInfo.ArgumentList.Add("-ExecutionPolicy unrestricted");
15 myScript.StartInfo.ArgumentList.Add("-NoProfile");
16 myScript.Start();
17 string myResult = myScript.StandardOutput.ReadToEnd();
18 myScript.WaitForExit();
19 Console.WriteLine(myResult);
20 }
21}
22

El código en esta rutina crea primero una instancia de System.Diagnostics.Process, y luego utiliza la clase "StartInfo" para configurar la llamada a PowerShell. Las únicas propiedades obligatorias de configurar son "RedirectStandardOutput" ("yes" para poder capturar en CSharp el resultado del script), "FileName" (la ruta completa al ejecutable de PowerShell, o solamente su nombre si PowerShell está configurado en las variables de Windows) y "ArgumentList" (para indicar la ruta al archivo .ps1 y los parámetros de entrada que PowerShell espera).

"UseShellExecute" no es obligatorio y tiene valores por defecto ("false" para .Net 7.x, "true" para el .Net Framework), pero si es necesario se puede forzar un cambio. Si la ruta al archivo .ps1 contiene espacios, utilice el argumento "-File" para indicarle al proceso que no codifique la ruta (y evitar que no pueda encuentrar el archivo en el computador local). Si las políticas de ejecución de PowerShell no han sido configuradas, puede forzar su cambio utilizando los parámetros "-ExecutionPolicy unrestricted" y "-NoProfile".

La ejecución del script ocurre asincrónicamente y se inicia con el método "Start". Para usar el método sincrónicamente, use el método "WaitForExit" como se muestra en el ejemplo. Finalmente, el resultado se puede leer desde el "StandardOutput", que es un objeto del tipo StreamReader.

Usando System.Management.Automation.PowerShell

Este namespace facilita de alguna forma la llamada de scripts de PowerShell y ofrece muchas más posibilidades especializadas, tales como la creación de RunSpaces de PowerShell, pero al mismo tiempo complica la llamada de los scripts pues es necesario configurar perfectamente la seguridad de PowerShell para evitar problemas con sus ExecutionPolicies.

La siguiente rutina no crea un RunSpace dedicado exclusivamente al script, sino que utiliza el RunSpace por defecto de PowerShell. El código requiere una directiva a "using System.Management.Automation;" y referencias a (que pueden ser agregadas con NuGets) "Microsoft.PowerShell.Commands.Diagnostics", "Microsoft.PowerShell.Commands.Management", "Microsoft.PowerShell.Commands.Utility", " Microsoft.PowerShell.ConsoleHost", "Microsoft.WSMan.Management" y "System.Management.Automation".

1void CallPowerShellWithAutomation()
2{
3 string strPowerShellFilePath =
4 @"C:ProjectsCalculatorPowerShell.ps1";
5 PowerShell myScript = PowerShell.Create();
6 myScript.AddCommand(strPowerShellFilePath).AddArgument("5 6");
7 myScript.AddCommand("Set-ExecutionPolicy").AddArgument("Unrestricted").AddParameter("Scope", "CurrentUser");
8
9 Collection<PSObject> myResults = myScript.Invoke();
10
11 foreach (PSObject oneItem in myResults)
12 {
13 if (oneItem != null)
14 {
15 Console.WriteLine(oneItem.BaseObject.ToString());
16 }
17 }
18}
19

El código en la rutina crea una instancia de System.Management.Automation.PowerShell y, usando el método "AddCommand" y "AddArgument", le indica primero la ruta al archivo .ps1 y los parámetros de entrada a utilizar. Luego, usando el mismo método, configura la ExecutionPolicy de PowerShell. Finalmente, el método "Invoke" ejecuta el script. El resultado de PowerShell se recoge en una colección de objetos del tipo "PSObject", que pueden ser leídos con un loop.

Este método se puede utilizar también para ejecutar cmdlets de PowerShell directamente. El siguiente ejemplo hace una llamada al cmdlet "Get-Process" de Windows que retorna una lista con todos los procesos que están ejecutando en el computador en el momento:

1void CallPowerShellCmdletWithAutomation()
2{
3 PowerShell myScript = PowerShell.Create().AddCommand("Get-Process");
4 Collection<PSObject> myResults = myScript.Invoke();
5 foreach (PSObject oneItem in myResults)
6 {
7 if (oneItem != null)
8 {
9 Console.WriteLine(oneItem.BaseObject.ToString());
10 }
11 }
12}
13

Conclusiones

Muchas veces es una excelente idea reutilizar código de CSharp en scripts de PowerShell y código de PowerShell en aplicaciones escritas con CSharp. Ambas opciones son posibles, y en más de una forma. Utilice los cmdlets "Invoke-Expression" o "Start-Process", cargue DLLs compilados por medio de Reflexión o con el cmdlet "Add-Type" para ejecutar código de CSharp desde PowerShell. Y desde CSharp, utilice "System.Diagnostics.Process" o "System.Management.Automation" para ejecutar código de PowerShell. Encontrar la mejor opción es más una cuestión de preferencias y rapidez de ejecución que un problema técnico. En cualquier caso, reutilización de código, inclusive entre diferentes tipos de lenguajes, agiliza, facilita y rebaja los costos en el proceso de creación de software.

Gustavo Velez
MVP M365 Apps & Services
https://guitaca.com
gustavo@gavd.net

Siguemos en LinkedInSiguemos en Twitter
Powered by  ENCAMINA