понедельник, 8 июня 2015 г.

Обязательные аргументы в PowerShell

Я не понимаю PowerShell. А раз так, то нужно попробовать устаканить свои знания и поделиться ими с миром.

Главная проблема PowerShell, ИМХО, – динамическая типизация и «адаптивная» система типов. PowerShell всеми силами старается избежать ошибки времени исполнения путем конвертации типов туда и обратно (Хоббит, блин!).

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

Параметр метода в PowerShell может быть $Null в одном из двух случаев: пользователь явно передал $Null в качестве значения аргумента. Или же пользователь вообще не указал данный аргумент при вызове метода. Мне, как автору метода, обычно все равно, почему параметр отсутствует. Я просто хочу гарантировать, что он не будет Null. Но, чтобы добиться этого, придется использовать разные подходы.

Обязательные и необязательные параметры в PowerShell

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

Function Foo
{
   
[CmdletBinding()]
    param
(
       
[string]$arg0
)

   
Write-Host "Foo: `$arg0: '$arg0'"
}

# Вызываем метод Foo без параметров
Foo 

Казалось бы, PowerShell поддерживает возможность сделать параметр обязательным – с помощью Parameter(Mandatory = $True). Но не факт, что он вам поможет.

Function Foo
{
   
[CmdletBinding()]
    param
(
       
[Parameter(Mandatory=$True)]
        [string]$arg0
)

   
Write-Host "Foo: `$arg0: '$arg0'"
}

При вызове метода Foo без параметров мы не получим ошибку времени исполнения. Вместо этого, PowerShell попросит пользователя ввести обязательный параметр (!!):

Supply values for the following parameters:
arg0:

Именно таким образом работает большинство стандартных командлетов. Но это далеко не лучшее поведение, если скрипты запускаются ночью и отсутствие параметра является багом в скрипте. К тому же, добавление НОВОГО обязательного параметра в функцию нарушит принцип Открыт-Закрыт, поскольку приведет к поломке всех существующих клиентов этой функции!

ПРИМЕЧАНИЕ
На всякий случай напомню, что принцип Открыт-Закрыт заключается не в расширении иерархии фигур квадратами и кругами без изменения метода Draw, а в отсутствии эффектов бабочки: изменение в одной части системы не должны ломать другие части системы. Подробности, по ссылке выше.

Эмуляция обязательных параметров в PowerShell

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

Function Foo
{
   
[CmdletBinding()]
    param
(
       
[string]$arg0 = $(throw "'arg0' is required argument"
))

   
Write-Host "Foo: `$arg0: '$arg0'"
}

Поскольку PowerShell – это почти-expression language, то предыдущий код работает и при вызове Foo без аргументов, мы получим ошибку:

'arg0' is required argument
At line:5 char:27
+ [string]$arg0 = $(throw "'arg0' is required argument"))
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

С этим подходом есть две проблемы.

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

try
{
   
Boo
}
catch
{
  
$_ | Format-List * -Force
}

ПРИМЕЧАНИЕ
Подробнее о трейсинге исключений можно почитать в заметке Resolve-Error. Хотя, меня немного удивляет необходимость пляски с бубмном для базовой операции – отображения причины ошибки.

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

Function Foo
{
   
[CmdletBinding()]
    param
(
       
[Parameter(ValueFromPipeline=$True)]
        [string]$arg0 = $(throw "'arg0' is required argument"
))

   
Write-Host "Foo: `$arg0: '$arg0'"
}

"foo" | Foo

Вызов этого кода все равно приведет к исключению: ’arg0’ is required argument!

ПРИМАЧАНИЕ
Параметры по умолчанию ведут себя достаточно странно в PowerShell. Так, если использовать параметры по умолчанию и задать Mandatory=$True, то PowerShell все равно спросит у пользователя значение аргумента. Если же задать ValueFromPipeline=$True, и передать аргументы через пайплайн, то инициализатор по умолчанию будет вызван, но его значение будет проигнорировано!

Сейчас мы более или менее научились работать с обязательными параметрами в PowerShell, но остается и другая проблема: как не дать пользователю передать в метод $Null.

Валидация аргументов в PowerShell

Поскольку в PowerShell есть конструкции if и throw, то всегда можно воспользоваться проверкой аргументов с их помощью. Но есть и другой способ – с помощью атрибутов Validate*.

Function Foo
{
   
[CmdletBinding()]
    param
(
       
[ValidateNotNullOrEmpty()]
        [string]$arg0
)

   
Write-Host "Foo: `$arg0: '$arg0'"
}

Теперь, при вызове Foo $Null, мы получим сообщение с ошибкой, которое четко покажет, кто не прав и что делать:

Foo : Cannot validate argument on parameter 'arg0'. The argument is null or empty. Provide an argument that is not null or empty, and then try the command again.
At line:17 char:5
+ Foo $Null

В целом, вариант вполне рабочий, только нужно помнить о нескольких моментах.

Во-первых, не забывайте использовать круглые скобки после имени атрибута. Если вместо [ValidateNotNullOrEmpty()] оставить лишь [ValidateNotNullOrEmpty], то вызов метода Foo $Null вообще может завершиться успешно! В этом случае, PowerShell будет рассматривать [ValidateNotNullOrEmpty] не как атрибут, а как аннотацию типов (!). Его не будет смущать, что их (аннотации) две и по умолчанию его даже не будет смущать, что такого типа не существует. Чтобы получить ошибку в этом случае придется включить строгий режим с помощью Set-StrictMode -Version Latest. (Я вообще рекомендую всегда работать со строгим режимом. Чем раньше упадем, тем быстрее разберемся с проблемой).

