Функціональний Powershell із класами — не оксюморон, я гарантую це

Привіт, Хабре! Представляю вашій увазі переклад статті "Functional PowerShell with Classes.
I promise it's not an oxymoron"
автора Christopher Kuech.

Об'єктно-орієнтована та функціональна парадигми програмування можуть здаватися не в ладах один з одним, але обидві однаково підтримуються в Powershell. Практично всі програмні мови, функціональні та ні, мають засоби розширеного зв'язування імен та значень; Класи, подібно struct-ам і record-ам, це лише один підхід. Якщо ми обмежимо використання Класів зв'язуванням імен і значень і уникатимемо таких "важких" об'єктно-орієнтованих програмних концепцій, як успадкування, поліморфізм, або змінність (mutability), ми зможемо використовувати їх переваги, не ускладнюючи наш код. Далі, додаючи незмінні методи перетворення типів, ми можемо збагатити Класами наш функціональний код.

Магія кастів

Касти одна з найпотужніших фіч у Powershell. Коли ви піддаєте значення касту, ви покладаєтеся на середовище, що додається у ваш додаток можливість неявних ініціалізації і валідації. Наприклад, простий каст рядка в [xml] прожене через код парсера і згенерує повне дерево xml. Ми можемо у своєму коді використовувати Класи з тією самою метою.

Каст хештабліц

Якщо у вас немає конструктора, ви можете продовжувати далі без нього, використовуючи каст хештаблиці в тип вашого класу. Не забудьте скористатися атрибутами валідації, щоби повною мірою скористатися цим патерном. Заодно ми можемо використовувати типізовані властивості класу, щоб запустити ще глибшу логіку ініціалізації та валідації.

class Cluster {
    [ValidatePattern("^[A-z]+$")]
    [string] $Service
    [ValidateSet("TEST", "STAGE", "CANARY", "PROD")]
    [string] $FlightingRing
    [ValidateSet("EastUS", "WestUS", "NorthEurope")]
    [string] $Region
    [ValidateRange(0, 255)]
    [int] $Index
}

[Cluster]@{
    Service       = "MyService"
    FlightingRing = "PROD"
    Region        = "EastUS"
    Index         = 2
}

Крім того, каст допомагає одержати чистий висновок. Порівняйте висновок масиву хештабліц Cluster переданий у Format-Table з тим, що вийде, якщо раніше кастувати ці хештаблиці в клас. Властивості класу завжди перераховуються у тому порядку, у якому вони там визначені. Не забудьте додати ключове слово hidden перед тими властивостями, які не повинні бути видно у видачі.

Функціональний Powershell із класами — не оксюморон, я гарантую це

Каст значень

Якщо у вас є конструктор з одним аргументом, каст значення до вашого типу класу передасть значення цьому вашому конструктору, в якому ви можете ініціалізувати інстанс вашого класу

class Cluster {
    [ValidatePattern("^[A-z]+$")]
    [string] $Service
    [ValidateSet("TEST", "STAGE", "CANARY", "PROD")]
    [string] $FlightingRing
    [ValidateSet("EastUS", "WestUS", "NorthEurope")]
    [string] $Region
    [ValidateRange(0, 255)]
    [int] $Index

    Cluster([string] $id) {
        $this.Service, $this.FlightingRing, $this.Region, $this.Index = $id -split "-"
    }
}

[Cluster]"MyService-PROD-EastUS-2"

Каст до рядка

Також можна перевизначити метод класу [string] ToString(), щоб визначити логіку рядкового представлення об'єкта, наприклад використовувати інтерполяцію рядків.

class Cluster {
    [ValidatePattern("^[A-z]+$")]
    [string] $Service
    [ValidateSet("TEST", "STAGE", "CANARY", "PROD")]
    [string] $FlightingRing
    [ValidateSet("EastUS", "WestUS", "NorthEurope")]
    [string] $Region
    [ValidateRange(0, 255)]
    [int] $Index

    [string] ToString() {
        return $this.Service, $this.FlightingRing, $this.Region, $this.Index -join "-"
    }
}

$cluster = [Cluster]@{
    Service       = "MyService"
    FlightingRing = "PROD"
    Region        = "EastUS"
    Index         = 2
}

Write-Host "We just created a model for '$cluster'"

Каст серіалізованих інстансів

Каст дозволяє безпечну десеріалізацію. Приклади нижче завершаться помилкою, якщо дані не відповідають нашій специфікації у Cluster

# Валидация сериализованных данных

[Cluster]$cluster = Get-Content "./my-cluster.json" | ConvertFrom-Json
[Cluster[]]$clusters = Import-Csv "./my-clusters.csv"

Касти у вашому функціональному коді

Функціональні програми спочатку визначають структури даних, потім імплементують програму як послідовність трансформацій над незмінними структурами даних. Незважаючи на суперечливе враження, класи реально допомагають писати функціональний код завдяки методам конвертації типів.

Чи функціональний Powershell я пишу?

