TabExpansion Plus Plus – rewizyta
Minęły przeszło dwa lata od posta, w którym starałem się Wam przybliżyć moduł TabExpansion++. W tak zwanym międzyczasie autor projektu, Jason Shirk, wrócił "do domu" i pracuje ponownie w zespole odpowiedzialnym za PowerShella. Wraz z nim do "rdzenia" powróciły oba jego projekty, o których pisałem na moim blogu: TabExpansion++ oraz PSReadline. O ile jednak PSReadline (pomijając znaczącą rozbudowę i dodanie kolejnych możliwości, oraz zmianę przestrzeni nazw, w której znajdziemy wszelkie metody z modułem związane) nie zmienił się znacznie, o tyle zmian w TabExpansion++ nie brakuje.
Pierwszą, najbardziej rzucającą się w oczy będzie lekka kosmetyka nazwy tego modułu.
TabExpansionPlusPlus
Uwzględniając fakt, że niektóre narzędzie raczej marnie radzą sobie z nazwami projektów/modułów zawierających znaki inne niż alfabet łaciński (względnie cyfry arabskie), moduł został przemianowany i obecnie nosi nazwę pokrywającą się z tą, którą nosi projekt Jasona na GitHubie. Istotniejsza jest nieco inna zmiana. Polecenia, które uprzednio oferowane były w ramach tego modułu, obecnie stanowią integralną część PowerShella. Jeśli mamy więc możliwość pracy wyłącznie na komputerach z zainstalowanym PowerShellem w wersji piątej, to zmienić się musi nasze podejście do rejestrowania i wykorzystywania uzupełniania składni.
Register-ArgumentCompleter
Pomijając fakt, że polecenie Register-ArgumentCompleter stało się częścią modułu Microsoft.PowerShell.Core (prawdopodobnie nie da się już bardziej zbliżyć do "rdzenia" PowerShella), nie doczekał się ono wielu zmian. Nadal posłużymy się tymi samymi parametrami. Nadal możemy ocenić, jakie wartości mają inne parametry przekazane przez użytkownika (jeśli miałyby one rzutować na listę oferowanych przez nas opcji przy dopełnianiu interesującego nas polecenia). Istotne zmiany dotyczyć będą bloku skryptu, który nie wspiera obecnie polecenia New-CompletionResult. Zamiast tego skorzystać musimy z konstruktora klasy CompletionResult:
Register-ArgumentCompleter -ParameterName ComputerName -ScriptBlock { | |
param( | |
$commandName, | |
$parameterName, | |
$wordToComplete, | |
$commandAst, | |
$fakeBoundParameter | |
) | |
$record = [System.Net.Dns]::Resolve($wordToComplete) | |
if ($record) { | |
$name = $record.HostName -replace '\..*$' | |
[System.Management.Automation.CompletionResult]::new( | |
$name, | |
$name, | |
[System.Management.Automation.CompletionResultType]::ParameterValue, | |
"Computer $name with aliases $($record.Aliases -join ', ')" | |
) | |
} | |
} |
System.Management.Automation.IArgumentCompleter
Przykład dość interesujący, przydatny szczególnie tam, gdzie nazwy hostów średnio nadają się do zapamiętywania, mamy zdefiniowane aliasy, a komputery nie mają odpowiednio skonfigurowanych SPN-ów, a chcemy ułatwić sobie pracę z PowerShell Remoting czy CIM. Warto też wspomnieć, że w wersji oferowanej w ramach PowerShell 5 jest to jedyna metoda, by rozszerzać polecenia istniejące. Metoda polegająca na tworzeniu specjalnych funkcji została zarzucona. Między innymi dlatego, że wykorzystywany tam atrybut może być wykorzystany do tego, by o dynamiczne dopełnianie tabulatorem rozbudować polecenie przez nas tworzone.
Atrybut ArgumentCompleter
TabExpansionPlusPlus w oryginalnej wersji nie umożliwiał nam prostego rozwiązania w sytuacji, w której tworzone przez nas polecenie chcielibyśmy rozszerzyć o dopełnianie wartości poszczególnych parametrów. W praktyce rozszerzanie własnych poleceń niczym nie różniło się od rozszerzania poleceń od nas niezależnych: musieliśmy utworzyć plik definiujące kod, który powinien być wykorzystany przy dopełnianiu argumentów poszczególnych parametrów naszego polecenia. Na szczęście od wersji piątej takie dopełnienia możemy uwzględnić w samym poleceniu. Mamy przy tym trzy możliwości:
- utworzyć klasę, która będzie wykorzystywana do tego rodzaju dopełnień
- stworzyć funkcję, którą w ramach tych dopełnień będziemy uruchamiać
- pełny kod odpowiedzialny za dopełnianie umieścić w ciele naszego polecenia
W przypadku pierwszym nasza klasa implementować musi IArgumentCompleter w ramach przestrzeni nazw System.Management.Automation:
class DopelnijVMke : System.Management.Automation.IArgumentCompleter { | |
[System.Collections.Generic.IEnumerable[System.Management.Automation.CompletionResult]] CompleteArgument( | |
[string]$commandName, | |
[string]$parameterName, | |
[string]$wordToComplete, | |
[System.Management.Automation.Language.CommandAst]$commandAst, | |
[System.Collections.IDictionary]$fakeBoundParameters | |
) { | |
return [System.Management.Automation.CompletionResult[]]@( | |
foreach ($vm in Get-VM -Name "$wordToComplete*") { | |
[System.Management.Automation.CompletionResult]::new( | |
$vm.Name, | |
$vm.Name, | |
[System.Management.Automation.CompletionResultType]::ParameterValue, | |
"Wirtualna maszyna $($vm.Name)" | |
) | |
} | |
) | |
} | |
} | |
function Show-VM { | |
param ( | |
[ValidateNotNullOrEmpty()] | |
[ArgumentCompleter([DopelnijVMke])] | |
[string]$VMName = 'Mgmt', | |
[ValidateNotNullOrEmpty()] | |
[string]$ComputerName = $env:COMPUTERNAME | |
) | |
vmconnect.exe $ComputerName $VMName | |
} |
Oczywiście, raz zaimplementowaną klasę możemy wykorzystywać wielokrotnie. Daje nam to spore możliwości wszędzie tam, gdzie kilka poleceń wymaga podobnej (bądź identycznej) logiki przy dopełniania argumentów różnych parametrów. Taki współdzielony kod możemy utworzyć również w zwyczajnej funkcji w ramach PowerShella, której następnie przekażemy parametry dostępne w ramach atrybutu ArgumentCompleter. Tu funkcja, której możemy użyć wszędzie tam, gdzie wartością argumentu musi być login (wartość atrybutu sAMAccountName) użytkownika dostępnego w ramach Active Directory:
function Get-LoginCompletion { | |
param( | |
[string]$commandName, | |
[string]$parameterName, | |
[string]$wordToComplete, | |
[System.Management.Automation.Language.CommandAst]$commandAst, | |
[System.Collections.IDictionary]$fakeBoundParameters | |
) | |
([ADSISearcher]"(&(objectClass=user)(sAMAccountName=$wordToComplete*))").FindAll() | | |
ForEach-Object { | |
$user = $_.GetDirectoryEntry() | |
[System.Management.Automation.CompletionResult]::new( | |
$user.sAMACcountNAme, | |
$user.sAMACcountNAme, | |
[System.Management.Automation.CompletionResultType]::ParameterValue, | |
"User $($user.Name)" | |
) | |
} | |
} | |
function New-JiraIssue { | |
param ( | |
[ArgumentCompleter({Get-LoginCompletion @args})] | |
[String]$Reporter, | |
[ArgumentCompleter({Get-LoginCompletion @args})] | |
[String]$Assignee | |
) | |
Write-Warning "Reporter: $Reporter, Assignee: $Assignee" | |
} |
Obie metody oferują pewną przenośność kodu. Ostatnia metoda, choć takiej możliwości nie oferuje, przyda nam się wszędzie tam, gdzie przenośność kodu przestaje mieć znaczenie. Doskonały przykład to funkcje tworzone ad-hoc. Nawet jednak wówczas warto rozpatrzyć, czy nie warto kodu tego wyodrębnić (na przykład w formie funkcji o zrozumiałej nazwie). Tego rodzaju rozwiązanie wykorzystałem przy tworzeniu funkcji, z której korzystam na co dzień do przeszukiwania Active Directory w firmie. Funkcja, choć pod pewnym względami łamie dobre praktyki (zwracane obiekty są w pełni uzależnione od przekazanych parametrów a w przypadku skrajnym funkcja zwróci po prostu kolekcję ciagów znaków), to wiele razy ułatwiła mi wyszukiwanie w ramach interaktywnej konsoli. Po dodaniu dopełniania argumentów dodatkowo zwolniła mnie z konieczności wyszukiwania za każdym razem specjalistycznych filtrów LDAP_MATCHING. Pełny kod funkcji można znaleźć na GitHubie. Fragment odpowiedzialny za dopełnianie elementów filtra:
[ArgumentCompleter({ | |
param( | |
[string]$commandName, | |
[string]$parameterName, | |
[string]$wordToComplete, | |
[System.Management.Automation.Language.CommandAst]$commandAst, | |
[System.Collections.IDictionary]$fakeBoundParameters | |
) | |
$types = @{ | |
And = '1.2.840.113556.1.4.803', 'LDAP_MATCHING_RULE_BIT_AND' | |
Or = '1.2.840.113556.1.4.804', 'LDAP_MATCHING_RULE_BIT_OR' | |
Chain = '1.2.840.113556.1.4.1941', 'LDAP_MATCHING_RULE_IN_CHAIN' | |
} | |
switch -Regex ($wordToComplete) { | |
'^(groupType|userAccountControl):$' { | |
foreach ($key in 'And', 'Or') { | |
$line = "$_$($types.$key[0]):=" | |
$attribute = $Matches[1] | |
[System.Management.Automation.CompletionResult]::new( | |
$line, | |
$line, | |
[System.Management.Automation.CompletionResultType]::ParameterValue, | |
"Matching rule $($types.$key[1]) for attribute $attribute" | |
) | |
} | |
} | |
'^(member|memberof):$' { | |
$line = "$_$($types.Chain[0]):=" | |
$attribute = $Matches[1] | |
[System.Management.Automation.CompletionResult]::new( | |
$line, | |
$line, | |
[System.Management.Automation.CompletionResultType]::ParameterValue, | |
"Matching rule $($types.Chain[1]) for attribute $attribute" | |
) | |
} | |
'^(?!.*=)' { | |
$searcher = [ADSISearcher]::new( | |
[ADSI]'LDAP://CN=Schema,CN=Configuration,DC=monad,DC=net', | |
"(&(objectClass=attributeSchema)(lDAPDisplayName=$wordToComplete*))", | |
@( | |
'lDAPDisplayName' | |
'name' | |
) | |
) | |
$searcher.FindAll() | ForEach-Object { | |
$attribute = -join $_.Properties['lDAPDisplayName'][0] | |
$name = -join $_.Properties['name'][0] | |
[System.Management.Automation.CompletionResult]::new( | |
$attribute, | |
$attribute, | |
[System.Management.Automation.CompletionResultType]::ParameterValue, | |
"Attribute $attribute ($name)" | |
) | |
} | |
} | |
} | |
})] | |
[string[]]$Filter = '(name=*)', |
Jak widać, kod wręcz domaga się kilku komentarzy celem wyjaśnienia OCB. Jeśli zamiast umieszczania całości w bloku skryptu skorzystałbym z dwu funkcji, kod wyglądałby zdecydowanie prościej:
param ( | |
[string]$commandName, | |
[string]$parameterName, | |
[string]$wordToComplete, | |
[System.Management.Automation.Language.CommandAst]$commandAst, | |
[System.Collections.IDictionary]$fakeBoundParameters | |
) | |
switch -Regex ($wordToComplete) { | |
'^(groupType|userAccountControl):$' { | |
New-MatchingRuleCompleter -Attribute $Matches[1] -Type AndOr | |
} | |
'^(member|memberof):$' { | |
New-MatchingRuleCompleter -Attribute $Matches[1] -Type Chain | |
} | |
'^(?!.*=)' { | |
New-AttributeCompleter -Pattern "$wordToComplete*" | |
} | |
} |
Ich definicje nadal wymagałyby równie skomplikowanego kodu, ale nazwa daje nam wyraźną wskazówkę w jakim celu funkcja powstała. Dodatkowo, funkcja Net-AttributeCompleter przyda nam się również w przypadku właściwości zwracanych obiektów. Nie musimy więc tego samego kodu pisać (a w przyszłości być może – udoskonalać/ poprawiać) w kilku miejscach.
Podsumowanie
TabExpansionPlusPlus wydaje się być na rozdrożu. Z jednej strony istnieje potrzeba, by nadal wspierać starsze systemy. Z drugiej – na nowszych obecna implementacja nie sprawdzi się a polecenia w ramach tego modułu i modułów wbudowanych kolidują ze sobą. Nie zmienia to jednak faktu, że jeśli tylko mamy taką możliwość i potrzebę, to warto zacząć nasz kod dostosowywać do nowego PowerShella, w którym przy pomocy atrybutu dla poszczególnych parametrów i polecenia Register-ArgumentCompleter możemy wzbogacać pisane przez nas (i innych) polecenia o dynamiczne dopełnianie elementów składni. Bardzo przydatne wszystkim tym, którzy podobnie jak ja spędzają stanowczo zbyt dużo czasu wypatrując literówek w wykonywanych przez siebie poleceniach…