3 February 2017

Powershell и кирилица в консольных приложениях (updated)

System administrationPowerShellDevOps
Tutorial
В процессе разработки очень часто возникает необходимость запустить из powershell скрипта консольное приложение. Что может быть проще?

#test.ps1
& $PSScriptRoot\ConsoleApp.exe



Изучим поведение консольных приложений при запуске их из командной строки, через PowerShell и через PowerShell ISE:

Результат выполнения


В PowerShell ISE возникла проблема с кодировкой, так как ISE ожидает вывод в кодировке 1251. Воспользуемся гуглом и найдем два решения проблемы: c использованием [Console]::OutputEncoding и через powershell pipeline. Воспользуемся первым решением:

test2.ps1
$ErrorActionPreference = "Stop"

function RunConsole($scriptBlock)
{
    $encoding = [Console]::OutputEncoding 
    [Console]::OutputEncoding = [System.Text.Encoding]::GetEncoding("cp866")
    try
    {
        &$scriptBlock
    }
    finally
    {
        [Console]::OutputEncoding = $encoding
    }
}

RunConsole {
    & $PSScriptRoot\ConsoleApp1.exe
}


Результат выполнения


В командной строке все хорошо, а вот в ISE ошибка. Exception setting «OutputEncoding»: «The handle is invalid.». Снова берем в руки гугл, и в первом же результате находим решение — надо запустить какое-нибудь консольное приложение для создания консоли. Ну что-же, попробуем.

test3.ps1
$ErrorActionPreference = "Stop"

function RunConsole($scriptBlock)
{
    # Популярное решение "устранения" ошибки: Exception setting "OutputEncoding": "The handle is invalid."
    & cmd /c ver | Out-Null

    $encoding = [Console]::OutputEncoding 
    [Console]::OutputEncoding = [System.Text.Encoding]::GetEncoding("cp866")
    try
    {
        &$scriptBlock
    }
    finally
    {
        [Console]::OutputEncoding = $encoding
    }
}

RunConsole {
    & $PSScriptRoot\ConsoleApp1.exe
}


Результат выполнения


Все красиво, все работает. Кто читал мою прошлую заметку, обратил внимание, что WinRM приносит нам много острых впечатлений. Попробуем запустить тест через WinRM. Для запуска воспользуемся вот таким скриптом:

remote1.ps1
param($script)

$ErrorActionPreference = "Stop"

$s = New-PSSession "."
try
{
    $path = "$PSScriptRoot\$script"
    Invoke-Command -Session $s -ScriptBlock { &$using:path }
}
finally
{
    Remove-PSSession -Session $s
}



Результат выполнения


Что-то пошло не так. Решение с созданием консоли не работает. Ранее мы находили два решения проблемы кодировки. Попробуем второй:

test4.ps1
$ErrorActionPreference = "Stop"
#$VerbosePreference = "Continue"

function RunConsole($scriptBlock)
{
    function ConvertTo-Encoding ([string]$From, [string]$To)
    {
        Begin
        {
            $encFrom = [System.Text.Encoding]::GetEncoding($from)
            $encTo = [System.Text.Encoding]::GetEncoding($to)
        }
        Process
        {
            $bytes = $encTo.GetBytes($_)
            $bytes = [System.Text.Encoding]::Convert($encFrom, $encTo, $bytes)
            $encTo.GetString($bytes)
        }
    }

    Write-Verbose "RunConsole: Pipline mode"
    &$scriptBlock | ConvertTo-Encoding cp866 windows-1251 
}

RunConsole {
    & $PSScriptRoot\ConsoleApp1.exe
}


Результат выполнения


В ISE и через WinRM решение работает, а вот через командную строку и shell — нет.
Надо объединить эти два способа и проблема будет решена!

test5.ps1
$ErrorActionPreference = "Stop"
#$VerbosePreference = "Continue"