Багато людей, що прийшли з C# або з подібним минулим, пишуть Powershell, який схожий на С#. Вчиняючи так, ви відмовляєтеся від використання концепцій функціонального програмування і, ймовірно, отримаєте користь посилено занурившись в об'єктно-орієнтоване програмування Powershell або краще вивчивши функціональне програмування.

Якщо ви глибоко покладаєтеся на трансформацію іммутабельних даних, використовуючи конвеєри (|), Where-Object, ForEach-Object, Select-Object, Group-Object, Sort-Object і т.д. - у вас більш функціональний стиль, і вам допоможе використання класів Powershell у функціональному стилі.

Функціональне використання класів

Касти, хоч і використовують альтернативний синтаксис, лише мапінг між двома доменами. У конвеєрі можна змапити масив значень, використовуючи ForEach-Object.

У прикладі нижче конструктор Node виконується кожного разу, коли відбувається каст до Datum, і це дає нам можливість не писати неабияку кількість коду. В результаті наш конвеєр фокусується на декларативному запиті даних та агрегації, тоді як наші класи беруть на себе парсинг даних та валідацію.

# Пример комбинирования классов с конвейерами для separation of concerns в конвейерах

class Node {
    [ValidateLength(3, 7)]
    [string] $Name
    [ValidateSet("INT", "PPE", "PROD")]
    [string] $FlightingRing
    [ValidateSet("EastUS", "WestUS", "NorthEurope", "WestEurope")]
    [string] $Region
    Node([string] $Name) {
        $Name -match "([a-z]+)(INT|PPE|PROD)([a-z]+)"
        $_, $this.Service, $this.FlightingRing, $this.Region = $Matches
        $this.Name = $Name
    }
}

class Datum {
    [string] $Name
    [int] $Value
    [Node] $Computer
    [int] Severity() {
        $this.Name -match "[0-9]+$"
        return $Matches[0]
    }
}

Write-Host "Urgent Security Audit Issues:"
Import-Csv "./audit-results.csv" `
    | ForEach-Object {[Datum]$_} `
    | Where-Object Value -gt 0 `
    | Group-Object {$_.Severity()} `
    | Where-Object Name -lt 2 `
    | ForEach-Object Group `
    | ForEach-Object Computer `
    | Where-Object FlightingRing -eq "PROD" `
    | Sort-Object Name, Region -Unique

Упаковка класу для перевикористання

Ніщо не таке вже добре, як здається

На жаль, класи не можуть бути експортовані модулями так само, як функції або змінні; але хитрощі деякі є. Допустимо, ваші класи визначені у файлі ./my-classes.ps1

  • Ви можете зробити файл дотсорсинг з класами:. ./my-classes.ps1. Це виконає my-classes.ps1 у вашій поточній області видимості та визначить там усі класи з файлу.

  • Ви можете створити модуль Powershell, який експортує всі ваші API (командлети) і встановити змінну ScriptsToProcess = "./my-classes.ps1" в маніфесті вашого модуля, з тим же результатом: ./my-classes.ps1 виконається у вашому оточенні .

Який би варіант ви не вибрали, не забувайте, що система типів Powershell не може розрулити однойменні типи, завантажені з різних місць.
Навіть якщо ви завантажили з різних місць два ідентичні класи з однаковими властивостями, ви ризикуєте нарватися на проблеми.

шлях вперед

Найкращий спосіб уникнути проблем з вирішенням типів - ніколи не виставляти для користувачів ваші класи. Замість того, щоб очікувати, що користувач імпортує певний у класі тип, експортуйте з вашого модуля функцію, яка звільняє від необхідності безпосередньо звертатися до класу. Що стосується Cluster, ми можемо експортувати функцію New-Cluster, яка підтримає дружні користувачеві набори параметрів і поверне Cluster.

class Cluster {
    [ValidatePattern("^[A-z]+$")]
    [string] $Service
    [ValidateSet("TEST", "STAGE", "CANARY", "PROD")]
    [string] $FlightingRing
    [ValidateSet("EastUS", "WestUS", "NorthEurope")]
    [string] $Region
    [ValidateRange(0, 255)]
    [int] $Index
}

function New-Cluster {
    [OutputType([Cluster])]
    Param(
        [Parameter(Mandatory, ParameterSetName = "Id", Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string] $Id,
        [Parameter(Mandatory, ParameterSetName = "Components")]
        [string] $Service,
        [Parameter(Mandatory, ParameterSetName = "Components")]
        [string] $FlightingRing,
        [Parameter(Mandatory, ParameterSetName = "Components")]
        [string] $Region,
        [Parameter(Mandatory, ParameterSetName = "Components")]
        [int] $Index
    )

    if ($Id) {
        $Service, $FlightingRing, $Region, $Index = $Id -split "-"
    }

    [Cluster]@{
        Service       = $Service
        FlightingRing = $FlightingRing
        Region        = $Region
        Index         = $Index
    }
}

Export-ModuleMember New-Cluster

Що ще почитати

About Classes
Defensive PowerShell
Functional Programming in PowerShell

Джерело: habr.com

Додати коментар або відгук