Введение


Эта статья адресована тем, кто уже познакомился с основами 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, но тоже очень хороша.

Comment Based Help


Ниже пример того как оформить справку скрипта. Скрипт кадрирует изображение приводя его к квадрату и выполняет ресайз, думаю у вас есть задача делать аватарки для пользователей (нехватает разве что поворота по данным 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:\Users\snd3r\Nextcloud )

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


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


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 "$PSScriptRoot\Log.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:\temp\SomeHeavy.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:\temp\SomeHeavy.log -ReadCount 0 | Select-String '328117'

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


readcount


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


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

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


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


foreach ( $line in ( Get-Content D:\temp\SomeHeavy.log -ReadCount 0 )) {
    if ( $line -match '328117' ) {
        "$( Get-Date -UFormat '%d.%m.%Y %H:%M:%S') $line" | Out-File D:\temp\Result.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:\temp\SomeHeavy.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:\temp\Result.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:\temp\SomeHeavy.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:\temp\Result.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:\temp\SomeHeavy.log'
while ( $line = $StreamReader.ReadLine()) {
    if ( $line -match '1443' ) {
        # проверка приближения к концу буфера
        if ( $StringBuilder.Length -gt ( $BufferSize - ( $BufferSize * 0.1 ))) {
            Out-File -InputObject $StringBuilder.ToString() -FilePath C:\temp\Result.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:\temp\Result.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 'System32\Robocopy.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="v" HorizontalAlignment="Left" Margin="357,68,0,0" VerticalAlignment="Top" Width="23" Height="23"/>
        <Button x:Name="SecondButton" Content="v" 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 — стрёмный стрём.

Комментарии (44)


  1. sizziff
    12.03.2019 08:40

    Очень интересно. Спасибо за статью.


  1. saipr
    12.03.2019 08:49

    PowerShell — мощная и удобная среда для работы с Windows-инфраструктурой.

    А Tcl/Tk — по настоящему мощная и удобная среда для работы с любой-инфраструктурой.


    1. e-lay
      12.03.2019 09:42

      Скорее с сетевой инфраструктурой 20-летней давности.

      Но по поводу v6 core (он же pwsh.exe) — вы правы. Он банально не дописан.
      Это кросс-платформенная (и вроде как даже open-source-ная) версия v5, в которой банально ещё не реализованы многие методы.


    1. legolegs
      12.03.2019 16:14

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


      1. saipr
        12.03.2019 18:56

        Даже не соображу как туда попала эта ссылка, как-то на автомате.
        Эх википедия, Tcl/Tk.


  1. gdi32dll
    12.03.2019 09:42

    Я тоже когда-то писал большие куски на PS, но потом я выучил C# и сейчас мне гораздо проще написать библиотеку в Visual Studio, а потом ипортнуть ее в мой PS скрипт.


    1. snd3r Автор
      12.03.2019 09:46

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


  1. mayorovp
    12.03.2019 10:30

    Что-то мне кажется, что вы потеряли сильные стороны powershell пытаясь сделать из него недо-C#. Ну вот, например, зачем вообще Robocopy сделан классом? У вас что, в системе может быть несколько экземпляров Robocopy? Почему бы не сделать Robocopy модулем?


    Import-Module robocopy.psm1
    
    # копирование одного файла
    Robocopy-CopyFile $Source $Dest
    
    # синхронизация папок
    Robocopy-SyncFolders $SourceDir $DestDir
    
    # синхронизация только файлов .xml и установка архивного бита
    Robocopy-SyncFolders $SourceDir $DestDir -Include '*.xml' -Archive $true
    
    # синхронизация всех файлов кроме *.zip *.tmp *.log и установка архивного бита
    Robocopy-SyncFolders $SourceDir $DestDir -Include '*.*' -Exclude '*.zip *.tmp *.log' -Archive $true

    Ну и вообще я бы не советовал активно использовать в powershell классы: вызов метода такого класса требует двух переходов powershell->clr и clr->powershell, которые довольно быстро сжирают стек.


    1. snd3r Автор
      12.03.2019 11:04

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

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

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

      Конкретно в этой задаче ни с какими проблемами со стеком я не сталкивался, хотя объемы он прокачивал хорошие.


      1. mayorovp
        12.03.2019 12:03

        Вы просто не комбинировали классы с try-catch при работе через WinRM. Ну или вам очень повезло. Потому что через WinRM в стеке помещается только 6 фреймов (try-catch + вызов метода).


        1. snd3r Автор
          12.03.2019 13:07

          Точно, классов я не так много написал вообще, а для работы через WinRM вообще ни одного, что бы столкнуться с такой проблемой.
          А вообще конечно жаль, иногда хочется прям классов или это у меня опыт C# дает о себе знать, а сама концепция не из лучших?


          1. mayorovp
            12.03.2019 13:41

            Концепция-то хорошая, да с powershell не очень-то стыкуется. Всё-таки powershell — это shell, он про нелюбимые вами пайпы, а не про методы.


            1. snd3r Автор
              12.03.2019 14:13

              В интерактивном сеансе консоли я очень люблю пайпы ^_^


      1. beatcracker
        12.03.2019 17:13

        Минусы классов:


        • Работают только в PS 5.x и выше. Не особо критично.
        • Не поддерживают генерацию хелпа из комментариев. Плохо.
        • Не работают в пайплайне (ValueFromPipeline/ValueFromPipelineByPropertyName). Можно решить костылем вида % { SomeClass.Method($_.xxx) }, но...
        • Весь класс должен быть в одном файле, дотсорсить (.) в него куски кода нельзя.
        • Если класс определен в модуле, использовать его снаружи можно только через набор костылей.
        • Ну и в общем странно реализованы: нет неймспейсов, приватных полей, сеттеров/геттеров.

        Поэтому я лично их нигде особо не использую.


        P.S. Отличная статья о современном PS, так держать!


        1. snd3r Автор
          12.03.2019 17:26

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

          Не работают в пайплайне

          Экземпляры класса (объекты) отлично работают с пайплайнами — и входят и выходят, нет поддержки у методов объекта, но опять же объект и командлет — две разные сущности на разных ступенях.


          Весь класс должен быть в одном файле, дотсорсить (.) в него куски кода нельзя.

          Это хорошо же. Класс должен быть целостным и обозримым, на мой взгляд.


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

          Один юзинг по-моему не совсем "набор" костылей, но и тут мне кажется всё логичным. Командлет внутри себя описывает класс для удобства описания своих объектов (конструктор, да и просто спецификация объекта в одном файле более удобны, чем поиск места где создается PSCustomObject и аттачатся методы) и отправляет его наружу. Не могу представить зачем снаружи создавать объекты этого же типа не вызывая командлет.


          Ну и в общем странно реализованы: нет неймспейсов, приватных полей, сеттеров/геттеров.

          Тут более чем согласен. Глобальная видимость всего вообще мне много крови попила)


  1. Sergey-S-Kovalev
    12.03.2019 11:41

    Требую продолжения!


  1. yudinetz
    12.03.2019 11:47

    Привет. Дам совет на миллион — вливайтесь в PowerShell community, оно очень дружелюбное и это позволит вам прокачать скилл еще больше, а именно:
    0. Начните с посещения PSPowerHour
    1. Как уже упомянули выше, используйте модули вместо классов. Посмотрите на Plaster для генерации бойлерплейта для модулей.
    2. Пишите тесты (Pester). Любой production модуль или скрипт на PowerShell должен содержать тесты.
    3. Мир переходит на Infrastructure as Code, поэтому посмотрите на PowerShell DSC и Operation Validation Framework.
    4. Вы нигде не упомянули CI/CD. Для powershell скриптов можно использовать Jenkins или Azure DevOps в связке с psake.
    5. Посетите конференции PowerShell + DevOps Global Summit, PSConf.EU, PSConf.Asia для расширения кругозора и нетворкинга.


    1. snd3r Автор
      12.03.2019 12:10

      Спасибо большое за совет! Но по ощущениям powershell будет решать всё меньше моих задач и уделение времени всему этому врядли будет оправданно.
      Все что вы пишете, у меня было в черновике этой статьи в качестве заголовков и некоторого плана, но, внезапно, оказалось что написать все что хотелось бы, не выйдет за время которое хотелось бы на это потратить =( В одном из скриншотов у меня в пути даже фигурирует Part 1, что подразумевало продолжение, как-раз про DSC, CI/CD, подписи скриптов и репозитарии, но будет ли время им заняться пока вопрос %(


    1. 4c74356b41
      12.03.2019 15:32
      -1

      1. ???
      2. new-module вполне сойдет, вместо plaster
      3. pester крайне мутен
      4. dsc крайне донен, ovf вообще никто не использует.
      5. чому именно psake? повершел вообще не сильно для этого удобен, на мой взгляд. тем более в связке с ADO. Зачем писать логику билда\деплоя в повершеле, если это задача ADO. Про женкинс молчу. это помойка.
      6. ???


    1. buldo
      12.03.2019 18:51

      Я бы не стал советовать psake — хоть проект и не плох, у него какие-то непонятки с поддержкой. Мы с ним огребли, когда переходили с VS2015 на VS2017. В тот момент проект был заброшен и поддержку Build tools 2017 пришлось делать самим.
      Сейчас, вроде бы, в репозитории есть какое-то шевеление.


  1. Boomburum
    12.03.2019 12:47

    Попросил добавить подсветку (раньше она была, кстати), но не обещаю, что это прям очень оперативно сделают :)


  1. legolegs
    12.03.2019 17:07
    +1

    Интересная статья, начало неплохо раскрывает суть инструмент и показывает его сильные стороны. Но с подходом в целом у PS что-то, на мой взгляд, сильно не так.
    Не хотелось бы писять в чужую песочницу и показаться токсичным хейтерком, тем более что автор явно любит и умеет свой инструмент, но я не могу не отметить, что треть материала уделена изобретению такого, простите, grep, который бы не ел 3гб памяти.

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

    <D:\temp\SomeHeavy.log grep '328117'
    работает со скоростью накопителя, требует O(1) памяти, написан 44 года назад.

    Мой пойнт в том, что новый инструмент должен быть быстрее и удобнее старого, а не медленнее и сложнее.


    1. sizziff
      12.03.2019 17:52

      Хочу присоединиться к вашему комментарию.
      Лично мне, очень не хватает нативных: grep, cat, zcat и tail например, для Windows Event Log, как это легко реализовано в Linux: cat /var/log/syslog | grep «любое вхождение»


    1. snd3r Автор
      12.03.2019 18:46

      Согласен, с вашей стороны всё выглядит именно так, но сугубо в силу того, что я не ставил себе задачу сравнить powershell vs bash + coreutils, но:

      Во-первых, такое сравнение выглядело бы не совсем уместно, так как powershell может запускать те же бинарники и отличия не будет и вовсе никакого (а чистый bash безусловно проиграет в сухую не имея такой стандартной библиотеки что есть у dotnet);
      Во-вторых, powershell несравненно удобнее, когда дело доходит до задач бОльших чем банальный греп — ибо вся та объектная метадата от которой я избавлялся в разделе перформанса, поможет просто и выразительно решить задачу при этом без регулярных выражений оперируя высокоуровневыми объектами.

      Думаю уместно будет провести параллель между языками C и Java. Powershell работает в своей почти такой же jvm (в dotnet она называется clr) и обладает всеми плюсами этой платформы, отличаясь от других clr языков только тем, что синтаксически он в первую очередь шелл.


      1. sizziff
        12.03.2019 19:40

        Согласен, с вашей стороны всё выглядит именно так

        Я привел конкретный пример из моей сисадминской практики: анализ лог файла. Я специально направляю логи из различных систем именно на syslogd на Linux машине, что бы потом их было удобно грепать. У меня cat + grep гигового syslog длится 10-15 секунд, а простым поиском вхождения подстроки получаю практически мгновенный результат.
        Напоминаю вот так легко: cat /var/log/syslog | grep «любое вхождение»
        Во-первых, такое сравнение выглядело бы не совсем уместно, так как powershell может запускать те же бинарники ...

        На мой взгляд, сравнение вполне уместно, grep хоть и не из bash, но входит в стандартную поставку ОС, а PS я видел только на Windows, это конечно риторический вопрос, почему MS не завезли в Windows столь полезную утилиту (хотя зачем то пилят WSL).
        Во-вторых, powershell несравненно удобнее, когда дело доходит до задач бОльших чем банальный греп
        Вот и получается что PS — очередной очень хороший язык, а админить из консоли легче не стало.


        1. snd3r Автор
          13.03.2019 06:42

          Я привел конкретный пример из моей сисадминской практики: анализ лог файла.

          Так стандартный Select-String справляется с "грепанием" очень просто и за приемлемое время.


          На мой взгляд, сравнение вполне уместно, grep хоть и не из bash, но входит в стандартную поставку ОС

          Смотря что называть стандартной поставкой. Системы управления конфигурациями не первый десяток лет решают эту проблему.
          Для меня стандартная поставка линукса — Alpine размером 5МБ, например, в нем даже баша нет.


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

          Про какого размера домен мы говорим и какие проблемы не стало легче решать? Я не знаю ни одной.


          1. sizziff
            13.03.2019 14:25

            Ну например задача:

            Просмотреть все логи в ОС Windows (Application, System, Security… ) и найти любые вхождения (сейчас мы еще пока что не знаем в каком конкретно логе искать) «ivanov».

            На Linux это так:
            cat /var/log/* | grep «ivanov»
            или так:
            grep «ivanov» /var/log/*

            Приведите пожалуйста рабочий пример, как тоже самое «легко и быстро» получить в PS на Windows.


            1. snd3r Автор
              13.03.2019 15:00
              +1

              Звучит как "- Я в своем шалаше вот эту стену беру и переставляю, а вы так в своей новостройке можете?".

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

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


              1. sizziff
                13.03.2019 15:41

                «Более комплексные» задачи я решаю с помощью Python + Pycharm. Если командлеты только на PS — тогда только PS. Но переходить на PS для меня всегда боль, поэтому Вам отдельное спасибо за эту статью + за vscode. Кстати мои скрипты на python по «ощущениям» работаю намного быстрее (доставал статистику из vmWare ESXi и с помощью PS и с помощью Python)

                Звучит как "- Я в своем шалаше вот эту стену беру и переставляю, а вы так в своей новостройке можете?".

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


                1. snd3r Автор
                  14.03.2019 06:51

                  Так как грепнуть journalctl?


                  1. sizziff
                    14.03.2019 11:33

                    journalctl -x --since «1 day ago» | grep \veeamone


                    1. snd3r Автор
                      14.03.2019 11:53

                      Это не похоже на вот это:


                      Просмотреть все логи в ОС Windows (Application, System, Security… ) и найти любые вхождения (сейчас мы еще пока что не знаем в каком конкретно логе искать) «ivanov».

                      Думаю на этом стоит остановиться.


                      1. sizziff
                        14.03.2019 12:19

                        Согласен все это наверное вкусовщина.
                        Последняя попытка :), две моих команды:
                        grep «ivanov» /var/log/*
                        journalctl -x | grep ivanov
                        Против:
                        ForEach ($event in (Get-WinEvent -ListLog *)){Get-EventLog -LogName $event.LogName -Message *ivanov*}
                        Не забываем что есть еще tail -f и journalctl -f — которые дают +20 к траблешутингу в реальном времени.

                        Мой личный опыт гугления проблем складывался так, что grep я вижу практически в каждом треде, где решается/обсуждается какая то проблема, а вот на форумах по продуктам MS, решение проблем с помощью конструкций PS Get-WinEvent и Get-EventLog не попадались. ИМХО.


                        1. snd3r Автор
                          14.03.2019 12:57

                          grep я вижу практически в каждом треде

                          Очевидно в силу особенностей архитектуры самих операционных систем и типовых решаемых ими задач.


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


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


                          При этом обе эти системы движутся в одну сторону, неумышленно, просто перенимая хорошие идеи: линукс к венде с системд, дбас етц.


                          Виндовс к линуксу — своими винкор сервером, наносервером, опенсорцным шеллом и дотнетом.


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


                        1. Nova_Logic
                          14.03.2019 22:53

                          get-evetlog имеет ключи -newest -before и -after.
                          Решение проблем с их использованием не так часто встречается потому-что есть еще и инструменты удаленного чтения event через тот-же compmgmt.msc.
                          И на мой взгляд grep по всем логам мало полезное занятие. Ибо тогда уж намного больше смысла при каком-то troubleshooting в отборе всех событий за какой-то промежуток времени. в случае powershell я без труда получу логи со всех нужных серверов сразу, смогу засунуть вывод в csv или xml и переформировать(например сценарий с отслеживанием проблем живой миграции hyper-v между хостами). И хоть на первый взгляд конструкции выглядят длиннее, они просты для чтения, понимания и написания(тот-же powershell ISE очень удобен для написания скриптов).


                        1. Nova_Logic
                          14.03.2019 22:55

                          Согласен все это наверное вкусовщина.
                          Последняя попытка :), две моих команды:
                          grep «ivanov» /var/log/*
                          journalctl -x | grep ivanov

                          Только вывод от первого и второго будет разный по формату, и чтобы как-то это объединить и отсортировать добавится ещё куча строк, что будет радикально отличаться от вывода конструкции, которую я написал. И не стоит тогда уж забывать, что задача «по всем логам» в среднем мало кому нужна, особенно в винде, где реально критичные события как правило будут в Application или System логе, а это уже будет:
                          Get-EventLog -LogName Application -Message *AAA*
                          Сложно?


            1. dklm
              13.03.2019 16:39

              а так?

              Get-WinEvent -Path "C:\Windows\System32\winevt\Logs\*.evtx" | Where Message -like "*ivanov*"


            1. Nova_Logic
              14.03.2019 11:20
              -1

              ForEach ($event in (Get-WinEvent -ListLog *)){Get-EventLog -LogName $event.LogName -Message *AAA*}


  1. AngReload
    12.03.2019 20:55

    В книге "введение в ps" почти 500 страниц, а вы пишете что прочесть можно "за пару вечеров"...


    1. snd3r Автор
      13.03.2019 06:38

      Читать там есть только первую половину книги, вторая — примеры использования и описание подсистемы WMI, пробежать глазами можно, но долго там сидеть негде. Пара вечеров потому что у меня в свое время это заняло реально пару вечеров.


  1. alcohollica
    13.03.2019 00:22

    Отличная статья!
    Отдельное спасибо за наводку на Visio Studio Code


  1. KvanTTT
    14.03.2019 09:16

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

    Как понимаю, на хабре используюется highlight.js для подсветки синтаксиса. Он же powershell поддерживает. Вы пробовали использовать расширение языка в маркдауне, а не название? То есть ps1 или ps?


    1. snd3r Автор
      14.03.2019 10:33

      Но ведь highlight.js отлично подсвечивает и ```powershell


      1. KvanTTT
        14.03.2019 19:18

        А, ну тогда не в курсе. Просто у меня, например, ```csharp не подсвечивалось, срабатывало только ```cs.