function RunConsole($scriptBlock)
{
    if([Environment]::UserInteractive)
    {
        # Популярное решение "устранения" ошибки: Exception setting "OutputEncoding": "The handle is invalid."
        & cmd /c ver | Out-Null

        $encoding = [Console]::OutputEncoding 
        [Console]::OutputEncoding = [System.Text.Encoding]::GetEncoding("cp866")

        try
        {
            Write-Verbose "RunConsole: Console.OutputEncoding mode"
            &$scriptBlock
            return
        }
        finally
        {
            [Console]::OutputEncoding = $encoding
        }
    }

    function ConvertTo-Encoding ([string]$From, [string]$To)
    {
        Begin
        {
            $encFrom = [System.Text.Encoding]::GetEncoding($from)
            $encTo = [System.Text.Encoding]::GetEncoding($to)
        }
        Process
        {
            $bytes = $encTo.GetBytes($_)
            $bytes = [System.Text.Encoding]::Convert($encFrom, $encTo, $bytes)
            $encTo.GetString($bytes)
        }
    }

    Write-Verbose "RunConsole: Pipline mode"
    &$scriptBlock | ConvertTo-Encoding cp866 windows-1251 
}

RunConsole {
    & $PSScriptRoot\ConsoleApp1.exe
}


Результат выполнения


Кажется, что проблема решена, но продолжим исследование и усложним наше консольное приложение, добавив в него вывод в stdError.

Результат выполнения


Становится все веселее :) В ISE исполнение скрипта прервалось на середине, а через WinRM мало того, что прервалось, так еще сообщение из stdErr прочитать невозможно. Первым шагом решим проблему с остановкой запускаемого из скрипта приложения, для этого перед запуском приложения изменим значение глобальной переменной $ErrorActionPreference.

test7.ps1
$ErrorActionPreference = "Stop"
#$VerbosePreference = "Continue"

function RunConsole($scriptBlock)
{
    if([Environment]::UserInteractive)
    {
        # Популярное решение "устранения" ошибки: Exception setting "OutputEncoding": "The handle is invalid."
        & cmd /c ver | Out-Null

        $encoding = [Console]::OutputEncoding 
        [Console]::OutputEncoding = [System.Text.Encoding]::GetEncoding("cp866")

        try
        {
            Write-Verbose "RunConsole: Console.OutputEncoding mode"
            $prevErrAction = $ErrorActionPreference
            $ErrorActionPreference = "Continue"
            try
            {
                &$scriptBlock
                return
            }
            finally
            {
                $ErrorActionPreference = $prevErrAction
            }
        }
        finally
        {
            [Console]::OutputEncoding = $encoding
        }
    }

    function ConvertTo-Encoding ([string]$From, [string]$To)
    {
        Begin
        {
            $encFrom = [System.Text.Encoding]::GetEncoding($from)
            $encTo = [System.Text.Encoding]::GetEncoding($to)
        }
        Process
        {
            $bytes = $encTo.GetBytes($_)
            $bytes = [System.Text.Encoding]::Convert($encFrom, $encTo, $bytes)
            $encTo.GetString($bytes)
        }
    }

    Write-Verbose "RunConsole: Pipline mode"
    $prevErrAction = $ErrorActionPreference
    $ErrorActionPreference = "Continue"
    try
    {
        &$scriptBlock | ConvertTo-Encoding cp866 windows-1251 
        return
    }
    finally
    {
        $ErrorActionPreference = $prevErrAction
    }
}

RunConsole {
    & $PSScriptRoot\ConsoleApp2.exe
}
Write-Host "ExitCode = $LASTEXITCODE"


Результат выполнения


Для тех что знает о существовании параметра -ErrorAction
error.cmd
echo error message 1>&2

errorActionTest.ps1
#error.cmd
#echo error message 1>&2

#errorActionTest.ps1
$ErrorActionPreference = "Stop"
Write-Host "before"
Invoke-Expression -ErrorAction SilentlyContinue -Command $PSScriptRoot\error.cmd
Write-Host "after"

Какой будет результат выполнения такого скрипта?

Вторым шагом доработаем скрипт удаленного запуска через WinRM, чтобы он не падал:

