همانطور که در
قسمت قبل اشاره شد، توابع نیز یکی از ویژگیهای اصلی PowerShell هستند. قبل از بررسی بیشتر توابع بهتر است ابتدا با مفهوم script block آشنا شویم. script blocks به مجموعهایی از دستورات گفته میشود که داخل یک بلاک قرار میگیرند. در واقع هر چیزی داخل {} یک script block محسوب میشود (البته به جز hash tables). به عنوان مثال در کد زیر از یک script block مخصوص، با نام فیلتر استفاده شده است که یک ورودی برای پارامتر FilterScript مربوط به دستور Where-Object میباشد. چیزی که این script block را متمایز میکند، خروجی آن است. به این معنا که خروجی آن باید یک مقدار بولین باشد:
Get-Process | Where-Object { $_.Name -eq 'Dropbox' }
script blocks را به صورت مستقیم درون command line هم میتوانیم استفاده کنیم. به محض تایپ کردن } و زدن کلید enter، امکان نوشتن اسکریپتهای چندخطی را درون ترمینال خواهیم داشت. در نهایت با بستن script block و زدن کلید enter، از بلاک خارج خواهیم شد:
PS /Users/sirwanafifi/Desktop> $block = {
>> $newVar = 10
>> Write-Host $newVar
>> }
با اینکار یک بلاک از کد را داخل متغیری با اسم block ذخیره کردهایم. برای فراخوانی این قطعه کد میتوانیم از یک عملگر مخصوص با نام invocation operator یا call operator استفاده کنیم:
PS /Users/sirwanafifi/Desktop> & $block
یا حتی میتوانیم از Invoke-Command نیز برای اجرای بلاک استفاده کنیم. همچنین از عملگر & برای فراخوانی یک expression رشتهایی نیز میتوان استفاده کرد:
PS /Users/sirwanafifi/Desktop> & "Get-Process"
البته این نکته را در نظر داشته باشید که & قادر به پارز کردن (parse) یک expression نیست. به عنوان مثال اجرای کد زیر با خطا مواجه خواهد شد (برای حل این مشکل میتوانید بجای آن از Invoke-Expression استفاده کنید که امکان پارز کردن پارامترها را نیز دارد):
PS /Users/sirwanafifi/Desktop> & "1 + 1"
or
PS /Users/sirwanafifi/Desktop> & "Get-Process -Name Slack"
توابع
در قسمت قبل با نحوه ایجاد توابع آشنا شدیم. به این نوع توابع، basic functions گفته میشود و سادهترین نوع توابع در PowerShell هستند. همچنین خیلی محدود نیز میباشند؛ یکسری ورودی/خروجی دارند. برای کنترل بیشتر روی نحوه فراخوانی توابع (به عنوان مثال دریافت ورودی از pipeline و…) باید از advanced functions یا توابع پیشرفته استفاده کنیم. در واقع به محض استفاده از اتریبیوتی با نام [()CmdletBinding] تابع ما تبدیل به یک advanced function خواهد شد. منظور از دریافت ورودی از pipeline این است که بتوانیم خروجی دستورات را به تابعمان pipe کنیم اینکار در basic function امکانپذیر نیست:
Function Add-Something {
Write-Host "$_ World"
}
"Hello" | Add-Something
اما با کمک advanced functions میتوانیم چنین قابلیتی را داشته باشیم:
Function Add-Something {
[CmdletBinding()]
Param(
[Parameter(ValueFromPipeline = $true)]
[string]$Name
)
Write-Host "$Name World"
}
"Hello" | Add-Something
یکی دیگر از ویژگیهای advanced functions امکان استفاده فلگ Verbose حین فراخوانی دستورات میباشد. به عنوان مثال قطعه کد زیر را در نظر بگیرید:
$API_KEY = "...."
Function Read-WeatherData {
[CmdletBinding()]
Param(
[Parameter(ValueFromPipeline = $true)]
[string]$CityName
)
$Url = "https://api.openweathermap.org/data/2.5/forecast?q=$CityName&cnt=40&appid=$API_KEY&units=metric"
Try {
Write-Verbose "Reading weather data for $CityName"
$Response = Invoke-RestMethod -Uri $Url
$Response.list | ForEach-Object {
Write-Verbose "Processing $($_.dt_txt)"
[PSCustomObject]@{
City = $Response.city.name
DateTime = [DateTime]::Parse($_.dt_txt)
Temperature = $_.main.temp
Humidity = $_.main.humidity
Pressure = $_.main.pressure
WindSpeed = $_.wind.speed
WindDirection = $_.wind.deg
Cloudiness = $_.clouds.all
Weather = $_.weather.main
WeatherDescription = $_.weather.description
}
} | Where-Object { $_.DateTime.Date -eq (Get-Date).Date }
Write-Verbose "Done processing $CityName"
}
Catch {
Write-Error $_.Exception.Message
}
}
کاری که تابع فوق انجام میدهد، دریافت دیتای پیشبینی وضعیت آبوهوای یک شهر است. در حالت عادی فراخوانی تابع فوق پیامهای Verbose را نمایش نمیدهد. از آنجائیکه تابع فوق یک advanced function است، میتوانیم فلگ Verbose را نیز وارد کنیم. با اینکار به صورت صریح گفتهایم که پیامهای از نوع Verbose را نیز نمایش دهد:
Read-WeatherData -CityName "London" -Verbose
هر چند این مقدار را همانطور که در قسمتهای قبلی عنوان شد میتوانیم تغییر دهیم که دیگر مجبور نباشیم با فراخوانی هر تابع، این فلگ را نیز ارسال کنیم. بیشتر دستورات native نیز قابلیت نمایش پیامهای Verbose را با ارسال همین فلگ در اختیارمان قرار میدهند. بنابراین بهتر است برای امکان مشاهده جزئیات بیشتر حین فراخوانی توابعمان از Write-Verbose استفاده کنیم. در ادامه اجزای دیگر توابع را بررسی خواهیم کرد (بیشتر این اجزا درون یک script block نیز قابل استفاده هستند)
کنترل کامل بر روی ورودیهای توابع
بر روی ورودیهای یک تابع میتوانیم کنترل نسبتاً کاملی داشتیم باشیم. PowerShell یک مجموعه وسیع از قابلیتها را برای هندل کردن پارامترها و همچنین اعتبارسنجی ورودیها ارائه میدهد. به عنوان مثال میتوانیم یک پارامتر را mandatory کنیم یا اینکه امکان positional binding و غیره را تعیین کنیم. اتریبیوت Parameter در واقع یک وهله از System.Management.Automation.ParameterAttribute میباشد. میتوانید با نوشتن دستور زیر لیستی از خواصی را که میتوانید همراه با این اتریبیوت تعیین کنید، مشاهده کنید:
PS /> [Parameter]::new()
ExperimentName :
ExperimentAction : None
Position : -2147483648
ParameterSetName : __AllParameterSets
Mandatory : False
ValueFromPipeline : False
ValueFromPipelineByPropertyName : False
ValueFromRemainingArguments : False
HelpMessage :
HelpMessageBaseName :
HelpMessageResourceId :
DontShow : False
TypeId : System.Management.Automation.ParameterAttribute
در ادامه یک مثال از نحوه هندل کردن ورودیهای یک تابع را بررسی خواهیم کرد. تابع زیر یک لیست از URLها را از کاربر دریافت کرده و یک health check توسط دستور Test-Connection انجام میدهد. در کد زیر پارامتر Websites را با تعدادی اتریبیوت مزین کردهایم. توسط اتریبیوت Parameter تعیین کردهایم که ورودی الزامی است و همچنین مقدار آن میتواند از pipeline نیز دریافت شود. در ادامه توسط ValidatePattern یک عبارت باقاعده را برای بررسی صحیح بودن URL دریافتی نوشتهایم. از آنجائیکه ورودی از نوع آرایهایی از string تعریف شده است، این تست برای هر آیتم از آرایه بررسی خواهد شد. برای پارامتر دوم یعنی Count نیز رنج مقداری را که کاربر وارد میکند، حداقل ۳ و حداکثر ۳ انتخاب کردهایم:
Function Ping-Website {
[CmdletBinding()]
Param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[ValidatePattern('^www\..*')]
[string[]]$Websites,
[ValidateRange(1, 3)]
[int]$Count = 3
)
$Results = @()
$Websites | ForEach-Object {
$Website = $_
$Result = Test-Connection -ComputerName $Website -Count $Count -Quiet
$ResultText = $Result ? 'Success' : 'Failed'
$Results += @{
Website = $Website
Result = $ResultText
}
Write-Verbose "The result of pinging $Website is $ResultText"
}
$Results | ForEach-Object {
$_ | Select-Object @{ Name = "Website"; Expression = { $_.Website }; }, @{ Name = "Result"; Expression = { $_.Result }; }, @{ Name = "Number Of Attempts"; Expression = { $Count }; }
}
}
یکی دیگر از اعتبارسنجیهایی که میتوانیم برای پارامترهای یک تابع انتخاب کنیم، ValidateScript است. توسط این اتریبیوت میتوانیم یک منطق سفارشی برای اعتبارسنجی مقادیر پارامترها بنویسیم. به عنوان مثال تابع فوق را به گونهایی تغییر خواهیم داد که لیست وبسایتها را از طریق یک فایل JSON دریافت کند. میخواهیم قبل از دریافت فایل مطمئن شویم که فایل، به صورت فیزیکی روی دیسک وجود دارد، در غیراینصورت باید یک خطا را به کاربر نمایش دهیم:
Function Ping-Website {
[CmdletBinding()]
Param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[ValidateScript({
If (-Not ($_ | Test-Path) ) {
Throw "File or folder does not exist"
}
If (-Not ($_ | Test-Path -PathType Leaf) ) {
Throw "The Path argument must be a file. Folder paths are not allowed."
}
If ($_ -NotMatch "(\.json)$") {
throw "The file specified in the path argument must be either of type json"
}
Return $true
})]
[Alias("src", "source", "file")]
[System.IO.FileInfo]$Path,
[int]$Count = 1
)
$Results = [System.Collections.ArrayList]@()
$Urls = Get-Content -Path $Path | ConvertFrom-Json
$Urls | ForEach-Object -Parallel {
$Website = $_.url
$Result = Test-Connection -ComputerName $Website -Count $using:Count -Quiet
$ResultText = $Result ? 'Success' : 'Failed'
$Item = @{
Website = $Website
Result = $ResultText
}
$null = ($using:Results).Add($Item)
}
$Results | ForEach-Object -Parallel {
$_ | Select-Object @{ Name = "Website"; Expression = { $_.Website }; }, @{ Name = "Result"; Expression = { $_.Result }; }, @{ Name = "Number Of Attempts"; Expression = { $using:Count }; }
}
}
تابع Ping-Website را جهت بررسی فیچر جدیدی که همراه با دستور ForEach-Object استفاده میشود، تغییر دادهایم تا به صورت Parallel عمل کند؛ این قابلیت از نسخه ۷ به بعد به PowerShell اضافه شده است. از آنجائیکه این قابلیت باعث میشود script block مربوط به ForEach-Object درون یک context دیگر با نام
runspace اجرا شود. در نتیجه برای دسترسی به متغیرهای بیرون از script block نیاز خواهیم داشت از یک متغیر خودکار تحتعنوان using قبل از نام متغیر و بعد از علامت $ استفاده کنیم. همچنین آرایه مثال قبل را نیز به ArrayList تغییر دادهایم. زیرا در حالت قبلی امکان تغییر سایز یک آرایه با سایز ثابت را نخواهیم داشت. نکته دیگری که در مورد کد فوق میتوان به آن توجه کرد، نال کردن خروجی متد Add مربوط به آرایهی Results است. همانطور که در قسمت قبل توضیح دادیم، از این تکنیک برای suppress کردن خروجی استفاده میکنیم و چون در اینجا خروجی متد Add یک عدد میباشد، با تکنیک فوق، خروجی را دیگر درون کنسول مشاهده نخواهیم کرد. توسط اتریبیوت Alias نیز نامهای دیگری را که میتوان برای پارامتر Path حین فراخوانی تابع استفاده کرد، تعیین کردهایم. لیست کامل اتریبیوتهایی را که میتوان برای پارامترهای یک تابع تعیین کرد، میتوانید در مستندات PowerShell ببینید.
نکته: اگر تابع فوق را همراه با فلگ Verbose فراخوانی کنیم، لاگهای موردنظر را درون کنسول مشاهده نخواهیم کرد؛ زیرا همانطور که اشاره شد script block درون یک context جدا اجرا میشود و باید متغیرهای خودکار مربوط به Output را مجدداً مقداردهی کنیم:
Function Ping-Website {
[CmdletBinding()]
Param(
# As before
)
# As before
$Urls | ForEach-Object -Parallel {
$DebugPreference = $using:DebugPreference
$VerbosePreference = $using:VerbosePreference
$InformationPreference = $using:InformationPreference
# As before
}
# As before
}
قابلیت تعریف بلاکها/توابع، به صورت تودرتو
درون توابع و script block امکان نوشتن بلاکهای تودرتو را نیز داریم:
$scriptBlock = {
$logOutput = {
param($message)
Write-Host $message
}
[int]$someVariable = 10
$doSomeWork = {
& $logOutput -message "Some variable value: $someVariable"
}
$someVariable = 20
& $doSomeWork
}
خروجی بلاک فوق Some variable value: 20 خواهد بود؛ زیرا قبل از فراخوانی doSomeWork مقدار متغیر عددی someVariable را به ۲۰ تغییر دادهایم. برای script blocks این امکان را داریم که دقیقاً در همان جایی که بلاک را تعریف میکنیم، یک snapshot تهیه کنیم. در اینحالت خروجی، مقدار Some variable value: 10 خواهد شد:
$scriptBlock = {
$logOutput = {
param($message)
Write-Host $message
}
[int]$someVariable = 10
$doSomeWork = {
& $logOutput -message "Some variable value: $someVariable"
}.GetNewClosure()
$someVariable = 20
& $doSomeWork
}
یکسری بلاکهای ویژه نیز درون توابع و script blockها میتوانیم بنویسیم که اصطلاحاً به name blocks معروف هستند:
begin
process
end
dynamicparam
درون یک تابع اگر هیچکدام از بلاکهای فوق استفاده نشود، به صورت پیشفرض بدنه تابع، درون بلاک end قرار خواهد گرفت. بلاک begin قبل از شروع pipeline اجرا میشود. process به ازای هر آیتم pipe شده اجرا خواهد شد. end نیز در پایان اجرا میشود. به عنوان مثال تابع زیر را در نظر بگیرید:
function Show-Pipeline {
begin {
Write-Host "Pipeline start"
}
process {
Write-Host "Pipeline process $_"
}
end {
Write-Host "Pipeline end $_"
}
}
در ادامه یکسری آیتم را به ورودی این تابع pipe خواهیم کرد:
PS /> 1..2 | Show-Pipeline
Pipeline start
Pipeline process 1
Pipeline process 2
Pipeline end 2
همانطور که مشاهده میکنید، به ازای هر آیتم pipe شده، یکبار بلاک process اجرا شده است. همچنین برای دسترسی به مقدار آیتم pipe شده نیز از متغیر خودکار _$ استفاده کردهایم (PSItem$ نیز به همین متغیر اشاره دارد).
با توجه به توضیحات named blockهای فوق، اکنون اگر بخواهیم نسخه اول تابع Ping-Website را با pipe کردن یک آرایه فراخوانی کنیم، خروجی که در کنسول نمایش داده خواهد شد، تنها آیتم آخر از آرایه خواهد بود:
PS /> "www.google.com", "www.yahoo.com" | Ping-Website
Website Result Number Of Attempts
------- ------ ------------------
www.yahoo.com Success 3
دلیل آن نیز این است که به صورت صریح کدها را درون بلاک process ننوشته بودیم. همانطور که عنوان شد، در حالت پیشفرض، بدنه توابع درون بلاک end قرار خواهند گرفت و تنها یکبار اجرا خواهند شد. بنابراین:
Function Ping-Website {
[CmdletBinding()]
Param(
# As before
)
process {
# As before
}
}
اینبار اگر تابع را مجدداً فراخوانی کنیم، خروجی مطلوب را نمایش خواهد داد:
PS /> "www.google.com", "www.yahoo.com" | Ping-Website
Website Result Number Of Attempts
------- ------ ------------------
www.google.com Success 3
www.yahoo.com Success 3
بلاک dynamicparam
از این بلاک برای تعریف پارامترهای داینامیک که به صورت on the fly نیاز هست ایجاد شوند، استفاده میشود. برای درک بهتر آن فرض کنید میخواهیم تابعی را بنویسیم که امکان خواندن یک فایل CSV را به ما میدهد. تا اینجای کار توسط Import-CSV به یک خط دستور قابل انجام است. اما فرض کنید میخواهیم به کاربر این امکان را بدهیم که یک ستون موردنظر از فایل را مشاهده کند. همچنین میخواهیم یک اعتبارسنجی هم روی نام ستونی که کاربر قرار است وارد کند نیز داشته باشیم. به عنوان مثال یک فایل CSV با ستونهای name, lname, age داریم و کاربر میخواهد تنها ستون اول یک name را واکشی کند:
PS /> Read-Csv ./users.csv -Columns name
برای اینکار میتوانیم با کمک dynamic param یک پارامتر را در زمان اجرا ایجاده کرده و مقادیری را که کاربر برای ستونها مجاز است وارد کند، براساس هدر فایل CSV تنظیم کنیم:
using namespace System.Management.Automation
Function Read-Csv {
Param (
[Parameter(Mandatory = $true, Position = 0)]
[string]$Path
)
DynamicParam {
$firstLine = Get-Content $Path | Select-Object -First 1
[String[]]$headers = $firstLine -split ', '
$parameters = [RuntimeDefinedParameterDictionary]::new()
$parameter = [RuntimeDefinedParameter]::new(
'Columns', [String[]], [Attribute[]]@(
[Parameter]@{ Mandatory = $false; Position = 1 }
[ValidateSet]::new($headers)
)
)
$parameters.Add($parameter.Name, $parameter)
Return $parameters
}
Begin {
$csvContent = Import-Csv $Path
If ($PSBoundParameters.ContainsKey('Columns')) {
$columns = $PSBoundParameters['Columns']
$csvContent | Select-Object -Property $columns
}
Else {
$csvContent
}
}
}
درون کنسول PowerShell هم یک IntelliSense برای مقادیر مجاز نمایش داده خواهد شد: