Powershell обработка ошибок выполнения

В Powershell существует несколько уровней ошибок и несколько способов их обработать. Проблемы одного уровня (Non-Terminating Errors) можно решить с помощью привычных для Powershell команд. Другой уровень ошибок (Terminating Errors) решается с помощью исключений (Exceptions) стандартного, для большинства языков, блока в виде Try, Catch и Finally. 

Как Powershell обрабатывает ошибки

До рассмотрения основных методов посмотрим на теоретическую часть.

Автоматические переменные $Error

В Powershell существует множество переменных, которые создаются автоматически. Одна из таких переменных — $Error хранит в себе все ошибки за текущий сеанс PS. Например так я выведу количество ошибок и их сообщение за весь сеанс:

Get-TestTest
$Error
$Error.Count

Переменная $Error в Powershell

При отсутствии каких либо ошибок мы бы получили пустой ответ, а счетчик будет равняться 0:

Счетчик ошибок с переменной $Error в Powershell

Переменная $Error являет массивом и мы можем по нему пройтись или обратиться по индексу что бы найти нужную ошибку:

$Error[0]

foreach ($item in $Error){$item}

Вывод ошибки по индексу в Powershell c $Error

Свойства объекта $Error

Так же как и все что создается в Powershell переменная $Error так же имеет свойства (дополнительную информацию) и методы. Названия свойств и методов можно увидеть через команду Get-Member:

$Error | Get-Member

Свойства переменной $Error в Powershell

Например, с помощью свойства InvocationInfo, мы можем вывести более структурный отчет об ошибки:

$Error[0].InvocationInfo

Детальная информация об ошибке с $Error в Powershell

Методы объекта $Error

Например мы можем очистить логи ошибок используя clear:

$Error.clear()

Очистка логов об ошибке в Powershell с $Error

Критические ошибки (Terminating Errors)

Критические (завершающие) ошибки останавливают работу скрипта. Например это может быть ошибка в названии командлета или параметра. В следующем примере команда должна была бы вернуть процессы «svchost» дважды, но из-за использования несуществующего параметра ‘—Error’ не выполнится вообще:

'svchost','svchost' | % {Get-Process -Name $PSItem} --Error 

Критические ошибки в Powershell Terminating Errors

Не критические ошибки (Non-Terminating Errors)

Не критические (не завершающие) ошибки не остановят работу скрипта полностью, но могут вывести сообщение об этом. Это могут быть ошибки не в самих командлетах Powershell, а в значениях, которые вы используете. На предыдущем примере мы можем допустить опечатку в названии процессов, но команда все равно продолжит работу:

'svchost111','svchost' | % {Get-Process -Name $PSItem}

Не критические ошибки в Powershell Non-Terminating Errors

Как видно у нас появилась информация о проблеме с первым процессом ‘svchost111’, так как его не существует. Обычный процесс ‘svchost’ он у нас вывелся корректно.

Параметр ErrorVariable

Если вы не хотите использовать автоматическую переменную $Error, то сможете определять свою переменную индивидуально для каждой команды. Эта переменная определяется в параметре ErrorVariable:

'svchost111','svchost' | % {Get-Process -Name $PSItem } -ErrorVariable my_err_var
$my_err_var

Использование ErrorVariable в Powershell

Переменная будет иметь те же свойства, что и автоматическая:

$my_err_var.InvocationInfo

Свойства  ErrorVariable в Powershell

Обработка некритических ошибок

У нас есть два способа определения последующих действий при ‘Non-Terminating Errors’. Это правило можно задать локально и глобально (в рамках сессии). Мы сможем полностью остановить работу скрипта или вообще отменить вывод ошибок.

Приоритет ошибок с $ErrorActionPreference

Еще одна встроенная переменная в Powershell $ErrorActionPreference глобально определяет что должно случится, если у нас появится обычная ошибка. По умолчанию это значение равно ‘Continue’, что значит «вывести информацию об ошибке и продолжить работу»:

$ErrorActionPreference

Определение $ErrorActionPreference в Powershell

Если мы поменяем значение этой переменной на ‘Stop’, то поведение скриптов и команд будет аналогично критичным ошибкам. Вы можете убедиться в этом на прошлом скрипте с неверным именем процесса:

$ErrorActionPreference = 'Stop'
'svchost111','svchost' | % {Get-Process -Name $PSItem}

Определение глобальной переменной $ErrorActionPreference в Powershell

Т.е. скрипт был остановлен в самом начале. Значение переменной будет храниться до момента завершения сессии Powershell. При перезагрузке компьютера, например, вернется значение по умолчанию.

Ниже значение, которые мы можем установить в переменной $ErrorActionPreference:

  • Continue — вывод ошибки и продолжение работы;
  • Inquire — приостановит работу скрипта и спросит о дальнейших действиях;
  • SilentlyContinue — скрипт продолжит свою работу без вывода ошибок;
  • Stop — остановка скрипта при первой ошибке.

Самый частый параметр, который мне приходится использовать — SilentlyContinue:

$ErrorActionPreference = 'SilentlyContinue'
'svchost111','svchost' | % {Get-Process -Name $PSItem}

Игнорирование ошибок в Powershell с ErrorActionPreference и SilentlyContinue

Использование параметра ErrorAction

Переменная $ErrorActionPreference указывает глобальный приоритет, но мы можем определить такую логику в рамках команды с параметром ErrorAction. Этот параметр имеет больший приоритет чем $ErrorActionPreference. В следующем примере, глобальная переменная определяет полную остановку скрипта, а в параметр ErrorAction говорит «не выводить ошибок и продолжить работу»:

$ErrorActionPreference = 'Stop'
'svchost111','svchost' | % {Get-Process -Name $PSItem -ErrorAction 'SilentlyContinue'}

Использование параметра ErrorAction в ошибках с Powershell

Кроме ‘SilentlyContinue’ мы можем указывать те же параметры, что и в переменной $ErrorActionPreference. 

Значение Stop, в обоих случаях, делает ошибку критической.

Обработка критических ошибок и исключений с Try, Catch и Finally

Когда мы ожидаем получить какую-то ошибку и добавить логику нужно использовать Try и Catch. Например, если в вариантах выше мы определяли нужно ли нам отображать ошибку или останавливать скрипт, то теперь сможем изменить выполнение скрипта или команды вообще. Блок Try и Catch работает только с критическими ошибками и в случаях если $ErrorActionPreference или ErrorAction имеют значение Stop.

Например, если с помощью Powershell мы пытаемся подключиться к множеству компьютеров один из них может быть выключен — это приведет к ошибке. Так как эту ситуацию мы можем предвидеть, то мы можем обработать ее. Процесс обработки ошибок называется исключением (Exception).

Синтаксис и логика работы команды следующая:

try {
    # Пытаемся подключиться к компьютеру
}
catch [Имя исключения 1],[Имя исключения 2]{
    # Раз компьютер не доступен, сделать то-то
}
finally {
    # Блок, который выполняется в любом случае последним
}

Блок try мониторит ошибки и если она произойдет, то она добавится в переменную $Error и скрипт перейдет к блоку Catch. Так как ошибки могут быть разные (нет доступа, нет сети, блокирует правило фаервола и т.д.) то мы можем прописывать один блок Try и несколько Catch:

try {
    # Пытаемся подключится
}
catch ['Нет сети']['Блокирует фаервол']{
    # Записываем в файл
}
catch ['Нет прав на подключение']{
    # Подключаемся под другим пользователем
}

Сам блок finally — не обязательный и используется редко. Он выполняется самым последним, после try и catch и не имеет каких-то условий.

Catch для всех типов исключений

Как и было показано выше мы можем использовать блок Catch для конкретного типа ошибок, например при проблемах с доступом. Если в этом месте ничего не указывать — в этом блоке будут обрабатываться все варианты ошибок:

try {
   'svchost111','svchost' | % {Get-Process -Name $PSItem -ErrorAction 'Stop'}
}
catch {
   Write-Host "Какая-то неисправность" -ForegroundColor RED
}

Игнорирование всех ошибок с try и catch в Powershell

Такой подход не рекомендуется использовать часто, так как вы можете пропустить что-то важное.

Мы можем вывести в блоке catch текст ошибки используя $PSItem.Exception:

try {
   'svchost111','svchost' | % {Get-Process -Name $PSItem -ErrorAction 'Stop'}
}
catch {
   Write-Host "Какая-то неисправность" -ForegroundColor RED
   $PSItem.Exception
}

Переменная PSITem в блоке try и catch в Powershell

Переменная $PSItem хранит информацию о текущей ошибке, а глобальная переменная $Error будет хранит информацию обо всех ошибках. Так, например, я выведу одну и ту же информацию:

$Error[0].Exception

Вывод сообщения об ошибке в блоке try и catch в Powershell

Создание отдельных исключений

Что бы обработать отдельную ошибку сначала нужно найти ее имя. Это имя можно увидеть при получении свойств и методов у значения переменной $Error:

$Error[0].Exception | Get-Member

Поиск имени для исключения ошибки в Powershell

Так же сработает и в блоке Catch с $PSItem:

Наименование ошибок для исключений в Powershell

Для вывода только имени можно использовать свойство FullName:

$Error[0].Exception.GetType().FullName

Вывод типа ошибок и их названия в Powershell

Далее, это имя, мы вставляем в блок Catch:

try {
   'svchost111','svchost' | % {Get-Process -Name $PSItem -ErrorAction 'Stop'}
}
catch [Microsoft.PowerShell.Commands.ProcessCommandException]{
   Write-Host "Произошла ошибка" -ForegroundColor RED
   $PSItem.Exception
}

Указываем исключение ошибки в блоке Try Catch Powershell

Так же, как и было описано выше мы можем усложнять эти блоки как угодно указывая множество исключений в одном catch.

Выброс своих исключений

Иногда нужно создать свои собственные исключения. Например мы можем запретить добавлять через какой-то скрипт названия содержащие маленькие буквы или сотрудников без указания возраста и т.д. Способов создать такие ошибки — два и они тоже делятся на критические и обычные.

Выброс с throw

Throw — выбрасывает ошибку, которая останавливает работу скрипта. Этот тип ошибок относится к критическим. Например мы можем указать только текст для дополнительной информации:

$name = 'AD.1'

if ($name -match '.'){
   throw 'Запрещено использовать точки в названиях'
}

Выброс ошибки с throw в Powershell

Если нужно, то мы можем использовать исключения, которые уже были созданы в Powershell:

$name = 'AD.1'

if ($name -like '*.*'){
   throw [System.IO.FileNotFoundException]'Запрещено использовать точки в названиях'
}

Выброс ошибки с throw в Powershell

Использование Write-Error

Команда Write-Error работает так же, как и ключ ErrorAction. Мы можем просто отобразить какую-то ошибку и продолжить выполнение скрипта:

$names = @('CL1', 'AD.1', 'CL3')

foreach ($name in $names){
   if ($name -like '*.*'){
      Write-Error -Message 'Обычная ошибка'
   }
   else{
      $name
   }
}

Использование Write-Error для работы с исключениями в Powershell

При необходимости мы можем использовать параметр ErrorAction. Значения этого параметра были описаны выше. Мы можем указать значение ‘Stop’, что полностью остановит выполнение скрипта:

$names = @('CL1', 'AD.1', 'CL3')

foreach ($name in $names){
   if ($name -like '*.*'){
      Write-Error -Message 'Обычная ошибка' -ErrorAction 'Stop'
   }
   else{
      $name
   }
}

Использование Write-Error и Stop в Powershell

Отличие команды Write-Error с ключом ErrorAction от обычных команд в том, что мы можем указывать исключения в параметре Exception:

Write-Error -Message 'Обычная ошибка' -ErrorAction 'Stop'

Write-Error -Message 'Исключение' -Exception [System.IO.FileNotFoundException] -ErrorAction 'Stop'

Свойства Write-Errror в Powershell

В Exception мы так же можем указывать сообщение. При этом оно будет отображаться в переменной $Error:

Write-Error -Exception [System.IO.FileNotFoundException]'Моё сообщение'

Свойства Write-Errror в Powershell 

Теги:

#powershell

#ошибки

Вообще я редко вижу смысл в том чтобы отлавливать ошибки в скриптах, но недавно ко мне попалась задача, где необходимо было обработать ошибки в скрипте PowerShell. Дело в том что данный скрипт использовался как часть работы System Center Orchestrator. Для этого я использовал Try/Catch/Finaly . Но все по порядку.

Немного про ошибки

Ошибки можно условно разделить на две больших категории.

  1. Прерывающие
  2. Не прерывающие

Первые завершают выполнение конвейера с ошибкой. Т.е. дальнейшие команды в конвейере не выполняются. Например, вы не правильно указали имя команды. Вторая категория лишь генерирует ошибку, но конвейер продолжает выполняться далее. Например, вы запросили содержимое не существующего каталога.

В любом случае всю информацию о всех ошибках можно поглядеть в переменной $error. Последняя ошибка идет с индексом 0, т.е. $error[0] — покажет последнюю ошибку. А $error[0].Exception описание последней ошибки.

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

PS C:> Get-Command NoCommand,Dir

Get-Command : Имя «NoCommand» не распознано как имя командлета, функции, файла сценария или выполняемой программы. Пров

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

строка:1 знак:1

+ Get-Command NoCommand,Dir

+ ~~~~~~~~~~~~~~~~~~~~~~~~~

    + CategoryInfo          : ObjectNotFound: (NoCommand:String) [Get-Command], CommandNotFoundException

    + FullyQualifiedErrorId : CommandNotFoundException,Microsoft.PowerShell.Commands.GetCommandCommand

CommandType     Name                                               ModuleName

                                                   

Alias           dir -> Get-ChildItem

PS C:> $error[0]

Get-Command : Имя «NoCommand» не распознано как имя командлета, функции, файла сценария или выполняемой программы. Пров

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

строка:1 знак:1

+ Get-Command NoCommand,Dir

+ ~~~~~~~~~~~~~~~~~~~~~~~~~

    + CategoryInfo          : ObjectNotFound: (NoCommand:String) [Get-Command], CommandNotFoundException

    + FullyQualifiedErrorId : CommandNotFoundException,Microsoft.PowerShell.Commands.GetCommandCommand

PS C:> $error[0].Exception

Имя «NoCommand» не распознано как имя командлета, функции, файла сценария или выполняемой программы. Проверьте правильн

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

Этот же пример, но в виде рисунка для наглядности.

Переменная Error

Для того чтобы выяснить была ли в какой-то части кода ошибка необходимо использовать Try. Это помогает избавиться от неожиданного и некорректного завершение вашего скрипта. Позволяя так же корректно завершить его работу.

Синтаксис выглядит в общем случае так.

Try

{

  часть кода в которой ищем ошибку

}

Catch

{

[тип ошибки, которую ищем] код,

     который будет выполнен когда ошибка будет найдена

}

Finally

{

код, который будет выполнен в любом случае

}

Однако использование Finally и определение типа ошибки — опционально и может не использоваться.

Для проверки напишем вот такой код где используем Try в PowerShell для того чтобы обработать ошибку деления на ноль и вывести информацию об ошибке.

try

{

[float](4/0)

}

catch

{

  Write-Host «Error!!!»

  Write-Host $error[0].Exception

}

Как видно я произвожу деление на ноль в блоке try, т.е. совершаю прерывающую ошибку. Только прерывающие ошибку будут отловлены. А далее в блоке catch произвожу уже необходимые отладочные действия, т.е. произвожу обработку ошибки.. В моей исходной задаче я в данном блоке заносил информацию об ошибках в специальный лог файл, но давайте попробуем запустить мой данный пример.

PS C:> .test.ps1

Error!!!

System.Management.Automation.RuntimeException: Попытка деления на нуль. -> System.DivideByZeroException: Попытка делен

ия на нуль.

   Конец трассировки внутреннего стека исключений

   в System.Management.Automation.ExceptionHandlingOps.CheckActionPreference(FunctionContext funcContext, Exception exce

ption)

   в System.Management.Automation.Interpreter.ActionCallInstruction`2.Run(InterpretedFrame frame)

   в System.Management.Automation.Interpreter.EnterTryCatchFinallyInstruction.Run(InterpretedFrame frame)

   в System.Management.Automation.Interpreter.EnterTryCatchFinallyInstruction.Run(InterpretedFrame frame)

Увы в блоке try в PowerShell должна присутствовать прерывающая ошибка.

Преобразуем не прерывающую ошибку в прерывающую

Существует общий параметр для всех командлетов в PowerShell -ErrorAction. Данный параметр может принимать четыре значения

  • Continue — выводит ошибку и продолжает выполнение
  • SilentlyContinue — не выводит ошибку и продолжает выполнение
  • Stop — завершает выполнение
  • Inquire — спрашивает у пользователя как поступить

В нашем случае подходит действие stop. Если использовать параметр со значением -ErrorAction Stop, то при возникновении ошибки при выполнении команды, данная ошибка прерывает выполнение команды. Ниже пример скрипта, использующего -ErrorAction Stop.

try

{

  Dir NoFolder -ErrorAction Stop

}

catch

{

  Write-Host «Error!!!»

  Write-Host $error[0].Exception

}

Ниже результат выполнения данного скрипта

PS C:> .test.ps1

Error!!!

System.Management.Automation.ItemNotFoundException: Не удается найти путь «C:NoFolder»,

так как он не существует.

   в System.Management.Automation.SessionStateInternal.GetChildItems(String path, Boolean recurse, C

mdletProviderContext context)

   в Microsoft.PowerShell.Commands.GetChildItemCommand.ProcessRecord()

В моей исходной задаче try в PowerShell необходимо было использовать для всех команд в скрипте. Поскольку обработка ошибки была общей для любой ошибки в скрипте весь скрипт можно поместить в один Try, конечно это не совсем правильно, но зато просто. Чтобы каждый раз не писать -ErrorAction Stop. Можно воспользоваться переменной $ErrorActionPreference, которая имеет те же значения и сходна по действию, однако распространяет свое действие на все командлеты в скрипте.

$ErrorActionPreference = «stop»

try

{

  Dir Folder

  Get-Process -ComputerName TestPC

}

catch

{

  Write-Host «Error!!!»

  Write-Host $error[0].Exception

}

Вместо заключения

Конечно, по началу вы мало будете задумываться об поиске ошибок, используя множество условных конструкций вы их минимизируете. Однако использование try в PowerShell позволит минимизировать случаи неожиданного завершения скрипта.

description Locale ms.date online version schema title

Describes how to use the `try`, `catch`, and `finally` blocks to handle terminating errors.

en-US

11/12/2021

https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_try_catch_finally?view=powershell-7.2&WT.mc_id=ps-gethelp

2.0.0

about Try Catch Finally

Short description

Describes how to use the try, catch, and finally blocks to handle
terminating errors.

Long description

Use try, catch, and finally blocks to respond to or handle terminating
errors in scripts. The Trap statement can also be used to handle terminating
errors in scripts. For more information, see about_Trap.

A terminating error stops a statement from running. If PowerShell does not
handle a terminating error in some way, PowerShell also stops running the
function or script using the current pipeline. In other languages, such as C#,
terminating errors are referred to as exceptions.

Use the try block to define a section of a script in which you want
PowerShell to monitor for errors. When an error occurs within the try block,
the error is first saved to the $Error automatic variable. PowerShell then
searches for a catch block to handle the error. If the try statement does
not have a matching catch block, PowerShell continues to search for an
appropriate catch block or Trap statement in the parent scopes. After a
catch block is completed or if no appropriate catch block or Trap
statement is found, the finally block is run. If the error cannot be handled,
the error is written to the error stream.

A catch block can include commands for tracking the error or for recovering
the expected flow of the script. A catch block can specify which error types
it catches. A try statement can include multiple catch blocks for different
kinds of errors.

A finally block can be used to free any resources that are no longer needed
by your script.

try, catch, and finally resemble the try, catch, and finally
keywords used in the C# programming language.

Syntax

A try statement contains a try block, zero or more catch blocks, and zero
or one finally block. A try statement must have at least one catch block
or one finally block.

The following shows the try block syntax:

The try keyword is followed by a statement list in braces. If a terminating
error occurs while the statements in the statement list are being run, the
script passes the error object from the try block to an appropriate catch
block.

The following shows the catch block syntax:

catch [[<error type>][',' <error type>]*] {<statement list>}

Error types appear in brackets. The outermost brackets indicate the element is
optional.

The catch keyword is followed by an optional list of error type
specifications and a statement list. If a terminating error occurs in the
try block, PowerShell searches for an appropriate catch block. If
one is found, the statements in the catch block are executed.

The catch block can specify one or more error types. An error type is a
Microsoft .NET Framework exception or an exception that is derived from a .NET
Framework exception. A catch block handles errors of the specified .NET
Framework exception class or of any class that derives from the specified
class.

If a catch block specifies an error type, that catch block handles that
type of error. If a catch block does not specify an error type, that catch
block handles any error encountered in the try block. A try statement can
include multiple catch blocks for the different specified error types.

The following shows the finally block syntax:

finally {<statement list>}

The finally keyword is followed by a statement list that runs every time the
script is run, even if the try statement ran without error or an error was
caught in a catch statement.

Note that pressing CTRL+C stops the pipeline. Objects
that are sent to the pipeline will not be displayed as output. Therefore, if
you include a statement to be displayed, such as «Finally block has run», it
will not be displayed after you press CTRL+C, even if the
finally block ran.

Catching errors

The following sample script shows a try block with a catch block:

try { NonsenseString }
catch { "An error occurred." }

The catch keyword must immediately follow the try block or another catch
block.

PowerShell does not recognize «NonsenseString» as a cmdlet or other item.
Running this script returns the following result:

When the script encounters «NonsenseString», it causes a terminating error. The
catch block handles the error by running the statement list inside the block.

Using multiple catch statements

A try statement can have any number of catch blocks. For example, the
following script has a try block that downloads MyDoc.doc, and it contains
two catch blocks:

try {
   $wc = new-object System.Net.WebClient
   $wc.DownloadFile("http://www.contoso.com/MyDoc.doc","c:tempMyDoc.doc")
}
catch [System.Net.WebException],[System.IO.IOException] {
    "Unable to download MyDoc.doc from http://www.contoso.com."
}
catch {
    "An error occurred that could not be resolved."
}

The first catch block handles errors of the System.Net.WebException and
System.IO.IOException types. The second catch block does not specify an
error type. The second catch block handles any other terminating errors that
occur.

PowerShell matches error types by inheritance. A catch block handles errors
of the specified .NET Framework exception class or of any class that derives
from the specified class. The following example contains a catch block that
catches a «Command Not Found» error:

catch [System.Management.Automation.CommandNotFoundException]
{"Inherited Exception" }

The specified error type, CommandNotFoundException, inherits from the
System.SystemException type. The following example also catches a Command
Not Found error:

catch [System.SystemException] {"Base Exception" }

This catch block handles the «Command Not Found» error and other errors that
inherit from the SystemException type.

If you specify an error class and one of its derived classes, place the catch
block for the derived class before the catch block for the general class.

[!NOTE]
PowerShell wraps all exceptions in a RuntimeException type. Therefore,
specifying the error type System.Management.Automation.RuntimeException
behaves the same as an unqualified catch block.

Using Traps in a Try Catch

When a terminating error occurs in a try block with a Trap defined within
the try block, even if there is a matching catch block, the Trap statement
takes control.

If a Trap exists at a higher block than the try, and there is no matching
catch block within the current scope, the Trap will take control, even if
any parent scope has a matching catch block.

Accessing exception information

Within a catch block, the current error can be accessed using $_, which
is also known as $PSItem. The object is of type ErrorRecord.

try { NonsenseString }
catch {
  Write-Host "An error occurred:"
  Write-Host $_
}

Running this script returns the following result:

An Error occurred:
The term 'NonsenseString' is not recognized as the name of a cmdlet, function,
script file, or operable program. Check the spelling of the name, or if a path
was included, verify that the path is correct and try again.

There are additional properties that can be accessed, such as ScriptStackTrace,
Exception, and ErrorDetails. For example, if we change the script to the
following:

try { NonsenseString }
catch {
  Write-Host "An error occurred:"
  Write-Host $_.ScriptStackTrace
}

The result will be similar to:

An Error occurred:
at <ScriptBlock>, <No file>: line 2

Freeing resources using finally

To free resources used by a script, add a finally block after the try and
catch blocks. The finally block statements run regardless of whether the
try block encounters a terminating error. PowerShell runs the finally block
before the script terminates or before the current block goes out of scope.

A finally block runs even if you use CTRL+C to stop the
script. A finally block also runs if an Exit keyword stops the script from
within a catch block.

See also

  • about_Break
  • about_Continue
  • about_Scopes
  • about_Throw
  • about_Trap

Продолжаем тему обработки ошибок в PowerShell, начатую в предыдущей статье. Сегодня речь пойдет об обработке прерывающих ошибок (исключений). Надо понимать, что сообщение об ошибке — это не то же самое, что исключение. Как вы помните, в PowerShell есть два типа ошибок — непрерывающие и прерывающие.

Непрерывающие ошибки позволяют продолжить работу, тогда как прерывающие останавливают выполнение команды. Остановка приводит к исключению (exception), которое и можно отлавливать и обрабатывать.

Примечание. Таким же образом можно обрабатывать и непрерывающие ошибки. Изменение параметра ErrorAction на Stop прервет выполнение команды и произведет исключение, которое можно уловить.

Для наглядности сгенерируем ошибку, попытавшись прочитать файл, на который у нас нет прав. А теперь обратимся к переменной $Error и выведем данные об исключении. Как видите, данное исключение имеет тип UnauthorizedAccessException и относится к базовому типу System.SystemException.

определение исключения

Для обработки исключений в PowerShell есть несколько способов, которые мы сегодня и рассмотрим.

Try/Catch/Finally

Конструкция Try/Catch/Finally предназначена для обработки исключений, возникающих в процессе выполнения скрипта. В блоке Try располагается исполняемый код, в котором должны отслеживаться ошибки. При возникновении в блоке Try прерывающей ошибки оболочка PowerShell ищет соответствующий блок Catch для обработки этой ошибки, и если он найден, то выполняет находящиеся в нем инструкции. Блок Catch может включать в себя любые команды, необходимые для обработки возникнувшей ошибки иили восстановления дальнейшей работы скрипта.

Блок Finally располагается обычно в конце скрипта. Команды, находящиеся в нем, выполняются в любом случае, независимо от возникновения ошибок. Была ли  ошибка перехвачена и обработана блоком Catch или при выполнении скрипта ошибок не возникало вообще, блок Finally будет выполнен. Присутствие этого блока в скрипте необязательно, основная его задача — высвобождение ресурсов (закрытие процессов, очистка памяти и т.п.).

В качестве примера в блок Try поместим код, который читает файлы из указанной директории. При возникновении проблем блок Catch выводит сообщение об ошибке, после чего отрабатывает блок Finally и работа скрипта завершается:

try {
Get-Content -Path ″C:Files*″ -ErrorAction Stop
}
catch {
Write-Host ″Some error was found.″
}
finally {
Write-Host ″Finish.″
}

обработка общей ошибки с помощью trycatch

Для блока Catch можно указать конкретный тип ошибки, добавив после ключевого слова Catch в квадратных скобках название исключения. Так в следующем примере укажем в качестве типа исключение System.UnauthorizedAccessException, которое возникает при отсутствии необходимых прав доступа к объекту:

try {
Get-Content -Path ″C:Files*″ -ErrorAction Stop
}
catch [System.UnauthorizedAccessException]
{
Write-Host ″File is not accessible.″
}
finally {
Write-Host ″Finish.″
}

обработка конкретного исключения в trycatch

Если для блока Catch указан тип ошибки, то этот блок будет обрабатывать только этот тип ошибок, или тип ошибок, наследуемый от указанного типа. При возникновении другого типа ошибка не будет обработана. Если же тип не указан, то блок будет обрабатывать любые типы ошибок, возникающие в блоке Try.

Блок Try может включать несколько блоков Catch, для разных типов ошибок, соответственно можно для каждого типа ошибки задать свой обработчик. Например, возьмем два блока Catch, один для ошибки при отсутствии прав доступа, а второй — для любых других ошибок, которые могут возникнуть в процессе выполнения скрипта:

try {
Get-Content -Path ″C:Files*″ -ErrorAction Stop
}
catch [System.UnauthorizedAccessException]
{
Write-Host ″File is not accessible.″
}
catch {
Write-Host ″Other type of error was found:″
Write-Host ″Exception type is $($_.Exception.GetType().Name)″
}
finally {
Write-Host ″Finish.″
}

обработка нескольких исключений в trycatch

Trap

Еще один способ обработки ошибок — это установка ловушки (trap). В этом варианте обработчик ошибок задается с помощью ключевого слова Trap, определяющего список команд, которые должны выполниться при возникновении прерывающей ошибки и исключения. Когда происходит исключение, то оболочка PowerShell ищет в коде инструкции Trap для данной ошибки, и если находит, то выполняет их.

Возьмем наиболее простой пример ловушки, которая обрабатывает все произошедшие исключения и выводит сообщение об ошибке:

trap {
Write-Host ″Error was found.″
}
Get-Content -Path C:File* -ErrorAction Stop

обработка исключения с помощью trap

Так же, как и для trycatch, ключевое слово Trap позволяет задавать тип исключения, указав его в квадратных скобках. К примеру, можно задать в скрипте несколько ловушек, одну нацелив на конкретную ошибку, а вторую на обработку оставшихся:

trap [System.Management.Automation.ItemNotFoundException]
{
Write-Host ″File is not accessible.″
break
}
trap {
Write-Host ″Other error was found.″
continue
}
Get-Content -Path C:File* -ErrorAction Stop

Вместе с Trap можно использовать ключевые слова Break и Continue, которые позволяют определить, должен ли скрипт продолжать выполняться после возникновения прерывающей ошибки. По умолчанию при возникновении исключения выполняются команды, входящие в блок Trap, выводится информация об ошибке, после чего выполнение скрипта продолжается со строки, вызвавшей ошибку:

trap {
Write-Host ″Error was found.″
}
Write-Host ″Before error.″
Get-Content -Path C:File* -ErrorAction Stop
Write-Host ″After error.″

обработка исключения с помощью trap без доп. параметров

Если использовать ключевое слово Break, то при возникновении ошибки будет выполнены команды в блоке Trap, после чего выполнение скрипта будет прервано:

trap {
Write-Host ″Error was found.″
break
}
Write-Host ″Before error.″
Get-Content -Path C:File* -ErrorAction Stop
Write-Host ″After error.″

Как  видно из примера, команда, следующая за исключением, не отработала и сообщение ″After error.″ выведено не было.

обработка исключения с помощью trap с параметром break

Если же в Trap включено ключевое слово Continue, то выполнение скрипта будет продолжено, так же как и в случае по умолчанию. Единственное отличие в том, что с Continue ошибка не записывается в поток Error и не выводится на экран.

trap {
Write-Host ″Error was found.″
continue
}
Write-Host ″Before error.″
Get-Content -Path C:File* -ErrorAction Stop
Write-Host ″After error.″

обработка исключения с помощью trap с параметром continue

Область действия

При отлове ошибок с помощью Trap надо помнить о такой вещи, как область действия (scope). Оболочка PowerShell является глобальной областью, в которую входят все процессы, запущенный скрипт получает собственную область, а если внутри скрипта определены функции, то внутри каждой определена своя, частная область действия. Это создает своего рода родительско-дочернюю иерархию.

При появлении исключения оболочка ищет ловушку в текущей области. Если в ней есть ловушка, она выполняется, если нет — то производится выход в вышестоящую область и поиск ловушки там. Когда ловушка находится, то происходит ее выполнение. Затем, если ловушка предусматривает продолжение работы, то оболочка возобновляет выполнение кода, начиная с той строки, которая вызвала исключение, но при этом оставаясь в той же области, не возвращаясь обратно.

Для примера возьмем функцию Content, внутри которой будет выполняться наш код. Область действия внутри функции назовем scope 1, а снаружи scope 2:

trap {
Write-Host ″Error was found.″
continue
}
function Content {
Write-Host ″Before error, scope1.″
Get-Content -Path C:File* -ErrorAction Stop
Write-Host ″After error, scope 1.″
}
Content
Write-Host ″After error, scope 2.″

При возникновении ошибки в функции Content оболочка будет искать ловушку внутри нее. Затем, не найдя ловушку внутри функции, оболочка выйдет из текущей области и будет искать в родительской области. Ловушка там есть, поэтому будет выполнена обработка ошибки, после чего Continue возобновит выполнение скрипта, но уже в родительской области (scope 2), не возвращаясь обратно в функцию (scope 1).

область действия, вариант 1

А теперь немного изменим скрипт, поместив ловушку внутрь функции:

function Content {
trap {
Write-Host ″Error was found.″
continue
}
Write-Host ″Before error, scope 1.″
Get-Content -Path C:File* -ErrorAction Stop
Write-Host ″After error, scope 1.″
}
Content
Write-Host ″After error, scope 2.″

Как видите, теперь при возникновении ошибки внутри функции будет произведена обработка ошибки, после чего выполнение  будет продолжено с той строки, в которой произошла ошибка — не выходя из функции.

область действия, вариант 2

Заключение

Если сравнить Try/Catch и Trap, то у каждого метода есть свои достоинства и недостатки. Конструкцию Try/Catch можно более точно нацелить на возможную ошибку, так как Catch обрабатывает только содержимое блока Try. Эту особенность удобно использовать при отладке скриптов, для поиска ошибок.

И наоборот, Trap является глобальным обработчиком, отлавливая ошибки во всем скрипте независимо от расположения. На мой взгляд это более жизненный вариант, который больше подходит для постоянной работы.

description Locale ms.date online version schema title

Describes how to use the `try`, `catch`, and `finally` blocks to handle terminating errors.

en-US

11/12/2021

https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_try_catch_finally?view=powershell-7.3&WT.mc_id=ps-gethelp

2.0.0

about Try Catch Finally

Short description

Describes how to use the try, catch, and finally blocks to handle
terminating errors.

Long description

Use try, catch, and finally blocks to respond to or handle terminating
errors in scripts. The Trap statement can also be used to handle terminating
errors in scripts. For more information, see about_Trap.

A terminating error stops a statement from running. If PowerShell does not
handle a terminating error in some way, PowerShell also stops running the
function or script using the current pipeline. In other languages, such as C#,
terminating errors are referred to as exceptions.

Use the try block to define a section of a script in which you want
PowerShell to monitor for errors. When an error occurs within the try block,
the error is first saved to the $Error automatic variable. PowerShell then
searches for a catch block to handle the error. If the try statement does
not have a matching catch block, PowerShell continues to search for an
appropriate catch block or Trap statement in the parent scopes. After a
catch block is completed or if no appropriate catch block or Trap
statement is found, the finally block is run. If the error cannot be handled,
the error is written to the error stream.

A catch block can include commands for tracking the error or for recovering
the expected flow of the script. A catch block can specify which error types
it catches. A try statement can include multiple catch blocks for different
kinds of errors.

A finally block can be used to free any resources that are no longer needed
by your script.

try, catch, and finally resemble the try, catch, and finally
keywords used in the C# programming language.

Syntax

A try statement contains a try block, zero or more catch blocks, and zero
or one finally block. A try statement must have at least one catch block
or one finally block.

The following shows the try block syntax:

The try keyword is followed by a statement list in braces. If a terminating
error occurs while the statements in the statement list are being run, the
script passes the error object from the try block to an appropriate catch
block.

The following shows the catch block syntax:

catch [[<error type>][',' <error type>]*] {<statement list>}

Error types appear in brackets. The outermost brackets indicate the element is
optional.

The catch keyword is followed by an optional list of error type
specifications and a statement list. If a terminating error occurs in the
try block, PowerShell searches for an appropriate catch block. If
one is found, the statements in the catch block are executed.

The catch block can specify one or more error types. An error type is a
Microsoft .NET Framework exception or an exception that is derived from a .NET
Framework exception. A catch block handles errors of the specified .NET
Framework exception class or of any class that derives from the specified
class.

If a catch block specifies an error type, that catch block handles that
type of error. If a catch block does not specify an error type, that catch
block handles any error encountered in the try block. A try statement can
include multiple catch blocks for the different specified error types.

The following shows the finally block syntax:

finally {<statement list>}

The finally keyword is followed by a statement list that runs every time the
script is run, even if the try statement ran without error or an error was
caught in a catch statement.

Note that pressing CTRL+C stops the pipeline. Objects
that are sent to the pipeline will not be displayed as output. Therefore, if
you include a statement to be displayed, such as «Finally block has run», it
will not be displayed after you press CTRL+C, even if the
finally block ran.

Catching errors

The following sample script shows a try block with a catch block:

try { NonsenseString }
catch { "An error occurred." }

The catch keyword must immediately follow the try block or another catch
block.

PowerShell does not recognize «NonsenseString» as a cmdlet or other item.
Running this script returns the following result:

When the script encounters «NonsenseString», it causes a terminating error. The
catch block handles the error by running the statement list inside the block.

Using multiple catch statements

A try statement can have any number of catch blocks. For example, the
following script has a try block that downloads MyDoc.doc, and it contains
two catch blocks:

try {
   $wc = new-object System.Net.WebClient
   $wc.DownloadFile("http://www.contoso.com/MyDoc.doc","c:tempMyDoc.doc")
}
catch [System.Net.WebException],[System.IO.IOException] {
    "Unable to download MyDoc.doc from http://www.contoso.com."
}
catch {
    "An error occurred that could not be resolved."
}

The first catch block handles errors of the System.Net.WebException and
System.IO.IOException types. The second catch block does not specify an
error type. The second catch block handles any other terminating errors that
occur.

PowerShell matches error types by inheritance. A catch block handles errors
of the specified .NET Framework exception class or of any class that derives
from the specified class. The following example contains a catch block that
catches a «Command Not Found» error:

catch [System.Management.Automation.CommandNotFoundException]
{"Inherited Exception" }

The specified error type, CommandNotFoundException, inherits from the
System.SystemException type. The following example also catches a Command
Not Found error:

catch [System.SystemException] {"Base Exception" }

This catch block handles the «Command Not Found» error and other errors that
inherit from the SystemException type.

If you specify an error class and one of its derived classes, place the catch
block for the derived class before the catch block for the general class.

[!NOTE]
PowerShell wraps all exceptions in a RuntimeException type. Therefore,
specifying the error type System.Management.Automation.RuntimeException
behaves the same as an unqualified catch block.

Using Traps in a Try Catch

When a terminating error occurs in a try block with a Trap defined within
the try block, even if there is a matching catch block, the Trap statement
takes control.

If a Trap exists at a higher block than the try, and there is no matching
catch block within the current scope, the Trap will take control, even if
any parent scope has a matching catch block.

Accessing exception information

Within a catch block, the current error can be accessed using $_, which
is also known as $PSItem. The object is of type ErrorRecord.

try { NonsenseString }
catch {
  Write-Host "An error occurred:"
  Write-Host $_
}

Running this script returns the following result:

An Error occurred:
The term 'NonsenseString' is not recognized as the name of a cmdlet, function,
script file, or operable program. Check the spelling of the name, or if a path
was included, verify that the path is correct and try again.

There are additional properties that can be accessed, such as ScriptStackTrace,
Exception, and ErrorDetails. For example, if we change the script to the
following:

try { NonsenseString }
catch {
  Write-Host "An error occurred:"
  Write-Host $_.ScriptStackTrace
}

The result will be similar to:

An Error occurred:
at <ScriptBlock>, <No file>: line 2

Freeing resources using finally

To free resources used by a script, add a finally block after the try and
catch blocks. The finally block statements run regardless of whether the
try block encounters a terminating error. PowerShell runs the finally block
before the script terminates or before the current block goes out of scope.

A finally block runs even if you use CTRL+C to stop the
script. A finally block also runs if an Exit keyword stops the script from
within a catch block.

See also

  • about_Break
  • about_Continue
  • about_Scopes
  • about_Throw
  • about_Trap

You have had it happen before. You run a PowerShell script, and suddenly the console is full of errors. Did you know you can handle these errors in a much better way? Enter PowerShell try catch blocks!

Using error handling with PowerShell try catch blocks allows for managing and responding to these terminating errors. In this post, you will be introduced to PowerShell try catch blocks and learn how to handle specific exception messages.

Understanding PowerShell Try Catch Syntax

The PowerShell try catch block syntax is straightforward. It is composed of two sections enclosed in curly brackets. The first identified section is the try block, and the second section is the catch block.

try {
    # Command(s) to try
}
catch {
    # What to do with terminating errors
}

The try block can have as many statements in it as you want; however, keep the statements to as few as possible, probably just a single statement. The point of error handling is to work with one statement at a time and deal with anything that occurs from the error.

Here is an example of an error occurring in the PowerShell console. The command is creating a new file using the New-Item cmdlet and specifying a non-existent folder for Path.

powershell error message

If this command was in a script, the output wastes some screen space, and the problem may not be immediately visible. Using a PowerShell try catch block, you can manipulate the error output and make it more readable.

Here is the same New-Item command in a try catch block. Note that line 5 uses the -ErrorAction parameter with a value of Stop to the command. Not all errors are considered “terminating,” so sometimes you need to add this bit of code to terminate into the catch block properly.

try {
    New-Item -Path C:doesnotexist `
        -Name myfile.txt `
        -ItemType File `
        -ErrorAction Stop
}
catch {
    Write-Warning -Message "Oops, ran into an issue"
}
powershell try catch block

Instead of a block of red angry-looking text, you have a simple warning message that it ran into an issue. The non-existent Path name along with forcing -ErrorAction Stop drops the logic into the catch block and displays the custom warning.

Adding the $Error Variable to Catch Output

While more readable, this is not very helpful. All you know is the command did not complete successfully, but you don’t know why. Instead of displaying my custom message, you can display the specific error message that occurred instead of the entire exception block.

When an error occurs in the try block, it is saved to the automatic variable named $Error. The $Error variable contains an array of recent errors, and you can reference the most recent error in the array at index 0.

try {
    New-Item -Path C:doesnotexist `
        -Name myfile.txt `
        -ItemType File `
        -ErrorAction Stop
}
catch {
    Write-Warning $Error[0]
}
powershell try catch block using $Error variable

The warning output is now more descriptive showing that the command failed because it couldn’t find part of the path. This message was a part of our original error message but is now more concise.

Alternatively, you can save the incoming error to a variable using $_. The dollar sign + underscore in PowerShell indicates the current item in the pipeline. In this case, the current item is the error message coming out of the try block.

Once you have saved this incoming message, you use it in a custom output message, like so:

try {
    New-Item -Path C:doesnotexist `
        -Name myfile.txt `
        -ItemType File `
        -ErrorAction Stop
}
catch {
    $message = $_
    Write-Warning "Something happened! $message"
}
try catch error messages

Adding Exception Messages to Catch Blocks

You can also use multiple catch blocks to handle specific errors differently. This example displays two different custom messages:

  • One for if the path does not exist
  • One for if an illegal character is used in the name

Note that the following screenshot shows the script running twice with two different commands in the try block. Each command, catch block, and the orange and green arrows indicate the final output.

Try with multiple catch statements

Looking at lines 14-16, there is a third catch block without an exception message. This is a “catch-all” block that will run if the error does not match any other exceptions. If you are running this script and seeing the message in the last catch block, you know the error is not related to an illegal character in the file name or part of the path not being valid.

Now how do you find the exception messages to use in the first two catch blocks? You find it by looking at the different information attached to the $Error variable. After a failed command occurs, you can run $Error[0].Exception.GetType().FullName to view the exception message for the last error that occurred.

Going back to the PowerShell console, rerun the New-Item command with a non-existent path, then run the $Error command to find the exception message.

powershell find exception message

The red text immediately following the failed command also contains the exception message but does not contain which module it is from. Looking at the $Error variable shows the full message to be used for a catch block.

Getting Exception Messages in PowerShell 7

PowerShell version 7 introduced the Get-Error command. This command displays the most recent error from the current session. After encountering an error, run Get-Error to show the exception type to use in the catch block.

powershell 7 get-error exception message type
Finding exception message type in PowerShell 7

Summary

Using PowerShell try catch blocks gives additional power to handle errors in a script and take different actions based on the error. The catch block can display more than just an error message. It can contain logic that will resolve the error and continue executing the rest of the script.

Do you have any tips on using try/catch blocks? Leave a comment below or find me on Twitter or LinkedIn for additional discussion.

Enjoyed this article? Check out more of my PowerShell articles here!

References:

Microsoft Docs: About Try Catch Finally
Microsoft Docs: About Automatic Variables ($Error)
Microsoft Blog: Understanding Non-Terminating Errors in PowerShell

Введение

Эта статья адресована тем, кто уже познакомился с основами PowerShell, запускал какие-то скрипты со stackexchange и, вероятно, имеет свой текстовый файл с теми или иными сниппетами облегчающими повседневную работу. Целью её написания есть уменьшение энтропии, увеличение читаемости и поддерживаемости кода PowerShell используемого в вашей компании и, как следствие, повышение продуктивности администратора работающего с ним.

kdpv

На своём предыдущем месте работы я в силу специфики задач и несовершенства мира, сильно прокачал навык работы с PowerShell. После смены работы нагрузки такого рода сильно снизились и всё что было вот-вот еще на кончиках пальцев стало всё глубже тонуть под опытом решения новых задач на новых технологиях. От того эта статья претендует быть лишь тем, чем себя объявляет, раскрывая список тем, который на мой взгляд был бы полезен мне самому лет 7 назад, тогда, когда моё знакомство с этим инструментом только начиналось.

Если вы не понимаете почему PowerShell — объектно-ориентированный шелл, какие от этого появляются бонусы и зачем это вообще надо, я, не смотря на возгласы хейтеров посоветую вам хорошую книгу быстро вводящую в суть этой среды — Попов Андрей Владимирович, Введение в Windows PowerShell. Да, она про старую версию PS, да, язык обрел некоторые расширения и улучшения, но эта книга хороша тем, что описывая ранний этап развития этой среды невольно делает акцент лишь фундаментальных вещах. Синтаксический сахар, которым обросла среда я думаю вы быстро и без того воспримите поняв как работает сама концепция. Прочтение этой книги займет у вас буквально пару вечеров, возвращайтесь после прочтения.

popov

Книга также доступна на сайте автора, правда я не уверен в том насколько лицензионно чисто такое использование: https://andpop.ru/courses/winscript/books/posh_popov.pdf

Стайл гайды

Оформление скриптов согласно стайлгайдам хорошая практика во всех случаях её применения, вряд ли тут может быть два мнения. Некоторые экосистемы позаботились об этом на уровне родного тулинга, из очевидного в голову приходит pep8 в сообществе Python и go fmt в Golang. Это бесценные инструменты экономящие время, к сожалению отсутствующие в стандартной поставке PowerShell, а от того переносящие проблему на нашу с вами голову. Единственным на текущий момент способом решения проблемы единого форматирования кода является вырабатывание рефлексов путем многократного написания кода удовлетворяющего стайлгайдам (на самом деле нет).

Стайлгайды в силу отсутствия официально утвержденных и подробно описанных компанией Микрософт были рождены в сообществе во времена PowerShell v3 и с тех пор развиваются в открытом виде на гитхабе: PowerShellPracticeAndStyle. Это заслуживающий внимания репозиторий для любого, кто хоть раз пользовался кнопкой «Save» в PowerShell ise.

Если попытаться сделать выжимку, то вероятно сведется она к следующим пунктам:

  • В PowerShell используется PascalCase для именования переменных, командлетов, имен модулей и практически всего, за исключением операторов;
  • Операторы языка, такие как if, switch, break, process, -match пишутся сугубо строчными буквами;
  • Фигурные скобки расставляются единственно верным способом, иначе еще называемым стилем Кернигана и Ричи ведущим свою историю из книги The C Programming Language;
  • Не используйте алиасы нигде кроме интерактивного сеанса консоли, не пишите в файл скрипта никаких ps | ? processname -eq firefox | %{$ws=0}{$ws+=$_.workingset}{$ws/1MB};
  • Указывайте явно имена параметров, поведение командлетов и их сигнатура может поменяться, плюс к этому, человеку незнакомому с конкретным командлетом это добавит контекста;
  • Оформляйте параметры вызова скриптов, а не пишите внутри скрипта функцию и последней строчкой вызов этой функции с необходимостью изменять значения глобальных переменных вместо указания параметров;
  • Указывайте [CmdletBinding()] — это подарит вашему командлету -Verbose и -Debug флаги и много других полезных фичей. Несмотря на твердость позиции некоторых пуристов в сообществе, я не сторонник указывания этого атрибута в простых инлайн-функциях и фильтрах состоящих из буквальных нескольких строк;
  • Пишите comment-based справку: одно предложение, ссылку на тикет, пример вызова;
  • Указывайте необходимую версию PowerShell в секции #requires;
  • Используйте Set-StrictMode -Version Latest, это поможет вам избежать проблем описанных ниже;
  • Обрабатывайте ошибки;
  • Не спешите переписывать всё на PowerShell. PowerShell — это в первую очередь шелл и вызывать бинари — его прямая задача. Нет ничего плохого в том, чтобы заиспользовать robocopy в скрипте, она, конечно, не rsync, но тоже очень хороша.

Ниже пример того как оформить справку скрипта. Скрипт кадрирует изображение приводя его к квадрату и выполняет ресайз, думаю у вас есть задача делать аватарки для пользователей (нехватает разве что поворота по данным exif). В разделе .EXAMPLE есть пример использования, попробуйте. В силу того, что PowerShell выполняется средой CLR, той же что и прочие dotnet языки, у него есть возможность использовать всю мощь библиотек dotnet:

<#
    .SYNOPSIS
    Resize-Image resizes an image file

    .DESCRIPTION
    This function uses the native .NET API to crop a square and resize an image file

    .PARAMETER InputFile
    Specify the path to the image

    .PARAMETER OutputFile
    Specify the path to the resized image

    .PARAMETER SquareHeight
    Define the size of the side of the square of the cropped image.

    .PARAMETER Quality
    Jpeg compression ratio

    .EXAMPLE
    Resize the image to a specific size:
    .Resize-Image.ps1 -InputFile "C:userpic.jpg" -OutputFile "C:userpic-400.jpg"-SquareHeight 400
#>

# requires -version 3

[CmdletBinding()]
Param(
    [Parameter( Mandatory )]
    [string]$InputFile,
    [Parameter( Mandatory )]
    [string]$OutputFile,
    [Parameter( Mandatory )]
    [int32]$SquareHeight,
    [ValidateRange( 1, 100 )]
    [int]$Quality = 85
)

# Add System.Drawing assembly
Add-Type -AssemblyName System.Drawing

# Open image file
$Image = [System.Drawing.Image]::FromFile( $InputFile )

# Calculate the offset for centering the image
$SquareSide = if ( $Image.Height -lt $Image.Width ) {
    $Image.Height
    $Offset = 0
} else {
    $Image.Width
    $Offset = ( $Image.Height - $Image.Width ) / 2
}
# Create empty square canvas for the new image
$SquareImage = New-Object System.Drawing.Bitmap( $SquareSide, $SquareSide )
$SquareImage.SetResolution( $Image.HorizontalResolution, $Image.VerticalResolution )

# Draw new image on the empty canvas
$Canvas = [System.Drawing.Graphics]::FromImage( $SquareImage )
$Canvas.DrawImage( $Image, 0, -$Offset )

# Resize image
$ResultImage = New-Object System.Drawing.Bitmap( $SquareHeight, $SquareHeight )
$Canvas = [System.Drawing.Graphics]::FromImage( $ResultImage )
$Canvas.DrawImage( $SquareImage, 0, 0, $SquareHeight, $SquareHeight )

$ImageCodecInfo = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() |
    Where-Object MimeType -eq 'image/jpeg'

# https://msdn.microsoft.com/ru-ru/library/hwkztaft(v=vs.110).aspx
$EncoderQuality     = [System.Drawing.Imaging.Encoder]::Quality
$EncoderParameters  = New-Object System.Drawing.Imaging.EncoderParameters( 1 )
$EncoderParameters.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter( $EncoderQuality, $Quality )

# Save the image
$ResultImage.Save( $OutputFile, $ImageCodecInfo, $EncoderParameters )

Приведенный выше скрипт начинается с многострочного комментария <# ... #>, в том случае если этот комментарий идет первым и содержит определенные ключевые слова, PowerShell догадается автоматически построить справку к скрипту. Такого рода справка буквально так и называется — справка основанная на коментарии:

help

Мало того, при вызове скрипта будут работать подсказки по параметрам, будь то консоль PowerShell, будь то редактор кода:

inline help

Еще раз обращу внимание на то, что пренебрегать ей не стоит. Если не знаете что туда написать, напишите что-нибудь, сходите до кулера и по возвращении у вас точно будет понимание того, что нужно в написанном изменить. Это работает. Не стоит фанатично заполнять все ключевые слова, PowerShell разработан быть самодокументируемым и если вы дали осмысленные и полные имена параметрам, короткого предложения в разделе .SYNOPIS и одного примера вполне хватит.

Strict mode

PowerShell, как и многие другие скриптовые языки обладает динамической типизацией. У такого вида типизации есть много сторонников: написать простую, но мощную высокоуровневую логику — дело пары минут, но когда ваше решение начнет подбираться к тысяче строк, вы обязательно столкнетесь с хрупкостью такого подхода.

Автовывод типов неизменно на этапе тестирования формировавший массив в том месте где вы всегда получали набор элементов, обязательно подложит свинью в случае, если получит один элемент и вот уже в следующем условии, вместо проверки количества элементов вы получите количество символов или иной атрибут, в зависимости от типа элемента. Логика скрипта сломается, при этом среда исполнения сделает вид что все хорошо.

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

Включается этот режим командлетом Set-StrictMode -Version Latest, хотя есть другие варианты «строгости», мой выбор — использовать последний.

В примере ниже строгий режим отлавливает обращение к несуществующему свойству. Так как внутри папки находится лишь один элемент, тип переменной $Input в результате выполнения будет FileInfo, а не ожидаемый массив соответствующих элементов:

strict

Для избежания такой проблемы, следует результат выполнения командлета явно привести к массиву:

$Items = @( Get-ChildItem C:Userssnd3rNextcloud )

Возьмите за правило всегда устанавливать строгий режим, это позволит вам избежать неожиданных результатов выполнения ваших скриптов.

Обработка ошибок

ErrorActionPreference

Просматривая чужие скрипты, например на гитхабе, я часто вижу либо полное игнорирование механизма обработки ошибок, либо явное включение режима тихого продолжения работы в случае возникновения ошибки. Вопрос обработки ошибок, безусловно, не самый простой в программировании вообще и в скриптах в частности, но игнорирования он определенно не заслуживает. По-умолчанию, PowerShell в случае возникновения ошибки выводит её и продолжает работу (я немного упростил концепцию, но ниже будет ссылка на гит-книгу по этой теме). Это удобно, в случае если вам срочно нужно распространить обновление широкоиспользуемой в домене программы на все машины, не дожидаясь пока она разольется на все точки деплоя sccm или распространится иным используемым у вас способом. Неприятно прерывать и перезапускать процесс в случае если одна из машин выключена, это правда.

С другой стороны, если вы делаете сложное резервное копирование системы состоящей из более чем одного файла данных более чем одной части информационной системы, вам нужно быть уверенным что ваша резервная копия консистентна и что все необходимые наборы данных были скопированы без ошибок.

Для изменения поведения командлетов в случае возникновения ошибки существует глобальная переменная $ErrorActionPreference, со следующим списком возможных значений: Stop, Inquire, Continue, Suspend, SilentlyContinue.

Я рекомендую всегда использовать значение Stop, когда количество скриптов или их сложность перестают сохраняться на стеке в голове, лучше быть уверенным, что в любюй непредвиденной ситуации скрипт остановит свою работу, а не наломает дров «по-тихому», закончив выполнение «успешно».

Помимо остановки скрипта в случае, если что-то пошло не так есть еще одно обязательное условие его применения — обработка исключительных ситуаций. Для этого есть конструкция try/catch, но работает только в том случае, если ошибка вызывает остановку выполнения. Не обязательно остановка должна быть включена на уровне всего скрипта, ErrorAction можно устанавливать и на уровне командлета параметром:

Get-ChildItem 'C:System Volume Information' -ErrorAction 'Stop'

Собственно такая возможность и определяет две логичные стратегии: разрешать все ошибки «по-умолчанию» и выставлять ErrorAction только для критичных мест, где их и обрабатывать; либо включать на уровне всего скрипта путем задания значения глобальной переменной и задавать -ErrorAction 'Continue' на некритичных операциях. Я всегда выбираю второй вариант, не спешу вам его навязывать, рекомендую лишь один раз разобраться в этом вопросе и использовать этот полезный инструмент.

try/catch

В обработчике ошибок можно делать матчинг по типу исключения и оперировать потоком исполнения или, например, добавлять чуть больше информации. Не смотря на то, что используя операторы try/catch/throw/trap можно выстроить весь поток выполнения скрипта, следует категорически этого избегать, так как такой способ оперирования выполнением мало того, что считается крайним антипаттерном, из разряда «goto-лапши», так еще и сильно просаживает производительность.

#requires -version 3
$ErrorActionPreference = 'Stop'

# создание объекта логгера, код которого смотрите ниже,
# инкапсулирующего знание о пути к логу и формату записи
$Logger = Get-Logger "$PSScriptRootLog.txt"

# глобальная ловушка ошибок
trap {
    $Logger.AddErrorRecord( $_ )
    exit 1
}

# счётчик попыток подключения
$count = 1;
while ( $true ) {
    try {
        # попытка подключения
        $StorageServers = @( Get-ADGroupMember -Identity StorageServers | Select-Object -Expand Name )
    } catch [System.Management.Automation.CommandNotFoundException] {
        # выбрасываемое наружу исключение в силу того, что нет смысла продолжать выполнение без установки модуля
        throw "Командлет Get-ADGroupMember недоступен, требуется добавить фичу Active Directory module for PowerShell; $( $_.Exception.Message )"
    } catch [System.TimeoutException] {
        # переход к следующей итерации цикла в случае если количество попыток не превышено
        if ( $count -le 3 ) { $count++; Start-Sleep -S 10; continue }
        # остановка выполнения и выбрасывание исключения наружу в силу невозможности получения необходимых данных
        throw "Подключение к серверу небыло установленно из-за ошибки таймаута соединения, было произведено $count попыток; $( $_.Exception.Message )"
    }
    # выход из цикла в случае отсутствия исключительных ситуаций
    break
}

Стоит отметить оператор trap — это глобальная ловушка ошибок. Она ловит все что не было обработано на более низких уровнях, либо выброшено наружу из обработчика исключения в силу невозможности самостоятельного исправления ситуации.

Помимо описанного выше объектно-ориентированного подхода исключений, PowerShell предоставляет и более привычные, совместимые с другими «классическими» шеллами концепции, например потоков ошибок, кодов возврата и переменных накапливающих ошибки. Всё это безусловно удобно, иногда безальтернативно, но выходит за рамки этого, в целом обзорного, топика. К счастью на эту тему есть хорошая открытая книга на github.

Код логгера, который я использую когда нет уверенности что в системе будет PowerShell 5 (где можно описать более удобно класс логгера), попробуйте его, он может быть вам полезен в силу своей простоты и краткости, дополнительные методы вы, уверен, добавите без труда.:

# Фабрика логгеров "для бедных", совместимая с PowerShell v3
function Get-Logger {
    [CmdletBinding()]
    param (
        [Parameter( Mandatory = $true )]
        [string] $LogPath,
        [string] $TimeFormat = 'yyyy-MM-dd HH:mm:ss'
    )

    $LogsDir = [System.IO.Path]::GetDirectoryName( $LogPath )
    New-Item $LogsDir -ItemType Directory -Force | Out-Null
    New-Item $LogPath -ItemType File -Force      | Out-Null

    $Logger = [PSCustomObject]@{
        LogPath    = $LogPath
        TimeFormat = $TimeFormat
    }

    Add-Member -InputObject $Logger -MemberType ScriptMethod AddErrorRecord -Value {
        param(
            [Parameter( Mandatory = $true )]
            [string]$String
        )
        "$( Get-Date -Format 'yyyy-MM-dd HH:mm:ss' ) [Error] $String" | Out-File $this.LogPath -Append
    }
    return $Logger
}

Повторю идею — не игнорируйте обработку ошибок. Это сэкономит ваше время и нервы в длительной перспективе.
Не думайте, что выполнение скрипта несмотря ни на что — хорошо. Хорошо — это вовремя упасть не наломав дров.

Инструменты

Начать улучшение инструментов работы с PowerShell стоит безусловно с эмулятора консоли. Я часто слышал от сторонников альтернативных ос, что консоль в windows плоха и что это вообще не консоль, а дос и проч. Мало кто адекватно мог сформулировать свои претензии на этот счет, но если кому-то удавалось, то на деле оказывалось что все проблемы можно решить. Подробнее о терминалах и новой консоли в windows на хабре уже было, там всё более чем ок.

Первым делом стоит установить Conemu или его сборку Cmder, что не особо важно, так как на мой взгляд по настройкам в любом случае стоит пробежаться. Я обычно выбираю cmder в минимальной конфигурации, без гита и прочих бинарей, которые ставлю сам, хотя несколько лет тюнил свой конфиг для чистой Conemu. Это действительно лучший эмулятор терминала для windows, позволяющий разделять экран (для любителей tmux/screen), создавать вкладки и включить quake-style режим консоли:

Conemu

cmder

Следущим шагом рекомендую поставить модули: oh-my-posh, posh-git и PSReadLine. Первые два сделают промт приятнее, добавив в него информацию о текущей сессии, статусе последней выполненной команды, индикатор привелегий и статус гит-репозитория в текущем расположении. PSReadLine сильно прокачивает промт, добавляя например поиск по истории введенных команд (CRTL + R) и удобные подсказки для командлетов по CRTL + Space:

readline

И да, теперь консоль можно очищать комбинацией CTRL + L, и забыть про cls.

Visual Studio Code

Редактор. Всё самое плохое, что я могу сказать про PowerShell, относится сугубо к PowerShell ISE, те кто видели первую версию с тремя панелями врядли забудут этот опыт. Отличающаяся кодировка терминала, отсутствие базовых возможностей редактора, вроде автоматического отступа, автозакрывающихся скобок, форматирования кода и целый набор порождаемых им антипаттернов про которые я вам не расскажу (на всякий случай) — это все про ISE.

Не используйте его, используйте Visual Studio Code с расширением PowerShell — тут есть всё, чего бы вы не захотели (в разумных пределах, конечно). И не забывайте, что в PoweShell до шестой версии (PowerShell Core 6.0) кодировка для скриптов — UTF8 BOM, иначе русский язык сломается.

vscode

Помимо подсветки синтаксиса, подсказки методов и возможности дебага скриптов, плагин устанавливает линтер, который так же поможет вам следовать закрепившимся в сообществе практикам, например в один клик (по лампочке) развернет сокращения. На деле это обычный модуль, который можно поставить и независимо, например добавить его в ваш пайплайн подписи скриптов: PSScriptAnalyzer

PSScriptAnalyzer

Задать параметры форматирования кода можно в настройках расширения, по всем настройкам (и редактора и расширений) есть поиск: File - Preferences - Settings:

OTBS

Для того чтобы получить новую консоль conpty следует установить флаг в настройках, позже, вероятно, этот совет будет неактуальным.

Стоит запомнить, что любое действие в VS Code можно выполнить из центра управления, вызываемого комбинацией CTRL + Shift + P. Отформатировать вставленный из чата кусок кода, отсортировать строки по алфавиту, поменять индент с пробелов на табы и проч, всё это в центре управления.

Например включить полный экран и расположение редактора по центру:

layout

Source Control; Git, SVN

Часто у системных администраторов Windows есть фобия разрешения конфликтов в системах контроля версий, вероятно от того, что если представитель этого множества пользуется git, то зачастую один и не встречается ни с какими проблемами такого рода. С vscode разрешение конфликтов сводится буквально к кликам мыши на тех частях кода что нужно оставить или заместить.

merge

Вот эти надписи между 303 и 304 строкой кликабельны, стоит нажать на все такие что появляются в документе в случае конфликта, сделать коммит фиксирующий изменения и отправить изменения на сервер. У — Удобство.

О том как работать с системами контроля версий доступно и с картинками написано в доке vscode, пробегитесь глазами до конца там кратко и хорошо.

Snippets

Сниппеты — своего рода макросы/шаблоны позволяющие ускорить написание кода. Однозначно must see.

Быстрое создание объекта:

customobject

Рыба для comment-based help:

help

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

splatting

Просмотр всех доступных сниппетов доступен по Ctrl + Alt + J:

snippets

Если после этого у вас появилось желание продолжить улучшать свое окружение, но вы еще ниразу не слышали про осом-листы, то вот же, положил. Так же, если у вас есть свой набор расширений пригождающихся вам при написании PowerShell-скриптов, буду рад увидеть их список в коментариях.

Производительность

Тема производительности не такая простая как может показаться на первый взгляд. С одной стороны, преждевременные оптимизации могут сильно снизить читаемость и поддерживаемость кода, сэкономив 300мс времени выполнения скрипта, обычное время работы которого может быть десяток минут, применение их в таком случае безусловно деструктивно. С другой — есть несколько довольно простых приемов, повышающих как читаемость кода так и скорость его работы, использовать которые вполне уместно на постоянной основе. Ниже я расскажу о некоторых из них, в случае если же перформанс для вас всё, а читаемость уходит на второй план в силу жестких временных ограничений простоя сервиса на время обслуживания, рекомендую обратиться к профильной литературе.

Pipeline и foreach

Самый простой и всегда рабочий способ поднять производительность — уйти от использования пайпов. В силу типобезопасности и удобства работы ради, PowerShell пропуская элементы через пайп оборачивает каждый из них в объект. В dotnet языках такое поведение называется боксинг. Боксинг хорош, он гарантирует безопасность, но у него есть своя цена, которую порой нет смысла платить.

Первым шагом поднять производительность и на мой взгляд повысить читаемость можно убрав все применения командлета Foreach-Object и заменив его на оператор foreach. Вас может смутить то, что на самом деле это две разных сущности, ведь foreach является алиасом для Foreach-Object — на практике главное отличие в том, что foreach не принимает значения из пайплайна, при этом работает по опыту до трех раз быстрее.

Представим задачу: нам нужно обработать большой лог для формирования какой-то его производной, например, выбрать и привести к другому формату ряд записей в нём:

Get-Content D:tempSomeHeavy.log | Select-String '328117'

В примере выше первый этап поставленной задачи — выбор необходимых записей пройденый самым очевидным путем, хорош своей когнитивной легкостью и выразительностью. При этом он содержит место сильно снижающее производительность — пайплайн, точнее будет сказать что виноват не сам пайплайн, а поведение командлета Get-Content. Для передачи данных по конвейеру он читает файл построчно и оборачивает каждую строку лога из базового типа string в объект, сильно увеличивая его размер, уменьшая локальность данных в кеше и делая не особо нужную нам работу. Избежать этого просто — нужно указать ему параметром о том, что эти данные нужно прочитать полностью за один раз:

When reading from and writing to binary files, use the AsByteStream parameter and a value of 0 for the ReadCount parameter. A ReadCount value of 0 reads the entire file in a single read operation. The default ReadCount value, 1, reads one byte in each read operation and converts each byte into a separate object, which causes errors when you use the Set-Content cmdlet to write the bytes to a file unless you use AsByteStream

https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.management/get-content

Get-Content D:tempSomeHeavy.log -ReadCount 0 | Select-String '328117'

На моём файле лога размером чуть более одного гигабайта преимущество второго подхода почти в три раза:

readcount

Я советую вам не верить мне на слово, а самим проверить так ли это на файле похожего размера. В целом же Select-String даёт хороший результат и если итоговое время вас устраивает — пора остановиться с оптимизацией этой части скрипта. В случае если итоговое время выполнения скрипта до сих пор сильно зависит от этапа получения данных, можно еще немного снизить время выборки данных заменив командлет Select-String. Это очень мощный и удобный инструмент, но что бы таким быть Select-String добавляет некоторое количество метаданных в свой вывод, опять же производя не бесплатную по времени работу, мы можем отказаться от лишних метаданных и сопутствующей работы заменив командлет оператором языка:

foreach ( $line in ( Get-Content D:tempSomeHeavy.log -ReadCount 0 )) {
    if ( $line -match '328117' ) {
        $line
    }
}

На моих тестах время выполнения уменьшилось до 30 секунд, то-есть я выйграл 30%, при этом уже на моём примере оно того не особо стоило, так как кода стало больше и хоть они банальный, но нежелание разобраться в нем у стороннего наблюдателя, по-моему опыту, увеличилось вдвое (как население в тех замках ;-). Если вы оперируете десятками гигабайт логов, то это несомненно ваш путь. Что еще хотелось бы отметить в приведенном выше коде, так это оператор -match; суть его — поиск совпадения по регулярному выражению. В конкретном случае в силу простоты этого выражения поиск сводится к вычислительно простому поиску по подстроке, но так бывает не всегда — от сложности вашего регулярного выражения время выполнения может увеличиваться с любой вообразимой вами прогрессией — регулярые выражения все-таки Тьюринг полный язык, будьте с ними осторожны.

Следующая задача — как-то обработать этот лог, напишем решение «в лоб» с добавлением в каждую отобранную строчку текущей даты и запись в файл через пайп:

foreach ( $line in ( Get-Content D:tempSomeHeavy.log -ReadCount 0 )) {
    if ( $line -match '328117' ) {
        "$( Get-Date -UFormat '%d.%m.%Y %H:%M:%S') $line" | Out-File D:tempResult.log -Append
    }
}

Результаты выполнения замеренные командлетом Measure-Command:

Hours             : 2
Minutes           : 20
Seconds           : 9
Milliseconds      : 101

Попробуем улучшить результат. Думаю многим очевидно, что запись каждой отдельной строки в файл не самая оптимальная операция, куда лучше сделать накопительный буфер который периодически сбрасывать на диск, в идеале сбросить его один раз. Так же стоит отметить, что строки в PowerShell неизменяемы и любая операция со строкой порождает новую область в памяти, куда записывается новая строка, а старая остается ждать сборщик мусора — это дорого и по скорости и по памяти. Для решения этой проблемы в дотнете есть специализированный класс, который позволяет изменять строки, при этом инкапсулируя логику более аккуратного выделения памяти и имя ему — StringBuilder. При создании класса выделяется буфер в оперативной памяти в который добавляются новые строки без повторного выделения памяти, в том случае если размера буфера не хватает для добавления новой строки, то создается новый вдвое большего размера и работа продолжается с ним. Помимо того что такая стратегия сильно уменьшает количество выделений памяти, её еще можно подтюнить если знать примерный объем памяти который будут занимать строки и задать его в конструкторе при создании объекта.

$StringBuilder = New-Object System.Text.StringBuilder
foreach ( $line in ( Get-Content D:tempSomeHeavy.log -ReadCount 0 )) {
    if ( $line -match '328117' ) {
        $null = $StringBuilder.AppendLine( "$( Get-Date -UFormat '%d.%m.%Y %H:%M:%S') $line" )
    }
}
Out-File -InputObject $StringBuilder.ToString() -FilePath D:tempResult.log -Append -Encoding UTF8

Время выполнения этого кода всего 5 минут, вместо прошлых двух с половиной часов:

Hours             : 0
Minutes           : 5
Seconds           : 37
Milliseconds      : 150

Стоит отметить конструкцию Out-File -InputObject, суть её в том, чтобы в очередной раз избавиться от пайплайна. Такой способ быстрее пайпа и работает со всеми командлетами — любое значение которое в сигнатуре командлета является значением принимаемым из пайпа может быть задано параметром. Наиболее простой способ узнать какой именно параметр принимает себе проходящие через пайп значения — выполнить Get-Help на командлете с параметром -Full, среди списка параметров один должен содержать в себе Accept pipeline input? true (ByValue):

-InputObject <psobject>

    Required?                    false
    Position?                    Named
    Accept pipeline input?       true (ByValue)
    Parameter set name           (All)
    Aliases                      None
    Dynamic?                     false

В обоих случаях PowerShell ограничивал себя тремя гигабайтами памяти:

taskmgr