remote2.ps1
param($script)

$ErrorActionPreference = "Stop"

$s = New-PSSession "."
try
{
    $path = "$PSScriptRoot\$script"

    $err = @()
    $r = Invoke-Command -Session $s -ErrorAction Continue -ErrorVariable err -ScriptBlock `
    {
        $ErrorActionPreference = "Stop"
        & $using:path | Out-Host
        return $true
    } 

    if($r -ne $true)
    {
        Write-Error "The remote script was completed with an error"
    }

    if($err.length -ne 0)
    {
        Write-Warning "Error occurred on remote host"
    }
}
finally
{
    Remove-PSSession -Session $s
}


Результат выполнения


И осталось самое сложное — скорректировать сообщение формируемое через stdErr и при этом не изменить его положение в логе. В процессе решения этой задачи коллеги предложили самостоятельно создать консоль, воспользовавшись win api функцией AllocConsole.

test8.ps1
$ErrorActionPreference = "Stop"
#$VerbosePreference = "continue"

$consoleAllocated = [Environment]::UserInteractive
function AllocConsole()
{
    if($Global:consoleAllocated)
    {
        return
    }

    &cmd /c ver | Out-Null
    $a = @' 
[DllImport("kernel32", SetLastError = true)] 
public static extern bool AllocConsole(); 
'@

    $params = New-Object CodeDom.Compiler.CompilerParameters 
    $params.MainClass = "methods" 
    $params.GenerateInMemory = $true 
    $params.CompilerOptions = "/unsafe" 
 
    $r = Add-Type -MemberDefinition $a -Name methods -Namespace kernel32 -PassThru -CompilerParameters $params

    Write-Verbose "Allocating console"
    [kernel32.methods]::AllocConsole() | Out-Null
    Write-Verbose "Console allocated"
    $Global:consoleAllocated = $true
}

function RunConsole($scriptBlock)
{
    AllocConsole

    $encoding = [Console]::OutputEncoding 
    [Console]::OutputEncoding = [System.Text.Encoding]::GetEncoding("cp866")
    $prevErrAction = $ErrorActionPreference
    $ErrorActionPreference = "Continue"
    try
    {
        & $scriptBlock
    }
    finally
    {
        $ErrorActionPreference = $prevErrAction
        [Console]::OutputEncoding = $encoding
    }
}

RunConsole {
    & $PSScriptRoot\ConsoleApp2.exe
}
Write-Host "ExitCode = $LASTEXITCODE"




Избавится от информации, которую добавляет powershell к stdErr мне так и не удалось.

Надеюсь, что эта информация окажется полезной не только мне! :)

update 1
В некоторых сценариях использования создавалась дополнительная консоль, в которую выдавался результат выполнения скриптов. В скрипт test8.ps1 внесены исправления.

update 2
Так как у многих комментаторов статьи возникла путаница между понятиями набор символов (char set) и кодировка (encoding) хотел бы еще раз обратить внимание, что в статье решается проблема именно несоответствия кодировок консоли и вызываемого приложения.

Как можно увидеть из скрипта test8.ps1, кодировка указывается в статическом свойстве [Console]::OutputEncoding, и никто не мешает указать в нем одну из кодировок семейства unicode:
[Console]::OutputEncoding = [System.Text.Encoding]::GetEncoding("utf-8")

Но, для работы скрипта в стандартной консоли windows (aka cmd.exe) необходимо изменить шрифт консоли со стандартного «Rasters Fonts» на Consolas или «Lucida Console». Если бы данные скрипты мы использовали только на собственных рабочих станциях или серверах, то такое изменение было бы допустимо, но так как нам приходится распространять наши решения заказчикам, вмешиваться в системные настройки серверов мы не имеем права. Именно по этой причине в скриптах используется cp866, как кодировка настроенная по умолчанию для консоли.
Tags:PowerShellDevOpsWinRMEncoding
Hubs: System administration PowerShell DevOps
+12
70.4k 92
Comments 37