Во-вторых, нужно знать, какой атрибут использовать для тех или иных типов. Так, в случае строк, нужно использовать именно ValidateNotNullOrEmpty, а не просто ValidateNotNull, поскольку PowerShell автоматом конвертирует $Null в пустую строку.

Заключение

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

Function Foo
{
   
[CmdletBinding()]
    param
(
       
[ValidateNotNullOrEmpty()]
        [string]$arg0 = $(throw "'arg0' is required argument"
))

   
Write-Host "Foo: `$arg0: '$arg0'"
}

В этом случае, пользователь не сможет передать в метод $null, а также не сможет «забыть» об аргументе $arg0.

Все, что вам нужно помнить, так это разницу между валидацией аргумента и защитой от необязательных параметров. В первом случае нужно использовать атрибуты валидации, а во втором - $(throw “msg”).

7 комментариев:

  1. Что-то я больше убеждаюсь в том, что PythonShell или там JavaScriptShell был бы более прост и понятен :)

    ОтветитьУдалить
    Ответы
    1. В свое время писал C#Script с кучей приблуд для удобной работы с файловой системой и немного с сетью имхо та джуниорская поделка была даже лучше чем повершелл, ну как минимум понятнее.

      Удалить
  2. PoSH (по моим ощущениям слабо знакомого с PoSH человека) создан для написания небольших скриптов "на коленке", либо вообще команд в консоли. Поэтому динамическая типизация и адаптация типов: для быстрого создания рабочих скриптов (или их прототипов). Если весь скрипт содержит даже 1000 строк, строгость архитектуры\дизайна не особо и нужна. Многое отлавливается во время теста, практически всегда передаются правильные параметры.
    При работе в консоли неверные параметры вообще не страшны. Более того, удобно просто набрать в консоли (интуитивно подходящее) имя командлета, и получить подсказку, что вводить. В результате частенько проще (и быстрее) подобрать нужную комбинацию команд\параметров, чем изучать мануалы.
    За валидацию спасибо. Даже не задумывался про такую возможность, и юзал if(!$arg){LogAndExcept}

    ОтветитьУдалить
    Ответы
    1. Спасибо за фидбек.

      Я вот тут несколько не согласен с

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

      У меня сейчас в моих скриптах отсилы 1500 строк, но я сразу же начал использовать модули для декомпозиции. И мне все равно сложно прыгать с места на место, чтобы удержать в голове, чего же нужно передать/вернуть. Без строгой валидации мне просто приходится "уговаривать" PS работать. Ну а когда что-то в скрипте на 1000 строк идет не так, то понять причино-следственные связи становится крайне сложно.

      Удалить
    2. А можно более подробную информацию по скриптам? Насколько точная была начальная постановка (не было\были наброски\были описаны модули и интерфейсы\было детализировано почти все)? Насколько линейная (сколько функций вызываются более, чем из одного места)? Весь код лежит в одном скрипте, или скрипты вызываются друг из друга? Насколько активно использовались командлеты PS и средства .NET? Сколько изменений ожидалось, и сколько пришлось сделать?

      С моей стороны скрипта на PoSH, для работы с TFS. Изначально была крайне подробная постановка, плюс часть функционала была вынесена в шарповые утилиты. Скрипт почти линеен, каждая функция (кроме логирования) вызывается один раз, суммарная длина одного скрипта - 600 строк. Все ошибки обрабатываются одинаково - запись в лог, создание сигнального файла, выход. Сам PoSH используется по минимуму, для проверки переменных, наличия\отсутствия файлов-записей, для создания\удаления файлов\каталогов. Ключевые моменты (операции с файлами и версиями в ТФС) скинуты на библиотеки ТФС API. Само собой, максимум предварительного и покомпонентного тестирования. Пришлось сделать одно изменение, это одно изменение в одной функции (тело, контракт, вызов).
      Львиная часть скрипта пришлась на формирование\чтение 10 путей (проверка на null, на существование элементов ФС, сопутствующее логирование). За счет подробного логирования и линейности скрипта местоположение ошибок находилось за минуты. Размер скрипта можно было значительно уменьшить, но читаемость решила. Минимум алиасов, минимум однострочников, максимум проверок и логирования.

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

      Удалить
    3. 1. Начальная постановка задачи.
      Была постановка проблемы - автоматизировать перформанс тестирование. Ничего более подробного не ставилось. Это привело к череде рефакторингов и итеративной разработке (что довольно сложно делать в скриптах).

      2. Насколько линейная логика
      Относительно линейная: запустить тулу несколько раз, собрать логи, залить их в базу. Сделать удобным весь этот процесс для пользователя. Каждый запуск кастомизируем: с какими аргументами запускаем, как прогреваем первый запуск, куда кладем результаты, какие дополнительные параметры пишем в базу.

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

      4. Насколько много PS-а/.NET-а.
      Тут получилось по феншую. Если есть в PS-е, используем встроенное, если нет встроенного, то делаем фасадную функцию, которая оборачивает .NET-й код в PS-ную функцию.

      5. Сколько изменений ожидалось/сколько пришлось сделать
      Тут была самая обычная итеративная разработка. Я точно не знал, что мне нужно, поэтому периодически вносил изменения то там, то сям. И тут я заметил, что динамическая природа скорее мне мешала (хотя, как известно, динамические языки хороши для прототипирования). Но мне было сложно удержать в голове задачу и еще сложнее было вносить минимальные правки: ошибка в одном месте приводила к вылету ошибки совсем в далеких местах. Например, переименовал, аргумент метода, теперь туда прилетал дефолт, который пробрасывался через три уровня функций и падал в совершенно левом месте. Собственно, вот эта статья - это результат моих попыток понять и сделать интефрейс функций максимально строгим.

      Удалить