Во время работы StringBuilder на каждой итерации выводит в консоль информацию о выделенной на текущий момент памяти и размере добавленных элементов из которой можно взять примерные значения для установки в конструкторе:

stringbuilder alloc

Всем хорош предыдущий подход, кроме разве что того, что 3Гб это 3Гб. Попробуем уменьшить потребление памяти и заиспользуем другой dotnet-класс написаный для решения таких проблем — StreamReader.

$StringBuilder = New-Object System.Text.StringBuilder
$StreamReader  = New-Object System.IO.StreamReader 'D:tempSomeHeavy.log'
while ( $line = $StreamReader.ReadLine()) {
    if ( $line -match '328117' ) {
        $null = $StringBuilder.AppendLine( "$( Get-Date -UFormat '%d.%m.%Y %H:%M:%S') $line" )
    }
}
$StreamReader.Dispose()
Out-File -InputObject $StringBuilder.ToString() -FilePath C:tempResult.log -Append -Encoding UTF8

Hours             : 0
Minutes           : 5
Seconds           : 33
Milliseconds      : 657

Время выполнения осталось практически тем же, но потребление памяти и его характер изменились. Если в предыдущем примере при чтении файла в памяти занималось место сразу под весь файл, у меня это больше гигабайта, а работа скрипта характеризовалась утилизацией трех гигабайт, то при использовании стримридера, занятая процессором память медленно увеличивалась пока не дошла до 2Гб. Конечный объем занятой памяти я заскринить не успел, но есть скрин того что происходило ближе к концу работы:

streamreader

Поведение программы по расходу памяти вполне очевидно — вход у неё грубо говоря «труба», а выход — наш StringBuilder — «бассейн» который и разливается до конца работы программы. Зададим размер буфера, что бы убрать лишние аллокации (я выбрал 100МБ) и начнем сбрасывать содержимое в файл при приближении к концу буфера. Последнюю проверку я реализовал в лоб — сравниваю прошел ли буфер отметку в 90% от общего размера (может быть эту операцию имеет смысл вынести из цикла, проверьте сами разницу во времени):

$BufferSize     = 104857600
$StringBuilder  = New-Object System.Text.StringBuilder $BufferSize
$StreamReader   = New-Object System.IO.StreamReader 'C:tempSomeHeavy.log'
while ( $line = $StreamReader.ReadLine()) {
    if ( $line -match '1443' ) {
        # проверка приближения к концу буфера
        if ( $StringBuilder.Length -gt ( $BufferSize - ( $BufferSize * 0.1 ))) {
            Out-File -InputObject $StringBuilder.ToString() -FilePath C:tempResult.log -Append -Encoding UTF8
            $StringBuilder.Clear()
        }
        $null = $StringBuilder.AppendLine( "$( Get-Date -UFormat '%d.%m.%Y %H:%M:%S') $line" )
    }
}
Out-File -InputObject $StringBuilder.ToString() -FilePath C:tempResult.log -Append -Encoding UTF8
$StreamReader.Dispose()

Hours             : 0
Minutes           : 5
Seconds           : 53
Milliseconds      : 417

Максимальное потребление памяти составило 1Гб при почти той же скорости выполнения:

streamreader with dump

Безусловно результаты по абсолютным числам утилизированной памяти будут отличаться от одной машины к другой, всё зависит от того сколько памяти вообще доступно на машине и соответственно насколько агрессивным будет её освобождение. Если память для вас критична, а несколько процентов производительности не так, то можно еще уменьшить её потребление заиспользовав StreamWriter, он как стримридер, только стримрайтер ;-) Оставлю вам его для самостоятельного изучения, а то мне уже кажется я тут засиделся, ибо конца этому нет.

По-моему идея обозначена довольно явно — любую проблему люди уже решали, всегда следует начать искать решение в стандартной библиотеке. Главное эту проблему локализовать, хотя вероятно еще важнее — не выдумывать. Если Select-String и Out-File вас устраивают по времени, машина не встает и не падает с OutOfMemoryException, то используйте их — простота и выразительность важнее.

Нативные бинарники

Часто, проникшись всем удобством и мощностью PowerShell администраторы начинают стремиться использовать встроенные командлеты вместо системных бинарников, с одной стороны их сложно в этом упрекнуть — удобство, с другой: PowerShell — в первую очередь шелл и запуск бинарников его прямая задача, с которой он отлично справляется.

Пример задачи, в прошлом решенной мной с помощью StringBuilder и вызова консольной команды dir — получение относительных путей всех файлов в каталоге и подкаталогах (большого количества файлов). С использованием нативной команды время выполнения меньше в пять раз:

$CurrentPath = ( Get-Location ).Path + ''
$StringBuilder = New-Object System.Text.StringBuilder
foreach ( $Line in ( &cmd /c dir /b /s /a-d )) {
    $null = $StringBuilder.AppendLine( $Line.Replace( $CurrentPath, '.' ))
}
$StringBuilder.ToString()

Hours             : 0
Minutes           : 0
Seconds           : 3
Milliseconds      : 9

$StringBuilder = New-Object System.Text.StringBuilder
foreach ( $Line in ( Get-ChildItem -File -Recurse | Resolve-Path -Relative )) {
    $null = $StringBuilder.AppendLine( $Line )
}
$StringBuilder.ToString()

Hours             : 0
Minutes           : 0
Seconds           : 16
Milliseconds      : 337

Присваивание результата работы в $null — наиболее дешевый способ подавления вывода. Наиболее дорогой, вы думаю догадались — отправка пайпом в Out-Null; мало того, такой способ подавления (присваивание результата в $null) еще и уменьшает время выполнения, хоть и незначительно.

# быстро:
$null = $StringBuilder.AppendLine( $Line )

# медленно:
$StringBuilder.AppendLine( $Line ) | Out-Null

Однажды у меня стояла задача синхронизировать каталоги с большим количеством файлов, при этом это была лишь часть работы довольно большого скрипта, этап подготовки. Реализация синхронизации каталогов с помощью Compare-Object, хоть и выглядела достойно и компактно, затрачивала на свою работу времени больше, чем весь планируемый мною временной бюджет скрипта. Выходом из этой ситуации стало использование широкоизвестной в узких кругах утилиты robocopy.exe, компромисом же стало написание враппера (точнее класса для PowerShell 5), кодом которого спешу с вами поделиться:

class Robocopy {
    [String]$RobocopyPath

    Robocopy () {
        $this.RobocopyPath = Join-Path $env:SystemRoot 'System32Robocopy.exe'
        if ( -not ( Test-Path $this.RobocopyPath -PathType Leaf )) {
            throw 'Исполняемый файл робокопи не найден'
        }

    }
    [void]CopyFile ( [String]$SourceFile, [String]$DestinationFolder ) {
        $this.CopyFile( $SourceFile, $DestinationFolder, $false )
    }
    [void]CopyFile ( [String]$SourceFile, [String]$DestinationFolder, [bool]$Archive ) {
        $FileName   = [IO.Path]::GetFileName( $SourceFile )
        $FolderName = [IO.Path]::GetDirectoryName( $SourceFile )

        $Arguments = @( '/R:0', '/NP', '/NC', '/NS', '/NJH', '/NJS', '/NDL' )
        if ( $Archive ) {
            $Arguments += $( '/A+:a' )
        }
        $ErrorFlag = $false
        &$this.RobocopyPath $FolderName $DestinationFolder $FileName $Arguments | Foreach-Object {
            if ( $ErrorFlag ) {
                $ErrorFlag = $false
                throw "$_ $ErrorString"
            } else {
                if ( $_ -match '(?<=(0x[da-f]{8}))(?<text>(.+$))' ) {
                    $ErrorFlag   = $true
                    $ErrorString = $matches.text
                } else {
                    $Logger.AddRecord( $_.Trim())
                }
            }
        }
        if ( $LASTEXITCODE -eq 8 ) {
            throw 'Some files or directories could not be copied'
        }
        if ( $LASTEXITCODE -eq 16 ) {
            throw 'Robocopy did not copy any files. Check the command line parameters and verify that Robocopy has enough rights to write to the destination folder.'
        }
    }
    [void]SyncFolders ( [String]$SourceFolder, [String]$DestinationFolder ) {
        $this.SyncFolders( $SourceFolder, $DestinationFolder, '*.*', '', $false )
    }
    [void]SyncFolders ( [String]$SourceFolder, [String]$DestinationFolder, [Bool]$Archive ) {
        $this.SyncFolders( $SourceFolder, $DestinationFolder, '*.*', '', $Archive )
    }
    [void]SyncFolders ( [String]$SourceFolder, [String]$DestinationFolder, [String]$Include ) {
        $this.SyncFolders( $SourceFolder, $DestinationFolder, $Include, '', $false )
    }
    [void]SyncFolders ( [String]$SourceFolder, [String]$DestinationFolder, [String]$Include, [Bool]$Archive ) {
        $this.SyncFolders( $SourceFolder, $DestinationFolder, $Include, '', $Archive )
    }
    [void]SyncFolders ( [String]$SourceFolder, [String]$DestinationFolder, [String]$Include, [String]$Exclude ) {
        $this.SyncFolders( $SourceFolder, $DestinationFolder, $Include, $Exclude, $false )
    }
    [void]SyncFolders ( [String]$SourceFolder, [String]$DestinationFolder, [String]$Include, [String]$Exclude, [Bool]$Archive ) {
        $Arguments = @( '/MIR', '/R:0', '/NP', '/NC', '/NS', '/NJH', '/NJS', '/NDL' )
        if ( $Exclude ) {
            $Arguments += $( '/XF' )
            $Arguments += $Exclude.Split(' ')
        }
        if ( $Archive ) {
            $Arguments += $( '/A+:a' )
        }
        $ErrorFlag = $false
        &$this.RobocopyPath $SourceFolder $DestinationFolder $Include $Arguments | Foreach-Object {
            if ( $ErrorFlag ) {
                $ErrorFlag = $false
                throw "$_ $ErrorString"
            } else {
                if ( $_ -match '(?<=(0x[da-f]{8}))(?<text>(.+$))' ) {
                    $ErrorFlag = $true
                    $ErrorString = $matches.text
                } else {
                    $Logger.AddRecord( $_.Trim())
                }
            }
        }
        if ( $LASTEXITCODE -eq 8 ) {
            throw 'Some files or directories could not be copied'
        }
        if ( $LASTEXITCODE -eq 16 ) {
            throw 'Robocopy did not copy any files. Check the command line parameters and verify that Robocopy has enough rights to write to the destination folder.'
        }
    }
}

Код этот я писал несколько лет назад и некоторые решения в нем могут показаться не лучшими, но проводить глубокую ревизию его мне в данный момент времени крайне лениво (иначе конца написанию этой статьи не будет), если у вас есть желание сделать его лучше — добро пожаловать в коментарии.

Внимательные читатели спросят, мол как так: в классе который борется за перформанс используется Foreach-Object!? Это правда, и приведенный пример один из случаев уместного использования этого командлета и вот почему: в отличие от foreach, командлет Foreach-Object не дожидается полного выполнения команды отправляющей данные в пайп — обработка происходит потоково, в конкретной ситуации, например, генерируя исключения сразуже, а не дожидаясь окончания процесса синхронизации. Парсинг вывода утилиты подходящее этому командлету место.

Использование описанного выше враппера до банального простое, стоит лишь добавить обработку исключений:

$Robocopy = New-Object Robocopy

# копирование одного файла
$Robocopy.CopyFile( $Source, $Dest )

# синхронизация папок
$Robocopy.SyncFolders( $SourceDir, $DestDir )

# синхронизация только файлов .xml и установка архивного бита
$Robocopy.SyncFolders( $SourceDir, $DestDir , '*.xml', $true )

# синхронизация всех файлов кроме *.zip *.tmp *.log и установка архивного бита
$Robocopy.SyncFolders( $SourceDir, $DestDir, '*.*', '*.zip *.tmp *.log', $true )

Послевкусие

Производительность скриптов тема обширная и отчасти холиварная — микрооптимизации могут забрать времени на реализацию и тестирование больше чем принесут, поддерживаемость и читаемость кода может снизиться настолько, что цена его поддержки будет выше чем профит от использования такого решения; вместе с тем, есть ряд простых рекомендаций делающих ваш код проще, понятне и быстрее, стоит лишь начать их использовать:

  • использовать оператор foreach вместо командлета Foreach-Object в скриптах;

  • минимизировать количество пайплайнов;

  • читать/писать файлы разом, а не построчно;

  • использовать StringBuilder и прочие специализированные классы;

  • профилировать код и понимать узкие места, прежде чем что-то оптимизировать;

  • не стыдиться вызывать нативные бинарники (пастить «батники» в скрипты не стоит);

И пожалуй главное еще раз: не спешите оптимизировать что-то без реальной необходимости, преждевременная оптимизация может всё испортить.

Jobs

Бывает так, что вы уже оптимизировали всё, что казалось необходимым и пришли к некоторому компромису, между читаемость и скоростью, но то ли данных стало больше, то ли дальнейшие алгоритмические трюки себя не оправдывают, а сократить время работы нужно. В этом случае незаменимым помощником может стать параллельность исполнения некоторых частей кода. Убедиться стоит разве что в том, что не будет проблем с IO, если вы вдруг решили что скорости диска хватит на любое количество потоков вмещающихся в память.

Минутка юмора на счет ssd

Вот так происходит первая загрузка свежеустановленной Windows Server 2019 в Hyper-V на ssd (решилось миграцией виртуалки на hdd):

2019ssd

Со второй версии PowerShell доступны командлеты для работы с заданиями (Get-Command *-Job), подробнее можно почитать например тут.

Ничего концептуально сложного в них нет, оформляем скриптблок, запускаем задание, получаем результаты в нужный момент:

$Job = Start-Job -ScriptBlock {
    Write-Output 'Good night'
    Start-Sleep -S 10
    Write-Output 'Good morning'
}

$Job | Wait-Job | Receive-Job
Remove-Job $Job

Пример выше призван уместить необходимые командлеты на минимальной единице площади, а не является хорошим паттерном написания асинхронного кода — не стоит делать асинхронный вызов для того, чтобы подождать его завершения. В качестве нестареющего примера использования джобов я рекомендую вам разобраться и подебажить вот этот скрипт распределенного пинга подсети.

Если вы решили не открывать ссылку, еще одна попытка с моей стороны:

jobs
https://xaegr.wordpress.com/2011/07/12/threadping/

Проблема, которая вроде бы и не проблема, но обозначена — каждый джоб хочет немного памяти, что бы быть быстрее и запускается полноценным процессом операционной системы со всеми плюсами и минусами этого подхода. Вот так, например, умирает приведенный выше джоб (50 мегабайт — это 50 мегабайт):

job dies

Джобы помогут решить любые ваши задачи параллельно и сделать это удобно. Обязательно изучите этот механизм, джобы — это лучший выбор для простого решения задачи на довольно высоком уровне абстракции, при этом уложиться получится в минимальное количество строк. Помните одну важную вещь — ваши скрипты должны быть читаемыми для людей которые придут после вас, скрипты пишут для людей.

Но бывает так, что этой абстракции перестает хватать в силу архитектурных ограничений такого решения, например, сложно в такой парадигме сделать интерактивный гуи с биндингом значений на форме к каким-то переменным.

Runspaces

Концепции ранспейсов посвещена целая серия статей статей в блоге майкрософта и я очень рекомендую обратиться к первоисточнику — Beginning Use of PowerShell Runspaces: Part 1. Коротко, ранспейс — это отдельный поток PowerShell который работает в том же процессе операционной системы, от того не имея оверхеда на новый процесс. Если концепция легких потоков вам нравится и вы хотите пускать их десятками (нет, концепции каналов в PowerShell нет), то у меня для вас хорошая новость: для удобства вся низкоуровневая логика вот в этом репозитарии модуля на гитхаб (там есть гифки) уже обернута в более знакомую концепцию джобов. А пока покажу как работать с ними руками, но первую ссылку из этого абзаца не забывайте посетить в любом случае.

В качестве примера использования ранспейсов могу привести скелет простой WPF формы, отрисовка которой происходит в том же потоке операционной системы что и основной процесс PowerShell, но в отдельном потоке рантайма. Взамодействие с ним происходит через потокобезопасный хэштейбл — вам не нужно писать никаких мьютексов, всё уже работает. Плюс такого подхода — вы можете в основном скрипте реализовать любой сложности и длительности работы алгоритм, блокировка которым основного потока исполнения не приведет к «зависанию» формы. Пруф в последней строке скрипта.

В конкретном примере запускается только один ранспейс, хотя ничего не мешает вам породить еще парочку в случае необходимости и завести им пул для удобства.

wpf

# Хештейбл синхронизированный между потоками
$GUISyncHash = [hashtable]::Synchronized(@{})

<#
    WPF форма
#>
$GUISyncHash.FormXAML = [xml](@"
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Sample WPF Form" Height="510" Width="410" ResizeMode="NoResize">
    <Grid>
        <Label Content="Пример формы" HorizontalAlignment="Left" Margin="10,10,0,0" VerticalAlignment="Top" Height="37" Width="374" FontSize="18"/>
        <Label Content="Откуда" HorizontalAlignment="Left" Margin="16,64,0,0" VerticalAlignment="Top" Height="26" Width="48"/>
        <TextBox x:Name="BackupPath" HorizontalAlignment="Left" Height="23" Margin="69,68,0,0" TextWrapping="Wrap" Text="" VerticalAlignment="Top" Width="300"/>
        <Label Content="Куда" HorizontalAlignment="Left" Margin="16,103,0,0" VerticalAlignment="Top" Height="26" Width="35"/>
        <TextBox x:Name="RestorePath" HorizontalAlignment="Left" Height="23" Margin="69,107,0,0" TextWrapping="Wrap" Text="" VerticalAlignment="Top" Width="300"/>
        <Button x:Name="FirstButton" Content="√" HorizontalAlignment="Left" Margin="357,68,0,0" VerticalAlignment="Top" Width="23" Height="23"/>
        <Button x:Name="SecondButton" Content="√" HorizontalAlignment="Left" Margin="357,107,0,0" VerticalAlignment="Top" Width="23" Height="23"/>
        <CheckBox x:Name="Check" Content="Сделать мне хорошо" HorizontalAlignment="Left" Margin="16,146,0,0" VerticalAlignment="Top" RenderTransformOrigin="-0.113,-0.267" Width="172"/>
        <Button x:Name="Go" Content="Go" HorizontalAlignment="Left" Margin="298,173,0,0" VerticalAlignment="Top" Width="82" Height="26"/>
        <ComboBox x:Name="Droplist" HorizontalAlignment="Left" Margin="16,173,0,0" VerticalAlignment="Top" Width="172" Height="26"/>
        <ListBox x:Name="ListBox" HorizontalAlignment="Left" Height="250" Margin="16,210,0,0" VerticalAlignment="Top" Width="364"/>
    </Grid>
</Window>
"@)

<#
    Поток формы
#>
$GUISyncHash.GUIThread = {
    $GUISyncHash.Window       = [Windows.Markup.XamlReader]::Load(( New-Object System.Xml.XmlNodeReader $GUISyncHash.FormXAML ))
    $GUISyncHash.Check        = $GUISyncHash.Window.FindName( "Check" )
    $GUISyncHash.GO           = $GUISyncHash.Window.FindName( "Go" )
    $GUISyncHash.ListBox      = $GUISyncHash.Window.FindName( "ListBox" )
    $GUISyncHash.BackupPath   = $GUISyncHash.Window.FindName( "BackupPath" )
    $GUISyncHash.RestorePath  = $GUISyncHash.Window.FindName( "RestorePath" )
    $GUISyncHash.FirstButton  = $GUISyncHash.Window.FindName( "FirstButton" )
    $GUISyncHash.SecondButton = $GUISyncHash.Window.FindName( "SecondButton" )
    $GUISyncHash.Droplist     = $GUISyncHash.Window.FindName( "Droplist" )

    $GUISyncHash.Window.Add_SourceInitialized({
        $GUISyncHash.GO.IsEnabled = $true
    })

    $GUISyncHash.FirstButton.Add_Click( {
        $GUISyncHash.ListBox.Items.Add( 'Click FirstButton' )
    })

    $GUISyncHash.SecondButton.Add_Click( {
        $GUISyncHash.ListBox.Items.Add( 'Click SecondButton' )
    })

    $GUISyncHash.GO.Add_Click( {
        $GUISyncHash.ListBox.Items.Add( 'Click GO' )
    })

    $GUISyncHash.Window.Add_Closed( {
        Stop-Process -Id $PID -Force
    })

    $null = $GUISyncHash.Window.ShowDialog()
}

$Runspace = @{}
$Runspace.Runspace = [RunspaceFactory]::CreateRunspace()
$Runspace.Runspace.ApartmentState = "STA"
$Runspace.Runspace.ThreadOptions = "ReuseThread"
$Runspace.Runspace.Open()
$Runspace.psCmd = { Add-Type -AssemblyName PresentationCore, PresentationFramework, WindowsBase }.GetPowerShell()
$Runspace.Runspace.SessionStateProxy.SetVariable( 'GUISyncHash', $GUISyncHash )
$Runspace.psCmd.Runspace = $Runspace.Runspace
$Runspace.Handle = $Runspace.psCmd.AddScript( $GUISyncHash.GUIThread ).BeginInvoke()

Start-Sleep -S 1

$GUISyncHash.ListBox.Dispatcher.Invoke( "Normal", [action] {
    $GUISyncHash.ListBox.Items.Add( 'Привет' )
})

$GUISyncHash.ListBox.Dispatcher.Invoke( "Normal", [action] {
    $GUISyncHash.ListBox.Items.Add( 'Наполняю выпадающее меню' )
})

foreach ( $item in 1..5 ) {
    $GUISyncHash.Droplist.Dispatcher.Invoke( "Normal", [action] {
        $GUISyncHash.Droplist.Items.Add( $item )
        $GUISyncHash.Droplist.SelectedIndex = 0
    })
}

$GUISyncHash.ListBox.Dispatcher.Invoke( "Normal", [action] {
    $GUISyncHash.ListBox.Items.Add( 'While ( $true ) { Start-Sleep -S 10 }' )
})

while ( $true ) { Start-Sleep -S 10 }

Еще один пример работы с WPF можете посмотреть в моём репозитории на github, там один поток и всё довольно просто, ну и еще он позволяет читать smart диска: https://github.com/snd3r/GetDiskSmart/. А ещё там можно посмотреть пример биндинга объектов к форме, когда работает магия MVVM:

binging

Если на вашем компьютере не стоит старшая Visual Studio, например потому что ваша организация не удовлетворяет требований к бесплатному использованию Community Edition или у вас нет желания добавлять в систему программу установка которой будет необратима без резервной копии раздела, то на гитхабе есть простой инструмент для рисования простых xaml-форм для wpf — https://github.com/punker76/kaxaml

kaxaml

Вместо заключения

PowerShell — мощная и удобная среда для работы с Windows-инфраструктурой. Он хорош концептуально, он удобен своим синтаксисом и самодокументирующими названиями командлетов, он может в перформанс себя как среды и вас как специалиста, стоит лишь разобраться в концепциях которыми он оперирует и начать получать удовольствие.

Когда я начинал писать эту статью, в планах была небольшая заметка по оформлению кода и наглядной демонстрации преимуществ ответственного отношения к стайлгайдам, под названием «PowerShell, хорошие практики», но Остапа понесло. Опыта написания такого размера технических статей у меня до этого небыло, поэтому прошу извинить за некоторую сумбурность повествования — писалось всё последовательно из головы, в лучших традициях снятия дампа сознания. При этом я старался давать ссылки на заслуживаюшие внимания ресурсы и рекомендую вам сделать все их фиолетовыми. Эти ссылки хоть на деле и из первой страницы гугла, но во-первых гугол у всех разный, во-вторых я все же сверял свои ожидания с контентом и только в случае совпадения добавлял в статью.

Если у вас есть что добавить из своего опыта — добро пожаловать в комментарии.

calm

P.S. Boomburum, не поддерживать в 2019 подсветку синтаксиса powershell — стрёмный стрём.

Понравилась статья? Поделить с друзьями:
  • Powershell не отображать ошибки
  • Powershell логирование ошибок
  • Powershell игнорировать ошибки
  • Powershell если ошибка то
  • Powershell выдает ошибку